mirror of
https://github.com/YuzuZensai/Termix.git
synced 2026-11-12 17:31:40 +00:00
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:
co-authored by
DivByZero
dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
devdanetra
Aleksandr Fominykh
sash
ZacharyZcR
parent
fd27a366d0
commit
9de904dd4c
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user