Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ed5636782 | ||
|
|
fd28289f40 | ||
|
|
121a83040d | ||
|
|
06f1877ae1 | ||
|
|
1d9e8aabf3 | ||
|
|
b111e5285b | ||
|
|
f4c491b570 | ||
|
|
29bd4fd70b | ||
|
|
269dadee8d | ||
|
|
03f68a0421 | ||
|
|
807e5eb2a9 | ||
|
|
8e8c29979f | ||
|
|
7d859c1af0 | ||
|
|
e9543dc30d | ||
|
|
9f313440ac | ||
|
|
c2508dffea | ||
|
|
d44bc06a93 | ||
|
|
c56b91570b | ||
|
|
d53c4e6513 | ||
|
|
0426d600cc | ||
|
|
34bd574354 | ||
|
|
89260b8754 | ||
|
|
8bf2171eaa | ||
|
|
420633f125 | ||
|
|
54871bf22e | ||
|
|
e6f8174a44 | ||
|
|
74289b8b01 | ||
|
|
4e2813d23e | ||
|
|
8938cb6cf7 | ||
|
|
2fb92b7816 | ||
|
|
cff2e08b92 | ||
|
|
f1e4866a8b | ||
|
|
b0429f9287 | ||
|
|
d41e5daa71 | ||
|
|
5474181e2e | ||
|
|
cb351f5b69 | ||
|
|
9301718974 | ||
|
|
8841aac40e | ||
|
|
568a7a87a5 | ||
|
|
e37bd38f34 | ||
|
|
1cafb5edac | ||
|
|
cc89beca31 | ||
|
|
92d9cdc4b7 | ||
|
|
20942d47c6 | ||
|
|
de8d20a030 | ||
|
|
1fb7e16d42 |
No files matched your search
+8
-1
@@ -66,4 +66,11 @@ UNSAFE_ENABLE_MOCK_INTEGRATION=true
|
||||
|
||||
# Whether demo mode is read-only. Defaults to true (the public demo blocks all mutations).
|
||||
# Set to false for writable preview deployments so the demo user can modify the dashboard.
|
||||
# DEMO_READ_ONLY=false
|
||||
# DEMO_READ_ONLY=false
|
||||
|
||||
# Community workshop (PocketBase) base URL used by the in-app "Install from Workshop" buttons
|
||||
# Workshop API override for local Homarr development:
|
||||
# NEXT_PUBLIC_WORKSHOP_API_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
|
||||
@@ -0,0 +1,19 @@
|
||||
# Workshop deployment with ACT
|
||||
|
||||
The Workshop workflow builds the same image used on the VPS and smoke-tests both Docusaurus and PocketBase. Registry login and pushes are skipped under ACT.
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
pnpm ci:act:workshop
|
||||
```
|
||||
|
||||
The wrapper selects `.github/workflows/deployment-workshop.yml`, derives the active Docker socket (including Docker Desktop and OrbStack contexts), and runs only the `deploy` job using a `workflow_dispatch` event.
|
||||
|
||||
To inspect the workflow without running it:
|
||||
|
||||
```sh
|
||||
act workflow_dispatch --list --workflows .github/workflows/deployment-workshop.yml
|
||||
```
|
||||
|
||||
No GitHub or registry secrets are required for the local run.
|
||||
@@ -11,3 +11,11 @@
|
||||
- [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||
- [ ] No shorthand variable names are used (eg. `x`, `y`, `i` or any abbrevation)
|
||||
- [ ] Documentation is up to date. Create a pull request [here](https://github.com/homarr-labs/documentation/).
|
||||
- [ ] When using AI; No temp files are checked in, the code style follows the rest of the project
|
||||
|
||||
**When contributing a new integration or widget:**
|
||||
|
||||
- [ ] I confirmed manually that the integration or widget is working with a real system and performed basic smoke
|
||||
tests (e.g. what happens if it is offline)
|
||||
- [ ] Optional but recommended: I confirm that I am willing to be added to the `CODEOWNERS` of this feature and will
|
||||
provide support and maintain the integration (e.g. in case of breaking or failure)
|
||||
@@ -1,56 +0,0 @@
|
||||
name: "[Deployment] Documentation"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["dev"]
|
||||
paths:
|
||||
- "apps/docs/**"
|
||||
- "packages/definitions/src/docs/**"
|
||||
- ".github/workflows/deployment-docs.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
SKIP_ENV_VALIDATION: true
|
||||
TURBO_API: ${{ secrets.TURBO_API }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
- name: Build docs
|
||||
run: pnpm turbo build --filter=@homarr/docs
|
||||
env:
|
||||
CI: "true"
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v6
|
||||
- name: Upload pages artifact
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: "apps/docs/build"
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v5
|
||||
@@ -0,0 +1,47 @@
|
||||
name: "[Deployment] Workshop"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["dev"]
|
||||
paths:
|
||||
- "apps/docs/**"
|
||||
- "apps/workshop/**"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: workshop-deployment
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Build, test, and publish
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
LOCAL_IMAGE: homarr/workshop:ci
|
||||
REGISTRY_IMAGE: ghcr.io/homarr-labs/workshop
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build combined Workshop image
|
||||
run: docker build --file apps/workshop/Dockerfile --tag "$LOCAL_IMAGE" .
|
||||
|
||||
- name: Smoke-test static docs and PocketBase
|
||||
run: bash apps/workshop/scripts/smoke-test.sh "$LOCAL_IMAGE"
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: ${{ env.ACT != 'true' }}
|
||||
run: echo "${{ github.token }}" | docker login ghcr.io --username "${{ github.actor }}" --password-stdin
|
||||
|
||||
- name: Publish immutable and latest tags
|
||||
if: ${{ env.ACT != 'true' }}
|
||||
run: |
|
||||
docker tag "$LOCAL_IMAGE" "$REGISTRY_IMAGE:sha-${GITHUB_SHA}"
|
||||
docker tag "$LOCAL_IMAGE" "$REGISTRY_IMAGE:latest"
|
||||
docker push "$REGISTRY_IMAGE:sha-${GITHUB_SHA}"
|
||||
docker push "$REGISTRY_IMAGE:latest"
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+35
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
A Helm chart to deploy homarr for Kubernetes
|
||||
|
||||
@@ -429,7 +429,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
|
||||
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
|
||||
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
|
||||
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
|
||||
| image.tag | string | `"v1.69.2"` | Overrides the image tag whose default is the chart appVersion |
|
||||
| image.tag | string | `"v1.70.0"` | Overrides the image tag whose default is the chart appVersion |
|
||||
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
|
||||
| ingress.annotations | object | `{}` | Ingress annotations |
|
||||
| ingress.enabled | bool | `false` | Enable ingress |
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
title: "Open WebUI"
|
||||
description: "Self-hosted AI chat interface for local and remote LLM providers"
|
||||
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 { openWebUiIntegration } from ".";
|
||||
import { openWebUiWidget } from "../../widgets/open-webui";
|
||||
|
||||
<IntegrationHeader integration={openWebUiIntegration} categories={["AI", "Chat"]} />
|
||||
|
||||
[Open WebUI](https://openwebui.com/) is a self-hosted AI chat interface that aggregates multiple LLM providers (Ollama, OpenAI-compatible APIs, and more) behind a single API. Homarr connects to it so you can chat with your models directly from your dashboard.
|
||||
|
||||
### Widgets & Capabilities
|
||||
|
||||
<IntegrationCapabilites items={[{ widget: openWebUiWidget }]} />
|
||||
|
||||
### Adding the integration
|
||||
|
||||
<AddingIntegration />
|
||||
|
||||
:::info URL configuration
|
||||
Use the root URL of your Open WebUI instance, for example `http://localhost:8080` (Open WebUI's default port). Homarr calls the Open WebUI API under `/api` automatically.
|
||||
:::
|
||||
|
||||
### Secrets
|
||||
|
||||
<IntegrationSecrets
|
||||
secrets={[
|
||||
{
|
||||
credentials: ["apiKey"],
|
||||
steps: [],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -1,8 +0,0 @@
|
||||
import { IntegrationDefinition } from "@site/src/types";
|
||||
|
||||
export const openWebUiIntegration: IntegrationDefinition = {
|
||||
name: "Open WebUI",
|
||||
description: "Self-hosted AI chat interface for local and remote LLM providers",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/open-webui.svg",
|
||||
path: "../../integrations/open-webui",
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
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.
|
||||
|
||||
:::warning
|
||||
|
||||
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.
|
||||
|
||||
## Workshop deployment
|
||||
|
||||
Workshop is powered by [PocketBase](https://pocketbase.io). The production image compiles the current Docusaurus site and serves it together with the API from one origin. The deployment lives in `apps/workshop/` with a Dockerfile, Compose configuration, version-controlled migrations, hooks, a smoke test, and ACT-local workflow instructions.
|
||||
|
||||
Homarr instances use `NEXT_PUBLIC_WORKSHOP_API_URL` to reach the central API and `NEXT_PUBLIC_WORKSHOP_URL` for links to the public page. Docusaurus uses `WORKSHOP_URL` only when its development server runs separately; the combined production image always uses its own origin.
|
||||
@@ -1,29 +1,25 @@
|
||||
---
|
||||
title: 'Notebook'
|
||||
description: 'A simple notebook widget that supports markdown.'
|
||||
title: "Notebook"
|
||||
description: "A simple notebook widget that supports markdown."
|
||||
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 { notebookWidget } from '.';
|
||||
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 { notebookWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={notebookWidget} categories={["Notes"]} />
|
||||
|
||||
<WidgetHeader
|
||||
widget={notebookWidget}
|
||||
categories={['Notes']}
|
||||
/>
|
||||
|
||||
The notebook widget focuses on usability and is designed to be as simple as possible to bring a familiar editing experience to regular users.
|
||||
The notebook widget focuses on usability and is designed to be as simple as possible to bring a familiar editing experience to regular users.
|
||||
It is based on Tiptap.dev and supports most of its features.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import editOff from './img/normal.png';
|
||||
import editOn from './img/edit.png';
|
||||
import editOff from "./img/normal.png";
|
||||
import editOn from "./img/edit.png";
|
||||
|
||||
<div className='flex gap-1'>
|
||||
<div className="flex gap-1">
|
||||
<img src={editOff} alt="notebook edit-off" width={128 * 3} />
|
||||
<img src={editOn} alt="notebook edit-on" width={128 * 3} />
|
||||
</div>
|
||||
@@ -33,24 +29,27 @@ import editOn from './img/edit.png';
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={notebookWidget.configuration} />
|
||||
|
||||
### Editing
|
||||
|
||||
When the permissions of the user allows it, you can enter the editing of the text by clicking the following button in the top right.
|
||||
|
||||

|
||||
|
||||
|
||||
You can then decide to save your changes or cancel them to exit editing, using the buttons placed in the same location.
|
||||
|
||||

|
||||
|
||||
### Notebook toolbar functions
|
||||
The notebook offers a variaty of functions to edit the text and more.
|
||||
|
||||
The notebook offers a variety of functions to edit the text and more.
|
||||
|
||||

|
||||
|
||||
#### Common text formatting
|
||||
|
||||
| Function | Description |
|
||||
| ---------------------- | ------------------------------------------------------------------- |
|
||||
| Bold | Makes the selected text bold |
|
||||
@@ -62,35 +61,51 @@ The notebook offers a variaty of functions to edit the text and more.
|
||||
| Clear formatting | Erases any of the preceding function's effects on the selected text |
|
||||
|
||||
#### Heading
|
||||
|
||||
Choose a heading size for the line, making the text bigger. Working like titles and sub-titles.
|
||||
|
||||
#### Text Alignement
|
||||
Select the alignement on the line. Left, center or right.
|
||||
#### Text Alignment
|
||||
|
||||
Select the alignment on the line. Left, center or right.
|
||||
|
||||
#### Text direction
|
||||
|
||||
Set the current paragraph or heading to left-to-right or right-to-left. This is useful when writing right-to-left text while Homarr is using a left-to-right language, or vice versa.
|
||||
|
||||
#### Misc functions
|
||||
|
||||
##### Blockquote
|
||||
|
||||
Puts the current line in a quoting block.
|
||||
|
||||
##### Horizontal line
|
||||
|
||||
Adds a line that goes from the far right to the far left as a separator.
|
||||
|
||||
#### Lists
|
||||
|
||||
##### Bullet lists
|
||||
|
||||
Simple list with a dot preceding each element.
|
||||
|
||||
##### Ordered lists
|
||||
|
||||
List with numbers preceding each element.
|
||||
|
||||
##### Check lists
|
||||
|
||||
List with a checkbox on each element.
|
||||
|
||||
##### Indentation changes
|
||||
|
||||

|
||||
|
||||
When you are currently in a list of any sort, the last 2 buttons will appear and will allow you to change the indentation of the current list item.
|
||||
|
||||
#### Insert functions
|
||||
|
||||
##### Link controls
|
||||
|
||||
The first buttons is to insert a link, or edit an existing link. The link will be applied on the selected text.
|
||||
|
||||
Pasting a link directly into the text will automatically put the link anchor on it too.
|
||||
@@ -102,16 +117,20 @@ The second button will remove the link while leaving the text intact.
|
||||
This is the modal for the link, The little button in the textbox can be clicked to make the link open in a new tab.
|
||||
|
||||
##### Image controls
|
||||
|
||||

|
||||
|
||||
##### Source
|
||||
|
||||
You can use a normal link to an image or you can also use locally stored images.
|
||||
|
||||
##### Width
|
||||
|
||||
The aspect ratio will always be respected, so only the width field is necessary.
|
||||
You can provide a value in a plain number or you can also use percents to have your image size dynamically change on different screens.
|
||||
|
||||
#### Table
|
||||
|
||||
This last control allows you to add a table, for which you can determine the number of columns and row before inserting it.
|
||||
|
||||

|
||||
@@ -119,6 +138,7 @@ This last control allows you to add a table, for which you can determine the num
|
||||
Clicking that button while in a table will delete it.
|
||||
|
||||
##### Table controls
|
||||
|
||||

|
||||
|
||||
While in a table, further controls will appear to help edit the table.
|
||||
@@ -134,4 +154,4 @@ All the following ones are for editing the wize of the table:
|
||||
- Delete Column
|
||||
- Add row on top
|
||||
- Add row below
|
||||
- Delete Row
|
||||
- Delete Row
|
||||
@@ -1,56 +0,0 @@
|
||||
---
|
||||
title: "AI Chat"
|
||||
description: "Chat with AI models through Open WebUI, with model picking and history."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
|
||||
import { openWebUiWidget } from ".";
|
||||
import { openWebUiIntegration } from "@site/docs/integrations/open-webui";
|
||||
|
||||
<WidgetHeader widget={openWebUiWidget} categories={["AI", "Chat"]} />
|
||||
|
||||
The AI Chat widget lets you chat with AI models served by Open WebUI directly from your dashboard. Responses stream in token-by-token, you can pick any model your Open WebUI instance exposes (Ollama, OpenAI, and other configured providers), and you can browse and reopen your Open WebUI chat history. A full attachment menu lets you ground a conversation on images, uploaded or stored files, web pages, knowledge bases, notes, and previous chats.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{
|
||||
integration: openWebUiIntegration,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={openWebUiWidget.configuration} />
|
||||
|
||||
### Features
|
||||
|
||||
| Feature | Description |
|
||||
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Model picker** | Lists every model exposed by Open WebUI's `/api/models` endpoint, selectable from the chat input. The first model is selected by default. |
|
||||
| **Streaming responses** | Assistant replies stream in live as they are generated. |
|
||||
| **Chat history** | Browse and reopen previous chats. New conversations are saved back to Open WebUI. |
|
||||
| **Image attachments** | Attach or capture images for vision-capable models (sent inline as multimodal content). A hint warns when the model has no vision support. |
|
||||
| **File attachments** | Upload new documents or attach files already stored in Open WebUI; their content is retrieved as context. |
|
||||
| **Web pages** | Attach a URL; Open WebUI ingests the page and the relevant parts are retrieved as context. |
|
||||
| **Knowledge bases** | Ground replies on any RAG knowledge collection your account owns, with an expandable picker to inspect a base's files. |
|
||||
| **Notes** | Attach the contents of your Open WebUI notes. |
|
||||
| **Reference chats** | Bring a previous conversation in as context for the current one. |
|
||||
| **Voice messages** | Record from your microphone and have Open WebUI transcribe it into the message box (requires STT configured in Open WebUI). |
|
||||
|
||||
### Notes
|
||||
|
||||
- The widget uses a single Open WebUI integration.
|
||||
- Conversations are stored in Open WebUI itself, under the account that owns the API key, so they also appear in the Open WebUI app.
|
||||
- Knowledge bases and attached web pages stay active for the whole conversation until you remove them; images apply only to the message you attach them to.
|
||||
- Conversation text is saved to Open WebUI history, but attached image data is not persisted.
|
||||
@@ -1,25 +0,0 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconRobot } from "@tabler/icons-react";
|
||||
|
||||
export const openWebUiWidget: WidgetDefinition = {
|
||||
icon: IconRobot,
|
||||
name: "AI Chat",
|
||||
description: "Chat with AI models through Open WebUI, with model picking and history.",
|
||||
path: "../../widgets/open-webui",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "System prompt",
|
||||
description: "Optional instruction prepended to every conversation.",
|
||||
values: { type: "string" },
|
||||
defaultValue: "empty",
|
||||
},
|
||||
{
|
||||
name: "Show chat history",
|
||||
description: "Show a button to browse and reopen previous Open WebUI chats.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -20,6 +20,11 @@ const config: Config = {
|
||||
onBrokenAnchors: "throw",
|
||||
onDuplicateRoutes: "throw",
|
||||
|
||||
customFields: {
|
||||
// Empty means same-origin, which is the production Workshop image shape.
|
||||
workshopUrl: process.env.WORKSHOP_URL ?? "",
|
||||
},
|
||||
|
||||
future: {
|
||||
v4: {
|
||||
removeLegacyPostBuildHeadAttribute: true,
|
||||
@@ -124,6 +129,11 @@ const config: Config = {
|
||||
position: "left",
|
||||
to: "/blog",
|
||||
},
|
||||
{
|
||||
label: "Workshop",
|
||||
position: "left",
|
||||
to: "/workshop",
|
||||
},
|
||||
{
|
||||
label: "About us",
|
||||
position: "left",
|
||||
@@ -278,7 +288,12 @@ const config: Config = {
|
||||
return {
|
||||
name: "resolve-homarr-packages",
|
||||
configureWebpack() {
|
||||
return { resolve: { symlinks: false } };
|
||||
return {
|
||||
resolve: {
|
||||
symlinks: false,
|
||||
alias: { "@": require("path").resolve(__dirname, "src") },
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -309,6 +324,18 @@ const config: Config = {
|
||||
},
|
||||
};
|
||||
},
|
||||
function workshopDetailRoutePlugin() {
|
||||
return {
|
||||
name: "workshop-detail-route",
|
||||
async contentLoaded({ actions }) {
|
||||
actions.addRoute({
|
||||
path: "/workshop/:id",
|
||||
component: "@site/src/components/workshop/DetailPage",
|
||||
exact: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
"@signalwire/docusaurus-plugin-llms-txt",
|
||||
async function tailwindCssPlugin() {
|
||||
return {
|
||||
|
||||
@@ -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",
|
||||
@@ -33,18 +34,22 @@
|
||||
"@signalwire/docusaurus-plugin-llms-txt": "^1.2.2",
|
||||
"@tabler/icons-react": "catalog:",
|
||||
"@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",
|
||||
"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": "^14.0.1",
|
||||
"tailwindcss": "^4.3.2"
|
||||
"swiper": "^14.0.2",
|
||||
"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",
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,178 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { IconCheck, IconEdit, IconSend, IconTrash, IconX } from "@tabler/icons-react";
|
||||
|
||||
import type { WorkshopComment } 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: WorkshopComment) =>
|
||||
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<WorkshopComment[]>([]);
|
||||
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<WorkshopComment>({
|
||||
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<WorkshopComment>({ 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<WorkshopComment>(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,361 @@
|
||||
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 { WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase";
|
||||
import { getPocketBase, getSubmissionFileUrl } from "@site/src/lib/pocketbase";
|
||||
import type { SubmissionType } from "@site/src/lib/workshop-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 "./workshop-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 = ({ workshopUrl }: { workshopUrl: string }) => {
|
||||
const location = useLocation();
|
||||
const submissionId = parseSubmissionId(location.pathname);
|
||||
const pb = useMemo(() => getPocketBase(workshopUrl), [workshopUrl]);
|
||||
|
||||
const [submission, setSubmission] = useState<WorkshopSubmission | null>(null);
|
||||
const [userVote, setUserVote] = useState<WorkshopVote | 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<WorkshopSubmission>(submissionId);
|
||||
if (cancelled) return;
|
||||
setSubmission(record);
|
||||
|
||||
if (pb.authStore.isValid && pb.authStore.record) {
|
||||
const votes = await pb.collection("votes").getFullList<WorkshopVote>({
|
||||
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 WorkshopVote),
|
||||
);
|
||||
setSubmission((s) => (s ? { ...s, upvotes: s.upvotes + upD, downvotes: s.downvotes + downD } : s));
|
||||
|
||||
try {
|
||||
if (!prev) {
|
||||
const created = await pb
|
||||
.collection("votes")
|
||||
.create<WorkshopVote>({ 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 configuredWorkshopUrl = (siteConfig.customFields?.workshopUrl as string | undefined) ?? "";
|
||||
|
||||
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 workshopUrl={configuredWorkshopUrl || window.location.origin} />}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -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/workshop-schema";
|
||||
import { validateSubmissionContent } from "@site/src/lib/workshop-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 "./useWorkshop";
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -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 WorkshopSubmission } from "@site/src/lib/pocketbase";
|
||||
import type { SubmissionType } from "@site/src/lib/workshop-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 "./workshop-utils";
|
||||
import type { SortKey, TypeFilter } from "./useWorkshop";
|
||||
import { useWorkshop } from "./useWorkshop";
|
||||
|
||||
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 WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
|
||||
const workshop = useWorkshop(workshopUrl);
|
||||
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(() => workshop.submissions.map((s) => s.id).join(","), [workshop.submissions]);
|
||||
const sortedIds = useMemo(() => {
|
||||
const q = search.toLowerCase();
|
||||
return workshop.submissions
|
||||
.filter((item) => {
|
||||
if (typeFilter === "yours") return item.author === workshop.user?.id;
|
||||
return typeFilter === "all" || item.type === typeFilter;
|
||||
})
|
||||
.filter((item) => !q || item.title.toLowerCase().includes(q))
|
||||
.toSorted(workshop.sorters[sort])
|
||||
.map((s) => s.id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally keyed on submissionIds string, not the array
|
||||
}, [submissionIds, workshop.sorters, typeFilter, sort, search, workshop.user?.id]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const byId = new Map(workshop.submissions.map((s) => [s.id, s]));
|
||||
return sortedIds.map((id) => byId.get(id)).filter(Boolean) as WorkshopSubmission[];
|
||||
}, [workshop.submissions, sortedIds]);
|
||||
|
||||
const empty = emptyState[workshop.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">
|
||||
{workshop.user ? (
|
||||
<>
|
||||
<Button onClick={() => setShowSubmit(true)}>
|
||||
<IconPlus size={14} /> Share yours
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={workshop.logout}>
|
||||
<IconLogout size={14} /> {workshop.user?.name || workshop.user?.username || "Account"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-end gap-0.5">
|
||||
<Button onClick={() => void workshop.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>
|
||||
|
||||
{workshop.error && (
|
||||
<div className="mb-4 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
|
||||
{workshop.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workshop.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>
|
||||
)}
|
||||
|
||||
{!workshop.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>
|
||||
{workshop.user && workshop.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={workshop.pb}
|
||||
userVote={workshop.votes[submission.id]?.value}
|
||||
currentUserId={workshop.user?.id}
|
||||
onVote={workshop.vote}
|
||||
onReport={workshop.report}
|
||||
onDelete={workshop.deleteSubmission}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showSubmit && (
|
||||
<SubmitForm
|
||||
onClose={() => setShowSubmit(false)}
|
||||
onSubmit={async (input) => {
|
||||
if (await workshop.submit(input)) setShowSubmit(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SubmissionCardProps {
|
||||
submission: WorkshopSubmission;
|
||||
pb: ReturnType<typeof useWorkshop>["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,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,349 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { WorkshopComment, WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase";
|
||||
import { getPocketBase } from "@site/src/lib/pocketbase";
|
||||
import type { SubmissionType } from "@site/src/lib/workshop-schema";
|
||||
import { schemaVersionByType, validateSubmissionContent } from "@site/src/lib/workshop-schema";
|
||||
import { errorMessage } from "@site/src/lib/utils";
|
||||
|
||||
import { voteDelta } from "./workshop-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<WorkshopComment[]>;
|
||||
add: (submissionId: string, content: string) => Promise<WorkshopComment | null>;
|
||||
update: (commentId: string, content: string) => Promise<WorkshopComment | null>;
|
||||
delete: (commentId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const sorters: Record<SortKey, (a: WorkshopSubmission, b: WorkshopSubmission) => 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 useWorkshop = (workshopUrl: string) => {
|
||||
const pb = useMemo(() => getPocketBase(workshopUrl), [workshopUrl]);
|
||||
const [submissions, setSubmissions] = useState<WorkshopSubmission[]>([]);
|
||||
const [votes, setVotes] = useState<Record<string, WorkshopVote>>({});
|
||||
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<WorkshopVote>({
|
||||
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<WorkshopSubmission>({ 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 WorkshopVote;
|
||||
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<WorkshopVote>({ 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<WorkshopVote>(existing.id, { value });
|
||||
setVotes((current) => ({ ...current, [submissionId]: updated }));
|
||||
} catch (caught) {
|
||||
if (!isNotFoundError(caught)) throw caught;
|
||||
const created = await pb
|
||||
.collection("votes")
|
||||
.create<WorkshopVote>({ 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<WorkshopComment>({
|
||||
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<WorkshopComment>({ 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<WorkshopComment>(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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { WorkshopSubmission } from "@site/src/lib/pocketbase";
|
||||
|
||||
export const downloadSubmissionJson = (submission: WorkshopSubmission) => {
|
||||
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];
|
||||
};
|
||||
+179
-14
@@ -4,9 +4,53 @@
|
||||
|
||||
@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 {
|
||||
@@ -23,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;
|
||||
@@ -39,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 {
|
||||
@@ -80,7 +217,7 @@
|
||||
}
|
||||
|
||||
.footer__copyright span.copyright_text {
|
||||
opacity: .6;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.medium-zoom-overlay {
|
||||
@@ -97,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;
|
||||
}
|
||||
|
||||
@@ -135,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;
|
||||
}
|
||||
|
||||
@@ -164,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);
|
||||
@@ -180,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 {
|
||||
@@ -188,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;
|
||||
}
|
||||
|
||||
@@ -203,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;
|
||||
}
|
||||
}
|
||||
@@ -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 WorkshopSubmission {
|
||||
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 WorkshopVote {
|
||||
id: string;
|
||||
submission: string;
|
||||
user: string;
|
||||
value: 1 | -1;
|
||||
}
|
||||
|
||||
export interface WorkshopComment {
|
||||
id: string;
|
||||
submission: string;
|
||||
author: string;
|
||||
content: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
expand?: { author?: { id: string; name?: string; username?: string } };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { validateSubmissionContent } from "./workshop-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);
|
||||
});
|
||||
});
|
||||
@@ -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 WorkshopValidationResult = { 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): WorkshopValidationResult => {
|
||||
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): WorkshopValidationResult => {
|
||||
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) => WorkshopValidationResult>;
|
||||
|
||||
export const schemaVersionByType = {
|
||||
widget: WIDGET_SCHEMA_VERSION,
|
||||
css: CSS_SCHEMA_VERSION,
|
||||
} satisfies Record<SubmissionType, string>;
|
||||
|
||||
export const validateSubmissionContent = (type: SubmissionType, raw: string): WorkshopValidationResult =>
|
||||
submissionValidators[type](raw);
|
||||
@@ -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 WorkshopPage() {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
const configuredWorkshopUrl = (siteConfig.customFields?.workshopUrl as string | undefined) ?? "";
|
||||
|
||||
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 { WorkshopApp } = require("@site/src/components/workshop/WorkshopApp");
|
||||
return <WorkshopApp workshopUrl={configuredWorkshopUrl || window.location.origin} />;
|
||||
}}
|
||||
</BrowserOnly>
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@site/*": ["./*"]
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const OpenWebUiChat = dynamic(() => import("~/components/open-webui-chat").then((mod) => mod.OpenWebUiChat), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export const OpenWebUiChatProvider = () => {
|
||||
return <OpenWebUiChat />;
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { InstallCssFromWorkshopButton } from "~/components/workshop-install/install-from-workshop-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">
|
||||
<InstallCssFromWorkshopButton onSelect={(css) => form.setFieldValue("customCss", css)} />
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ActionIcon, Button, Group, Text } from "@mantine/core";
|
||||
import type { FileWithPath } from "@mantine/dropzone";
|
||||
import { IconPencil } from "@tabler/icons-react";
|
||||
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import { formatBytes } from "@homarr/common";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
@@ -22,7 +22,7 @@ export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text visibleFrom="md" c="gray.6" size="sm">
|
||||
{humanFileSize(file.size)}
|
||||
{formatBytes(file.size)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Button
|
||||
|
||||
@@ -32,7 +32,6 @@ import { SearchEngineOptimization } from "~/components/layout/search-engine-opti
|
||||
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
|
||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||
import { DayJsLoader } from "./_client-providers/dayjs-loader";
|
||||
import { OpenWebUiChatProvider } from "./_client-providers/open-webui-chat";
|
||||
import { JotaiProvider } from "./_client-providers/jotai";
|
||||
import { CustomMantineProvider } from "./_client-providers/mantine";
|
||||
import { AuthProvider } from "./_client-providers/session";
|
||||
@@ -148,7 +147,6 @@ export default async function Layout(props: {
|
||||
<StackedProvider>
|
||||
<Notifications pauseResetOnHover="notification" />
|
||||
<ServiceWorkerRegistration />
|
||||
<OpenWebUiChatProvider />
|
||||
{props.children}
|
||||
</StackedProvider>
|
||||
</body>
|
||||
|
||||
@@ -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 { InstallWidgetFromWorkshopButton } from "~/components/workshop-install/install-from-workshop-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">
|
||||
<InstallWidgetFromWorkshopButton />
|
||||
<ImportCustomWidgetButton />
|
||||
<MobileAffixButton component={Link} href="/manage/custom-widgets/new" leftSection={<IconPlus size={16} />}>
|
||||
{t("action.create")}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { z } from "zod/v4";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import { formatBytes } from "@homarr/common";
|
||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||
import { createLocalImageUrl } from "@homarr/icons/local";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
@@ -125,7 +125,7 @@ const Row = async ({ media }: RowProps) => {
|
||||
/>
|
||||
</TableTd>
|
||||
<TableTd>{media.name}</TableTd>
|
||||
<TableTd>{humanFileSize(media.size)}</TableTd>
|
||||
<TableTd>{formatBytes(media.size)}</TableTd>
|
||||
<TableTd>
|
||||
{media.creator ? (
|
||||
<Group gap="sm">
|
||||
|
||||
@@ -19,7 +19,7 @@ import { MantineReactTable } from "mantine-react-table";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { humanFileSize, useTimeAgo } from "@homarr/common";
|
||||
import { formatBytes, useTimeAgo } from "@homarr/common";
|
||||
import type { ContainerState } from "@homarr/docker";
|
||||
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
@@ -143,7 +143,7 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
|
||||
|
||||
return (
|
||||
<Text size="sm" c={memoryUsageColor(bytesUsage, row.original.state)}>
|
||||
{humanFileSize(bytesUsage)}
|
||||
{formatBytes(bytesUsage)}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Box, Drawer } from "@mantine/core";
|
||||
import { useHotkeys, useMediaQuery } from "@mantine/hooks";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import { openWebUiChatEvent } from "@homarr/spotlight";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Chat } from "@homarr/widgets/_open-webui/chat";
|
||||
|
||||
/**
|
||||
* App-wide launcher for the Open WebUI chat. Renders a right-side panel that can
|
||||
* be opened from the header user menu, the {@link hotkeys.toggleAiChat} keybind,
|
||||
* or the spotlight "ask AI" mode (via the {@link openWebUiChatEvent} window
|
||||
* event). Renders nothing unless the signed-in user owns an Open WebUI
|
||||
* integration.
|
||||
*/
|
||||
export const OpenWebUiChat = () => {
|
||||
const t = useI18n();
|
||||
const { data: session } = useSession();
|
||||
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [initialQuery, setInitialQuery] = useState<string | undefined>(undefined);
|
||||
// Bumped on every open so the chat remounts and a fresh initial query is sent.
|
||||
const [openNonce, setOpenNonce] = useState(0);
|
||||
|
||||
const { data: integrations = [] } = clientApi.integration.all.useQuery(undefined, {
|
||||
enabled: Boolean(session?.user?.id),
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const myOpenWebUiIntegration = useMemo(() => {
|
||||
if (!session?.user?.id) return null;
|
||||
return integrations.find(
|
||||
(integration) => integration.kind === "openWebUi" && integration.creatorId === session.user.id,
|
||||
);
|
||||
}, [integrations, session?.user?.id]);
|
||||
|
||||
const open = useCallback((query?: string) => {
|
||||
setInitialQuery(query?.trim() ? query.trim() : undefined);
|
||||
setOpenNonce((nonce) => nonce + 1);
|
||||
setOpened(true);
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setOpened((previous) => {
|
||||
if (previous) return false;
|
||||
setInitialQuery(undefined);
|
||||
setOpenNonce((nonce) => nonce + 1);
|
||||
return true;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Open (with an optional query) when the spotlight "ask AI" mode dispatches.
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
if (!myOpenWebUiIntegration) return;
|
||||
open((event as CustomEvent<{ query?: string }>).detail?.query);
|
||||
};
|
||||
window.addEventListener(openWebUiChatEvent, handler);
|
||||
return () => window.removeEventListener(openWebUiChatEvent, handler);
|
||||
}, [open, myOpenWebUiIntegration]);
|
||||
|
||||
useHotkeys([[hotkeys.toggleAiChat, () => myOpenWebUiIntegration && toggle()]]);
|
||||
|
||||
if (!myOpenWebUiIntegration) return null;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
position="right"
|
||||
size={isMobile ? "100%" : 480}
|
||||
title={t("widget.openWebUi.name")}
|
||||
keepMounted={false}
|
||||
styles={{
|
||||
body: {
|
||||
height: "calc(100dvh - 60px)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
|
||||
<Chat
|
||||
key={openNonce}
|
||||
options={{ systemPrompt: "", showHistory: true }}
|
||||
integrationIds={[myOpenWebUiIntegration.id]}
|
||||
itemId={undefined}
|
||||
boardId={undefined}
|
||||
isEditMode={false}
|
||||
setOptions={() => {
|
||||
// No-op: options are fixed for the launcher chat.
|
||||
}}
|
||||
width={480}
|
||||
height={600}
|
||||
initialQuery={initialQuery}
|
||||
/>
|
||||
</Box>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
@@ -11,17 +11,14 @@ import {
|
||||
IconHome,
|
||||
IconLogin,
|
||||
IconLogout,
|
||||
IconRobot,
|
||||
IconSettings,
|
||||
IconTool,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { signOut, useSession } from "@homarr/auth/client";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { openOpenWebUiChat } from "@homarr/spotlight";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
@@ -49,15 +46,6 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
|
||||
const { openModal } = useModalAction(LogoutModal);
|
||||
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
|
||||
|
||||
const { data: integrations = [] } = clientApi.integration.all.useQuery(undefined, {
|
||||
enabled: Boolean(session.data?.user?.id),
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const hasOpenWebUiIntegration = integrations.some(
|
||||
(integration) => integration.kind === "openWebUi" && integration.creatorId === session.data?.user?.id,
|
||||
);
|
||||
|
||||
const handleSignout = useCallback(async () => {
|
||||
await signOut({
|
||||
redirect: false,
|
||||
@@ -108,11 +96,6 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
|
||||
{t("docker")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{hasOpenWebUiIntegration && (
|
||||
<Menu.Item leftSection={<IconRobot size="1rem" />} onClick={() => openOpenWebUiChat()}>
|
||||
{t("aiChat")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconUsersGroup } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { customWidgetImportSchema } from "@homarr/validation/custom-widget";
|
||||
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { WorkshopBrowserModal } from "./workshop-browser-modal";
|
||||
|
||||
function parseWidgetContent(content: string) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch {
|
||||
return { ok: false as const, message: "Submission content is not valid JSON" };
|
||||
}
|
||||
const result = customWidgetImportSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
return { ok: false as const, message: result.error.issues[0]?.message ?? "Invalid widget schema" };
|
||||
}
|
||||
return { ok: true as const, data: result.data };
|
||||
}
|
||||
|
||||
export const InstallWidgetFromWorkshopButton = () => {
|
||||
const t = useScopedI18n("customWidget.workshop");
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
const utils = clientApi.useUtils();
|
||||
const importMutation = clientApi.customWidget.import.useMutation({
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({ title: t("title"), message: t("notification.widgetInstalled") });
|
||||
void utils.customWidget.all.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
setPendingId(null);
|
||||
close();
|
||||
},
|
||||
onError: (err) => {
|
||||
showErrorNotification({ title: t("title"), message: err.message || t("notification.widgetInstallFailed") });
|
||||
setPendingId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSelect = (submission: { id: string; content: string }) => {
|
||||
const parsed = parseWidgetContent(submission.content);
|
||||
if (!parsed.ok) {
|
||||
showErrorNotification({ title: t("title"), message: parsed.message });
|
||||
return;
|
||||
}
|
||||
setPendingId(submission.id);
|
||||
importMutation.mutate(parsed.data);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MobileAffixButton variant="default" leftSection={<IconUsersGroup size={16} />} onClick={open}>
|
||||
{t("action.installFromWorkshop")}
|
||||
</MobileAffixButton>
|
||||
<WorkshopBrowserModal
|
||||
type="widget"
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
actionLabel={t("action.install")}
|
||||
pendingId={pendingId}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const InstallCssFromWorkshopButton = ({ onSelect }: { onSelect: (css: string) => void }) => {
|
||||
const t = useScopedI18n("customWidget.workshop");
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="default" leftSection={<IconUsersGroup size={16} />} onClick={open}>
|
||||
{t("action.installFromWorkshop")}
|
||||
</Button>
|
||||
<WorkshopBrowserModal
|
||||
type="css"
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
actionLabel={t("action.use")}
|
||||
onSelect={(submission) => {
|
||||
onSelect(submission.content);
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
export const WORKSHOP_API_URL = process.env.NEXT_PUBLIC_WORKSHOP_API_URL || "https://workshop.homarr.dev";
|
||||
|
||||
export interface WorkshopSubmission {
|
||||
id: string;
|
||||
collectionId: string;
|
||||
collectionName: string;
|
||||
type: "css" | "widget";
|
||||
title: string;
|
||||
description: string;
|
||||
schemaVersion: string;
|
||||
content: string;
|
||||
screenshots: string[];
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
version: number;
|
||||
author: string;
|
||||
authorName: string;
|
||||
commentCount: number;
|
||||
created: string;
|
||||
}
|
||||
|
||||
interface PBListResponse {
|
||||
page: number;
|
||||
perPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
items: WorkshopSubmission[];
|
||||
}
|
||||
|
||||
export const getWorkshopFileUrl = (submission: WorkshopSubmission, filename: string) =>
|
||||
`${WORKSHOP_API_URL}/api/files/submissions/${submission.id}/${encodeURIComponent(filename)}`;
|
||||
|
||||
export const fetchWorkshopSubmissions = async (
|
||||
type: "css" | "widget",
|
||||
page = 1,
|
||||
perPage = 50,
|
||||
): Promise<PBListResponse> => {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
perPage: String(perPage),
|
||||
sort: "-upvotes",
|
||||
filter: `(type='${type}')`,
|
||||
});
|
||||
const response = await fetch(`${WORKSHOP_API_URL}/api/collections/marketplace/records?${params}`);
|
||||
if (!response.ok) throw new Error(`Workshop request failed (${response.status})`);
|
||||
return response.json() as Promise<PBListResponse>;
|
||||
};
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
Modal,
|
||||
Pagination,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { IconChevronLeft, IconChevronRight, IconExternalLink } from "@tabler/icons-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import type { WorkshopSubmission } from "./workshop-api";
|
||||
import { fetchWorkshopSubmissions, getWorkshopFileUrl } from "./workshop-api";
|
||||
|
||||
const WORKSHOP_URL = process.env.NEXT_PUBLIC_WORKSHOP_URL || "https://homarr.dev/workshop";
|
||||
|
||||
interface Props {
|
||||
type: "css" | "widget";
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (submission: WorkshopSubmission) => void;
|
||||
actionLabel: string;
|
||||
pendingId?: string | null;
|
||||
}
|
||||
|
||||
const displayName = (submission: WorkshopSubmission) => submission.authorName;
|
||||
|
||||
export const WorkshopBrowserModal = ({ type, opened, onClose, onSelect, actionLabel, pendingId }: Props) => {
|
||||
const [page, setPage] = useState(1);
|
||||
const handleClose = () => {
|
||||
setPage(1);
|
||||
onClose();
|
||||
};
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["workshop-submissions", type, page],
|
||||
queryFn: () => fetchWorkshopSubmissions(type, page),
|
||||
enabled: opened,
|
||||
});
|
||||
|
||||
const errorText = error instanceof Error ? error.message : "unknown error";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
<Group gap="sm">
|
||||
<Text fw={600}>Install from Workshop</Text>
|
||||
<Anchor
|
||||
href={WORKSHOP_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="xs"
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
|
||||
>
|
||||
Open Workshop <IconExternalLink size={12} />
|
||||
</Anchor>
|
||||
</Group>
|
||||
}
|
||||
size={1200}
|
||||
scrollAreaComponent={ScrollArea.Autosize}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{isLoading && (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{error && <Alert color="red">Failed to load the workshop: {errorText}</Alert>}
|
||||
|
||||
{data && data.items.length === 0 && (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
No submissions available yet.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{data && data.items.length > 0 && (
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{data.items.map((submission) => (
|
||||
<SubmissionCard
|
||||
key={submission.id}
|
||||
submission={submission}
|
||||
actionLabel={actionLabel}
|
||||
pending={pendingId === submission.id}
|
||||
onSelect={() => onSelect(submission)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{data.totalPages > 1 && (
|
||||
<Center>
|
||||
<Pagination total={data.totalPages} value={page} onChange={setPage} />
|
||||
</Center>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const SubmissionCard = ({
|
||||
submission,
|
||||
actionLabel,
|
||||
pending,
|
||||
onSelect,
|
||||
}: {
|
||||
submission: WorkshopSubmission;
|
||||
actionLabel: string;
|
||||
pending: boolean;
|
||||
onSelect: () => void;
|
||||
}) => {
|
||||
const screenshots = submission.screenshots ?? [];
|
||||
const [imgIdx, setImgIdx] = useState(0);
|
||||
const score = submission.upvotes - submission.downvotes;
|
||||
const scorePrefix = score > 0 ? "+" : "";
|
||||
|
||||
return (
|
||||
<Card withBorder padding={0}>
|
||||
{screenshots.length > 0 && (
|
||||
<Box pos="relative">
|
||||
<Image
|
||||
src={screenshots[imgIdx] ? getWorkshopFileUrl(submission, screenshots[imgIdx]) : undefined}
|
||||
alt={submission.title}
|
||||
h={180}
|
||||
fit="cover"
|
||||
/>
|
||||
{screenshots.length > 1 && (
|
||||
<>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="dark"
|
||||
size="xs"
|
||||
pos="absolute"
|
||||
top="50%"
|
||||
left={4}
|
||||
style={{ transform: "translateY(-50%)" }}
|
||||
onClick={() => setImgIdx((i) => (i - 1 + screenshots.length) % screenshots.length)}
|
||||
>
|
||||
<IconChevronLeft size={12} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="dark"
|
||||
size="xs"
|
||||
pos="absolute"
|
||||
top="50%"
|
||||
right={4}
|
||||
style={{ transform: "translateY(-50%)" }}
|
||||
onClick={() => setImgIdx((i) => (i + 1) % screenshots.length)}
|
||||
>
|
||||
<IconChevronRight size={12} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack gap={4} p="sm">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Box
|
||||
w={8}
|
||||
h={8}
|
||||
bg={submission.type === "css" ? "blue.5" : "yellow.5"}
|
||||
style={{ borderRadius: "50%", flexShrink: 0 }}
|
||||
/>
|
||||
<Text fw={600} size="sm" truncate style={{ flex: 1 }}>
|
||||
{submission.title}
|
||||
</Text>
|
||||
<Badge variant="light" size="sm" style={{ flexShrink: 0 }}>
|
||||
{scorePrefix}
|
||||
{score}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
{displayName(submission)} · v{submission.version}
|
||||
</Text>
|
||||
|
||||
{submission.description && (
|
||||
<Text size="xs" c="dimmed" lineClamp={2}>
|
||||
{submission.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Group gap="xs" mt={4}>
|
||||
<Button size="xs" style={{ flex: 1 }} loading={pending} onClick={onSelect}>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="sm"
|
||||
component="a"
|
||||
href={`${WORKSHOP_URL}/${submission.id}/`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="View in workshop"
|
||||
>
|
||||
<IconExternalLink size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
pb_data/
|
||||
.env
|
||||
.env.local
|
||||
@@ -0,0 +1,54 @@
|
||||
# syntax=docker/dockerfile:1.10
|
||||
|
||||
FROM node:24.18.0-alpine AS docs
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
COPY . .
|
||||
RUN --mount=type=cache,id=workshop-pnpm,target=/pnpm/store \
|
||||
pnpm config set store-dir /pnpm/store && \
|
||||
pnpm install --frozen-lockfile --filter @homarr/docs...
|
||||
RUN WORKSHOP_URL="" pnpm --filter @homarr/docs build
|
||||
|
||||
FROM alpine:3.22 AS pocketbase
|
||||
ARG POCKETBASE_VERSION=0.39.6
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN apk add --no-cache ca-certificates curl unzip
|
||||
RUN case "${TARGETARCH}" in \
|
||||
amd64) PB_ARCH=amd64 ;; \
|
||||
arm64) PB_ARCH=arm64 ;; \
|
||||
arm) PB_ARCH=armv7 ;; \
|
||||
*) echo "Unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \
|
||||
esac && \
|
||||
BASE_URL="https://github.com/pocketbase/pocketbase/releases/download/v${POCKETBASE_VERSION}" && \
|
||||
ARCHIVE="pocketbase_${POCKETBASE_VERSION}_linux_${PB_ARCH}.zip" && \
|
||||
curl --fail --location --silent --show-error "${BASE_URL}/${ARCHIVE}" --output "/tmp/${ARCHIVE}" && \
|
||||
curl --fail --location --silent --show-error "${BASE_URL}/checksums.txt" --output /tmp/checksums.txt && \
|
||||
cd /tmp && \
|
||||
grep " ${ARCHIVE}$" checksums.txt | sha256sum -c - && \
|
||||
unzip "/tmp/${ARCHIVE}" pocketbase -d /out
|
||||
|
||||
FROM alpine:3.22 AS runtime
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
COPY --from=pocketbase /out/pocketbase /usr/local/bin/pocketbase
|
||||
COPY --from=docs /app/apps/docs/build /pb_public
|
||||
COPY apps/workshop/pb_hooks /pb_hooks
|
||||
COPY apps/workshop/pb_migrations /pb_migrations
|
||||
|
||||
RUN addgroup -S workshop && \
|
||||
adduser -S -G workshop -h /home/workshop workshop && \
|
||||
mkdir -p /pb_data && \
|
||||
chown -R workshop:workshop /pb_data /pb_public /pb_hooks /pb_migrations
|
||||
|
||||
USER workshop
|
||||
EXPOSE 8090
|
||||
VOLUME ["/pb_data"]
|
||||
|
||||
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=10 \
|
||||
CMD wget --quiet --spider http://127.0.0.1:8090/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/pocketbase"]
|
||||
CMD ["serve", "--http=0.0.0.0:8090", "--dir=/pb_data", "--publicDir=/pb_public", "--hooksDir=/pb_hooks", "--migrationsDir=/pb_migrations", "--indexFallback=true"]
|
||||
@@ -0,0 +1,15 @@
|
||||
.git
|
||||
.github
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
**/.docusaurus
|
||||
**/.next
|
||||
**/.turbo
|
||||
**/build
|
||||
**/coverage
|
||||
**/dist
|
||||
**/node_modules
|
||||
**/pb_data
|
||||
.pnpm-store
|
||||
e2e
|
||||
@@ -0,0 +1,80 @@
|
||||
# Homarr Workshop
|
||||
|
||||
Workshop ships as one container. Its build stage compiles the current Docusaurus site, and its runtime stage serves those static files and the PocketBase API from the same origin.
|
||||
|
||||
## Local production-like run
|
||||
|
||||
Configure optional values in the repository-root `.env`, then run:
|
||||
|
||||
```sh
|
||||
pnpm dev:workshop
|
||||
```
|
||||
|
||||
Open:
|
||||
|
||||
- documentation: `http://localhost:8090/`;
|
||||
- Workshop: `http://localhost:8090/workshop/`;
|
||||
- PocketBase API: `http://localhost:8090/api/`;
|
||||
- PocketBase dashboard: `http://localhost:8090/_/`.
|
||||
|
||||
The `homarr_workshop_deployment_data` volume persists records and uploaded files across image rebuilds. Migrations run automatically when PocketBase starts. It is intentionally separate from volumes created by earlier prototypes with incompatible schemas.
|
||||
|
||||
## GitHub OAuth
|
||||
|
||||
Create a GitHub OAuth application with these local values:
|
||||
|
||||
- homepage: `http://localhost:8090`;
|
||||
- callback: `http://localhost:8090/api/oauth2-redirect`.
|
||||
|
||||
Set these values only in the repository-root `.env`:
|
||||
|
||||
```dotenv
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
```
|
||||
|
||||
For production, use `https://workshop.homarr.dev` and `https://workshop.homarr.dev/api/oauth2-redirect`. Create the first PocketBase superuser through the installer link printed on first startup or through the PocketBase CLI.
|
||||
|
||||
## Local image validation
|
||||
|
||||
Build and test the exact runtime shape:
|
||||
|
||||
```sh
|
||||
docker build -f apps/workshop/Dockerfile -t homarr/workshop:ci .
|
||||
pnpm test:workshop-image
|
||||
```
|
||||
|
||||
The smoke test starts a temporary container and verifies the documentation homepage, `/workshop/`, the migrated public collection, and `/api/health`.
|
||||
|
||||
## GitHub Actions and ACT
|
||||
|
||||
`.github/workflows/deployment-workshop.yml` runs only for changes below `apps/docs/` or `apps/workshop/`, plus manual dispatches. It builds and tests the combined image, then publishes:
|
||||
|
||||
- `ghcr.io/homarr-labs/workshop:latest`;
|
||||
- `ghcr.io/homarr-labs/workshop:sha-<commit>`.
|
||||
|
||||
Run the same build and smoke test locally without registry credentials:
|
||||
|
||||
```sh
|
||||
pnpm ci:act:workshop
|
||||
```
|
||||
|
||||
See `.github/act/workshop.md` for the direct ACT command and Docker socket behavior.
|
||||
|
||||
## VPS deployment
|
||||
|
||||
Copy `apps/workshop/docker-compose.yml` to the checkout on the VPS, authenticate Docker to GHCR if the package is private, and run from the repository root. Compose reads the optional root `.env` automatically:
|
||||
|
||||
```sh
|
||||
docker compose -f apps/workshop/docker-compose.yml pull
|
||||
docker compose -f apps/workshop/docker-compose.yml up -d --no-build
|
||||
```
|
||||
|
||||
The service binds to `127.0.0.1:8090` by default. Put a TLS reverse proxy in front of it for `workshop.homarr.dev`, forward the original scheme and host, and persist the `homarr_workshop_deployment_data` volume. Back up that volume before PocketBase upgrades.
|
||||
|
||||
## Content schema
|
||||
|
||||
- Custom widgets use `homarr-custom-widget-v2` JSON.
|
||||
- Custom CSS uses raw CSS tagged as `homarr-custom-css-v1`.
|
||||
|
||||
Client-side validation lives in `apps/docs/src/lib/workshop-schema.ts`. PocketBase collection rules and hooks remain in this directory.
|
||||
@@ -0,0 +1,28 @@
|
||||
name: homarr-workshop
|
||||
|
||||
services:
|
||||
workshop:
|
||||
image: ghcr.io/homarr-labs/workshop:${WORKSHOP_TAG:-latest}
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: apps/workshop/Dockerfile
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
|
||||
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
|
||||
ports:
|
||||
- "127.0.0.1:${WORKSHOP_PORT:-8090}:8090"
|
||||
volumes:
|
||||
- workshop_data:/pb_data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--spider", "http://127.0.0.1:8090/api/health"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
volumes:
|
||||
workshop_data:
|
||||
name: homarr_workshop_deployment_data
|
||||
@@ -0,0 +1,75 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
// --- Submissions ---
|
||||
|
||||
onRecordCreateRequest((e) => {
|
||||
e.record.set("author", e.auth?.id);
|
||||
e.record.set("upvotes", 0);
|
||||
e.record.set("downvotes", 0);
|
||||
e.record.set("version", 1);
|
||||
e.next();
|
||||
}, "submissions");
|
||||
|
||||
onRecordAfterCreateSuccess((e) => {
|
||||
try {
|
||||
var votes = e.app.findCollectionByNameOrId("votes");
|
||||
var vote = new Record(votes);
|
||||
vote.set("submission", e.record.id);
|
||||
vote.set("user", e.record.get("author"));
|
||||
vote.set("value", 1);
|
||||
e.app.save(vote);
|
||||
} catch (err) {
|
||||
console.log("auto-upvote failed for submission " + e.record.id + ": " + err);
|
||||
}
|
||||
e.next();
|
||||
}, "submissions");
|
||||
|
||||
onRecordUpdateRequest((e) => {
|
||||
var original = e.app.findRecordById("submissions", e.record.id);
|
||||
e.record.set("author", original.get("author"));
|
||||
e.record.set("upvotes", original.get("upvotes"));
|
||||
e.record.set("downvotes", original.get("downvotes"));
|
||||
e.record.set("type", original.get("type"));
|
||||
e.record.set("schemaVersion", original.get("schemaVersion"));
|
||||
e.record.set("version", original.get("version"));
|
||||
e.next();
|
||||
}, "submissions");
|
||||
|
||||
// --- Votes ---
|
||||
|
||||
onRecordCreateRequest((e) => {
|
||||
var v = +e.record.get("value");
|
||||
if (v !== 1 && v !== -1) throw new BadRequestError("value must be 1 or -1");
|
||||
e.record.set("user", e.auth?.id);
|
||||
e.next();
|
||||
}, "votes");
|
||||
|
||||
onRecordUpdateRequest((e) => {
|
||||
var v = +e.record.get("value");
|
||||
if (v !== 1 && v !== -1) throw new BadRequestError("value must be 1 or -1");
|
||||
var original = e.app.findRecordById("votes", e.record.id);
|
||||
e.record.set("user", original.get("user"));
|
||||
e.record.set("submission", original.get("submission"));
|
||||
e.next();
|
||||
}, "votes");
|
||||
|
||||
// Vote count hooks removed — the `marketplace` view computes upvotes/downvotes via SQL
|
||||
|
||||
// --- Reports & Comments ---
|
||||
|
||||
onRecordCreateRequest((e) => {
|
||||
e.record.set("user", e.auth?.id);
|
||||
e.next();
|
||||
}, "reports");
|
||||
|
||||
onRecordCreateRequest((e) => {
|
||||
e.record.set("author", e.auth?.id);
|
||||
e.next();
|
||||
}, "comments");
|
||||
|
||||
onRecordUpdateRequest((e) => {
|
||||
var original = e.app.findRecordById("comments", e.record.id);
|
||||
e.record.set("author", original.get("author"));
|
||||
e.record.set("submission", original.get("submission"));
|
||||
e.next();
|
||||
}, "comments");
|
||||
@@ -0,0 +1,172 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
migrate(
|
||||
(app) => {
|
||||
const users = app.findCollectionByNameOrId("users");
|
||||
|
||||
const githubClientId = $os.getenv("GITHUB_CLIENT_ID");
|
||||
const githubClientSecret = $os.getenv("GITHUB_CLIENT_SECRET");
|
||||
if (githubClientId && githubClientSecret) {
|
||||
users.oauth2.enabled = true;
|
||||
users.oauth2.providers = [
|
||||
{
|
||||
name: "github",
|
||||
clientId: githubClientId,
|
||||
clientSecret: githubClientSecret,
|
||||
},
|
||||
];
|
||||
}
|
||||
app.save(users);
|
||||
|
||||
let submissions;
|
||||
try {
|
||||
submissions = app.findCollectionByNameOrId("submissions");
|
||||
} catch {
|
||||
submissions = new Collection({
|
||||
type: "base",
|
||||
name: "submissions",
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
createRule: "@request.auth.id != ''",
|
||||
updateRule: "author = @request.auth.id",
|
||||
deleteRule: "author = @request.auth.id",
|
||||
fields: [
|
||||
{ name: "type", type: "select", required: true, maxSelect: 1, values: ["css", "widget"] },
|
||||
{ name: "title", type: "text", required: true, min: 3, max: 100 },
|
||||
{ name: "description", type: "text", max: 2000 },
|
||||
{ name: "schemaVersion", type: "text", required: true, max: 100 },
|
||||
{ name: "content", type: "text", required: true, max: 1000000 },
|
||||
{
|
||||
name: "screenshots",
|
||||
type: "file",
|
||||
maxSelect: 5,
|
||||
maxSize: 5242880,
|
||||
mimeTypes: ["image/png", "image/jpeg", "image/webp", "image/gif"],
|
||||
},
|
||||
{
|
||||
name: "author",
|
||||
type: "relation",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
collectionId: users.id,
|
||||
cascadeDelete: true,
|
||||
},
|
||||
{ name: "upvotes", type: "number", onlyInt: true },
|
||||
{ name: "downvotes", type: "number", onlyInt: true },
|
||||
{ name: "version", type: "number", onlyInt: true },
|
||||
{ name: "changelog", type: "text", max: 2000 },
|
||||
{ name: "created", type: "autodate", onCreate: true },
|
||||
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||
],
|
||||
});
|
||||
app.save(submissions);
|
||||
}
|
||||
|
||||
let votes;
|
||||
try {
|
||||
votes = app.findCollectionByNameOrId("votes");
|
||||
} catch {
|
||||
votes = new Collection({
|
||||
type: "base",
|
||||
name: "votes",
|
||||
listRule: "user = @request.auth.id",
|
||||
viewRule: "user = @request.auth.id",
|
||||
createRule: "@request.auth.id != ''",
|
||||
updateRule: "user = @request.auth.id",
|
||||
deleteRule: "user = @request.auth.id",
|
||||
fields: [
|
||||
{
|
||||
name: "submission",
|
||||
type: "relation",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
collectionId: submissions.id,
|
||||
cascadeDelete: true,
|
||||
},
|
||||
{ name: "user", type: "relation", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
|
||||
{ name: "value", type: "number", required: true, onlyInt: true, min: -1, max: 1 },
|
||||
{ name: "created", type: "autodate", onCreate: true },
|
||||
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||
],
|
||||
});
|
||||
votes.addIndex("idx_votes_unique_user_submission", true, "submission, user", "");
|
||||
app.save(votes);
|
||||
}
|
||||
|
||||
let reports;
|
||||
try {
|
||||
reports = app.findCollectionByNameOrId("reports");
|
||||
} catch {
|
||||
reports = new Collection({
|
||||
type: "base",
|
||||
name: "reports",
|
||||
listRule: null,
|
||||
viewRule: null,
|
||||
createRule: "@request.auth.id != ''",
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{
|
||||
name: "submission",
|
||||
type: "relation",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
collectionId: submissions.id,
|
||||
cascadeDelete: true,
|
||||
},
|
||||
{ name: "user", type: "relation", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
|
||||
{ name: "reason", type: "text", required: true, min: 3, max: 1000 },
|
||||
{ name: "created", type: "autodate", onCreate: true },
|
||||
],
|
||||
});
|
||||
app.save(reports);
|
||||
}
|
||||
|
||||
let comments;
|
||||
try {
|
||||
comments = app.findCollectionByNameOrId("comments");
|
||||
} catch {
|
||||
comments = new Collection({
|
||||
type: "base",
|
||||
name: "comments",
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
createRule: "@request.auth.id != ''",
|
||||
updateRule: "author = @request.auth.id",
|
||||
deleteRule: "author = @request.auth.id",
|
||||
fields: [
|
||||
{
|
||||
name: "submission",
|
||||
type: "relation",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
collectionId: submissions.id,
|
||||
cascadeDelete: true,
|
||||
},
|
||||
{
|
||||
name: "author",
|
||||
type: "relation",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
collectionId: users.id,
|
||||
cascadeDelete: true,
|
||||
},
|
||||
{ name: "content", type: "text", required: true, min: 1, max: 2000 },
|
||||
{ name: "created", type: "autodate", onCreate: true },
|
||||
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
|
||||
],
|
||||
});
|
||||
comments.addIndex("idx_comments_submission", false, "submission", "");
|
||||
app.save(comments);
|
||||
}
|
||||
},
|
||||
(app) => {
|
||||
for (const name of ["comments", "reports", "votes", "submissions"]) {
|
||||
try {
|
||||
app.delete(app.findCollectionByNameOrId(name));
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,49 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
migrate(
|
||||
(app) => {
|
||||
// Keep databases created by the original prototype valid after the
|
||||
// migration file was renamed with the feature.
|
||||
try {
|
||||
app.findCollectionByNameOrId("marketplace");
|
||||
return;
|
||||
} catch {
|
||||
// Fresh database; create the public view below.
|
||||
}
|
||||
|
||||
const collection = new Collection({
|
||||
name: "marketplace",
|
||||
type: "view",
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
viewQuery: `
|
||||
SELECT
|
||||
s.id,
|
||||
s.type,
|
||||
s.title,
|
||||
s.description,
|
||||
s.schemaVersion,
|
||||
s.content,
|
||||
s.screenshots,
|
||||
s.version,
|
||||
s.changelog,
|
||||
s.author,
|
||||
s.created,
|
||||
s.updated,
|
||||
COALESCE(NULLIF(u.name, ''), 'unknown') as authorName,
|
||||
COALESCE(SUM(CASE WHEN v.value = 1 THEN 1 ELSE 0 END), 0) as upvotes,
|
||||
COALESCE(SUM(CASE WHEN v.value = -1 THEN 1 ELSE 0 END), 0) as downvotes,
|
||||
(SELECT COUNT(*) FROM comments c WHERE c.submission = s.id) as commentCount
|
||||
FROM submissions s
|
||||
LEFT JOIN users u ON u.id = s.author
|
||||
LEFT JOIN votes v ON v.submission = s.id
|
||||
GROUP BY s.id
|
||||
`,
|
||||
});
|
||||
app.save(collection);
|
||||
},
|
||||
(app) => {
|
||||
const collection = app.findCollectionByNameOrId("marketplace");
|
||||
app.delete(collection);
|
||||
},
|
||||
);
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
image="${1:-homarr/workshop:ci}"
|
||||
container="homarr-workshop-smoke-${$}"
|
||||
port="${WORKSHOP_SMOKE_PORT:-18090}"
|
||||
|
||||
cleanup() {
|
||||
docker rm --force "$container" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
docker run --detach --name "$container" --publish "127.0.0.1:${port}:8090" "$image" >/dev/null
|
||||
|
||||
attempt=0
|
||||
until curl --fail --silent "http://127.0.0.1:${port}/api/health" >/dev/null; do
|
||||
attempt=$((attempt + 1))
|
||||
if [ "$attempt" -ge 30 ]; then
|
||||
docker logs "$container"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
curl --fail --silent "http://127.0.0.1:${port}/" | grep --quiet "Homarr"
|
||||
curl --fail --silent "http://127.0.0.1:${port}/workshop/" | grep --quiet "Workshop"
|
||||
curl --fail --silent "http://127.0.0.1:${port}/api/collections/marketplace/records?page=1&perPage=1" >/dev/null
|
||||
curl --fail --silent "http://127.0.0.1:${port}/api/health" | grep --quiet '"code":200'
|
||||
|
||||
echo "Workshop image serves Docusaurus and PocketBase successfully"
|
||||
+7
-4
@@ -17,6 +17,9 @@
|
||||
"db:studio": "pnpm -F db studio",
|
||||
"dev": "pnpm -F nextjs dev",
|
||||
"dev:docs": "pnpm -F @homarr/docs dev",
|
||||
"dev:workshop": "docker compose -f ./apps/workshop/docker-compose.yml up --build",
|
||||
"test:workshop-image": "bash ./apps/workshop/scripts/smoke-test.sh",
|
||||
"ci:act:workshop": "bash ./scripts/ci/workshop-act.sh",
|
||||
"docker:dev": "docker compose -f ./development/development.docker-compose.yml up",
|
||||
"format": "turbo format --continue -- --check",
|
||||
"format:fix": "turbo format --continue",
|
||||
@@ -33,6 +36,9 @@
|
||||
"typecheck": "turbo typecheck",
|
||||
"with-env": "dotenv -e .env --"
|
||||
},
|
||||
"dependencies": {
|
||||
"jsonpath-plus": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "catalog:",
|
||||
"@semantic-release/changelog": "catalog:",
|
||||
@@ -64,8 +70,5 @@
|
||||
"node": ">=24.18.0",
|
||||
"pnpm": ">=11.10.0"
|
||||
},
|
||||
"packageManager": "pnpm@11.10.0",
|
||||
"dependencies": {
|
||||
"jsonpath-plus": "catalog:"
|
||||
}
|
||||
"packageManager": "pnpm@11.10.0"
|
||||
}
|
||||
@@ -99,7 +99,6 @@ export const integrationRouter = createTRPCRouter({
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
creatorId: integration.creatorId,
|
||||
permissions: {
|
||||
hasUseAccess:
|
||||
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
|
||||
@@ -345,67 +344,24 @@ export const integrationRouter = createTRPCRouter({
|
||||
const appId = await createAppIfNecessaryAsync(ctx.db, input.app);
|
||||
|
||||
const integrationId = createId();
|
||||
const userId = ctx.session.user.id;
|
||||
const secretValues = input.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
value: encryptSecret(secret.value),
|
||||
integrationId,
|
||||
}));
|
||||
|
||||
// Create the integration, its creator permission and secrets atomically so
|
||||
// a failure part-way through can't leave a committed integration the
|
||||
// creator can neither use nor manage.
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
handleAsync: async (db, schema) => {
|
||||
await db.transaction(async (transaction) => {
|
||||
await transaction.insert(schema.integrations).values({
|
||||
id: integrationId,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
kind: input.kind,
|
||||
appId,
|
||||
creatorId: userId,
|
||||
});
|
||||
// Auto-grant the creator "full" permission so they can immediately
|
||||
// use and manage the integration they just created.
|
||||
await transaction.insert(schema.integrationUserPermissions).values({
|
||||
integrationId,
|
||||
userId,
|
||||
permission: "full",
|
||||
});
|
||||
if (secretValues.length >= 1) {
|
||||
await transaction.insert(schema.integrationSecrets).values(secretValues);
|
||||
}
|
||||
});
|
||||
},
|
||||
handleSync: (db) => {
|
||||
db.transaction((transaction) => {
|
||||
transaction
|
||||
.insert(integrations)
|
||||
.values({
|
||||
id: integrationId,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
kind: input.kind,
|
||||
appId,
|
||||
creatorId: userId,
|
||||
})
|
||||
.run();
|
||||
transaction
|
||||
.insert(integrationUserPermissions)
|
||||
.values({
|
||||
integrationId,
|
||||
userId,
|
||||
permission: "full",
|
||||
})
|
||||
.run();
|
||||
if (secretValues.length >= 1) {
|
||||
transaction.insert(integrationSecrets).values(secretValues).run();
|
||||
}
|
||||
});
|
||||
},
|
||||
await ctx.db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
kind: input.kind,
|
||||
appId,
|
||||
});
|
||||
|
||||
if (input.secrets.length >= 1) {
|
||||
await ctx.db.insert(integrationSecrets).values(
|
||||
input.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
value: encryptSecret(secret.value),
|
||||
integrationId,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("Created integration", {
|
||||
id: integrationId,
|
||||
name: input.name,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { describe, expect, test, vi } from "vitest";
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { encryptSecret } from "@homarr/common/server";
|
||||
import { and, eq } from "@homarr/db";
|
||||
import { apps, integrations, integrationSecrets, integrationUserPermissions, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
@@ -302,10 +301,6 @@ describe("create should create a new integration", () => {
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-create"]),
|
||||
});
|
||||
|
||||
// The creator must exist in the DB for the creatorId foreign key
|
||||
await db.insert(users).values({ id: defaultUserId });
|
||||
|
||||
const input = {
|
||||
name: "Jellyfin",
|
||||
kind: "jellyfin" as const,
|
||||
@@ -322,23 +317,10 @@ describe("create should create a new integration", () => {
|
||||
|
||||
const dbIntegration = await db.query.integrations.findFirst();
|
||||
const dbSecret = await db.query.integrationSecrets.findFirst();
|
||||
const dbPermission = await db.query.integrationUserPermissions.findFirst({
|
||||
where: and(
|
||||
eq(integrationUserPermissions.integrationId, dbIntegration!.id),
|
||||
eq(integrationUserPermissions.userId, defaultUserId),
|
||||
),
|
||||
});
|
||||
expect(dbIntegration).toBeDefined();
|
||||
expect(dbIntegration!.name).toBe(input.name);
|
||||
expect(dbIntegration!.kind).toBe(input.kind);
|
||||
expect(dbIntegration!.url).toBe(input.url);
|
||||
expect(dbIntegration!.creatorId).toBe(defaultUserId);
|
||||
|
||||
// Creator should be auto-granted "full" permission
|
||||
expect(dbPermission).toBeDefined();
|
||||
expect(dbPermission!.integrationId).toBe(dbIntegration!.id);
|
||||
expect(dbPermission!.userId).toBe(defaultUserId);
|
||||
expect(dbPermission!.permission).toBe("full");
|
||||
|
||||
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
|
||||
expect(dbSecret).toBeDefined();
|
||||
@@ -354,10 +336,6 @@ describe("create should create a new integration", () => {
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-create"]),
|
||||
});
|
||||
|
||||
// The creator must exist in the DB for the creatorId foreign key
|
||||
await db.insert(users).values({ id: defaultUserId });
|
||||
|
||||
const input = {
|
||||
name: "Jellyseerr",
|
||||
kind: "jellyseerr" as const,
|
||||
@@ -379,7 +357,6 @@ describe("create should create a new integration", () => {
|
||||
expect(dbIntegration!.name).toBe(input.name);
|
||||
expect(dbIntegration!.kind).toBe(input.kind);
|
||||
expect(dbIntegration!.url).toBe(input.url);
|
||||
expect(dbIntegration!.creatorId).toBe(defaultUserId);
|
||||
|
||||
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
|
||||
expect(dbSecret).toBeDefined();
|
||||
@@ -397,10 +374,6 @@ describe("create should create a new integration", () => {
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-create", "app-create"]),
|
||||
});
|
||||
|
||||
// The creator must exist in the DB for the creatorId foreign key
|
||||
await db.insert(users).values({ id: defaultUserId });
|
||||
|
||||
const input = {
|
||||
name: "Jellyfin",
|
||||
kind: "jellyfin" as const,
|
||||
@@ -432,7 +405,6 @@ describe("create should create a new integration", () => {
|
||||
expect(dbIntegration!.name).toBe(input.name);
|
||||
expect(dbIntegration!.kind).toBe(input.kind);
|
||||
expect(dbIntegration!.url).toBe(input.url);
|
||||
expect(dbIntegration!.creatorId).toBe(defaultUserId);
|
||||
expect(dbIntegration!.app!.name).toBe(input.app.name);
|
||||
expect(dbIntegration!.app!.pingUrl).toBe(input.app.pingUrl);
|
||||
expect(dbIntegration!.app!.href).toBe(input.app.href);
|
||||
@@ -459,10 +431,6 @@ describe("create should create a new integration", () => {
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-create"]),
|
||||
});
|
||||
|
||||
// The creator must exist in the DB for the creatorId foreign key
|
||||
await db.insert(users).values({ id: defaultUserId });
|
||||
|
||||
const input = {
|
||||
name: "Jellyfin",
|
||||
kind: "jellyfin" as const,
|
||||
@@ -487,7 +455,6 @@ describe("create should create a new integration", () => {
|
||||
expect(dbIntegration!.kind).toBe(input.kind);
|
||||
expect(dbIntegration!.url).toBe(input.url);
|
||||
expect(dbIntegration!.appId).toBe(appId);
|
||||
expect(dbIntegration!.creatorId).toBe(defaultUserId);
|
||||
|
||||
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
|
||||
expect(dbSecret).toBeDefined();
|
||||
|
||||
@@ -43,6 +43,5 @@ export const widgetRouter = createTRPCRouter({
|
||||
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
|
||||
traefik: lazy(() => import("./traefik").then((mod) => mod.traefikRouter)),
|
||||
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
|
||||
openWebUi: lazy(() => import("./open-webui").then((mod) => mod.openWebUiRouter)),
|
||||
secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)),
|
||||
});
|
||||
@@ -1,379 +0,0 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import {
|
||||
openWebUiChatRequestHandler,
|
||||
openWebUiChatsRequestHandler,
|
||||
openWebUiFilesRequestHandler,
|
||||
openWebUiKnowledgeFilesRequestHandler,
|
||||
openWebUiKnowledgeRequestHandler,
|
||||
openWebUiModelsRequestHandler,
|
||||
openWebUiNoteRequestHandler,
|
||||
openWebUiNotesRequestHandler,
|
||||
} from "@homarr/request-handler/open-webui";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
const logger = createLogger({ module: "openWebUiRouter" });
|
||||
|
||||
/**
|
||||
* Open WebUI integrations hold a personal API key that grants access to a
|
||||
* single user's data (chat history, files, notes, knowledge bases). To prevent
|
||||
* other Homarr users from reading that data, every procedure that touches the
|
||||
* integration verifies that the caller is the integration's creator.
|
||||
*
|
||||
* The check is intentionally placed **after** the integration middleware (which
|
||||
* already validates the caller's permission level) so the error message does
|
||||
* not leak whether the integration exists.
|
||||
*/
|
||||
const throwIfNotCreator = (integration: { creatorId?: string | null }, userId: string | undefined) => {
|
||||
// Existing integrations that predate the creatorId column have a null
|
||||
// creatorId; we skip the check for backward compatibility.
|
||||
if (integration.creatorId && integration.creatorId !== userId) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Only the integration creator can access this data" });
|
||||
}
|
||||
};
|
||||
|
||||
// Hosts that should never be fetched server-side. processWeb is already
|
||||
// creator-gated, but we still refuse loopback and the cloud metadata endpoint so
|
||||
// a stray URL can't turn Open WebUI's fetcher into an SSRF vector. Private LAN
|
||||
// ranges stay allowed on purpose (self-hosters routinely ingest internal wikis).
|
||||
const isBlockedWebHost = (hostname: string): boolean => {
|
||||
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
||||
return (
|
||||
host === "localhost" ||
|
||||
host.endsWith(".localhost") ||
|
||||
host === "0.0.0.0" ||
|
||||
host === "::" ||
|
||||
host === "::1" ||
|
||||
host.startsWith("127.") ||
|
||||
host === "169.254.169.254" || // AWS/GCP/Azure instance metadata
|
||||
host === "metadata.google.internal"
|
||||
);
|
||||
};
|
||||
|
||||
// A user-provided URL for web-page ingestion: must be http(s) and not target a
|
||||
// loopback/metadata host.
|
||||
const webUrlSchema = z
|
||||
.string()
|
||||
.url()
|
||||
.refine((value) => {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return (parsed.protocol === "http:" || parsed.protocol === "https:") && !isBlockedWebHost(parsed.hostname);
|
||||
}, "URL must be an http(s) address and must not point at a loopback or metadata host");
|
||||
|
||||
// Roughly 18 MB of raw bytes once base64-decoded (base64 inflates by ~4/3).
|
||||
// Bounds the upload/transcription payloads so a huge request can't pressure
|
||||
// memory here or upstream before we even start decoding.
|
||||
const MAX_BASE64_LENGTH = 25_000_000;
|
||||
const boundedBase64Schema = z.string().min(1).max(MAX_BASE64_LENGTH);
|
||||
const uploadFilenameSchema = z.string().min(1).max(255);
|
||||
const contentTypeSchema = z.string().min(1).max(255);
|
||||
|
||||
// Tag persisted conversations with the owning Homarr user so they can be found
|
||||
// back in Open WebUI even when an integration/account is shared between users.
|
||||
const homarrUserTag = (userId: string) => `homarr-user:${userId}`;
|
||||
|
||||
const messageSchema = z.object({
|
||||
role: z.enum(["system", "user", "assistant"]),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
// Outbound completion messages may carry multimodal parts (text + images),
|
||||
// so users can attach images to a message.
|
||||
const contentPartSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("text"), text: z.string() }),
|
||||
z.object({ type: z.literal("image_url"), image_url: z.object({ url: z.string() }) }),
|
||||
]);
|
||||
|
||||
const completionMessageSchema = z.object({
|
||||
role: z.enum(["system", "user", "assistant"]),
|
||||
content: z.union([z.string(), z.array(contentPartSchema)]),
|
||||
});
|
||||
|
||||
const chatPayloadSchema = z.object({
|
||||
title: z.string(),
|
||||
models: z.array(z.string()),
|
||||
messages: z.array(messageSchema),
|
||||
});
|
||||
|
||||
type StreamEvent = { type: "delta"; content: string } | { type: "done" } | { type: "error"; message: string };
|
||||
|
||||
type CompletionMessage = z.infer<typeof completionMessageSchema>;
|
||||
|
||||
// Extract the plain text of a message, flattening multimodal content parts.
|
||||
const messageText = (message: CompletionMessage): string =>
|
||||
typeof message.content === "string"
|
||||
? message.content
|
||||
: message.content
|
||||
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join(" ");
|
||||
|
||||
// Number of chunks to retrieve per collection when grounding a reply.
|
||||
const RETRIEVAL_K = 4;
|
||||
|
||||
// Instruction prepended to the retrieved context so the model knows how to use
|
||||
// it. Model-facing, so intentionally not localized.
|
||||
const RETRIEVAL_INSTRUCTION =
|
||||
"Use the following context from the user's attached sources (web pages, knowledge bases, " +
|
||||
"files, notes and chats) to answer. If the answer is not in the context, say so.";
|
||||
|
||||
// Retrieve relevant chunks from the attached collections and prepend them as a
|
||||
// system context message so the model can ground its reply on them.
|
||||
const groundMessagesAsync = async (
|
||||
client: { queryCollectionsAsync: (names: string[], query: string, k?: number) => Promise<string[]> },
|
||||
messages: CompletionMessage[],
|
||||
collections: string[],
|
||||
contextTexts: string[],
|
||||
): Promise<CompletionMessage[]> => {
|
||||
if (collections.length === 0 && contextTexts.length === 0) return messages;
|
||||
|
||||
// Use the most recent user message that has text. The latest turn can be
|
||||
// image-only (no text), in which case we fall back to the prior question so
|
||||
// attached collections still get a meaningful retrieval query.
|
||||
const query =
|
||||
messages
|
||||
.filter((message) => message.role === "user")
|
||||
.map(messageText)
|
||||
.findLast((text) => text.trim() !== "") ?? "";
|
||||
|
||||
let documents: string[] = [];
|
||||
if (collections.length > 0) {
|
||||
try {
|
||||
documents = await client.queryCollectionsAsync(collections, query, RETRIEVAL_K);
|
||||
} catch (error) {
|
||||
logger.warn("Open WebUI context retrieval failed; continuing without it", { error });
|
||||
}
|
||||
}
|
||||
|
||||
const blocks = [...documents, ...contextTexts].filter((block) => block.trim() !== "");
|
||||
if (blocks.length === 0) return messages;
|
||||
|
||||
const contextMessage: CompletionMessage = {
|
||||
role: "system",
|
||||
content: `${RETRIEVAL_INSTRUCTION}\n\n${blocks.join("\n\n")}`,
|
||||
};
|
||||
return [contextMessage, ...messages];
|
||||
};
|
||||
|
||||
export const openWebUiRouter = createTRPCRouter({
|
||||
getModels: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
|
||||
const handler = openWebUiModelsRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await handler.getDataAsync();
|
||||
return data;
|
||||
}),
|
||||
|
||||
// Chat history exposes private user data, so we require "interact" access
|
||||
// (not just "query") and verify the caller is the integration's creator.
|
||||
getChats: protectedProcedure
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.query(async ({ ctx }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const handler = openWebUiChatsRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await handler.getDataAsync();
|
||||
return data;
|
||||
}),
|
||||
|
||||
// Knowledge bases, files and notes expose private source content/metadata, so
|
||||
// gate them like the interactive chat paths (protected + "interact" access,
|
||||
// creator-only) instead of leaving them reachable by anonymous board viewers.
|
||||
getKnowledge: protectedProcedure
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.query(async ({ ctx }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const handler = openWebUiKnowledgeRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await handler.getDataAsync();
|
||||
return data;
|
||||
}),
|
||||
|
||||
getFiles: protectedProcedure
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.query(async ({ ctx }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const handler = openWebUiFilesRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await handler.getDataAsync();
|
||||
return data;
|
||||
}),
|
||||
|
||||
getNotes: protectedProcedure
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.query(async ({ ctx }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const handler = openWebUiNotesRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await handler.getDataAsync();
|
||||
return data;
|
||||
}),
|
||||
|
||||
getNote: protectedProcedure
|
||||
.input(z.object({ noteId: z.string() }))
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const handler = openWebUiNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
|
||||
const { data } = await handler.getDataAsync();
|
||||
return data;
|
||||
}),
|
||||
|
||||
getKnowledgeFiles: protectedProcedure
|
||||
.input(z.object({ knowledgeId: z.string() }))
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const handler = openWebUiKnowledgeFilesRequestHandler.handler(ctx.integration, {
|
||||
knowledgeId: input.knowledgeId,
|
||||
});
|
||||
const { data } = await handler.getDataAsync();
|
||||
return data;
|
||||
}),
|
||||
|
||||
// Ingest a web page so its content can ground later messages. Returns the
|
||||
// collection to retrieve from plus a display title.
|
||||
processWeb: protectedProcedure
|
||||
.input(z.object({ url: webUrlSchema }))
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const client = await createIntegrationAsync(ctx.integration);
|
||||
return await client.processWebAsync(input.url);
|
||||
}),
|
||||
|
||||
// Upload a file (e.g. a document) so it can be retrieved from later. Returns
|
||||
// the file id; its vector collection is `file-{id}`.
|
||||
uploadFile: protectedProcedure
|
||||
.input(
|
||||
z.object({ filename: uploadFilenameSchema, contentBase64: boundedBase64Schema, contentType: contentTypeSchema }),
|
||||
)
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const client = await createIntegrationAsync(ctx.integration);
|
||||
return await client.uploadFileAsync(input.filename, input.contentBase64, input.contentType);
|
||||
}),
|
||||
|
||||
// Transcribe recorded audio to text for voice messages.
|
||||
transcribe: protectedProcedure
|
||||
.input(
|
||||
z.object({ filename: uploadFilenameSchema, contentBase64: boundedBase64Schema, contentType: contentTypeSchema }),
|
||||
)
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const client = await createIntegrationAsync(ctx.integration);
|
||||
const text = await client.transcribeAudioAsync(input.filename, input.contentBase64, input.contentType);
|
||||
return { text };
|
||||
}),
|
||||
|
||||
// Full chat messages contain private user data; restrict to the creator.
|
||||
getChat: protectedProcedure
|
||||
.input(z.object({ chatId: z.string() }))
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const handler = openWebUiChatRequestHandler.handler(ctx.integration, { chatId: input.chatId });
|
||||
const { data } = await handler.getDataAsync();
|
||||
return data;
|
||||
}),
|
||||
|
||||
createChat: protectedProcedure
|
||||
.input(chatPayloadSchema)
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const client = await createIntegrationAsync(ctx.integration);
|
||||
const chat = await client.createChatAsync({
|
||||
title: input.title,
|
||||
models: input.models,
|
||||
messages: input.messages,
|
||||
meta: { tags: [homarrUserTag(ctx.session.user.id)] },
|
||||
});
|
||||
openWebUiChatsRequestHandler.invalidateCache();
|
||||
return chat;
|
||||
}),
|
||||
|
||||
updateChat: protectedProcedure
|
||||
.input(z.object({ chatId: z.string(), chat: chatPayloadSchema }))
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const client = await createIntegrationAsync(ctx.integration);
|
||||
const chat = await client.updateChatAsync(input.chatId, {
|
||||
...input.chat,
|
||||
meta: { tags: [homarrUserTag(ctx.session.user.id)] },
|
||||
});
|
||||
openWebUiChatsRequestHandler.invalidateCache();
|
||||
openWebUiChatRequestHandler.invalidateCache();
|
||||
return chat;
|
||||
}),
|
||||
|
||||
// Streaming chat completion. Delivered over the WebSocket subscription link so
|
||||
// tokens can be emitted as they arrive. A completion spends the owner's upstream
|
||||
// LLM credits, so it must not be reachable by anonymous viewers of a public
|
||||
// board: protectedProcedure requires a session (the WS context resolves it from
|
||||
// the session-token cookie) and the "interact" middleware checks integration
|
||||
// access, matching processWeb/uploadFile/transcribe above.
|
||||
sendMessage: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
model: z.string(),
|
||||
messages: z.array(completionMessageSchema),
|
||||
// Collections to ground the reply on (web pages, knowledge bases, files).
|
||||
// We retrieve relevant chunks and inject them before streaming.
|
||||
collections: z.array(z.string()).optional(),
|
||||
// Verbatim context blocks (notes, referenced chats) injected as-is.
|
||||
contextTexts: z.array(z.string()).optional(),
|
||||
}),
|
||||
)
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.subscription(({ ctx, input }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
|
||||
return observable<StreamEvent>((emit) => {
|
||||
const controller = new AbortController();
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const client = await createIntegrationAsync(ctx.integration);
|
||||
const messages = await groundMessagesAsync(
|
||||
client,
|
||||
input.messages,
|
||||
input.collections ?? [],
|
||||
input.contextTexts ?? [],
|
||||
);
|
||||
await client.streamChatCompletionAsync(
|
||||
{ model: input.model, messages },
|
||||
(delta) => emit.next({ type: "delta", content: delta }),
|
||||
controller.signal,
|
||||
);
|
||||
emit.next({ type: "done" });
|
||||
emit.complete();
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
logger.error("Open WebUI chat completion failed", {
|
||||
integrationId: ctx.integration.id,
|
||||
error,
|
||||
});
|
||||
emit.next({
|
||||
type: "error",
|
||||
message: error instanceof Error ? error.message : "Failed to stream chat completion",
|
||||
});
|
||||
}
|
||||
emit.complete();
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -11,7 +11,6 @@ interface IntegrationContextProps {
|
||||
name: string;
|
||||
url: string;
|
||||
kind: IntegrationKind;
|
||||
creatorId: string | null;
|
||||
permissions: {
|
||||
hasFullAccess: boolean;
|
||||
hasInteractAccess: boolean;
|
||||
|
||||
@@ -15,7 +15,6 @@ export const getIntegrationsWithPermissionsAsync = async (session: Session | nul
|
||||
name: true,
|
||||
url: true,
|
||||
kind: true,
|
||||
creatorId: true,
|
||||
},
|
||||
with: {
|
||||
userPermissions: {
|
||||
|
||||
@@ -21,11 +21,17 @@ export const randomInt = (min: number, max: number) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Number of bytes to si format. (Division by 1024)
|
||||
* Does not accept floats, size in bytes should be an integer.
|
||||
* Will return "NaI" and logs a warning if a float is passed.
|
||||
* Concat as parameters so it is not added if the returned value is "NaI" or "∞".
|
||||
* Returns "∞" if the size is too large to be represented in the current format.
|
||||
* Number of bytes to a human-readable string using binary (KiB) suffixes.
|
||||
* Does not accept floats; size in bytes should be an integer.
|
||||
* Will return "NaI" and log a warning if a float is passed.
|
||||
* `concat` is appended after the unit so it is omitted when the returned
|
||||
* value is "NaI" or "∞". Returns "∞" if the size is too large to be
|
||||
* represented in the current format.
|
||||
*
|
||||
* @deprecated Use `formatBytes` for single values, `formatBytesPair` for
|
||||
* paired "used / total" displays, and `formatByteRate` for byte-per-second
|
||||
* rates. This function is kept for backwards compatibility but should not
|
||||
* be used in new code.
|
||||
*/
|
||||
export const humanFileSize = (size: number, concat = ""): string => {
|
||||
//64bit limit for Number stops at EiB
|
||||
@@ -48,6 +54,99 @@ export const humanFileSize = (size: number, concat = ""): string => {
|
||||
return "∞";
|
||||
};
|
||||
|
||||
const BINARY_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"] as const;
|
||||
const DECIMAL_UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB"] as const;
|
||||
|
||||
export type ByteUnitSystem = "binary" | "decimal";
|
||||
|
||||
export interface FormatBytesOptions {
|
||||
/**
|
||||
* Unit system to use. "binary" uses 1024 as the base and KiB/MiB/GiB/TiB suffixes,
|
||||
* "decimal" uses 1000 as the base and KB/MB/GB/TB suffixes. Defaults to "binary".
|
||||
*/
|
||||
unit?: ByteUnitSystem;
|
||||
}
|
||||
|
||||
const pickUnitIndex = (bytes: number, base: number, lastIndex: number): number => {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return 0;
|
||||
let index = 0;
|
||||
let value = bytes;
|
||||
while (value >= base && index < lastIndex) {
|
||||
value /= base;
|
||||
index++;
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
const sanitizeBytes = (bytes: number): number => (Number.isFinite(bytes) && bytes > 0 ? bytes : 0);
|
||||
|
||||
const resolveUnitConfig = (options: FormatBytesOptions) => {
|
||||
const { unit = "binary" } = options;
|
||||
return {
|
||||
units: unit === "binary" ? BINARY_UNITS : DECIMAL_UNITS,
|
||||
base: unit === "binary" ? 1024 : 1000,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a byte value as a human-readable string with a unit suffix.
|
||||
*
|
||||
* Picks the largest unit that keeps the scaled value below the base. Use
|
||||
* `formatBytesPair` when two related values (e.g. used and available) should
|
||||
* share a common unit.
|
||||
*
|
||||
* @example
|
||||
* formatBytes(0); // "0.0 B"
|
||||
* formatBytes(1024); // "1.0 KiB"
|
||||
* formatBytes(985828802560); // "918.1 GiB"
|
||||
* formatBytes(985828802560, { unit: "decimal" }); // "985.8 GB"
|
||||
*/
|
||||
export const formatBytes = (bytes: number, options: FormatBytesOptions = {}): string => {
|
||||
const { units, base } = resolveUnitConfig(options);
|
||||
const safe = sanitizeBytes(bytes);
|
||||
const index = pickUnitIndex(safe, base, units.length - 1);
|
||||
const scaled = safe / base ** index;
|
||||
return `${scaled.toFixed(1)} ${units[index]}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format two related byte values (typically `used` and `available`) with a
|
||||
* shared unit. The unit is picked from the sum of the two values so the
|
||||
* combined display stays consistent.
|
||||
*
|
||||
* @example
|
||||
* formatBytesPair(985828802560, 2858736793190);
|
||||
* // { used: "0.9 TiB", available: "2.6 TiB" }
|
||||
*/
|
||||
export const formatBytesPair = (
|
||||
used: number,
|
||||
available: number,
|
||||
options: FormatBytesOptions = {},
|
||||
): { used: string; available: string } => {
|
||||
const { units, base } = resolveUnitConfig(options);
|
||||
const safeUsed = sanitizeBytes(used);
|
||||
const safeAvailable = sanitizeBytes(available);
|
||||
const index = pickUnitIndex(safeUsed + safeAvailable, base, units.length - 1);
|
||||
const suffix = units[index];
|
||||
return {
|
||||
used: `${(safeUsed / base ** index).toFixed(1)} ${suffix}`,
|
||||
available: `${(safeAvailable / base ** index).toFixed(1)} ${suffix}`,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a byte-per-second rate (e.g. network throughput) as a human-readable
|
||||
* string with a `/s` suffix. The value is formatted with the same rules as
|
||||
* `formatBytes` and a trailing `/s` is appended.
|
||||
*
|
||||
* @example
|
||||
* formatByteRate(0); // "0.0 B/s"
|
||||
* formatByteRate(1024); // "1.0 KiB/s"
|
||||
* formatByteRate(985828802560, { unit: "decimal" }); // "985.8 GB/s"
|
||||
*/
|
||||
export const formatByteRate = (bytes: number, options: FormatBytesOptions = {}): string =>
|
||||
`${formatBytes(bytes, options)}/s`;
|
||||
|
||||
const IMPERIAL_MULTIPLIER = 1.609344;
|
||||
|
||||
export const metricToImperial = (metricValue: number) => metricValue / IMPERIAL_MULTIPLIER;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { formatByteRate, formatBytes, formatBytesPair } from "../number";
|
||||
|
||||
describe("formatBytes", () => {
|
||||
it("returns bytes for small values", () => {
|
||||
expect(formatBytes(0)).toBe("0.0 B");
|
||||
expect(formatBytes(1)).toBe("1.0 B");
|
||||
expect(formatBytes(1023)).toBe("1023.0 B");
|
||||
});
|
||||
|
||||
it("scales binary units correctly", () => {
|
||||
expect(formatBytes(1024)).toBe("1.0 KiB");
|
||||
expect(formatBytes(1024 ** 2)).toBe("1.0 MiB");
|
||||
expect(formatBytes(1024 ** 3)).toBe("1.0 GiB");
|
||||
expect(formatBytes(1024 ** 4)).toBe("1.0 TiB");
|
||||
});
|
||||
|
||||
it("scales decimal units when requested", () => {
|
||||
expect(formatBytes(1000, { unit: "decimal" })).toBe("1.0 KB");
|
||||
expect(formatBytes(1000 ** 2, { unit: "decimal" })).toBe("1.0 MB");
|
||||
expect(formatBytes(1000 ** 3, { unit: "decimal" })).toBe("1.0 GB");
|
||||
expect(formatBytes(1000 ** 4, { unit: "decimal" })).toBe("1.0 TB");
|
||||
});
|
||||
|
||||
it("formats a typical storage value with one decimal", () => {
|
||||
expect(formatBytes(985828802560)).toBe("918.1 GiB");
|
||||
expect(formatBytes(985828802560, { unit: "decimal" })).toBe("985.8 GB");
|
||||
});
|
||||
|
||||
it("caps at the largest unit to avoid overflow", () => {
|
||||
expect(formatBytes(Number.MAX_SAFE_INTEGER)).toMatch(/PiB$/);
|
||||
});
|
||||
|
||||
it("returns the zero value for negative or non-finite input", () => {
|
||||
expect(formatBytes(-1)).toBe("0.0 B");
|
||||
expect(formatBytes(Number.NaN)).toBe("0.0 B");
|
||||
expect(formatBytes(Number.POSITIVE_INFINITY)).toBe("0.0 B");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatBytesPair", () => {
|
||||
it("picks the unit of the larger total and applies it to both values", () => {
|
||||
const { used, available } = formatBytesPair(985828802560, 2858736793190);
|
||||
expect(used).toBe("0.9 TiB");
|
||||
expect(available).toBe("2.6 TiB");
|
||||
});
|
||||
|
||||
it("uses a smaller unit when both values fit in it", () => {
|
||||
const { used, available } = formatBytesPair(1024 * 1024 * 100, 1024 * 1024 * 200);
|
||||
expect(used).toBe("100.0 MiB");
|
||||
expect(available).toBe("200.0 MiB");
|
||||
});
|
||||
|
||||
it("falls back to zero when both inputs are invalid", () => {
|
||||
const { used, available } = formatBytesPair(Number.NaN, -1);
|
||||
expect(used).toBe("0.0 B");
|
||||
expect(available).toBe("0.0 B");
|
||||
});
|
||||
|
||||
it("uses the same unit across both values when one is invalid", () => {
|
||||
const { used, available } = formatBytesPair(Number.NaN, 1024 ** 4);
|
||||
expect(used).toBe("0.0 TiB");
|
||||
expect(available).toBe("1.0 TiB");
|
||||
});
|
||||
|
||||
it("honours the decimal unit option", () => {
|
||||
const { used, available } = formatBytesPair(500_000_000_000, 1_500_000_000_000, { unit: "decimal" });
|
||||
expect(used).toBe("0.5 TB");
|
||||
expect(available).toBe("1.5 TB");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatByteRate", () => {
|
||||
it("appends a /s suffix to the formatted value", () => {
|
||||
expect(formatByteRate(0)).toBe("0.0 B/s");
|
||||
expect(formatByteRate(1024)).toBe("1.0 KiB/s");
|
||||
expect(formatByteRate(1024 ** 3)).toBe("1.0 GiB/s");
|
||||
});
|
||||
|
||||
it("honours the decimal unit option", () => {
|
||||
expect(formatByteRate(1000, { unit: "decimal" })).toBe("1.0 KB/s");
|
||||
expect(formatByteRate(1000 ** 2, { unit: "decimal" })).toBe("1.0 MB/s");
|
||||
});
|
||||
|
||||
it("returns zero for invalid input", () => {
|
||||
expect(formatByteRate(-1)).toBe("0.0 B/s");
|
||||
expect(formatByteRate(Number.NaN)).toBe("0.0 B/s");
|
||||
});
|
||||
});
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE `integration` ADD `creator_id` varchar(64);--> statement-breakpoint
|
||||
ALTER TABLE `integration` ADD CONSTRAINT `integration_creator_id_user_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -302,13 +302,6 @@
|
||||
"when": 1783176100870,
|
||||
"tag": "0042_add_widget_secret",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 43,
|
||||
"version": "5",
|
||||
"when": 1783786572320,
|
||||
"tag": "0043_add_integration_creator_id",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE "integration" ADD COLUMN "creator_id" varchar(64);--> statement-breakpoint
|
||||
ALTER TABLE "integration" ADD CONSTRAINT "integration_creator_id_user_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -64,13 +64,6 @@
|
||||
"when": 1783176100870,
|
||||
"tag": "0008_add_widget_secret",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1783786378808,
|
||||
"tag": "0009_add_integration_creator_id",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE `integration` ADD `creator_id` text REFERENCES user(id) ON DELETE SET NULL;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -288,13 +288,6 @@
|
||||
"when": 1783176100870,
|
||||
"tag": "0040_add_widget_secret",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 41,
|
||||
"version": "6",
|
||||
"when": 1783786326756,
|
||||
"tag": "0041_add_integration_creator_id",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -209,7 +209,6 @@ export const integrations = mysqlTable(
|
||||
url: text().notNull(),
|
||||
kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(),
|
||||
appId: varchar({ length: 128 }).references(() => apps.id, { onDelete: "set null" }),
|
||||
creatorId: varchar({ length: 64 }).references(() => users.id, { onDelete: "set null" }),
|
||||
},
|
||||
(integrations) => ({
|
||||
kindIdx: index("integration__kind_idx").on(integrations.kind),
|
||||
@@ -689,10 +688,6 @@ export const integrationRelations = relations(integrations, ({ one, many }) => (
|
||||
fields: [integrations.appId],
|
||||
references: [apps.id],
|
||||
}),
|
||||
creator: one(users, {
|
||||
fields: [integrations.creatorId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
|
||||
|
||||
@@ -208,7 +208,6 @@ export const integrations = pgTable(
|
||||
url: text().notNull(),
|
||||
kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(),
|
||||
appId: varchar({ length: 128 }).references(() => apps.id, { onDelete: "set null" }),
|
||||
creatorId: varchar({ length: 64 }).references(() => users.id, { onDelete: "set null" }),
|
||||
},
|
||||
(integrations) => ({
|
||||
kindIdx: index("integration__kind_idx").on(integrations.kind),
|
||||
@@ -688,10 +687,6 @@ export const integrationRelations = relations(integrations, ({ one, many }) => (
|
||||
fields: [integrations.appId],
|
||||
references: [apps.id],
|
||||
}),
|
||||
creator: one(users, {
|
||||
fields: [integrations.creatorId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
|
||||
|
||||
@@ -195,7 +195,6 @@ export const integrations = sqliteTable(
|
||||
url: text().notNull(),
|
||||
kind: text().$type<IntegrationKind>().notNull(),
|
||||
appId: text().references(() => apps.id, { onDelete: "set null" }),
|
||||
creatorId: text().references(() => users.id, { onDelete: "set null" }),
|
||||
},
|
||||
(integrations) => ({
|
||||
kindIdx: index("integration__kind_idx").on(integrations.kind),
|
||||
@@ -678,10 +677,6 @@ export const integrationRelations = relations(integrations, ({ one, many }) => (
|
||||
fields: [integrations.appId],
|
||||
references: [apps.id],
|
||||
}),
|
||||
creator: one(users, {
|
||||
fields: [integrations.creatorId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
|
||||
|
||||
@@ -329,7 +329,6 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/integrations/peanut"
|
||||
| "/docs/integrations/beszel"
|
||||
| "/docs/integrations/traefik"
|
||||
| "/docs/integrations/open-webui"
|
||||
| "/docs/widgets/clock"
|
||||
| "/docs/widgets/weather"
|
||||
| "/docs/widgets/app"
|
||||
@@ -382,5 +381,4 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/widgets/beszel-alerts"
|
||||
| "/docs/widgets/beszel-system-stats"
|
||||
| "/docs/widgets/traefik"
|
||||
| "/docs/widgets/custom-api"
|
||||
| "/docs/widgets/open-webui";
|
||||
| "/docs/widgets/custom-api";
|
||||
@@ -54,6 +54,5 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
|
||||
beszel: "beszel",
|
||||
traefik: "traefik",
|
||||
archiveTeamWarrior: null,
|
||||
openWebUi: "open-webui",
|
||||
mock: null,
|
||||
};
|
||||
@@ -55,5 +55,4 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
||||
beszelSystemStats: "beszel-system-stats",
|
||||
traefik: "traefik",
|
||||
customApi: "custom-api",
|
||||
openWebUi: "open-webui",
|
||||
};
|
||||
@@ -3,5 +3,4 @@ export const hotkeys = {
|
||||
toggleColorScheme: "mod+j",
|
||||
saveNotebook: "mod+s",
|
||||
openSpotlight: "mod+k",
|
||||
toggleAiChat: "mod+shift+a",
|
||||
};
|
||||
@@ -379,7 +379,7 @@ export const integrationDefs = {
|
||||
category: ["healthMonitoring"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/patchmon"),
|
||||
defaultPort: 3413,
|
||||
apiKeySettingsPath: "/settings/api-keys",
|
||||
apiKeySettingsPath: "/settings/integrations",
|
||||
},
|
||||
tracearr: {
|
||||
name: "Tracearr",
|
||||
@@ -469,15 +469,6 @@ export const integrationDefs = {
|
||||
documentationUrl: null,
|
||||
defaultUrl: "http://localhost:8001",
|
||||
},
|
||||
openWebUi: {
|
||||
name: "Open WebUI",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/open-webui.svg",
|
||||
category: ["ai"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/open-webui"),
|
||||
defaultPort: 8080,
|
||||
apiKeySettingsPath: "/?settings=open#account",
|
||||
},
|
||||
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
|
||||
mock: {
|
||||
name: "Mock",
|
||||
@@ -592,7 +583,6 @@ export const integrationCategories = [
|
||||
"documents",
|
||||
"mediaLibrary",
|
||||
"uptimeMonitoring",
|
||||
"ai",
|
||||
"subtitleManager",
|
||||
"reverseProxy",
|
||||
] as const;
|
||||
|
||||
@@ -15,6 +15,12 @@ describe("getIntegrationApiKeyUrl", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("builds URL for patchmon", () => {
|
||||
expect(getIntegrationApiKeyUrl("http://patchmon.local:3413", "patchmon")).toBe(
|
||||
"http://patchmon.local:3413/settings/integrations",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for integrations without apiKeySettingsPath", () => {
|
||||
expect(getIntegrationApiKeyUrl("http://localhost", "plex")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -43,7 +43,6 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
|
||||
vpn: getIntegrationKindsByCategory("vpn"),
|
||||
archiveTeamWarrior: ["archiveTeamWarrior"],
|
||||
anchorNote: ["anchor"],
|
||||
openWebUi: ["openWebUi"],
|
||||
traefik: ["traefik"],
|
||||
};
|
||||
|
||||
|
||||
@@ -53,7 +53,6 @@ export const widgetKinds = [
|
||||
"beszelSystemStats",
|
||||
"traefik",
|
||||
"customApi",
|
||||
"openWebUi",
|
||||
] as const;
|
||||
export type WidgetKind = (typeof widgetKinds)[number];
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ import { MockIntegration } from "../mock/mock-integration";
|
||||
import { NavidromeIntegration } from "../navidrome/navidrome-integration";
|
||||
import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
|
||||
import { GotifyIntegration } from "../gotify/gotify-integration";
|
||||
import { OpenWebUiIntegration } from "../open-webui/open-webui-integration";
|
||||
import { NTFYIntegration } from "../ntfy/ntfy-integration";
|
||||
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
|
||||
import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
|
||||
@@ -132,7 +131,6 @@ export const integrationCreators = {
|
||||
uptimeKuma: UptimeKumaIntegration,
|
||||
peaNut: PeaNutIntegration,
|
||||
beszel: BeszelIntegration,
|
||||
openWebUi: OpenWebUiIntegration,
|
||||
bazarr: BazarrIntegration,
|
||||
traefik: TraefikIntegration,
|
||||
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
|
||||
import "@homarr/redis";
|
||||
|
||||
import dayjs from "dayjs";
|
||||
@@ -48,7 +46,7 @@ export class DashDotIntegration extends Integration implements ISystemHealthMoni
|
||||
.map((storage, index) => ({
|
||||
deviceName: `Storage ${index + 1}: (${storage.disks.map((disk) => disk.device).join(", ")})`,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
used: humanFileSize(storageLoad[index]!),
|
||||
used: `${storageLoad[index]!}`,
|
||||
available: storageLoad[index] ? `${storage.size - storageLoad[index]}` : `${storage.size}`,
|
||||
percentage: storageLoad[index] ? (storageLoad[index] / storage.size) * 100 : 0,
|
||||
})),
|
||||
|
||||
@@ -30,7 +30,7 @@ export class QBitTorrentIntegration extends Integration implements IDownloadClie
|
||||
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
|
||||
const type = "torrent";
|
||||
const client = await this.getClientAsync();
|
||||
const torrents = await client.listTorrents({ limit: input.limit });
|
||||
const torrents = await client.listTorrents({ limit: input.limit, sort: "progress", reverse: false });
|
||||
const rates = torrents.reduce(
|
||||
({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }),
|
||||
{ down: 0, up: 0 },
|
||||
|
||||
@@ -42,7 +42,6 @@ export { NavidromeIntegration } from "./navidrome/navidrome-integration";
|
||||
export { UptimeKumaIntegration } from "./uptime-kuma/uptime-kuma-integration";
|
||||
export { UmamiIntegration } from "./umami/umami-integration";
|
||||
export { PeaNutIntegration } from "./peanut/peanut-integration";
|
||||
export { OpenWebUiIntegration } from "./open-webui/open-webui-integration";
|
||||
export { BazarrIntegration } from "./bazarr/bazarr-integration";
|
||||
export { TraefikIntegration } from "./traefik/traefik-integration";
|
||||
|
||||
|
||||
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user