release-2.5.0 (#994)

* feat(sshid) - sshid.io equivalent for termix (#919)

* feat(ssh-id): database schema, migrations and field encryption

Adds ssh_identities, ssh_identity_keys and ssh_identity_ca tables (public keys
stored plaintext for the unauthenticated resolver; CA private key registered
for per-user field encryption), with UNIQUE(user_id), an index on
ssh_identity_keys(identity_id), and idempotent CREATE TABLE migrations.

* feat(ssh-id): backend API — resolver, key management, CA and certificates

Mounts /sshid (nginx route added). Public text/plain authorized_keys resolver
(+ exact /:algo filter, HTML viewer) and CA public-key endpoint; no-store +
noindex headers on every resolver response including early 404s. Authenticated
management: claim/rename/delete handle, add/import/generate/enable/delete keys,
and a per-user CA (create/rotate/delete) with pure-Node OpenSSH certificate
issuance. Audit logging on all mutations; UNIQUE races map to a precise 409.
Unit tests for key parsing and certificate signing (ssh-keygen-validated).

* feat(ssh-id): frontend panel, API client and i18n

SSH ID panel wired into the app rail and AppShell: claim handle, resolver URL +
curl one-liner, key list, generate, paste/import, CA enable/rotate/remove with
server trust command, and per-key certificate issuance. API client re-exported
through main-axios.ts; all strings i18n'd.

* style(ssh-id): align panel and resolver page with Termix theme

- Rebuild the SSH ID sidebar panel with the theme's square components
  (SectionCard / SettingRow / FakeSwitch) instead of rounded ad-hoc cards;
  use accent-brand and destructive tokens rather than raw red/green.
- Fix panel scrolling: move overflow to a block scroll container so the
  cards keep their natural height instead of being clipped.
- Restyle the public resolver HTML page (/sshid/u/:handle) to the Termix
  dark theme: square corners, #18181b/#303032 palette, #f59145 accent,
  uppercase section labels.
- Tidy copy: 'Save To Credentials' label, drop the redundant generate intro,
  and correct the generate tooltip (the key is stored when saving to vault).

* feat: rename to Termix ID, improve UI, backend inconsistencies, and general bug fixes

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* ci(deps): bump actions/checkout from 6 to 7 in the github-actions group (#922)

Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump the dev-patch-updates group with 11 updates (#923)

Bumps the dev-patch-updates group with 11 updates:

| Package | From | To |
| --- | --- | --- |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.0` | `6.7.1` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.0` | `6.43.1` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.0` | `4.3.1` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.8` | `4.1.9` |
| [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) | `4.1.8` | `4.1.9` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.2` | `0.5.3` |
| [lint-staged](https://github.com/lint-staged/lint-staged) | `17.0.7` | `17.0.8` |
| [prettier](https://github.com/prettier/prettier) | `3.8.3` | `3.8.4` |
| [sharp](https://github.com/lovell/sharp) | `0.35.1` | `0.35.2` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.0` | `4.3.1` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.8` | `4.1.9` |


Updates `@codemirror/search` from 6.7.0 to 6.7.1
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)

Updates `@codemirror/view` from 6.43.0 to 6.43.1
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `@tailwindcss/vite` from 4.3.0 to 4.3.1
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/@tailwindcss-vite)

Updates `@vitest/coverage-v8` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/coverage-v8)

Updates `@vitest/ui` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/ui)

Updates `eslint-plugin-react-refresh` from 0.5.2 to 0.5.3
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.2...v0.5.3)

Updates `lint-staged` from 17.0.7 to 17.0.8
- [Release notes](https://github.com/lint-staged/lint-staged/releases)
- [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lint-staged/lint-staged/compare/v17.0.7...v17.0.8)

Updates `prettier` from 3.8.3 to 3.8.4
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.3...3.8.4)

Updates `sharp` from 0.35.1 to 0.35.2
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.35.1...v0.35.2)

Updates `tailwindcss` from 4.3.0 to 4.3.1
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/tailwindcss)

Updates `vitest` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/vitest)

---
updated-dependencies:
- dependency-name: "@codemirror/search"
  dependency-version: 6.7.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitest/ui"
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: lint-staged
  dependency-version: 17.0.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: prettier
  dependency-version: 3.8.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: sharp
  dependency-version: 0.35.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: tailwindcss
  dependency-version: 4.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: vitest
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump nanoid in the prod-patch-updates group (#925)

Bumps the prod-patch-updates group with 1 update: [nanoid](https://github.com/ai/nanoid).


Updates `nanoid` from 5.1.11 to 5.1.15
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/5.1.11...5.1.15)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-version: 5.1.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the major-updates group with 5 updates (#926)

Bumps the major-updates group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [js-yaml](https://github.com/nodeca/js-yaml) | `4.2.0` | `5.0.0` |
| [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.39.4` | `10.0.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.9.2` | `26.0.0` |
| [concurrently](https://github.com/open-cli-tools/concurrently) | `9.2.1` | `10.0.3` |
| [eslint](https://github.com/eslint/eslint) | `9.39.4` | `10.5.0` |


Updates `js-yaml` from 4.2.0 to 5.0.0
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...5.0.0)

Updates `@eslint/js` from 9.39.4 to 10.0.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js)

Updates `@types/node` from 25.9.2 to 26.0.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `concurrently` from 9.2.1 to 10.0.3
- [Release notes](https://github.com/open-cli-tools/concurrently/releases)
- [Commits](https://github.com/open-cli-tools/concurrently/compare/v9.2.1...v10.0.3)

Updates `eslint` from 9.39.4 to 10.5.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.39.4...v10.5.0)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@eslint/js"
  dependency-version: 10.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@types/node"
  dependency-version: 26.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: concurrently
  dependency-version: 10.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: eslint
  dependency-version: 10.5.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat(ssh): add HashiCorp Vault SSH signer authentication

* fix: small fixes to vault feature to align with Termix codebase

* chore: add view docs links for vault/termix id

* fix: file upload fails with 400 and missing schema migrations on upgrade (#929)

Two bugs introduced in v2.4.1:

1. uploadFileStream uses fileManagerApi.post() which triggers axios's
   transformRequest to JSON-serialize the FormData because the instance
   default Content-Type is application/json. Change to postForm() which
   sets Content-Type: multipart/form-data so the browser XHR sends the
   correct multipart body with boundary.

2. Two schema items added to schema.ts were not included in migrateSchema()
   in db/index.ts, causing 500 errors on existing installations upgrading
   from v2.4.0:
   - user_preferences.status_color_scheme (no such column)
   - dashboard_service_links table (no such table)

Fixes #928

Co-authored-by: sash <sash@fominykh.io>

* fix: support PuTTY PPK ssh keys (#930)

* fix: chunk large file manager uploads (#932)

* fix: route dashboard hosts by protocol (#934)

* fix: resolve tunnel endpoints reliably (#935)

* Fix Electron OIDC browser auth failures (#936)

* Allow RDP connections without stored credentials (#937)

* Sync role credential shares for OIDC users (#938)

* Fix terminal link dialog layering (#940)

* Confirm large files before opening editor (#942)

* Confirm closing active host connections (#943)

* Preserve file path case in file manager UI (#941)

* fix: preserve unicode guacamole tokens (#933)

* Persist VNC authentication settings (#944)

* Fix Guacamole websocket base path (#946)

* Promote file manager terminals to tabs (#939)

* Guard Guacamole disconnect during startup (#945)

* chore: increment ver

* feat: bitwarden ssh agent integration

* feat: serial connections support

* fix: various small bug fixes

* feat: open all sessions in a folder and terminal custom theme color support

* feat: cross host file manager clipboard and several small bug fixes

* feat: tailscale/wireguard support and added a new status state for when backend is checking status

* feat: grafana like server stats history, new alert system, ntfy/webhook support

* feat: new grid and widget based homepage function

* feat: new donate button in dashboard

* fix: alert ui incorrectly using termix css and fixed issue with alert system not loading

* chore: fix ci checks (#966)

* Fix dashboard service link creation (#950)

* Fix jump host SOCKS5 proxy selection (#951)

* Fix jump host SOCKS5 proxy selection

* fix: type jump host socks proxy config

* Fix tmux detection path handling (#949)

* Support GUACD_URL environment config (#952)

* Retry autostart tunnel host fetches (#953)

* Retry autostart tunnel host fetches

* chore: format tunnel route

* Fix PUID html ownership in Docker entrypoint (#954)

* feat: allow custom tunnel endpoints (#977)

* fix: skip metrics start for non-ssh hosts (#976)

* Fix Proxmox import auth fallback (#956)

* Fix Proxmox import auth fallback

* chore: format proxmox import auth

* Fix SSH heading syntax highlighting (#955)

* Fix SSH heading syntax highlighting

* chore: format terminal highlighter

* Initialize auth before fullscreen terminal routes (#957)

* Add WebAuthn passkey authentication (#959)

* chore: add Biome tooling (#965)

* chore: add biome tooling

* chore: support tailwind syntax in biome

* Fix VNC required argument handshake (#968)

* Prioritize host results in command palette search (#969)

* Prevent sidebar host hover layout shift (#970)

* Add terminal font zoom with mouse wheel (#971)

* Add host temperature metrics card (#972)

* Make file downloads reliable in desktop app (#973)

* Add app rail hover expansion setting (#974)

* fix: use correct translation key for nav.close (#964)

* fix(tunnel): skip endpoint credential validation for direct tunnels (#963)

* fix: SSH port connection bug (#975)

* fix: chunked upload for files >=1.5GB to bypass browser ArrayBuffer limit (#948)

* feat: support SSH agent auth across SSH features (#960)

* feat: add Podman container runtime support (#958)

* chore: update readme and release notes

* fix: stabilize Windows app icon (#978)

* Add safe host sharing export (#979)

* Add external editor support for file manager (#985)

* Rework SSH credential password handling (#984)

* Support password fallback for SSH key credentials

* Complete SSH credential password fallback

* Fix runtime base path for auth callbacks (#982)

* Fix TUI terminal output highlighting (#983)

Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* Add app fullscreen mode (#993)

* Fix host metrics startup polling (#986)

* chore: update release notes

* fix: line chart text overlap

* chore: update RELEASE_NOTES.md

* chore: add donation goal to readme

* chore: update readme

* fix: hide full-screen button in electron app

* fix: failed unit tests

* chore: lint, format, and bump version to 2.5.0

* chore: remove timeout from crowdin translate

* chore: sync Crowdin translations for 2.5.0

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: DivByZero <mr.oplus@yahoo.fr>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: devdanetra <46488477+devdanetra@users.noreply.github.com>
Co-authored-by: Aleksandr Fominykh <neoformalex@users.noreply.github.com>
Co-authored-by: sash <sash@fominykh.io>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
Luke Gustafson
2026-06-29 13:28:26 -05:00
committed by GitHub
co-authored by DivByZero dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> devdanetra Aleksandr Fominykh sash ZacharyZcR
parent fd27a366d0
commit 9de904dd4c
348 changed files with 125184 additions and 76537 deletions
@@ -0,0 +1,150 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { getAllWidgetTypes } from "../widgets/WidgetRegistry";
import type { ContextMenuState, WidgetTypeId } from "@/types/homepage-types";
interface AddWidgetMenuProps {
state: ContextMenuState;
onAdd: (typeId: WidgetTypeId, canvasX: number, canvasY: number) => void;
onClose: () => void;
}
const CATEGORY_ORDER = ["links", "info", "system", "monitoring"] as const;
const MARGIN = 8;
export function AddWidgetMenu({ state, onAdd, onClose }: AddWidgetMenuProps) {
const { t } = useTranslation();
const menuRef = useRef<HTMLDivElement>(null);
const allTypes = getAllWidgetTypes();
const [pos, setPos] = useState({ left: state.screenX, top: state.screenY });
const [selectedCat, setSelectedCat] = useState<
(typeof CATEGORY_ORDER)[number]
>(CATEGORY_ORDER[0]);
const categoryLabels: Record<(typeof CATEGORY_ORDER)[number], string> = {
links: t("homepage.categoryLinks"),
info: t("homepage.categoryInfo"),
system: t("homepage.categorySystem"),
monitoring: t("homepage.categoryMonitoring"),
};
useLayoutEffect(() => {
if (!state.visible || !menuRef.current) return;
const rect = menuRef.current.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let left = state.screenX;
let top = state.screenY;
if (state.anchorRect) {
left = state.anchorRect.right - rect.width;
top = state.anchorRect.top - rect.height - MARGIN;
}
if (left + rect.width > vw - MARGIN) left = vw - rect.width - MARGIN;
if (left < MARGIN) left = MARGIN;
if (top < MARGIN) {
top = state.anchorRect
? state.anchorRect.bottom + MARGIN
: state.screenY + MARGIN;
}
if (top + rect.height > vh - MARGIN) top = vh - rect.height - MARGIN;
if (top < MARGIN) top = MARGIN;
setPos({ left, top });
}, [state.visible, state.screenX, state.screenY, state.anchorRect]);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [onClose]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
const visibleCategories = CATEGORY_ORDER.filter((cat) =>
allTypes.some((type) => type.category === cat),
);
const selectedTypes = allTypes.filter(
(type) => type.category === selectedCat,
);
return (
<div
ref={menuRef}
className="fixed z-50 bg-card border border-border shadow-xl min-w-[220px] flex flex-col"
style={{
left: pos.left,
top: pos.top,
visibility: state.visible ? "visible" : "hidden",
}}
onMouseDown={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="px-3 py-2 border-b border-border shrink-0">
<span className="text-[10px] font-bold uppercase tracking-widest text-accent-brand">
{t("homepage.addWidget")}
</span>
</div>
{/* Category tabs */}
<div className="flex items-center gap-0.5 px-2 pt-2 pb-1 shrink-0">
{visibleCategories.map((cat) => (
<button
key={cat}
onClick={() => setSelectedCat(cat)}
className={`px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wider transition-colors ${
selectedCat === cat
? "bg-accent-brand text-white"
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
}`}
>
{categoryLabels[cat]}
</button>
))}
</div>
{/* Widget list */}
<div className="overflow-y-auto" style={{ maxHeight: "240px" }}>
{selectedTypes.length === 0 ? (
<div className="px-3 py-4 text-center text-xs text-muted-foreground/60">
No widgets in this category
</div>
) : (
selectedTypes.map((type) => (
<button
key={type.id}
className="w-full flex items-center gap-2.5 px-3 py-2 text-left text-xs text-foreground hover:bg-muted/60 transition-colors"
onClick={() => {
onAdd(type.id, state.canvasX, state.canvasY);
onClose();
}}
>
<span className="text-accent-brand shrink-0">{type.icon}</span>
<div className="flex flex-col min-w-0">
<span className="font-medium truncate">{type.name}</span>
<span className="text-[10px] text-muted-foreground truncate">
{type.description}
</span>
</div>
</button>
))
)}
</div>
</div>
);
}
@@ -0,0 +1,46 @@
import { useTranslation } from "react-i18next";
import type {
AlertFeedConfig,
WidgetEditFormProps,
} from "@/types/homepage-types";
import { Input } from "@/components/input";
export function AlertFeedEditForm({
config,
onChange,
}: WidgetEditFormProps<AlertFeedConfig>) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.maxItems")}
</label>
<Input
type="number"
min={1}
max={50}
value={config.maxItems}
onChange={(e) =>
onChange({
...config,
maxItems: Math.max(1, Number(e.target.value)),
})
}
className="h-8 text-xs"
/>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={config.showAcknowledged}
onChange={(e) =>
onChange({ ...config, showAcknowledged: e.target.checked })
}
className="accent-accent-brand"
/>
{t("homepage.showAcknowledged")}
</label>
</div>
);
}
@@ -0,0 +1,65 @@
import { useTranslation } from "react-i18next";
import { Input } from "@/components/input";
import { Button } from "@/components/button";
import { Trash2, Plus } from "lucide-react";
import type {
BookmarkListConfig,
BookmarkLink,
WidgetEditFormProps,
} from "@/types/homepage-types";
export function BookmarkListEditForm({
config,
onChange,
}: WidgetEditFormProps<BookmarkListConfig>) {
const { t } = useTranslation();
const { links } = config;
const updateLink = (i: number, patch: Partial<BookmarkLink>) => {
const next = links.map((l, idx) => (idx === i ? { ...l, ...patch } : l));
onChange({ ...config, links: next });
};
const removeLink = (i: number) => {
onChange({ ...config, links: links.filter((_, idx) => idx !== i) });
};
const addLink = () => {
onChange({ ...config, links: [...links, { label: "", url: "" }] });
};
return (
<div className="flex flex-col gap-2 max-h-60 overflow-y-auto pr-1">
{links.map((link, i) => (
<div key={i} className="flex items-center gap-1.5">
<Input
value={link.label}
onChange={(e) => updateLink(i, { label: e.target.value })}
placeholder={t("homepage.linkLabel")}
className="h-7 text-xs flex-1"
/>
<Input
value={link.url}
onChange={(e) => updateLink(i, { url: e.target.value })}
placeholder="https://..."
className="h-7 text-xs flex-1"
/>
<button
onClick={() => removeLink(i)}
className="p-1 hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
>
<Trash2 size={12} />
</button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={addLink}
className="mt-1 h-7 text-xs gap-1"
>
<Plus size={11} /> {t("homepage.addLink")}
</Button>
</div>
);
}
@@ -0,0 +1,41 @@
import { useTranslation } from "react-i18next";
import { Input } from "@/components/input";
import type {
CalendarConfig,
WidgetEditFormProps,
} from "@/types/homepage-types";
export function CalendarEditForm({
config,
onChange,
}: WidgetEditFormProps<CalendarConfig>) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.timezone")}
</label>
<Input
value={config.timezone ?? ""}
onChange={(e) =>
onChange({ ...config, timezone: e.target.value || undefined })
}
placeholder="UTC"
className="h-8 text-xs"
/>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={config.startOnMonday}
onChange={(e) =>
onChange({ ...config, startOnMonday: e.target.checked })
}
className="accent-accent-brand"
/>
{t("homepage.startOnMonday")}
</label>
</div>
);
}
@@ -0,0 +1,48 @@
import { useTranslation } from "react-i18next";
import { Input } from "@/components/input";
import { Switch } from "@/components/switch";
import type { ClockConfig, WidgetEditFormProps } from "@/types/homepage-types";
export function ClockEditForm({
config,
onChange,
}: WidgetEditFormProps<ClockConfig>) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.timezone")}
</label>
<Input
value={config.timezone ?? ""}
onChange={(e) =>
onChange({ ...config, timezone: e.target.value || undefined })
}
placeholder="America/New_York (leave blank for local)"
className="h-8 text-sm"
/>
</div>
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-foreground">
{t("homepage.showSeconds")}
</label>
<Switch
checked={config.showSeconds}
onCheckedChange={(v) => onChange({ ...config, showSeconds: v })}
/>
</div>
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-foreground">
{t("homepage.format12h")}
</label>
<Switch
checked={config.format === "12h"}
onCheckedChange={(v) =>
onChange({ ...config, format: v ? "12h" : "24h" })
}
/>
</div>
</div>
);
}
@@ -0,0 +1,57 @@
import { useTranslation } from "react-i18next";
import { Input } from "@/components/input";
import type {
CountdownConfig,
WidgetEditFormProps,
} from "@/types/homepage-types";
export function CountdownEditForm({
config,
onChange,
}: WidgetEditFormProps<CountdownConfig>) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.countdownLabel")}
</label>
<Input
value={config.label}
onChange={(e) => onChange({ ...config, label: e.target.value })}
placeholder={t("homepage.countdownLabelPlaceholder")}
className="h-8 text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.targetDate")}
</label>
<Input
type="datetime-local"
value={config.targetDate}
onChange={(e) => onChange({ ...config, targetDate: e.target.value })}
className="h-8 text-xs"
/>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={config.showDays}
onChange={(e) => onChange({ ...config, showDays: e.target.checked })}
className="accent-accent-brand"
/>
{t("homepage.countdownShowDays")}
</label>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={config.showHours}
onChange={(e) => onChange({ ...config, showHours: e.target.checked })}
className="accent-accent-brand"
/>
{t("homepage.countdownShowHours")}
</label>
</div>
);
}
@@ -0,0 +1,137 @@
import { useTranslation } from "react-i18next";
import { Input } from "@/components/input";
import type {
CustomApiConfig,
CustomApiDisplayMode,
WidgetEditFormProps,
} from "@/types/homepage-types";
const MODES: { id: CustomApiDisplayMode; label: string }[] = [
{ id: "value", label: "Value" },
{ id: "json", label: "JSON" },
{ id: "table", label: "Table" },
];
export function CustomApiEditForm({
config,
onChange,
}: WidgetEditFormProps<CustomApiConfig>) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.url")}
</label>
<Input
value={config.url}
onChange={(e) => onChange({ ...config, url: e.target.value })}
placeholder="https://api.example.com/data"
className="h-8 text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.displayMode")}
</label>
<div className="flex gap-1">
{MODES.map((m) => (
<button
key={m.id}
type="button"
onClick={() => onChange({ ...config, displayMode: m.id })}
className={`px-2 py-0.5 text-[10px] border transition-colors ${config.displayMode === m.id ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
>
{m.label}
</button>
))}
</div>
</div>
{config.displayMode === "value" && (
<>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.displayField")}
</label>
<Input
value={config.displayField ?? ""}
onChange={(e) =>
onChange({
...config,
displayField: e.target.value || undefined,
})
}
placeholder="data.temperature"
className="h-8 text-xs"
/>
</div>
<div className="flex gap-2">
<div className="flex flex-col gap-1 flex-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.customApiLabel")}
</label>
<Input
value={config.label ?? ""}
onChange={(e) =>
onChange({ ...config, label: e.target.value || undefined })
}
placeholder={t("homepage.customApiLabelPlaceholder")}
className="h-8 text-xs"
/>
</div>
<div className="flex flex-col gap-1 w-20">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.customApiUnit")}
</label>
<Input
value={config.unit ?? ""}
onChange={(e) =>
onChange({ ...config, unit: e.target.value || undefined })
}
placeholder="°C"
className="h-8 text-xs"
/>
</div>
</div>
</>
)}
{config.displayMode === "table" && (
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.jsonPath")}
</label>
<Input
value={config.jsonPath ?? ""}
onChange={(e) =>
onChange({ ...config, jsonPath: e.target.value || undefined })
}
placeholder="data.items"
className="h-8 text-xs"
/>
</div>
)}
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.refreshInterval")} ({t("homepage.seconds")})
</label>
<Input
type="number"
min={10}
max={86400}
value={config.refreshInterval}
onChange={(e) =>
onChange({
...config,
refreshInterval: Math.max(10, Number(e.target.value)),
})
}
className="h-8 text-xs"
/>
</div>
</div>
);
}
@@ -0,0 +1,64 @@
import { useTranslation } from "react-i18next";
import { Input } from "@/components/input";
import type {
DashboardLinksConfig,
WidgetEditFormProps,
} from "@/types/homepage-types";
export function DashboardLinksEditForm({
config,
onChange,
}: WidgetEditFormProps<DashboardLinksConfig>) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.columns")}
</label>
<div className="flex gap-1">
{([1, 2, 3] as const).map((c) => (
<button
key={c}
type="button"
onClick={() => onChange({ ...config, columns: c })}
className={`px-3 py-0.5 text-[10px] border transition-colors ${config.columns === c ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
>
{c}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.maxItems")}
</label>
<Input
type="number"
min={1}
max={100}
value={config.maxItems ?? ""}
onChange={(e) =>
onChange({
...config,
maxItems: e.target.value ? Number(e.target.value) : undefined,
})
}
placeholder={t("homepage.noLimit")}
className="h-8 text-xs"
/>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={config.showIcons}
onChange={(e) => onChange({ ...config, showIcons: e.target.checked })}
className="accent-accent-brand"
/>
{t("homepage.showImage")}
</label>
</div>
);
}
@@ -0,0 +1,46 @@
import { useTranslation } from "react-i18next";
import { Input } from "@/components/input";
import type {
DockerActivityConfig,
WidgetEditFormProps,
} from "@/types/homepage-types";
export function DockerActivityEditForm({
config,
onChange,
}: WidgetEditFormProps<DockerActivityConfig>) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.maxItems")}
</label>
<Input
type="number"
min={1}
max={50}
value={config.maxItems}
onChange={(e) =>
onChange({
...config,
maxItems: Math.max(1, Number(e.target.value)),
})
}
className="h-8 text-xs"
/>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={config.showHostName}
onChange={(e) =>
onChange({ ...config, showHostName: e.target.checked })
}
className="accent-accent-brand"
/>
{t("homepage.showHostName")}
</label>
</div>
);
}
@@ -0,0 +1,18 @@
import type {
DockerWidgetConfig,
WidgetEditFormProps,
} from "@/types/homepage-types";
import { SingleHostEditForm } from "./SingleHostEditForm";
export function DockerWidgetEditForm({
config,
onChange,
}: WidgetEditFormProps<DockerWidgetConfig>) {
return (
<SingleHostEditForm
hostId={config.hostId}
onChange={(hostId) => onChange({ ...config, hostId })}
filter={(h) => !!(h.enableSsh && h.enableDocker)}
/>
);
}
@@ -0,0 +1,18 @@
import type {
FileManagerWidgetConfig,
WidgetEditFormProps,
} from "@/types/homepage-types";
import { SingleHostEditForm } from "./SingleHostEditForm";
export function FileManagerWidgetEditForm({
config,
onChange,
}: WidgetEditFormProps<FileManagerWidgetConfig>) {
return (
<SingleHostEditForm
hostId={config.hostId}
onChange={(hostId) => onChange({ ...config, hostId })}
filter={(h) => !!(h.enableSsh && h.enableFileManager)}
/>
);
}
@@ -0,0 +1,44 @@
import { useTranslation } from "react-i18next";
import { Input } from "@/components/input";
import type { FolderConfig, WidgetEditFormProps } from "@/types/homepage-types";
function getAccentColor(): string {
return (
getComputedStyle(document.documentElement)
.getPropertyValue("--accent-brand")
.trim() || "#f59145"
);
}
export function FolderEditForm({
config,
onChange,
}: WidgetEditFormProps<FolderConfig>) {
const { t } = useTranslation();
const accent = getAccentColor();
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.color")}
</label>
<div className="flex items-center gap-2">
<input
type="color"
value={config.color || accent}
onChange={(e) => onChange({ ...config, color: e.target.value })}
className="w-8 h-8 border border-border cursor-pointer"
/>
<Input
value={config.color ?? ""}
onChange={(e) =>
onChange({ ...config, color: e.target.value || undefined })
}
placeholder={accent}
className="h-8 text-sm flex-1"
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,90 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type {
HostGridConfig,
WidgetEditFormProps,
} from "@/types/homepage-types";
import { getSSHHosts } from "@/api/ssh-host-management-api";
export function HostGridEditForm({
config,
onChange,
}: WidgetEditFormProps<HostGridConfig>) {
const { t } = useTranslation();
const [hosts, setHosts] = useState<{ id: number; name: string }[]>([]);
useEffect(() => {
getSSHHosts()
.then((h) => setHosts(h.map((x) => ({ id: x.id, name: x.name }))))
.catch(() => {});
}, []);
const toggleHost = (id: number) => {
const next = config.hostIds.includes(id)
? config.hostIds.filter((x) => x !== id)
: [...config.hostIds, id];
onChange({ ...config, hostIds: next });
};
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.hostGridHosts")}
</label>
<div className="flex flex-col gap-0.5 max-h-40 overflow-y-auto border border-border/40 p-1">
{hosts.length === 0 && (
<span className="text-[10px] text-muted-foreground p-1">
{t("homepage.noHosts")}
</span>
)}
{hosts.map((h) => (
<label
key={h.id}
className="flex items-center gap-2 text-[10px] cursor-pointer px-1 py-0.5 hover:bg-muted/40"
>
<input
type="checkbox"
checked={config.hostIds.includes(h.id)}
onChange={() => toggleHost(h.id)}
className="accent-accent-brand"
/>
{h.name}
</label>
))}
</div>
<span className="text-[9px] text-muted-foreground">
{t("homepage.hostGridAllHint")}
</span>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.columns")}
</label>
<div className="flex gap-1">
{([2, 3, 4] as const).map((c) => (
<button
key={c}
type="button"
onClick={() => onChange({ ...config, columns: c })}
className={`px-3 py-0.5 text-[10px] font-medium border transition-colors ${config.columns === c ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
>
{c}
</button>
))}
</div>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={config.showIp}
onChange={(e) => onChange({ ...config, showIp: e.target.checked })}
className="accent-accent-brand"
/>
{t("homepage.showIp")}
</label>
</div>
);
}
@@ -0,0 +1,122 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import type {
HostMetricKey,
HostStatusConfig,
WidgetEditFormProps,
} from "@/types/homepage-types";
interface SimpleHost {
id: string;
name: string;
metricsEnabled: boolean;
}
const METRIC_OPTIONS: { key: HostMetricKey; labelKey: string }[] = [
{ key: "cpu", labelKey: "homepage.metricCpu" },
{ key: "memory", labelKey: "homepage.metricMemory" },
{ key: "disk", labelKey: "homepage.metricDisk" },
{ key: "uptime", labelKey: "homepage.metricUptime" },
{ key: "system", labelKey: "homepage.metricSystem" },
{ key: "network", labelKey: "homepage.metricNetwork" },
{ key: "processes", labelKey: "homepage.metricProcesses" },
];
function parseMetricsEnabled(statsConfig: unknown): boolean {
if (!statsConfig) return false;
try {
const cfg =
typeof statsConfig === "string" ? JSON.parse(statsConfig) : statsConfig;
if (typeof cfg === "object" && cfg !== null) {
const c = cfg as Record<string, unknown>;
return c.metricsEnabled !== false;
}
} catch {
// ignore
}
return false;
}
export function HostStatusEditForm({
config,
onChange,
}: WidgetEditFormProps<HostStatusConfig>) {
const { t } = useTranslation();
const [hosts, setHosts] = useState<SimpleHost[]>([]);
const shownMetrics: HostMetricKey[] = config.shownMetrics?.length
? config.shownMetrics
: config.showMetrics !== false
? ["cpu", "memory", ...(config.showDisk ? ["disk" as HostMetricKey] : [])]
: [];
useEffect(() => {
getSSHHosts()
.then((all) =>
setHosts(
all
.filter((x) => parseMetricsEnabled(x.statsConfig))
.map((x) => ({
id: String(x.id),
name: x.name,
metricsEnabled: true,
})),
),
)
.catch(() => {});
}, []);
function toggleMetric(key: HostMetricKey) {
const next = shownMetrics.includes(key)
? shownMetrics.filter((k) => k !== key)
: [...shownMetrics, key];
onChange({ ...config, shownMetrics: next });
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.host")}
</label>
<select
className="h-8 border border-border bg-background text-sm px-2"
value={config.hostId || ""}
onChange={(e) =>
onChange({ ...config, hostId: parseInt(e.target.value) || 0 })
}
>
<option value="">{t("homepage.selectHost")}</option>
{hosts.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.displayedMetrics")}
</label>
<div className="flex flex-col gap-1">
{METRIC_OPTIONS.map(({ key, labelKey }) => (
<label
key={key}
className="flex items-center gap-2 cursor-pointer select-none"
>
<input
type="checkbox"
checked={shownMetrics.includes(key)}
onChange={() => toggleMetric(key)}
className="w-3.5 h-3.5 accent-primary"
/>
<span className="text-xs text-foreground">{t(labelKey)}</span>
</label>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,35 @@
import { useTranslation } from "react-i18next";
import { Input } from "@/components/input";
import { Switch } from "@/components/switch";
import type { IframeConfig, WidgetEditFormProps } from "@/types/homepage-types";
export function IframeEditForm({
config,
onChange,
}: WidgetEditFormProps<IframeConfig>) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.url")}
</label>
<Input
value={config.url}
onChange={(e) => onChange({ ...config, url: e.target.value })}
placeholder="https://example.com"
className="h-8 text-sm"
/>
</div>
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-foreground">
{t("homepage.scrolling")}
</label>
<Switch
checked={config.scrolling}
onCheckedChange={(v) => onChange({ ...config, scrolling: v })}
/>
</div>
</div>
);
}
@@ -0,0 +1,71 @@
import { useTranslation } from "react-i18next";
import { Input } from "@/components/input";
import type {
ImageWidgetConfig,
WidgetEditFormProps,
} from "@/types/homepage-types";
export function ImageWidgetEditForm({
config,
onChange,
}: WidgetEditFormProps<ImageWidgetConfig>) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.imageUrl")}
</label>
<Input
value={config.imageUrl}
onChange={(e) => onChange({ ...config, imageUrl: e.target.value })}
placeholder="https://example.com/image.png"
className="h-8 text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.imageLinkUrl")}
</label>
<Input
value={config.linkUrl ?? ""}
onChange={(e) =>
onChange({ ...config, linkUrl: e.target.value || undefined })
}
placeholder="https://example.com"
className="h-8 text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.imageFit")}
</label>
<div className="flex gap-1">
{(["contain", "cover", "fill"] as const).map((f) => (
<button
key={f}
type="button"
onClick={() => onChange({ ...config, fit: f })}
className={`px-2 py-0.5 text-[10px] border transition-colors capitalize ${config.fit === f ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
>
{f}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.altText")}
</label>
<Input
value={config.alt ?? ""}
onChange={(e) =>
onChange({ ...config, alt: e.target.value || undefined })
}
placeholder={t("homepage.altTextPlaceholder")}
className="h-8 text-xs"
/>
</div>
</div>
);
}
@@ -0,0 +1,134 @@
import { useTranslation } from "react-i18next";
import { Trash2, Plus } from "lucide-react";
import { Input } from "@/components/input";
import { Button } from "@/components/button";
import type {
LinkTreeConfig,
LinkTreeSection,
LinkTreeLink,
WidgetEditFormProps,
} from "@/types/homepage-types";
export function LinkTreeEditForm({
config,
onChange,
}: WidgetEditFormProps<LinkTreeConfig>) {
const { t } = useTranslation();
const { sections, compact } = config;
const updateSection = (si: number, patch: Partial<LinkTreeSection>) => {
onChange({
...config,
sections: sections.map((s, i) => (i === si ? { ...s, ...patch } : s)),
});
};
const removeSection = (si: number) =>
onChange({ ...config, sections: sections.filter((_, i) => i !== si) });
const addSection = () =>
onChange({
...config,
sections: [...sections, { heading: "", links: [] }],
});
const updateLink = (si: number, li: number, patch: Partial<LinkTreeLink>) => {
const newSections = sections.map((s, i) => {
if (i !== si) return s;
return {
...s,
links: s.links.map((l, j) => (j === li ? { ...l, ...patch } : l)),
};
});
onChange({ ...config, sections: newSections });
};
const removeLink = (si: number, li: number) => {
const newSections = sections.map((s, i) =>
i !== si ? s : { ...s, links: s.links.filter((_, j) => j !== li) },
);
onChange({ ...config, sections: newSections });
};
const addLink = (si: number) => {
const newSections = sections.map((s, i) =>
i !== si ? s : { ...s, links: [...s.links, { label: "", url: "" }] },
);
onChange({ ...config, sections: newSections });
};
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2 max-h-64 overflow-y-auto pr-1">
{sections.map((section, si) => (
<div
key={si}
className="flex flex-col gap-1 border border-border/40 p-2"
>
<div className="flex items-center gap-1.5">
<Input
value={section.heading}
onChange={(e) => updateSection(si, { heading: e.target.value })}
placeholder={t("homepage.sectionHeading")}
className="h-6 text-xs flex-1 font-semibold"
/>
<button
onClick={() => removeSection(si)}
className="p-1 hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
>
<Trash2 size={12} />
</button>
</div>
<div className="flex flex-col gap-0.5 pl-2">
{section.links.map((link, li) => (
<div key={li} className="flex items-center gap-1">
<Input
value={link.label}
onChange={(e) =>
updateLink(si, li, { label: e.target.value })
}
placeholder={t("homepage.linkLabel")}
className="h-6 text-[10px] flex-1"
/>
<Input
value={link.url}
onChange={(e) =>
updateLink(si, li, { url: e.target.value })
}
placeholder="https://..."
className="h-6 text-[10px] flex-1"
/>
<button
onClick={() => removeLink(si, li)}
className="p-0.5 hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
>
<Trash2 size={10} />
</button>
</div>
))}
<button
onClick={() => addLink(si)}
className="flex items-center gap-0.5 text-[9px] text-accent-brand hover:opacity-80 mt-0.5 self-start"
>
<Plus size={9} /> {t("homepage.addLink")}
</button>
</div>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={addSection}
className="mt-1 h-7 text-xs gap-1"
>
<Plus size={11} /> {t("homepage.addSection")}
</Button>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={compact}
onChange={(e) => onChange({ ...config, compact: e.target.checked })}
className="accent-accent-brand"
/>
{t("homepage.compactMode")}
</label>
</div>
);
}
@@ -0,0 +1,76 @@
import { useTranslation } from "react-i18next";
import { Input } from "@/components/input";
import { Textarea } from "@/components/textarea";
import type {
MarkdownNotesConfig,
WidgetEditFormProps,
} from "@/types/homepage-types";
export function MarkdownNotesEditForm({
config,
onChange,
}: WidgetEditFormProps<MarkdownNotesConfig>) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.content")}
</label>
<Textarea
value={config.content}
onChange={(e) => onChange({ ...config, content: e.target.value })}
className="text-sm min-h-[120px] resize-none rounded-none font-mono"
/>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={config.renderMarkdown}
onChange={(e) =>
onChange({ ...config, renderMarkdown: e.target.checked })
}
className="accent-accent-brand"
/>
{t("homepage.renderMarkdown")}
</label>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.backgroundColor")}
</label>
<div className="flex items-center gap-2">
<input
type="color"
value={config.backgroundColor ?? "#1e1e2e"}
onChange={(e) =>
onChange({ ...config, backgroundColor: e.target.value })
}
className="w-8 h-8 border border-border cursor-pointer"
/>
<Input
value={config.backgroundColor ?? ""}
onChange={(e) =>
onChange({
...config,
backgroundColor: e.target.value || undefined,
})
}
placeholder="#1e1e2e"
className="h-8 text-sm flex-1"
/>
{config.backgroundColor && (
<button
type="button"
onClick={() =>
onChange({ ...config, backgroundColor: undefined })
}
className="text-[10px] text-muted-foreground hover:text-foreground shrink-0"
>
{t("homepage.clearColor")}
</button>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,110 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type {
MetricsChartConfig,
MetricsChartMetric,
MetricsChartRange,
WidgetEditFormProps,
} from "@/types/homepage-types";
import { getSSHHosts } from "@/api/ssh-host-management-api";
const METRICS: { id: MetricsChartMetric; label: string }[] = [
{ id: "cpu", label: "CPU" },
{ id: "memory", label: "Memory" },
{ id: "disk", label: "Disk" },
{ id: "net_rx", label: "RX MB/s" },
{ id: "net_tx", label: "TX MB/s" },
];
const RANGES: { id: MetricsChartRange; label: string }[] = [
{ id: "15m", label: "15m" },
{ id: "1h", label: "1h" },
{ id: "6h", label: "6h" },
{ id: "24h", label: "24h" },
];
export function MetricsChartEditForm({
config,
onChange,
}: WidgetEditFormProps<MetricsChartConfig>) {
const { t } = useTranslation();
const [hosts, setHosts] = useState<{ id: number; name: string }[]>([]);
useEffect(() => {
getSSHHosts()
.then((h) => setHosts(h.map((x) => ({ id: x.id, name: x.name }))))
.catch(() => {});
}, []);
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.host")}
</label>
<select
value={config.hostId}
onChange={(e) =>
onChange({ ...config, hostId: Number(e.target.value) })
}
className="h-8 text-xs bg-background border border-border px-2 text-foreground"
>
<option value={0}>{t("homepage.selectHost")}</option>
{hosts.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.chartMetric")}
</label>
<div className="flex flex-wrap gap-1">
{METRICS.map((m) => (
<button
key={m.id}
type="button"
onClick={() => onChange({ ...config, metric: m.id })}
className={`px-2 py-0.5 text-[10px] font-medium border transition-colors ${config.metric === m.id ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground hover:border-accent-brand/50"}`}
>
{m.label}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.metricRange")}
</label>
<div className="flex gap-1">
{RANGES.map((r) => (
<button
key={r.id}
type="button"
onClick={() => onChange({ ...config, range: r.id })}
className={`px-2 py-0.5 text-[10px] font-medium border transition-colors ${config.range === r.id ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground hover:border-accent-brand/50"}`}
>
{r.label}
</button>
))}
</div>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={config.showCurrentValue}
onChange={(e) =>
onChange({ ...config, showCurrentValue: e.target.checked })
}
className="accent-accent-brand"
/>
{t("homepage.showCurrentValue")}
</label>
</div>
);
}
@@ -0,0 +1,25 @@
import { useTranslation } from "react-i18next";
import { Textarea } from "@/components/textarea";
import type { NotesConfig, WidgetEditFormProps } from "@/types/homepage-types";
export function NotesEditForm({
config,
onChange,
}: WidgetEditFormProps<NotesConfig>) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.content")}
</label>
<Textarea
value={config.content}
onChange={(e) => onChange({ ...config, content: e.target.value })}
placeholder="Write your notes here..."
className="text-sm min-h-[120px] resize-none rounded-none"
/>
</div>
</div>
);
}
@@ -0,0 +1,96 @@
import { useTranslation } from "react-i18next";
import { Trash2, Plus } from "lucide-react";
import { Input } from "@/components/input";
import { Button } from "@/components/button";
import type {
PingStatusConfig,
PingUrl,
WidgetEditFormProps,
} from "@/types/homepage-types";
export function PingStatusEditForm({
config,
onChange,
}: WidgetEditFormProps<PingStatusConfig>) {
const { t } = useTranslation();
const { urls, refreshInterval, showLatency } = config;
const updateUrl = (i: number, patch: Partial<PingUrl>) => {
onChange({
...config,
urls: urls.map((u, idx) => (idx === i ? { ...u, ...patch } : u)),
});
};
const removeUrl = (i: number) =>
onChange({ ...config, urls: urls.filter((_, idx) => idx !== i) });
const addUrl = () =>
onChange({ ...config, urls: [...urls, { label: "", url: "" }] });
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2 max-h-48 overflow-y-auto pr-1">
{urls.map((u, i) => (
<div key={i} className="flex items-center gap-1.5">
<Input
value={u.label}
onChange={(e) => updateUrl(i, { label: e.target.value })}
placeholder={t("homepage.pingLabel")}
className="h-7 text-xs flex-1"
/>
<Input
value={u.url}
onChange={(e) => updateUrl(i, { url: e.target.value })}
placeholder="https://..."
className="h-7 text-xs flex-1"
/>
<button
onClick={() => removeUrl(i)}
className="p-1 hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
>
<Trash2 size={12} />
</button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={addUrl}
className="mt-1 h-7 text-xs gap-1"
>
<Plus size={11} /> {t("homepage.addPingUrl")}
</Button>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.refreshInterval")} ({t("homepage.seconds")})
</label>
<Input
type="number"
min={10}
max={3600}
value={refreshInterval}
onChange={(e) =>
onChange({
...config,
refreshInterval: Math.max(10, Number(e.target.value)),
})
}
className="h-8 text-xs"
/>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={showLatency}
onChange={(e) =>
onChange({ ...config, showLatency: e.target.checked })
}
className="accent-accent-brand"
/>
{t("homepage.showLatency")}
</label>
</div>
);
}
@@ -0,0 +1,143 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type {
QuickConnectConfig,
QuickConnectType,
WidgetEditFormProps,
} from "@/types/homepage-types";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import type { SSHHostWithStatus } from "@/main-axios";
const ALL_TYPES: QuickConnectType[] = [
"terminal",
"files",
"docker",
"tunnel",
"host-metrics",
"rdp",
"vnc",
"telnet",
];
export function QuickConnectEditForm({
config,
onChange,
}: WidgetEditFormProps<QuickConnectConfig>) {
const { t } = useTranslation();
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
useEffect(() => {
getSSHHosts()
.then(setHosts)
.catch(() => {});
}, []);
const toggleHost = (id: number) => {
const current = config.hostIds;
const ids = current.includes(id)
? current.filter((x) => x !== id)
: [...current, id];
onChange({ ...config, hostIds: ids });
};
const toggleType = (type: QuickConnectType) => {
const types = config.connectionTypes.includes(type)
? config.connectionTypes.filter((x) => x !== type)
: [...config.connectionTypes, type];
onChange({ ...config, connectionTypes: types });
};
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.selectHosts")}
</label>
<div className="flex flex-col gap-1 max-h-36 overflow-y-auto border border-border/30 p-2">
{hosts.length === 0 && (
<span className="text-xs text-muted-foreground/60">
{t("homepage.noHosts")}
</span>
)}
{hosts.map((h) => (
<label
key={h.id}
className="flex items-center gap-2 text-xs cursor-pointer"
>
<input
type="checkbox"
checked={config.hostIds.includes(h.id)}
onChange={() => toggleHost(h.id)}
className="accent-accent-brand"
/>
<span className="truncate">{h.name || h.ip}</span>
</label>
))}
</div>
<p className="text-[10px] text-muted-foreground/60">
{t("homepage.hostGridAllHint")}
</p>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.connectionTypes")}
</label>
<div className="grid grid-cols-2 gap-1">
{ALL_TYPES.map((type) => (
<label
key={type}
className="flex items-center gap-2 text-xs cursor-pointer"
>
<input
type="checkbox"
checked={config.connectionTypes.includes(type)}
onChange={() => toggleType(type)}
className="accent-accent-brand"
/>
{t(`homepage.connType_${type}`)}
</label>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.layout")}
</label>
<div className="flex gap-3">
{(["list", "grid"] as const).map((l) => (
<label
key={l}
className="flex items-center gap-1.5 text-xs cursor-pointer"
>
<input
type="radio"
name="layout"
value={l}
checked={config.layout === l}
onChange={() => onChange({ ...config, layout: l })}
className="accent-accent-brand"
/>
{l === "list"
? t("homepage.listLayout")
: t("homepage.gridLayout")}
</label>
))}
</div>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={config.showStatus}
onChange={(e) =>
onChange({ ...config, showStatus: e.target.checked })
}
className="accent-accent-brand"
/>
{t("homepage.showStatus")}
</label>
</div>
);
}
@@ -0,0 +1,92 @@
import { useTranslation } from "react-i18next";
import { Input } from "@/components/input";
import type {
RecentActivityConfig,
ActivityType,
WidgetEditFormProps,
} from "@/types/homepage-types";
const ALL_TYPES: ActivityType[] = [
"terminal",
"file_manager",
"docker",
"tunnel",
"rdp",
"vnc",
"telnet",
];
export function RecentActivityEditForm({
config,
onChange,
}: WidgetEditFormProps<RecentActivityConfig>) {
const { t } = useTranslation();
const toggleType = (type: ActivityType) => {
const next = config.filterTypes.includes(type)
? config.filterTypes.filter((x) => x !== type)
: [...config.filterTypes, type];
onChange({ ...config, filterTypes: next });
};
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.maxItems")}
</label>
<Input
type="number"
min={1}
max={50}
value={config.maxItems}
onChange={(e) =>
onChange({
...config,
maxItems: Math.max(1, Number(e.target.value)),
})
}
className="h-8 text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.filterActivityTypes")}
</label>
<div className="flex flex-wrap gap-1">
{ALL_TYPES.map((type) => (
<button
key={type}
type="button"
onClick={() => toggleType(type)}
className={`px-2 py-0.5 text-[10px] border transition-colors ${
config.filterTypes.includes(type) ||
config.filterTypes.length === 0
? "bg-accent-brand border-accent-brand text-white"
: "border-border text-muted-foreground"
}`}
>
{type.replace("_", " ")}
</button>
))}
</div>
<span className="text-[9px] text-muted-foreground">
{t("homepage.filterTypesHint")}
</span>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={config.showTimestamp}
onChange={(e) =>
onChange({ ...config, showTimestamp: e.target.checked })
}
className="accent-accent-brand"
/>
{t("homepage.showTimestamp")}
</label>
</div>
);
}
@@ -0,0 +1,56 @@
import { useTranslation } from "react-i18next";
import { Input } from "@/components/input";
import { Switch } from "@/components/switch";
import type {
RssFeedConfig,
WidgetEditFormProps,
} from "@/types/homepage-types";
export function RssFeedEditForm({
config,
onChange,
}: WidgetEditFormProps<RssFeedConfig>) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.feedUrl")}
</label>
<Input
value={config.feedUrl}
onChange={(e) => onChange({ ...config, feedUrl: e.target.value })}
placeholder="https://example.com/feed.xml"
className="h-8 text-sm"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.maxItems")}
</label>
<Input
type="number"
min={1}
max={50}
value={config.maxItems}
onChange={(e) =>
onChange({
...config,
maxItems: Math.max(1, Math.min(50, Number(e.target.value))),
})
}
className="h-8 text-sm"
/>
</div>
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-foreground">
{t("homepage.showDescription")}
</label>
<Switch
checked={config.showDescription}
onCheckedChange={(v) => onChange({ ...config, showDescription: v })}
/>
</div>
</div>
);
}
@@ -0,0 +1,82 @@
import { useTranslation } from "react-i18next";
import { Input } from "@/components/input";
import type {
SearchBarConfig,
SearchEngine,
WidgetEditFormProps,
} from "@/types/homepage-types";
const ENGINES: { id: SearchEngine; label: string }[] = [
{ id: "google", label: "Google" },
{ id: "duckduckgo", label: "DuckDuckGo" },
{ id: "bing", label: "Bing" },
{ id: "custom", label: "Custom" },
];
export function SearchBarEditForm({
config,
onChange,
}: WidgetEditFormProps<SearchBarConfig>) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.searchEngine")}
</label>
<div className="flex flex-wrap gap-1">
{ENGINES.map((e) => (
<button
key={e.id}
type="button"
onClick={() => onChange({ ...config, engine: e.id })}
className={`px-2 py-0.5 text-[10px] border transition-colors ${config.engine === e.id ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
>
{e.label}
</button>
))}
</div>
</div>
{config.engine === "custom" && (
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.customSearchUrl")}
</label>
<Input
value={config.customUrl ?? ""}
onChange={(e) => onChange({ ...config, customUrl: e.target.value })}
placeholder="https://example.com/search?q={q}"
className="h-8 text-xs"
/>
</div>
)}
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.searchPlaceholderLabel")}
</label>
<Input
value={config.placeholder ?? ""}
onChange={(e) =>
onChange({ ...config, placeholder: e.target.value || undefined })
}
placeholder={t("homepage.searchPlaceholderHint")}
className="h-8 text-xs"
/>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={config.openInNewTab}
onChange={(e) =>
onChange({ ...config, openInNewTab: e.target.checked })
}
className="accent-accent-brand"
/>
{t("homepage.openInNewTab")}
</label>
</div>
);
}
@@ -0,0 +1,83 @@
import { useTranslation } from "react-i18next";
import { Trash2, Plus } from "lucide-react";
import { Input } from "@/components/input";
import { Button } from "@/components/button";
import type {
SearchLinksConfig,
SearchLinkShortcut,
WidgetEditFormProps,
} from "@/types/homepage-types";
export function SearchLinksEditForm({
config,
onChange,
}: WidgetEditFormProps<SearchLinksConfig>) {
const { t } = useTranslation();
const { shortcuts } = config;
const update = (i: number, patch: Partial<SearchLinkShortcut>) => {
onChange({
...config,
shortcuts: shortcuts.map((s, idx) =>
idx === i ? { ...s, ...patch } : s,
),
});
};
const remove = (i: number) =>
onChange({ ...config, shortcuts: shortcuts.filter((_, idx) => idx !== i) });
const add = () =>
onChange({
...config,
shortcuts: [...shortcuts, { label: "", queryTemplate: "" }],
});
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2 max-h-56 overflow-y-auto pr-1">
{shortcuts.map((s, i) => (
<div
key={i}
className="flex flex-col gap-1 border border-border/40 p-2"
>
<div className="flex items-center gap-1.5">
<Input
value={s.icon ?? ""}
onChange={(e) =>
update(i, { icon: e.target.value || undefined })
}
placeholder="🔍"
className="h-6 text-xs w-10 text-center"
/>
<Input
value={s.label}
onChange={(e) => update(i, { label: e.target.value })}
placeholder={t("homepage.linkLabel")}
className="h-6 text-xs flex-1"
/>
<button
onClick={() => remove(i)}
className="p-1 hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
>
<Trash2 size={12} />
</button>
</div>
<Input
value={s.queryTemplate}
onChange={(e) => update(i, { queryTemplate: e.target.value })}
placeholder="https://example.com/search?q={q}"
className="h-6 text-xs"
/>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={add}
className="mt-1 h-7 text-xs gap-1"
>
<Plus size={11} /> {t("homepage.addSearchShortcut")}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,114 @@
import { useTranslation } from "react-i18next";
import { Trash2, Plus } from "lucide-react";
import { Input } from "@/components/input";
import { Button } from "@/components/button";
import type {
ServiceGridConfig,
ServiceGridItem,
WidgetEditFormProps,
} from "@/types/homepage-types";
export function ServiceGridEditForm({
config,
onChange,
}: WidgetEditFormProps<ServiceGridConfig>) {
const { t } = useTranslation();
const { services, columns, showLabels, iconSize } = config;
const updateItem = (i: number, patch: Partial<ServiceGridItem>) => {
onChange({
...config,
services: services.map((s, idx) => (idx === i ? { ...s, ...patch } : s)),
});
};
const removeItem = (i: number) =>
onChange({ ...config, services: services.filter((_, idx) => idx !== i) });
const addItem = () =>
onChange({ ...config, services: [...services, { label: "", url: "" }] });
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5 max-h-48 overflow-y-auto pr-1">
{services.map((s, i) => (
<div key={i} className="flex items-center gap-1.5">
<Input
value={s.label}
onChange={(e) => updateItem(i, { label: e.target.value })}
placeholder={t("homepage.linkLabel")}
className="h-7 text-xs flex-1"
/>
<Input
value={s.url}
onChange={(e) => updateItem(i, { url: e.target.value })}
placeholder="https://..."
className="h-7 text-xs flex-1"
/>
<button
onClick={() => removeItem(i)}
className="p-1 hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
>
<Trash2 size={12} />
</button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={addItem}
className="mt-1 h-7 text-xs gap-1"
>
<Plus size={11} /> {t("homepage.addService")}
</Button>
</div>
<div className="flex gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.columns")}
</label>
<div className="flex gap-1">
{([2, 3, 4] as const).map((c) => (
<button
key={c}
type="button"
onClick={() => onChange({ ...config, columns: c })}
className={`px-3 py-0.5 text-[10px] border transition-colors ${columns === c ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
>
{c}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.iconSize")}
</label>
<div className="flex gap-1">
{(["sm", "md", "lg"] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => onChange({ ...config, iconSize: s })}
className={`px-2 py-0.5 text-[10px] border transition-colors uppercase ${iconSize === s ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
>
{s}
</button>
))}
</div>
</div>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={showLabels}
onChange={(e) =>
onChange({ ...config, showLabels: e.target.checked })
}
className="accent-accent-brand"
/>
{t("homepage.showLabels")}
</label>
</div>
);
}
@@ -0,0 +1,101 @@
import { useTranslation } from "react-i18next";
import { Input } from "@/components/input";
import { Switch } from "@/components/switch";
import type {
ServiceLinkConfig,
WidgetEditFormProps,
} from "@/types/homepage-types";
function getAccentColor(): string {
return (
getComputedStyle(document.documentElement)
.getPropertyValue("--accent-brand")
.trim() || "#f59145"
);
}
export function ServiceLinkEditForm({
config,
onChange,
}: WidgetEditFormProps<ServiceLinkConfig>) {
const { t } = useTranslation();
const accent = getAccentColor();
const showImage = config.showImage ?? true;
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.url")}
</label>
<Input
value={config.url}
onChange={(e) => onChange({ ...config, url: e.target.value })}
placeholder="https://example.com"
className="h-8 text-sm"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.description")}
</label>
<Input
value={config.description ?? ""}
onChange={(e) => onChange({ ...config, description: e.target.value })}
placeholder="My service"
className="h-8 text-sm"
/>
</div>
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-foreground">
{t("homepage.showImage")}
</label>
<Switch
checked={showImage}
onCheckedChange={(v) => onChange({ ...config, showImage: v })}
/>
</div>
{showImage && (
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.imageUrl")}
</label>
<Input
value={config.imageUrl ?? ""}
onChange={(e) =>
onChange({ ...config, imageUrl: e.target.value || undefined })
}
placeholder="https://example.com/logo.png"
className="h-8 text-sm"
/>
<span className="text-[10px] text-muted-foreground/60">
{t("homepage.imageUrlHint")}
</span>
</div>
)}
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.color")}
</label>
<div className="flex items-center gap-2">
<input
type="color"
value={config.accentColor || accent}
onChange={(e) =>
onChange({ ...config, accentColor: e.target.value })
}
className="w-8 h-8 border border-border cursor-pointer"
/>
<Input
value={config.accentColor ?? ""}
onChange={(e) =>
onChange({ ...config, accentColor: e.target.value || undefined })
}
placeholder={accent}
className="h-8 text-sm flex-1"
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,45 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import type { SSHHostWithStatus } from "@/main-axios";
interface SingleHostEditFormProps {
hostId: number;
onChange: (hostId: number) => void;
filter?: (host: SSHHostWithStatus) => boolean;
}
export function SingleHostEditForm({
hostId,
onChange,
filter,
}: SingleHostEditFormProps) {
const { t } = useTranslation();
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
useEffect(() => {
getSSHHosts()
.then((all) => setHosts(filter ? all.filter(filter) : all))
.catch(() => {});
}, []);
return (
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.host")}
</label>
<select
value={hostId || ""}
onChange={(e) => onChange(Number(e.target.value))}
className="h-8 text-xs border border-border bg-background px-2"
>
<option value="">{t("homepage.selectHost")}</option>
{hosts.map((h) => (
<option key={h.id} value={h.id}>
{h.name || h.ip}
</option>
))}
</select>
</div>
);
}
@@ -0,0 +1,105 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type {
SshQuickConnectConfig,
WidgetEditFormProps,
} from "@/types/homepage-types";
import { getSSHHosts } from "@/api/ssh-host-management-api";
export function SshQuickConnectEditForm({
config,
onChange,
}: WidgetEditFormProps<SshQuickConnectConfig>) {
const { t } = useTranslation();
const [hosts, setHosts] = useState<{ id: number; name: string }[]>([]);
useEffect(() => {
getSSHHosts()
.then((h) => setHosts(h.map((x) => ({ id: x.id, name: x.name }))))
.catch(() => {});
}, []);
const toggleHost = (id: number) => {
const next = config.hostIds.includes(id)
? config.hostIds.filter((x) => x !== id)
: [...config.hostIds, id];
onChange({ ...config, hostIds: next });
};
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.host")}
</label>
<div className="flex flex-col gap-0.5 max-h-40 overflow-y-auto border border-border/40 p-1">
{hosts.map((h) => (
<label
key={h.id}
className="flex items-center gap-2 text-[10px] cursor-pointer px-1 py-0.5 hover:bg-muted/40"
>
<input
type="checkbox"
checked={config.hostIds.includes(h.id)}
onChange={() => toggleHost(h.id)}
className="accent-accent-brand"
/>
{h.name}
</label>
))}
</div>
<span className="text-[9px] text-muted-foreground">
{t("homepage.hostGridAllHint")}
</span>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.connectionType")}
</label>
<div className="flex gap-1">
{(["terminal", "files", "docker"] as const).map((ct) => (
<button
key={ct}
type="button"
onClick={() => onChange({ ...config, connectionType: ct })}
className={`px-2 py-0.5 text-[10px] border transition-colors capitalize ${config.connectionType === ct ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
>
{ct}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.layout")}
</label>
<div className="flex gap-1">
{(["list", "grid"] as const).map((l) => (
<button
key={l}
type="button"
onClick={() => onChange({ ...config, layout: l })}
className={`px-2 py-0.5 text-[10px] border transition-colors capitalize ${config.layout === l ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
>
{l}
</button>
))}
</div>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={config.showStatus}
onChange={(e) =>
onChange({ ...config, showStatus: e.target.checked })
}
className="accent-accent-brand"
/>
{t("homepage.showStatus")}
</label>
</div>
);
}
@@ -0,0 +1,33 @@
import { useTranslation } from "react-i18next";
import type {
SshTerminalConfig,
WidgetEditFormProps,
} from "@/types/homepage-types";
import { SingleHostEditForm } from "./SingleHostEditForm";
export function SshTerminalEditForm({
config,
onChange,
}: WidgetEditFormProps<SshTerminalConfig>) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-3">
<SingleHostEditForm
hostId={config.hostId}
onChange={(hostId) => onChange({ ...config, hostId })}
filter={(h) => !!(h.enableSsh && h.enableTerminal)}
/>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={config.autoConnect}
onChange={(e) =>
onChange({ ...config, autoConnect: e.target.checked })
}
className="accent-accent-brand"
/>
{t("homepage.sshTerminalAutoConnect")}
</label>
</div>
);
}
@@ -0,0 +1,49 @@
import { useTranslation } from "react-i18next";
import type {
SystemOverviewConfig,
WidgetEditFormProps,
} from "@/types/homepage-types";
export function SystemOverviewEditForm({
config,
onChange,
}: WidgetEditFormProps<SystemOverviewConfig>) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2">
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={config.showVersion}
onChange={(e) =>
onChange({ ...config, showVersion: e.target.checked })
}
className="accent-accent-brand"
/>
{t("homepage.overviewVersion")}
</label>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={config.showDbHealth}
onChange={(e) =>
onChange({ ...config, showDbHealth: e.target.checked })
}
className="accent-accent-brand"
/>
{t("homepage.overviewDatabase")}
</label>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={config.showUptime}
onChange={(e) =>
onChange({ ...config, showUptime: e.target.checked })
}
className="accent-accent-brand"
/>
{t("homepage.overviewUptime")}
</label>
</div>
);
}
@@ -0,0 +1,27 @@
import { useTranslation } from "react-i18next";
import type {
TermixUptimeConfig,
WidgetEditFormProps,
} from "@/types/homepage-types";
export function TermixUptimeEditForm({
config,
onChange,
}: WidgetEditFormProps<TermixUptimeConfig>) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2">
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={config.showDetailed}
onChange={(e) =>
onChange({ ...config, showDetailed: e.target.checked })
}
className="accent-accent-brand"
/>
{t("homepage.showSeconds")}
</label>
</div>
);
}
@@ -0,0 +1,142 @@
import { useTranslation } from "react-i18next";
import { Input } from "@/components/input";
import { Textarea } from "@/components/textarea";
import type {
TextBannerConfig,
TextBannerFontSize,
TextBannerAlign,
TextBannerWeight,
WidgetEditFormProps,
} from "@/types/homepage-types";
const SIZES: { id: TextBannerFontSize; label: string }[] = [
{ id: "sm", label: "SM" },
{ id: "md", label: "MD" },
{ id: "lg", label: "LG" },
{ id: "xl", label: "XL" },
];
const ALIGNS: { id: TextBannerAlign; label: string }[] = [
{ id: "left", label: "Left" },
{ id: "center", label: "Center" },
{ id: "right", label: "Right" },
];
const WEIGHTS: { id: TextBannerWeight; label: string }[] = [
{ id: "normal", label: "Normal" },
{ id: "semibold", label: "Semibold" },
{ id: "bold", label: "Bold" },
];
export function TextBannerEditForm({
config,
onChange,
}: WidgetEditFormProps<TextBannerConfig>) {
const { t } = useTranslation();
const ButtonGroup = <T extends string>({
opts,
value,
onSelect,
}: {
opts: { id: T; label: string }[];
value: T;
onSelect: (v: T) => void;
}) => (
<div className="flex gap-1">
{opts.map((o) => (
<button
key={o.id}
type="button"
onClick={() => onSelect(o.id)}
className={`px-2 py-0.5 text-[10px] border transition-colors ${value === o.id ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground"}`}
>
{o.label}
</button>
))}
</div>
);
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.content")}
</label>
<Textarea
value={config.text}
onChange={(e) => onChange({ ...config, text: e.target.value })}
rows={2}
className="text-sm min-h-[60px] resize-none rounded-none"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.fontSize")}
</label>
<ButtonGroup
opts={SIZES}
value={config.fontSize}
onSelect={(v) => onChange({ ...config, fontSize: v })}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.textAlign")}
</label>
<ButtonGroup
opts={ALIGNS}
value={config.textAlign}
onSelect={(v) => onChange({ ...config, textAlign: v })}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.fontWeight")}
</label>
<ButtonGroup
opts={WEIGHTS}
value={config.fontWeight}
onSelect={(v) => onChange({ ...config, fontWeight: v })}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.backgroundColor")}
</label>
<div className="flex items-center gap-2">
<input
type="color"
value={config.backgroundColor ?? "#1e1e2e"}
onChange={(e) =>
onChange({ ...config, backgroundColor: e.target.value })
}
className="w-8 h-8 border border-border cursor-pointer"
/>
<Input
value={config.backgroundColor ?? ""}
onChange={(e) =>
onChange({
...config,
backgroundColor: e.target.value || undefined,
})
}
placeholder="#1e1e2e"
className="h-8 text-sm flex-1"
/>
{config.backgroundColor && (
<button
type="button"
onClick={() =>
onChange({ ...config, backgroundColor: undefined })
}
className="text-[10px] text-muted-foreground hover:text-foreground shrink-0"
>
{t("homepage.clearColor")}
</button>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,18 @@
import type {
TunnelWidgetConfig,
WidgetEditFormProps,
} from "@/types/homepage-types";
import { SingleHostEditForm } from "./SingleHostEditForm";
export function TunnelWidgetEditForm({
config,
onChange,
}: WidgetEditFormProps<TunnelWidgetConfig>) {
return (
<SingleHostEditForm
hostId={config.hostId}
onChange={(hostId) => onChange({ ...config, hostId })}
filter={(h) => !!(h.enableSsh && h.enableTunnel)}
/>
);
}
@@ -0,0 +1,59 @@
import { useTranslation } from "react-i18next";
import { Input } from "@/components/input";
import { Switch } from "@/components/switch";
import type {
WeatherConfig,
WidgetEditFormProps,
} from "@/types/homepage-types";
export function WeatherEditForm({
config,
onChange,
}: WidgetEditFormProps<WeatherConfig>) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.location")}
</label>
<Input
value={config.location}
onChange={(e) => onChange({ ...config, location: e.target.value })}
placeholder="New York, London, Tokyo..."
className="h-8 text-sm"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.temperatureUnit")}
</label>
<div className="flex gap-0 border border-border">
{(["C", "F"] as const).map((u) => (
<button
key={u}
type="button"
onClick={() => onChange({ ...config, unit: u })}
className={`flex-1 h-8 text-sm transition-colors ${
config.unit === u
? "bg-accent-brand text-white"
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
}`}
>
°{u}
</button>
))}
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-foreground">
{t("homepage.showForecast")}
</label>
<Switch
checked={config.showForecast}
onCheckedChange={(v) => onChange({ ...config, showForecast: v })}
/>
</div>
</div>
);
}
@@ -0,0 +1,378 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/dialog";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import type {
CanvasWidget,
ServiceLinkConfig,
FolderConfig,
NotesConfig,
BookmarkListConfig,
ClockConfig,
HostStatusConfig,
WeatherConfig,
IframeConfig,
RssFeedConfig,
MetricsChartConfig,
HostGridConfig,
AlertFeedConfig,
PingStatusConfig,
RecentActivityConfig,
TermixUptimeConfig,
SystemOverviewConfig,
SshTerminalConfig,
QuickConnectConfig,
FileManagerWidgetConfig,
DockerWidgetConfig,
TunnelWidgetConfig,
CalendarConfig,
CountdownConfig,
SearchBarConfig,
TextBannerConfig,
ImageWidgetConfig,
MarkdownNotesConfig,
CustomApiConfig,
ServiceGridConfig,
DashboardLinksConfig,
SearchLinksConfig,
LinkTreeConfig,
} from "@/types/homepage-types";
import { ServiceLinkEditForm } from "./ServiceLinkEditForm";
import { FolderEditForm } from "./FolderEditForm";
import { NotesEditForm } from "./NotesEditForm";
import { BookmarkListEditForm } from "./BookmarkListEditForm";
import { ClockEditForm } from "./ClockEditForm";
import { HostStatusEditForm } from "./HostStatusEditForm";
import { WeatherEditForm } from "./WeatherEditForm";
import { IframeEditForm } from "./IframeEditForm";
import { RssFeedEditForm } from "./RssFeedEditForm";
import { MetricsChartEditForm } from "./MetricsChartEditForm";
import { HostGridEditForm } from "./HostGridEditForm";
import { AlertFeedEditForm } from "./AlertFeedEditForm";
import { PingStatusEditForm } from "./PingStatusEditForm";
import { RecentActivityEditForm } from "./RecentActivityEditForm";
import { TermixUptimeEditForm } from "./TermixUptimeEditForm";
import { SystemOverviewEditForm } from "./SystemOverviewEditForm";
import { SshTerminalEditForm } from "./SshTerminalEditForm";
import { QuickConnectEditForm } from "./QuickConnectEditForm";
import { FileManagerWidgetEditForm } from "./FileManagerWidgetEditForm";
import { DockerWidgetEditForm } from "./DockerWidgetEditForm";
import { TunnelWidgetEditForm } from "./TunnelWidgetEditForm";
import { CalendarEditForm } from "./CalendarEditForm";
import { CountdownEditForm } from "./CountdownEditForm";
import { SearchBarEditForm } from "./SearchBarEditForm";
import { TextBannerEditForm } from "./TextBannerEditForm";
import { ImageWidgetEditForm } from "./ImageWidgetEditForm";
import { MarkdownNotesEditForm } from "./MarkdownNotesEditForm";
import { CustomApiEditForm } from "./CustomApiEditForm";
import { ServiceGridEditForm } from "./ServiceGridEditForm";
import { DashboardLinksEditForm } from "./DashboardLinksEditForm";
import { SearchLinksEditForm } from "./SearchLinksEditForm";
import { LinkTreeEditForm } from "./LinkTreeEditForm";
interface WidgetEditDialogProps {
widget: CanvasWidget | null;
onSave: (
id: number,
title: string | null,
config: Record<string, unknown>,
) => void;
onClose: () => void;
}
export function WidgetEditDialog({
widget,
onSave,
onClose,
}: WidgetEditDialogProps) {
const { t } = useTranslation();
const [title, setTitle] = useState(widget?.title ?? "");
const [config, setConfig] = useState<Record<string, unknown>>(
widget?.config ?? {},
);
if (!widget) return null;
const handleSave = () => {
onSave(widget.id, title || null, config);
onClose();
};
const renderForm = () => {
switch (widget.typeId) {
case "service_link":
return (
<ServiceLinkEditForm
config={config as ServiceLinkConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "folder":
return (
<FolderEditForm
config={config as FolderConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "notes":
return (
<NotesEditForm
config={config as NotesConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "bookmark_list":
return (
<BookmarkListEditForm
config={config as BookmarkListConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "clock":
return (
<ClockEditForm
config={config as ClockConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "host_status":
return (
<HostStatusEditForm
config={config as HostStatusConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "weather":
return (
<WeatherEditForm
config={config as WeatherConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "iframe_embed":
return (
<IframeEditForm
config={config as IframeConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "rss_feed":
return (
<RssFeedEditForm
config={config as RssFeedConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "metrics_chart":
return (
<MetricsChartEditForm
config={config as MetricsChartConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "host_grid":
return (
<HostGridEditForm
config={config as HostGridConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "alert_feed":
return (
<AlertFeedEditForm
config={config as AlertFeedConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "ping_status":
return (
<PingStatusEditForm
config={config as PingStatusConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "recent_activity":
return (
<RecentActivityEditForm
config={config as RecentActivityConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "termix_uptime":
return (
<TermixUptimeEditForm
config={config as TermixUptimeConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "system_overview":
return (
<SystemOverviewEditForm
config={config as SystemOverviewConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "ssh_terminal":
return (
<SshTerminalEditForm
config={config as SshTerminalConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "quick_connect":
return (
<QuickConnectEditForm
config={config as QuickConnectConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "file_manager_widget":
return (
<FileManagerWidgetEditForm
config={config as FileManagerWidgetConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "docker_widget":
return (
<DockerWidgetEditForm
config={config as DockerWidgetConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "tunnel_widget":
return (
<TunnelWidgetEditForm
config={config as TunnelWidgetConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "calendar":
return (
<CalendarEditForm
config={config as CalendarConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "countdown":
return (
<CountdownEditForm
config={config as CountdownConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "search_bar":
return (
<SearchBarEditForm
config={config as SearchBarConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "text_banner":
return (
<TextBannerEditForm
config={config as TextBannerConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "image_widget":
return (
<ImageWidgetEditForm
config={config as ImageWidgetConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "markdown_notes":
return (
<MarkdownNotesEditForm
config={config as MarkdownNotesConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "custom_api":
return (
<CustomApiEditForm
config={config as CustomApiConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "service_grid":
return (
<ServiceGridEditForm
config={config as ServiceGridConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "dashboard_links":
return (
<DashboardLinksEditForm
config={config as DashboardLinksConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "search_links":
return (
<SearchLinksEditForm
config={config as SearchLinksConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "link_tree":
return (
<LinkTreeEditForm
config={config as LinkTreeConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
default:
return null;
}
};
return (
<Dialog
open={!!widget}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent
className="max-w-sm"
onMouseDown={(e) => e.stopPropagation()}
>
<DialogHeader>
<DialogTitle>{t("homepage.editWidget")}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.title_label")}
</label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("homepage.widgetTitlePlaceholder")}
className="h-8 text-sm"
/>
</div>
{renderForm()}
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={onClose}>
{t("homepage.cancel")}
</Button>
<Button size="sm" onClick={handleSave}>
{t("homepage.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}