mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
release-2.9.0 (#1532)
* fix(macos): skip single-instance lock in Mac App Store builds (#1454)
fix(macos): skip single-instance lock in Mac App Store builds
* Fix/backend optional sharp (#1455)
Fix/backend optional sharp
* feat: issue #1218 (#1218)
https://github.com/Termix-SSH/Support/issues/1218
* feat: Add option to use saved global custom theme under Connection Defaults (#1209)
https://github.com/Termix-SSH/Support/issues/1209
* feat: Add Fleet Sharing functionality to the UI (#1228)
https://github.com/Termix-SSH/Support/issues/1228
* feat: Add a list view to the Docker management page (#1237)
https://github.com/Termix-SSH/Support/issues/1237
* feat: issue #1264 (#1264)
https://github.com/Termix-SSH/Support/issues/1264
* feat: 2FA With FortiToken (#1288)
https://github.com/Termix-SSH/Support/issues/1288
* feat: Allow to disable showing paths to folders (#1274)
https://github.com/Termix-SSH/Support/issues/1274
* feat: Sync Network Graph between Desktop and Remote Server (#1245)
https://github.com/Termix-SSH/Support/issues/1245
* feat: Version Number on offline servers (#1291)
https://github.com/Termix-SSH/Support/issues/1291
* feat: Support OrbStack Docker socket path on macOS / Fix "Docker is not installed" on mac... (#1302)
https://github.com/Termix-SSH/Support/issues/1302
* feat: Sidebar host click should focus existing tab instead of opening a new connection (#1289)
https://github.com/Termix-SSH/Support/issues/1289
* fix: Search in side bar shows overlapping hosts when grouped by tags (#1303)
https://github.com/Termix-SSH/Support/issues/1303
* fix: I can't send a file on its own. (#1304)
https://github.com/Termix-SSH/Support/issues/1304
* fix: sudo password not autofilling (#1248)
https://github.com/Termix-SSH/Support/issues/1248
* fix: node-MainThread (#1300)
https://github.com/Termix-SSH/Support/issues/1300
* fix: Tunnel authentication with shared credentials (#1295)
https://github.com/Termix-SSH/Support/issues/1295
* fix: host key updates not syncing, metrics 404 race on first connect
Host key writes never bumped updatedAt so sync never picked them up.
Also retry the first metrics fetch briefly instead of failing right away.
* chore: increment ver
* feat: add category and icon fields to plugin manifest schema
* ci(deps): bump the github-actions group with 2 updates (#1458)
Bumps the github-actions group with 2 updates: [crowdin/github-action](https://github.com/crowdin/github-action) and [actions/github-script](https://github.com/actions/github-script).
Updates `crowdin/github-action` from 2 to 3
- [Release notes](https://github.com/crowdin/github-action/releases)
- [Commits](https://github.com/crowdin/github-action/compare/v2...v3)
Updates `actions/github-script` from 7 to 9
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)
---
updated-dependencies:
- dependency-name: crowdin/github-action
dependency-version: '3'
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: github-actions
- dependency-name: actions/github-script
dependency-version: '9'
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 13 updates (#1459)
Bumps the dev-patch-updates group with 13 updates:
| Package | From | To |
| --- | --- | --- |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.11.0` | `6.11.1` |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.1` | `6.7.2` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.9` | `6.43.12` |
| [@testing-library/dom](https://github.com/testing-library/dom-testing-library) | `10.4.1` | `10.4.2` |
| [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.5` | `14.6.7` |
| [@types/ssh2](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ssh2) | `1.15.5` | `1.15.6` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.0` | `6.1.1` |
| [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.34.1` | `3.34.3` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.4` | `0.5.7` |
| [i18next](https://github.com/i18next/i18next) | `26.4.0` | `26.4.2` |
| [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.8` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.12` | `17.0.14` |
Updates `@codemirror/commands` from 6.11.0 to 6.11.1
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/commits)
Updates `@codemirror/search` from 6.7.1 to 6.7.2
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)
Updates `@codemirror/view` from 6.43.9 to 6.43.12
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)
Updates `@testing-library/dom` from 10.4.1 to 10.4.2
- [Release notes](https://github.com/testing-library/dom-testing-library/releases)
- [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/dom-testing-library/compare/v10.4.1...v10.4.2)
Updates `@testing-library/react` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.2...v16.3.3)
Updates `@testing-library/user-event` from 14.6.5 to 14.6.7
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.5...v14.6.7)
Updates `@types/ssh2` from 1.15.5 to 1.15.6
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/ssh2)
Updates `@vitejs/plugin-react` from 6.1.0 to 6.1.1
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.1/packages/plugin-react)
Updates `cytoscape` from 3.34.1 to 3.34.3
- [Release notes](https://github.com/cytoscape/cytoscape.js/releases)
- [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.34.1...v3.34.3)
Updates `eslint-plugin-react-refresh` from 0.5.4 to 0.5.7
- [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.4...v0.5.7)
Updates `i18next` from 26.4.0 to 26.4.2
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.4.0...v26.4.2)
Updates `prettier` from 3.9.6 to 3.9.8
- [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.9.6...3.9.8)
Updates `react-i18next` from 17.0.12 to 17.0.14
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.12...v17.0.14)
---
updated-dependencies:
- dependency-name: "@codemirror/commands"
dependency-version: 6.11.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@codemirror/search"
dependency-version: 6.7.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
dependency-version: 6.43.12
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/dom"
dependency-version: 10.4.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/react"
dependency-version: 16.3.3
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/user-event"
dependency-version: 14.6.7
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@types/ssh2"
dependency-version: 1.15.6
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@vitejs/plugin-react"
dependency-version: 6.1.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: cytoscape
dependency-version: 3.34.3
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.7
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: i18next
dependency-version: 26.4.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: prettier
dependency-version: 3.9.8
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: react-i18next
dependency-version: 17.0.14
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 the prod-patch-updates group with 6 updates (#1461)
Bumps the prod-patch-updates group with 6 updates:
| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.10` | `3.14.13` |
| [compression](https://github.com/expressjs/compression) | `1.8.1` | `1.8.2` |
| [jose](https://github.com/panva/jose) | `6.2.9` | `6.2.12` |
| [jszip](https://github.com/Stuk/jszip) | `3.10.1` | `3.10.2` |
| [socks](https://github.com/JoshGlazebrook/socks) | `2.8.9` | `2.8.10` |
| [undici](https://github.com/nodejs/undici) | `8.10.0` | `8.10.2` |
Updates `@tanstack/react-virtual` from 3.14.10 to 3.14.13
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.13/packages/react-virtual)
Updates `compression` from 1.8.1 to 1.8.2
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/v1.8.1...v1.8.2)
Updates `jose` from 6.2.9 to 6.2.12
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.9...v6.2.12)
Updates `jszip` from 3.10.1 to 3.10.2
- [Changelog](https://github.com/Stuk/jszip/blob/main/CHANGES.md)
- [Commits](https://github.com/Stuk/jszip/compare/v3.10.1...v3.10.2)
Updates `socks` from 2.8.9 to 2.8.10
- [Release notes](https://github.com/JoshGlazebrook/socks/releases)
- [Commits](https://github.com/JoshGlazebrook/socks/compare/2.8.9...2.8.10)
Updates `undici` from 8.10.0 to 8.10.2
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.10.0...v8.10.2)
---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
dependency-version: 3.14.13
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: compression
dependency-version: 1.8.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: jose
dependency-version: 6.2.12
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: jszip
dependency-version: 3.10.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: socks
dependency-version: 2.8.10
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: undici
dependency-version: 8.10.2
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>
* feat: add plugin worker protocol, bootstrap and plugin_storage table
Worker-side ctx proxy plus the message envelope it talks over, and the
per-plugin key/value table behind ctx.storage with migrations for all
three dialects. Rows cascade with the plugin so uninstalling leaves
nothing behind.
* feat: add allowlisted host view for plugins
An allowlist rather than a list of fields to strip. A blocklist rots:
the moment a new secret-bearing column lands on SSHHost it starts
leaking silently. With an allowlist a new field stays invisible to
plugins until someone adds it here on purpose.
* feat: add plugin loader with manifest validation and crash restart
Validates an unpacked plugin, spawns a worker_threads worker and manages
activate/deactivate/crash-restart with backoff, disabling after three
tries.
The crash counter clears only after a plugin stays up past a stability
window, not on activation. Clearing it on activation let a plugin that
activates cleanly and then dies restart forever, because every attempt
looked like the first.
loader.ts documents what the worker boundary does and does not buy you.
Node's permission model is process-wide, not per-worker, so it cannot be
enabled for plugin workers alone and is not claimed here.
* feat: route internal events through a shared plugin event bus
Formalises three ad-hoc publish paths that already existed:
automation-events.ts, metrics/automation-bridge.ts and the
hostSessionStatus singleton, now the host.session.status topic.
Both invariants the old code relied on are preserved. Publishing stays
fire-and-forget, so a failing subscriber cannot disturb a metrics poll or
a host delete. The automations engine subscribes to the bus at scheduler
start instead of being imported ad hoc, which keeps the edge
one-directional: automations may import repositories, hosts modules must
not import automations.
* feat: add plugin ctx broker with permission gate and audit
Every ctx call a plugin makes is checked against its granted
capabilities, performed by the main thread, then audited. A capability
must be both declared in the manifest and granted in the database, so
widening a plugin's reach always needs a new manifest the user can see,
never just a database row.
Audit attribution is set by the broker, not the plugin. The plugin
supplies only the details, so it cannot forge the actor.
fix: stop plugin workers seeing plaintext credentials in error messages
A failing ctx.ssh.connect handed the worker the raw error from deep in
the SSH stack, which embeds the connect config. The worker received, in
full:
Authentication failed for root using password <SECRET>
where <SECRET> was the host's real plaintext password. Any plugin with
ssh.exec could read it by catching the error, defeating the whole point
of the handle-based ssh API.
Errors crossing the boundary are now sanitised. Only PluginFacingError
and PluginPermissionError, both authored here, reach a plugin verbatim;
anything else becomes a generic message and the real one goes to the
log. This uses a marker class rather than matching on message text,
because matching would start leaking again the moment an upstream error
happened to contain a familiar phrase.
Covered by credential-isolation.test.ts, which plants a sentinel in
every secret-bearing field and asserts it appears in no message the
worker ever receives.
* feat: dispatch plugin HTTP routes and wire the runtime into startup
ctx.http.route now registers a real router on the /plugin-api/:pluginId
dispatcher, replacing its stub 404. A plugin never touches the Express
req/res: it gets a plain summary and returns a plain object, so sockets
and session cookies stay on this side of the boundary. Authorization and
cookie headers are not forwarded.
Plugins load last in the start-up sequence, after the server is fully
wired, and are terminated before the database on shutdown so none can
outlive it. A plugin that fails to load cannot stop the backend.
fix: correct the nginx plugin-api location regex
The pattern was ^/plugin-api/(/.*)?$, which needs a double slash and so
never matched /plugin-api/<id>/<route>. Every other block in the file
uses ^/prefix(/.*)?$. Left alone, plugin routes would work in dev and
404 only behind Docker.
* fix: update stale SFTP transfer test for single file destination paths
The test still asserted transferToHost was called with the destination
directory "/srv". Commit 7727fa09 (#1304) deliberately changed a single
file transfer to send the full destination path including the filename,
so the correct expectation is "/srv/remote-1.txt". The code was right
and the test was left behind.
* feat: ship the ssh terminal as a first-party in-process plugin
Adds a hardcoded process:transport-owner tier so the terminal can own its
WebSocket server and ssh2 clients, which cannot cross the worker boundary.
* feat: ship docker as a first-party in-process plugin
Moves docker backend and frontend into plugins/docker/, alongside
ssh-terminal. Adds a host editor tab seam, a dashboard card registry,
and a build step to compile plugin backends into dist.
* feat: ship host metrics as a first-party in-process plugin
* fix: harden plugin auth and add per-request identity for ctx calls
Require auth on /plugin-api, gate plugin enable/grant/revoke routes
behind admin.plugins.manage, and thread the calling user through
worker ctx.hosts/ctx.ssh calls instead of always using the install
owner. Also adds the plugin permissions grant/revoke UI.
* feat: ship the AI assistant as a first-party in-process plugin
Moves src/backend/ai and its routes into plugins/ai, mounted through a
small /ai dispatcher instead of its own port. Moves the ai-enabled and
ai-private-endpoints settings routes out of user-settings-routes.ts
into the plugin too, so it has no leftover core dependents.
* fix: remove compiled plugin JS left behind in plugins/ after build
copy-bundled-plugins.cjs compiled and rewrote plugin backend TS in
place, copied it to dist/plugins, but never deleted the .js it had
just emitted next to the .ts sources. Those files shadowed the real
source for vitest/tsx and had import paths rewritten for the dist
layout, so tests failed with "Cannot find module" once a local build
had run. Affects docker, host-metrics and ai, not just this one.
* feat: add a permission-gated backend service registry for plugins
* feat: add a permission-gated UI action registry and action slots for plugins
Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal
plugin offers and the ai plugin fills, gated on ai.services.use.
* feat: add cross-plugin secret references gated by the provider's RBAC permission
* feat: move proxmox discovery/import/sync into a first-party plugin
* feat: move remote desktop (RDP/VNC/Telnet) into a first-party plugin
Relocates guacamole backend/frontend source and tests into
plugins/remote-desktop, following the ssh-terminal/docker pattern.
* feat: wire remote-desktop plugin into core and rename i18n keys
Repoints core consumers (dispatcher, collab, session-sharing, tab
utils) at the plugin, adds isTabTypeAvailable gating for rdp/vnc/
telnet, widens hostCapability to accept an array, and renames the
guacamole i18n namespace to remoteDesktop.
* feat: move fleets into a first-party plugin
* feat: move automations into a first-party plugin
* feat: remove legacy alert rules, superseded by automations
Alert rules already auto-migrate into automations on boot and the old
engine stands down after. Deletes the rule/firing tables, routes, UI,
and AI tools; keeps notification channels since automations still use
them. Also fixes automations' notify step, which still called the
deleted repository.
* feat: move network topology into a first-party plugin
* feat: move tailscale device discovery and settings into a plugin
Adds plugins/tailscale/ with a devices sidebar tab, admin settings
section, and re-auth dialog. Host Metrics tailscale manager card and
SSH re-auth banner parsing stay in core.
* feat: move workspaces into a first-party plugin
* feat: move web endpoint into a first-party plugin
* feat: collapse plugin runtime to one tier behind a plugin SDK
* feat: make each plugin an npm workspace built by a termix-plugin CLI
* feat: give plugins their own tables, migrations and sync entities
* feat: serve plugin http and websockets from one mount instead of ports
* feat: namespace plugin permissions and keep them valid when a plugin is off
* feat: give plugins their own settings and one settings screen
* feat: load plugin frontends at runtime and remove plugin ids from the shell
* fix: mock the ai status fetch in its activate test
* feat: add auth extension points and one ssh connect pipeline
* feat: rebuild workspaces as the reference plugin
* fix: gate second factors after SSO/LDAP behind an admin setting
* feat: convert network-topology to a full plugin (B1)
* feat: move snippets backend and tables into a plugin (B2)
* feat: point core frontend snippets pickers at the plugin (B2)
* feat: add the snippets plugin (B2)
* feat: wire ai, automations, fleets and ssh-terminal to snippets (B2)
* feat: finish the tailscale plugin, including SSH auth (B3)
* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
* chore: update proxmox and host-metrics locale files for B5
* chore: regenerate drizzle migration journals for B5
* feat: add ctx.hosts create/update to the SDK, host-metrics drops proxmox
* feat: move proxmox node stats and collectors into the proxmox plugin
* feat: drop proxmox host columns, use plugin settings instead
* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
* feat: convert file manager into a plugin
* feat: convert ssh tunnels into a plugin
* feat: finish the web-endpoint plugin, add ctx.desktop (B8)
* feat: move the terminal fully into the ssh-terminal plugin (B9)
* feat: convert tmux monitoring into a plugin (B10)
* feat: convert serial console into the serial plugin
* feat: move session sharing and collab rooms into a plugin (B12)
* feat: turn session recording into a plugin (B13)
* feat: finish the remote-desktop plugin (B14)
* feat: finish the host-metrics plugin, move host status into core (B16)
* feat: finish the docker plugin (B15)
* feat: finish the automations plugin (B17)
* feat: finish the ai plugin (B18)
* feat: finish the homepage plugin, move service links and canvas out of core (B19)
* feat: convert wake-on-lan into a plugin (B20)
* feat: convert secret-sources into a plugin (B20)
* feat: move totp and passkeys into plugins (C1)
* feat: move sso and ldap logins into plugins (C2)
* feat: move opkssh and warpgate into plugins (C3)
* feat: move step-ca into a plugin (C4)
* feat: move vault ssh signing into a plugin (C5)
* feat: move termix identity into a plugin (C6)
* feat: move acme certificate automation into a plugin (C7)
* fix: finish 2.9.0 plugin moves and get every check green
* chore: lock the plugin boundary and remove 2.9.0 leftovers
* fix: harden the plugin runtime after the 2.9.0 security review
* feat: add plugin packing, signing, pinned trust and tmxplug bundling
* test: prove 2.8 to 2.9.0 upgrade is lossless and fix what it caught
* fix: give bundled plugin backends a real require for CJS deps
* feat: improve UI loading time and jitter
* feat: show credential sharing inline in the sidebar like host sharing
* feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning
* feat: unify alerts into one plugin, channels, rules and inbox
* feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling
* fix: set blacksmith builder cache-key to stop broken gha cache fallback
* fix: link plugin-sdk workspace before npm ci in docker deps stage
* fix: link every plugin workspace before npm ci in docker deps stage
* fix(host-metrics): exclude image and firmware filesystems (#1469)
* fix(host-metrics): exclude image and firmware filesystems
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(hosts): normalize imported jump host references (#1470)
* fix(hosts): normalize imported jump host references
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(ci): build plugin SDK and repair MySQL settings migration (#1471)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): keep inline rename consistent (#1476)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): keep inline rename consistent
* fix(dashboard): reuse translated host status labels (#1475)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(dashboard): reuse translated host status labels
* docs(api): clarify local and remote version fields (#1474)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* docs(api): clarify local and remote version fields
* fix(file-manager): preserve selected backgrounds on hover (#1473)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): preserve selection feedback on hover
* test(ui): await dialog focus cleanup before teardown
* fix(file-manager): restore Ctrl/Cmd+F file search (#1472)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): focus file search with Ctrl or Cmd F
* fix: keep rounded-full circular in production builds
* fix(file-manager): cancel uploads and stop remote writes (#1477)
* fix(file-manager): cancel uploads and stop remote writes
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): apply compact density to directory tree (#1478)
* fix(file-manager): apply compact density to directory tree
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): preserve font sizes in compact mode (#1480)
* fix(file-manager): preserve font sizes in compact mode
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): prioritize file names in narrow lists (#1483)
* fix(file-manager): prioritize file names in narrow lists
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* fix(remote-desktop): probe the saved host guacd endpoint (#1481)
* fix(remote-desktop): probe the saved host guacd endpoint
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* fix(homepage): check allowlisted private service reachability (#1479)
* fix(homepage): check allowlisted private service reachability
* test(css): exercise the configured PostCSS plugin chain
* fix(remote-desktop): stop unsolicited clipboard read prompts (#1482)
* fix(remote-desktop): read clipboard only on explicit paste
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* chore: simplify plugin readmes, changelogs and manifest descriptions
* fix: keep plugins loaded after signing in from the login screen
* fix(file-manager): accept OS file drops over existing rows (#1484)
* fix(file-manager): accept OS file drops over existing rows
* test(file-manager): target the file label in drop tests
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* fix(file-manager): pipeline SFTP download reads (#1485)
* feat: add plugin sdk build to beta release
* fix: make link-store secret check immune to random base64 matches
* fix(ldap): preserve the directory-provided bind DN (#1486)
* fix: compute beta changes across dev branches and move the beta tag
* chore: untrack plugin sdk ARCHITECTURE.md
* chore: remove references to plugin sdk ARCHITECTURE.md
* feat: merge host editor terminal tabs into one owned by ssh-terminal
* feat: add descriptions to plugin host enable toggles
* fix(file-manager): show download progress in file viewers (#1487)
* fix(electron): signal backend readiness over IPC (#1488)
* fix(split-screen): allow layout creation without randomUUID (#1490)
* fix(split-screen): allow layout creation without randomUUID
* test(credentials): avoid flaky Ed25519 key generation
* fix(sidebar): remeasure visible rows after density changes (#1489)
* fix(sidebar): remeasure visible rows after density changes
* test(credentials): avoid flaky Ed25519 key generation
* feat(split-screen): redesign split screen with free-form panes and pane picker
* feat(sidebar): add host click behavior setting, default to always opening a new tab
* feat(snippets): redesign panel to match the host list
* fix(session-recording): stop session logs panel from refetching in a loop
* feat(snippets): bring back sharing, target hosts and drag reorder
* feat(ssh-terminal): add toolbar position, start state, display and fade host settings
* feat(ssh-terminal): add host setting to turn off password prompt auto-fill
* fix(ai): stop streams getting cut off and keep tool steps across messages
* feat: improve quick connect system using plugin api
* fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport
* feat: move terminal settings, look and ssh tools into ssh-terminal plugin
* feat: move snippets, keybinding actions and macros out of core into plugins
* feat: store rdp, vnc and telnet logins in a generic per-protocol table
* feat: type the plugin host record, move plugin strings out of core and add lint checks
* feat: replace core homepage widget registry with generic extension points
* feat: plugins only see their own host settings in the browser
* feat: harden plugin api for separate repos, signed updates and generic external login
* fix: keep plugin css below core utilities and version plugin assets by their css
* fix: load and save plugin host settings in the editor and tighten settings row spacing
* feat: redesign user host defaults system
* fix(remote-desktop): release held keys on focus loss (#1498)
* fix(session-sharing): replace local presentation after takeover (#1500)
* fix(file-manager): refresh disk usage with directory listing (#1503)
* fix(ssh-terminal): preserve input order during local echo reconciliation (#1505)
* feat(remote-desktop): expose display zoom controls for RDP (#1514)
* fix(file-manager): restore text selection and copying in Markdown previews (#1518)
* fix(sidebar): keep compact host addresses visible (#1524)
* fix(hosts): preserve plugin settings when reopening the editor (#1499)
* fix(ssh-terminal): close owner sockets when presentations unmount (#1501)
* fix(session-sharing): synchronize shared SSH terminal dimensions (#1502)
* fix(session-sharing): show participant presence in member terminals (#1504)
* fix(ai): sync provider configuration and keys across linked devices (#1506)
* feat(ssh-terminal): add a duplicate tab action (#1510)
* feat(file-manager): allow disabling move-to-trash confirmation (#1511)
* feat(file-manager): default to the remote login directory (#1512)
* feat(homepage): synchronize canvas layouts across devices (#1515)
* fix(shortcuts): prevent accidental double-Shift palette activation (#1519)
* feat(tmux-monitor): add a unified multi-host view (#1520)
* fix(electron): allow explicit quit with active connections (#1522)
* feat: reconnect all disconnected terminal tabs (#1523)
* fix(hosts): preserve recording and sharing settings in the dev editor (#1509)
* fix(database): preserve RDP domain during startup backfill (#1497)
* feat(sidebar): add keyboard type-ahead search for hosts (#1516)
* feat(automations): add per-host maintenance mode and recurring schedules (#1521)
* feat(tmux): add a per-host mouse toggle and preserve remote configuration (#1513)
* fix(session-sharing): use the linked server for desktop meetings and links (#1507)
* fix(tunnels): support TOTP for client tunnels and jump hosts (#1508)
* fix: type errors and lint failures from merged plugin prs
* feat(installer): make the Windows desktop shortcut optional (#1517)
* fix: ship the plugin sdk and serialport in desktop builds
* fix: prefer ipv4 when resolving ssh host names (#1365)
* fix: readable terminal selection with the light theme (#1319)
* fix: build desktop session share links on the linked server (#1350)
* fix: show meeting guests to members and hide the roster from guests (#1353)
* fix: reliable file manager saves with sudo prompt and no base64 guessing (#1335)
* fix: show disk usage for the mount being browsed (#1347)
* fix: load the linux tray icon reliably and log tray failures (#1332)
* fix: cover the gnome close path in the desktop quit test (#1332)
* fix: prompt for jump host TOTP in terminal, file manager and metrics (#1312)
* feat: optional auto reconnect for dropped ssh sessions (#1321)
* feat: tighter compact host rows with inline tags (#1325)
* feat: bring back the alert feed homepage widget
* feat: pick target terminals for snippets again
* feat: share button on connection tabs again
* feat: recent servers list when linking the desktop app
* feat: list the fixed app shortcuts in keyboard shortcuts
* fix: translate hardcoded toasts and drop em dashes from ui text
* fix: translate leftover hardcoded ui text
* fix: toolbar files button opens at the shell directory again
* fix: show the remote desktop note under connection origin again
* chore: openapi docs for host metrics manager routes
* chore: openapi docs for undocumented routes and fix two broken doc blocks
* fix: declare terminal.listSessions use for snippet targets
* revert: drop the alert feed widget, the notification center replaces it
* feat: restore the alert feed homepage widget
* fix: quick connect fills the host address from plugin auth editors like tailscale
* fix: docker container logs use the base background color
* chore: fix dependabot alerts and apply safe npm upgrades
* fix: proxmox stats tab reads its switch from plugin host settings
* fix: count plugin ssh logins toward host status and stop showing reachable as a warning
* fix: hide the built-in source pill in the host editor
* fix: host status shows online when the port answers and only warns on a failed login
* fix: drop the top border on the split layout menu
* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default
* fix: remote desktop auth method labels and notes for vnc and telnet
* fix: add description to the web endpoint enable switch
* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release
* feat: move telemetry out of core into a usage statistics plugin
* fix: recent activity card no longer cuts off host names early
* fix: remove duplicate top border on split screen menu
* fix: redesign session log player controls, fix tooltip overlap and drop dot separators
* fix: file manager and sftp text showing raw i18n keys
* fix: onboarding rerunning for existing users after upgrading
* fix: fleet detail buttons overflowing the sidebar
* fix: session log player scrolling off screen horizontally
* fix: network graph lag when adding hosts and on every render
* fix: maintenance index name, shared host read only view and badge polling
* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag
* chore: update release notes
* fix: remote desktop tabs reconnecting on every tab switch
* fix: build plugin sdk before linting in release workflow
* chore: lint, format, and bump version to 2.9.0
* chore: skip crowdin pre-translate in release for now
* chore: sync Crowdin translations for 2.9.0
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Koi <38512047+datlt4@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
4405 files changed
+1341026
-289722
No files matched your search
@@ -0,0 +1,77 @@
|
||||
import { mapLayoutItemIds } from "./layout-item-ids.js";
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
homepageItems,
|
||||
homepageLayouts,
|
||||
dashboardServiceLinks,
|
||||
} from "./tables.js";
|
||||
import { createHomepageRepository } from "./repository.js";
|
||||
import { registerHomepageRoutes } from "./routes.js";
|
||||
import { registerOutboundRoutes } from "./outbound-routes.js";
|
||||
import { createHomepageItemsService } from "./services.js";
|
||||
|
||||
export type { HomepageItemsV1 } from "./services.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
const items = await ctx.db.define(homepageItems);
|
||||
const layouts = await ctx.db.define(homepageLayouts);
|
||||
const serviceLinks = await ctx.db.define(dashboardServiceLinks);
|
||||
const repo = createHomepageRepository(ctx.db, {
|
||||
items,
|
||||
layouts,
|
||||
serviceLinks,
|
||||
});
|
||||
|
||||
const router = ctx.http.router<Router>();
|
||||
registerHomepageRoutes(router, repo, ctx);
|
||||
registerOutboundRoutes(router, ctx);
|
||||
|
||||
ctx.sync.registerEntity({
|
||||
type: "homepageItems",
|
||||
table: items,
|
||||
order: 80,
|
||||
// A tile inside a folder points at the folder's row.
|
||||
references: [
|
||||
{
|
||||
field: "folderId",
|
||||
syncField: "folderSyncId",
|
||||
entityType: "homepageItems",
|
||||
},
|
||||
],
|
||||
});
|
||||
ctx.sync.registerEntity({
|
||||
type: "dashboardServiceLinks",
|
||||
table: serviceLinks,
|
||||
order: 70,
|
||||
});
|
||||
|
||||
ctx.sync.registerEntity({
|
||||
type: "homepageLayouts",
|
||||
table: layouts,
|
||||
order: 81,
|
||||
singleton: true,
|
||||
serialize: async (row, resolveSyncId) => ({
|
||||
...row,
|
||||
layout: await mapLayoutItemIds(row.layout as string, async (id) =>
|
||||
typeof id === "number" && Number.isInteger(id)
|
||||
? resolveSyncId("homepageItems", id)
|
||||
: null,
|
||||
),
|
||||
}),
|
||||
deserialize: async (row, resolveId) => ({
|
||||
...row,
|
||||
layout: await mapLayoutItemIds(row.layout as string, async (id) =>
|
||||
typeof id === "string" ? resolveId("homepageItems", id) : null,
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
ctx.services.provide("homepage.items", createHomepageItemsService(ctx, repo));
|
||||
|
||||
ctx.log.info("Homepage routes mounted at /plugin-api/homepage");
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
// Everything above was registered through ctx and is disposed by core.
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Canvas positions refer to local widget ids; only sync ids can cross devices. */
|
||||
export async function mapLayoutItemIds(
|
||||
layoutJson: string,
|
||||
mapId: (id: string | number) => Promise<string | number | null>,
|
||||
): Promise<string> {
|
||||
const layout = JSON.parse(layoutJson);
|
||||
if (!layout || typeof layout !== "object" || Array.isArray(layout)) {
|
||||
throw new Error("Invalid homepage layout");
|
||||
}
|
||||
if (layout.entries === undefined) return layoutJson;
|
||||
if (!Array.isArray(layout.entries))
|
||||
throw new Error("Invalid homepage layout entries");
|
||||
const entries = [];
|
||||
for (const entry of layout.entries) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const id = entry.itemId;
|
||||
if (typeof id !== "string" && typeof id !== "number") continue;
|
||||
const itemId = await mapId(id);
|
||||
if (itemId !== null) entries.push({ ...entry, itemId });
|
||||
}
|
||||
return JSON.stringify({ ...layout, entries });
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
import type { Request, Response, Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
|
||||
const FETCH_TIMEOUT_MS = 8000;
|
||||
const MAX_FAVICON_BYTES = 512 * 1024;
|
||||
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
|
||||
const MAX_CACHE_TTL_SECONDS = 24 * 60 * 60;
|
||||
|
||||
interface RssItem {
|
||||
title: string;
|
||||
link: string;
|
||||
pubDate: string | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
async function readBufferLimited(
|
||||
response: globalThis.Response,
|
||||
maxBytes: number,
|
||||
): Promise<Buffer> {
|
||||
const declaredLength = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
||||
await response.body?.cancel().catch(() => {});
|
||||
throw new Error(`Response exceeds ${maxBytes} bytes`);
|
||||
}
|
||||
if (!response.body) return Buffer.alloc(0);
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) {
|
||||
await reader.cancel().catch(() => {});
|
||||
throw new Error(`Response exceeds ${maxBytes} bytes`);
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
return Buffer.concat(chunks, total);
|
||||
}
|
||||
|
||||
async function readTextLimited(
|
||||
response: globalThis.Response,
|
||||
maxBytes: number,
|
||||
): Promise<string> {
|
||||
const buffer = await readBufferLimited(response, maxBytes);
|
||||
return buffer.toString("utf8");
|
||||
}
|
||||
|
||||
function parseRss(xml: string): RssItem[] {
|
||||
const items: RssItem[] = [];
|
||||
const itemRegex = /<item[\s>]([\s\S]*?)<\/item>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
const getText = (tag: string, src: string): string | null => {
|
||||
const m = new RegExp(
|
||||
`<${tag}[^>]*><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/${tag}>|<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`,
|
||||
"i",
|
||||
).exec(src);
|
||||
if (!m) return null;
|
||||
return (m[1] ?? m[2]).trim();
|
||||
};
|
||||
|
||||
const getLink = (src: string): string => {
|
||||
// Self-closing <link rel="alternate" href="..." /> (BBC style)
|
||||
const selfClose = /<link[^>]+href="([^"]+)"[^>]*\/?>/i.exec(src);
|
||||
if (selfClose) return selfClose[1];
|
||||
// Plain text <link>url</link>
|
||||
return getText("link", src) ?? "";
|
||||
};
|
||||
|
||||
while ((match = itemRegex.exec(xml)) !== null) {
|
||||
const src = match[1];
|
||||
items.push({
|
||||
title: getText("title", src) ?? "(no title)",
|
||||
link: getLink(src),
|
||||
pubDate: getText("pubDate", src) ?? getText("updated", src),
|
||||
description: getText("description", src) ?? getText("summary", src),
|
||||
});
|
||||
if (items.length >= 50) break;
|
||||
}
|
||||
|
||||
// Atom feed fallback
|
||||
if (items.length === 0) {
|
||||
const entryRegex = /<entry[\s>]([\s\S]*?)<\/entry>/gi;
|
||||
while ((match = entryRegex.exec(xml)) !== null) {
|
||||
const src = match[1];
|
||||
const linkMatch = /<link[^>]+href="([^"]+)"/.exec(src);
|
||||
items.push({
|
||||
title: getText("title", src) ?? "(no title)",
|
||||
link: linkMatch?.[1] ?? "",
|
||||
pubDate: getText("published", src) ?? getText("updated", src),
|
||||
description: getText("summary", src) ?? getText("content", src),
|
||||
});
|
||||
if (items.length >= 50) break;
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* The favicon, RSS, ping and proxy routes all make outbound requests. Every
|
||||
* one goes through ctx.fetch, which keeps the existing safe-outbound rules
|
||||
* (http/https only, no embedded credentials, DNS pinned, private and
|
||||
* loopback addresses refused).
|
||||
*/
|
||||
export function registerOutboundRoutes(
|
||||
router: Router,
|
||||
ctx: PluginContext,
|
||||
): void {
|
||||
const faviconCache = new Map<
|
||||
string,
|
||||
{ data: Buffer; contentType: string; expires: number }
|
||||
>();
|
||||
const FAVICON_CACHE_SIZE = 100;
|
||||
const FAVICON_CACHE_TTL_MS = 1000 * 60 * 60 * 24;
|
||||
|
||||
const rssCache = new Map<string, { data: RssItem[]; expires: number }>();
|
||||
const RSS_CACHE_SIZE = 50;
|
||||
const RSS_CACHE_TTL_MS = 1000 * 60 * 15;
|
||||
|
||||
interface PingCacheEntry {
|
||||
ok: boolean;
|
||||
statusCode: number | null;
|
||||
latencyMs: number;
|
||||
expires: number;
|
||||
}
|
||||
const pingCache = new Map<string, PingCacheEntry>();
|
||||
|
||||
const privateRequestOptions = async (url: string) => {
|
||||
const settings = await ctx.settings.getAll("admin");
|
||||
const raw = settings.privateEndpoints;
|
||||
const allowPrivateHosts =
|
||||
typeof raw === "string"
|
||||
? [
|
||||
...new Set(
|
||||
raw
|
||||
.split(/[\r\n,]+/)
|
||||
.map((host) => host.trim().toLowerCase())
|
||||
.filter((host) => /^[a-z0-9._:-]+$/.test(host)),
|
||||
),
|
||||
]
|
||||
: [];
|
||||
const hostname = new URL(url).hostname
|
||||
.replace(/^\[|\]$/g, "")
|
||||
.toLowerCase();
|
||||
const ca = settings.privateCertificateAuthority;
|
||||
return {
|
||||
allowPrivateHosts,
|
||||
...(allowPrivateHosts.includes(hostname) &&
|
||||
typeof ca === "string" &&
|
||||
ca.trim()
|
||||
? { tls: { ca: ca.trim() } }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
const PING_CACHE_SIZE = 200;
|
||||
|
||||
const proxyCache = new Map<string, { data: unknown; expires: number }>();
|
||||
const PROXY_CACHE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/homepage/favicon:
|
||||
* get:
|
||||
* summary: Proxy favicon fetch
|
||||
* description: Fetches and caches a site favicon server-side to avoid CORS issues.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: url
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Favicon image.
|
||||
* 400:
|
||||
* description: Invalid URL.
|
||||
* 500:
|
||||
* description: Failed to fetch favicon.
|
||||
*/
|
||||
router.get("/favicon", async (req: Request, res: Response) => {
|
||||
const rawUrl = req.query.url as string;
|
||||
if (!rawUrl) return res.status(400).json({ error: "url is required" });
|
||||
|
||||
let domain: string;
|
||||
try {
|
||||
domain = new URL(rawUrl).hostname;
|
||||
} catch {
|
||||
return res.status(400).json({ error: "Invalid URL" });
|
||||
}
|
||||
|
||||
const cached = faviconCache.get(domain);
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
res.setHeader("Content-Type", cached.contentType);
|
||||
res.setHeader("Cache-Control", "public, max-age=86400");
|
||||
return res.send(cached.data);
|
||||
}
|
||||
|
||||
const faviconUrl = `https://www.google.com/s2/favicons?sz=64&domain_url=${encodeURIComponent(domain)}`;
|
||||
|
||||
try {
|
||||
const response = await ctx.fetch(faviconUrl, {
|
||||
timeoutMs: FETCH_TIMEOUT_MS,
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error(`Favicon fetch failed: ${response.status}`);
|
||||
const data = await readBufferLimited(response, MAX_FAVICON_BYTES);
|
||||
const contentType =
|
||||
response.headers.get("content-type") || "image/x-icon";
|
||||
|
||||
if (faviconCache.size >= FAVICON_CACHE_SIZE) {
|
||||
const oldest = faviconCache.keys().next().value;
|
||||
if (oldest) faviconCache.delete(oldest);
|
||||
}
|
||||
faviconCache.set(domain, {
|
||||
data,
|
||||
contentType,
|
||||
expires: Date.now() + FAVICON_CACHE_TTL_MS,
|
||||
});
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader("Cache-Control", "public, max-age=86400");
|
||||
res.send(data);
|
||||
} catch {
|
||||
ctx.log.warn(`Failed to fetch favicon for ${domain}`);
|
||||
res.status(500).json({ error: "Failed to fetch favicon" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/homepage/rss:
|
||||
* get:
|
||||
* summary: Proxy and parse an RSS/Atom feed
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: url
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: max
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 10
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Array of feed items.
|
||||
* 400:
|
||||
* description: Invalid or missing URL.
|
||||
* 500:
|
||||
* description: Failed to fetch or parse the feed.
|
||||
*/
|
||||
router.get("/rss", async (req: Request, res: Response) => {
|
||||
const feedUrl = req.query.url as string;
|
||||
const max = Math.min(50, Math.max(1, Number(req.query.max) || 10));
|
||||
|
||||
if (!feedUrl) return res.status(400).json({ error: "url is required" });
|
||||
try {
|
||||
new URL(feedUrl);
|
||||
} catch {
|
||||
return res.status(400).json({ error: "Invalid URL" });
|
||||
}
|
||||
|
||||
const cached = rssCache.get(feedUrl);
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
return res.json(cached.data.slice(0, max));
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await ctx.fetch(feedUrl, {
|
||||
timeoutMs: FETCH_TIMEOUT_MS,
|
||||
});
|
||||
if (!response.ok) throw new Error(`RSS fetch failed: ${response.status}`);
|
||||
const xml = await readTextLimited(response, MAX_RESPONSE_BYTES);
|
||||
const items = parseRss(xml);
|
||||
|
||||
if (rssCache.size >= RSS_CACHE_SIZE) {
|
||||
const oldest = rssCache.keys().next().value;
|
||||
if (oldest) rssCache.delete(oldest);
|
||||
}
|
||||
rssCache.set(feedUrl, {
|
||||
data: items,
|
||||
expires: Date.now() + RSS_CACHE_TTL_MS,
|
||||
});
|
||||
|
||||
res.json(items.slice(0, max));
|
||||
} catch {
|
||||
ctx.log.warn(`Failed to fetch RSS feed ${feedUrl}`);
|
||||
res.status(500).json({ error: "Failed to fetch feed" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/homepage/ping:
|
||||
* get:
|
||||
* summary: Check the HTTP reachability and latency of a URL
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: url
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: ttl
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Cache TTL in seconds (min 10)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Ping result with ok, statusCode and latencyMs.
|
||||
* 400:
|
||||
* description: Invalid or missing URL.
|
||||
*/
|
||||
router.get("/ping", async (req: Request, res: Response) => {
|
||||
let targetUrl = req.query.url as string;
|
||||
const ttl = Math.max(10, Number(req.query.ttl) || 30) * 1000;
|
||||
|
||||
if (!targetUrl) return res.status(400).json({ error: "url is required" });
|
||||
if (!/^https?:\/\//i.test(targetUrl)) targetUrl = `https://${targetUrl}`;
|
||||
try {
|
||||
new URL(targetUrl);
|
||||
} catch {
|
||||
return res.status(400).json({ error: "Invalid URL" });
|
||||
}
|
||||
|
||||
const requestOptions = await privateRequestOptions(targetUrl);
|
||||
const cacheKey = JSON.stringify([targetUrl, requestOptions]);
|
||||
const cached = pingCache.get(cacheKey);
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
return res.json({
|
||||
ok: cached.ok,
|
||||
statusCode: cached.statusCode,
|
||||
latencyMs: cached.latencyMs,
|
||||
});
|
||||
}
|
||||
|
||||
const requestStatus = async (
|
||||
url: string,
|
||||
method: "HEAD" | "GET",
|
||||
): Promise<number | null> => {
|
||||
const response = await ctx.fetch(url, {
|
||||
...requestOptions,
|
||||
method,
|
||||
redirect: "manual",
|
||||
timeoutMs: FETCH_TIMEOUT_MS,
|
||||
});
|
||||
await response.body?.cancel().catch(() => {});
|
||||
return response.status ?? null;
|
||||
};
|
||||
|
||||
const start = performance.now();
|
||||
try {
|
||||
let code = await requestStatus(targetUrl, "HEAD");
|
||||
if (code === 405) {
|
||||
code = await requestStatus(targetUrl, "GET");
|
||||
}
|
||||
const result = {
|
||||
ok: code !== null && (code < 400 || code === 401 || code === 403),
|
||||
statusCode: code,
|
||||
latencyMs: Math.round(performance.now() - start),
|
||||
};
|
||||
if (pingCache.size >= PING_CACHE_SIZE) {
|
||||
const oldest = pingCache.keys().next().value;
|
||||
if (oldest) pingCache.delete(oldest);
|
||||
}
|
||||
pingCache.set(cacheKey, { ...result, expires: Date.now() + ttl });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
ctx.log.warn(`Ping failed for ${targetUrl}: ${String(err)}`);
|
||||
res.status(500).json({ error: "Ping failed" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/homepage/proxy:
|
||||
* get:
|
||||
* summary: Proxy a JSON API URL and return the parsed response
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: url
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: ttl
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Cache TTL in seconds (min 10, default 60)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The JSON body returned by the target URL.
|
||||
* 400:
|
||||
* description: Invalid or missing URL, or non-JSON response.
|
||||
* 500:
|
||||
* description: Failed to fetch the target URL.
|
||||
*/
|
||||
router.get("/proxy", async (req: Request, res: Response) => {
|
||||
const targetUrl = req.query.url as string;
|
||||
const ttl =
|
||||
Math.min(
|
||||
MAX_CACHE_TTL_SECONDS,
|
||||
Math.max(10, Number(req.query.ttl) || 60),
|
||||
) * 1000;
|
||||
|
||||
if (!targetUrl) return res.status(400).json({ error: "url is required" });
|
||||
try {
|
||||
new URL(targetUrl);
|
||||
} catch {
|
||||
return res.status(400).json({ error: "Invalid URL" });
|
||||
}
|
||||
|
||||
const requestOptions = await privateRequestOptions(targetUrl);
|
||||
const cacheKey = JSON.stringify([targetUrl, requestOptions]);
|
||||
const cached = proxyCache.get(cacheKey);
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
return res.json(cached.data);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await ctx.fetch(targetUrl, {
|
||||
...requestOptions,
|
||||
timeoutMs: FETCH_TIMEOUT_MS,
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error(`Proxy fetch failed: ${response.status}`);
|
||||
const text = await readTextLimited(response, MAX_RESPONSE_BYTES);
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error("Response is not valid JSON");
|
||||
}
|
||||
|
||||
if (proxyCache.size >= PROXY_CACHE_SIZE) {
|
||||
const oldest = proxyCache.keys().next().value;
|
||||
if (oldest) proxyCache.delete(oldest);
|
||||
}
|
||||
proxyCache.set(cacheKey, { data, expires: Date.now() + ttl });
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
ctx.log.warn(`Proxy fetch failed for ${targetUrl}: ${msg}`);
|
||||
if (msg.includes("not valid JSON")) {
|
||||
return res.status(400).json({ error: "Response is not valid JSON" });
|
||||
}
|
||||
res.status(500).json({ error: "Failed to fetch URL" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export interface HomepageItemRecord {
|
||||
id: number;
|
||||
userId: string;
|
||||
typeId: string;
|
||||
title: string | null;
|
||||
config: string;
|
||||
folderId: number | null;
|
||||
syncId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface HomepageLayoutRecord {
|
||||
id: number;
|
||||
userId: string;
|
||||
layout: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ServiceLinkRecord {
|
||||
id: number;
|
||||
userId: string;
|
||||
label: string;
|
||||
url: string;
|
||||
order: number;
|
||||
syncId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// Tables come from ctx.db.define, which the SDK hands back untyped, and the
|
||||
// drizzle handle is the server's own. Typed at this module's edge instead.
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export type HomepageRepository = ReturnType<typeof createHomepageRepository>;
|
||||
|
||||
export function createHomepageRepository(
|
||||
db: PluginDatabase,
|
||||
tables: { items: Table; layouts: Table; serviceLinks: Table },
|
||||
) {
|
||||
const client = () => db.client<Drizzle>();
|
||||
|
||||
async function findItemById(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<HomepageItemRecord | null> {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(tables.items)
|
||||
.where(and(eq(tables.items.id, id), eq(tables.items.userId, userId)))
|
||||
.limit(1);
|
||||
return (rows[0] as HomepageItemRecord) ?? null;
|
||||
}
|
||||
|
||||
async function findItemBySyncId(syncId: string): Promise<HomepageItemRecord> {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(tables.items)
|
||||
.where(eq(tables.items.syncId, syncId))
|
||||
.limit(1);
|
||||
return rows[0] as HomepageItemRecord;
|
||||
}
|
||||
|
||||
async function findServiceLinkById(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<ServiceLinkRecord | null> {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(tables.serviceLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(tables.serviceLinks.id, id),
|
||||
eq(tables.serviceLinks.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return (rows[0] as ServiceLinkRecord) ?? null;
|
||||
}
|
||||
|
||||
async function findServiceLinkBySyncId(
|
||||
syncId: string,
|
||||
): Promise<ServiceLinkRecord> {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(tables.serviceLinks)
|
||||
.where(eq(tables.serviceLinks.syncId, syncId))
|
||||
.limit(1);
|
||||
return rows[0] as ServiceLinkRecord;
|
||||
}
|
||||
|
||||
return {
|
||||
findItemById,
|
||||
|
||||
async listItemsByUser(userId: string): Promise<HomepageItemRecord[]> {
|
||||
const drizzle = await client();
|
||||
return drizzle
|
||||
.select()
|
||||
.from(tables.items)
|
||||
.where(eq(tables.items.userId, userId));
|
||||
},
|
||||
|
||||
async createItem(
|
||||
userId: string,
|
||||
input: { typeId: string; title: string | null; config: string },
|
||||
): Promise<HomepageItemRecord> {
|
||||
const syncId = randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const drizzle = await client();
|
||||
await drizzle.insert(tables.items).values({
|
||||
userId,
|
||||
typeId: input.typeId,
|
||||
title: input.title,
|
||||
config: input.config,
|
||||
syncId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await db.persist();
|
||||
return findItemBySyncId(syncId);
|
||||
},
|
||||
|
||||
async updateItem(
|
||||
userId: string,
|
||||
id: number,
|
||||
updates: { title?: string | null; config?: string },
|
||||
): Promise<HomepageItemRecord | null> {
|
||||
const existing = await findItemById(userId, id);
|
||||
if (!existing) return null;
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.update(tables.items)
|
||||
.set({ ...updates, updatedAt: new Date().toISOString() })
|
||||
.where(and(eq(tables.items.id, id), eq(tables.items.userId, userId)));
|
||||
await db.persist();
|
||||
return findItemById(userId, id);
|
||||
},
|
||||
|
||||
async deleteItem(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<HomepageItemRecord | null> {
|
||||
const existing = await findItemById(userId, id);
|
||||
if (!existing) return null;
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.delete(tables.items)
|
||||
.where(and(eq(tables.items.id, id), eq(tables.items.userId, userId)));
|
||||
await db.persist();
|
||||
return existing;
|
||||
},
|
||||
|
||||
async findLayoutByUser(
|
||||
userId: string,
|
||||
): Promise<HomepageLayoutRecord | null> {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(tables.layouts)
|
||||
.where(eq(tables.layouts.userId, userId))
|
||||
.limit(1);
|
||||
return (rows[0] as HomepageLayoutRecord) ?? null;
|
||||
},
|
||||
|
||||
async upsertLayout(
|
||||
userId: string,
|
||||
layout: string,
|
||||
now: string,
|
||||
): Promise<HomepageLayoutRecord> {
|
||||
const drizzle = await client();
|
||||
const existing = await drizzle
|
||||
.select()
|
||||
.from(tables.layouts)
|
||||
.where(eq(tables.layouts.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (existing[0]) {
|
||||
await drizzle
|
||||
.update(tables.layouts)
|
||||
.set({ layout, updatedAt: now })
|
||||
.where(eq(tables.layouts.userId, userId));
|
||||
} else {
|
||||
await drizzle.insert(tables.layouts).values({
|
||||
userId,
|
||||
layout,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
await db.persist();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(tables.layouts)
|
||||
.where(eq(tables.layouts.userId, userId))
|
||||
.limit(1);
|
||||
return rows[0] as HomepageLayoutRecord;
|
||||
},
|
||||
|
||||
findServiceLinkById,
|
||||
|
||||
async listServiceLinksByUser(userId: string): Promise<ServiceLinkRecord[]> {
|
||||
const drizzle = await client();
|
||||
return drizzle
|
||||
.select()
|
||||
.from(tables.serviceLinks)
|
||||
.where(eq(tables.serviceLinks.userId, userId))
|
||||
.orderBy(asc(tables.serviceLinks.order), asc(tables.serviceLinks.id));
|
||||
},
|
||||
|
||||
async createServiceLink(
|
||||
userId: string,
|
||||
input: { label: string; url: string },
|
||||
): Promise<ServiceLinkRecord> {
|
||||
const drizzle = await client();
|
||||
const existing = await drizzle
|
||||
.select({ order: tables.serviceLinks.order })
|
||||
.from(tables.serviceLinks)
|
||||
.where(eq(tables.serviceLinks.userId, userId))
|
||||
.orderBy(asc(tables.serviceLinks.order));
|
||||
const nextOrder =
|
||||
existing.length > 0 ? existing[existing.length - 1].order + 1 : 0;
|
||||
|
||||
const syncId = randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
await drizzle.insert(tables.serviceLinks).values({
|
||||
userId,
|
||||
label: input.label,
|
||||
url: input.url,
|
||||
order: nextOrder,
|
||||
syncId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await db.persist();
|
||||
return findServiceLinkBySyncId(syncId);
|
||||
},
|
||||
|
||||
async updateServiceLink(
|
||||
userId: string,
|
||||
id: number,
|
||||
updates: { label?: string; url?: string },
|
||||
): Promise<ServiceLinkRecord | null> {
|
||||
const existing = await findServiceLinkById(userId, id);
|
||||
if (!existing) return null;
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.update(tables.serviceLinks)
|
||||
.set({ ...updates, updatedAt: new Date().toISOString() })
|
||||
.where(
|
||||
and(
|
||||
eq(tables.serviceLinks.id, id),
|
||||
eq(tables.serviceLinks.userId, userId),
|
||||
),
|
||||
);
|
||||
await db.persist();
|
||||
return findServiceLinkById(userId, id);
|
||||
},
|
||||
|
||||
async deleteServiceLink(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<ServiceLinkRecord | null> {
|
||||
const existing = await findServiceLinkById(userId, id);
|
||||
if (!existing) return null;
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.delete(tables.serviceLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(tables.serviceLinks.id, id),
|
||||
eq(tables.serviceLinks.userId, userId),
|
||||
),
|
||||
);
|
||||
await db.persist();
|
||||
return existing;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
import type { Request, Response, Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import type { HomepageRepository } from "./repository.js";
|
||||
import { isValidServiceLinkUrl, normalizeServiceLinkUrl } from "./url.js";
|
||||
|
||||
function actor(ctx: PluginContext): string {
|
||||
// Core's plugin router authenticates every request and runs it as that user.
|
||||
return ctx.currentActor() as string;
|
||||
}
|
||||
|
||||
function parseId(raw: unknown): number | null {
|
||||
const id = typeof raw === "string" ? parseInt(raw, 10) : NaN;
|
||||
return Number.isInteger(id) ? id : null;
|
||||
}
|
||||
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
}
|
||||
|
||||
function logError(
|
||||
ctx: PluginContext,
|
||||
message: string,
|
||||
error: unknown,
|
||||
operation: string,
|
||||
): void {
|
||||
ctx.log.error(
|
||||
`${message} (${operation})`,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts the homepage routes on the plugin's router, served at
|
||||
* /plugin-api/homepage with core auth in front.
|
||||
*/
|
||||
export function registerHomepageRoutes(
|
||||
router: Router,
|
||||
repo: HomepageRepository,
|
||||
ctx: PluginContext,
|
||||
): void {
|
||||
router.use(ctx.rbac.require("use") as never);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/homepage/items:
|
||||
* get:
|
||||
* summary: Get homepage items
|
||||
* description: Returns all homepage widget items for the authenticated user.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of homepage items.
|
||||
* 500:
|
||||
* description: Failed to fetch homepage items.
|
||||
*/
|
||||
router.get("/items", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
try {
|
||||
const items = await repo.listItemsByUser(userId);
|
||||
res.json(items);
|
||||
} catch (err) {
|
||||
logError(
|
||||
ctx,
|
||||
"Failed to fetch homepage items",
|
||||
err,
|
||||
"homepage_items_list_failed",
|
||||
);
|
||||
res.status(500).json({ error: "Failed to fetch homepage items" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/homepage/items:
|
||||
* post:
|
||||
* summary: Create homepage item
|
||||
* description: Creates a new homepage widget item for the authenticated user.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - typeId
|
||||
* properties:
|
||||
* typeId:
|
||||
* type: string
|
||||
* title:
|
||||
* type: string
|
||||
* config:
|
||||
* type: object
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Homepage item created.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 500:
|
||||
* description: Failed to create homepage item.
|
||||
*/
|
||||
router.post("/items", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const { typeId, title, config } = req.body ?? {};
|
||||
|
||||
if (!isNonEmptyString(typeId)) {
|
||||
return res.status(400).json({ error: "typeId is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await repo.createItem(userId, {
|
||||
typeId,
|
||||
title: title ?? null,
|
||||
config: config ? JSON.stringify(config) : "{}",
|
||||
});
|
||||
res.status(201).json(created);
|
||||
} catch (err) {
|
||||
logError(
|
||||
ctx,
|
||||
"Failed to create homepage item",
|
||||
err,
|
||||
"homepage_item_create_failed",
|
||||
);
|
||||
res.status(500).json({ error: "Failed to create homepage item" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/homepage/items/{id}:
|
||||
* put:
|
||||
* summary: Update homepage item
|
||||
* description: Updates a homepage widget item's title or config.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* title:
|
||||
* type: string
|
||||
* config:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Homepage item updated.
|
||||
* 400:
|
||||
* description: Invalid id.
|
||||
* 404:
|
||||
* description: Not found.
|
||||
* 500:
|
||||
* description: Failed to update homepage item.
|
||||
*/
|
||||
router.put("/items/:id", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const id = parseId(req.params.id);
|
||||
if (id === null) return res.status(400).json({ error: "Invalid id" });
|
||||
|
||||
const { title, config } = req.body ?? {};
|
||||
const updates: { title?: string | null; config?: string } = {};
|
||||
if (title !== undefined) updates.title = title;
|
||||
if (config !== undefined) updates.config = JSON.stringify(config);
|
||||
|
||||
try {
|
||||
const updated = await repo.updateItem(userId, id, updates);
|
||||
if (!updated) return res.status(404).json({ error: "Not found" });
|
||||
res.json(updated);
|
||||
} catch (err) {
|
||||
logError(
|
||||
ctx,
|
||||
"Failed to update homepage item",
|
||||
err,
|
||||
"homepage_item_update_failed",
|
||||
);
|
||||
res.status(500).json({ error: "Failed to update homepage item" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/homepage/items/{id}:
|
||||
* delete:
|
||||
* summary: Delete homepage item
|
||||
* description: Deletes a homepage widget item.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Homepage item deleted.
|
||||
* 400:
|
||||
* description: Invalid id.
|
||||
* 404:
|
||||
* description: Not found.
|
||||
* 500:
|
||||
* description: Failed to delete homepage item.
|
||||
*/
|
||||
router.delete("/items/:id", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const id = parseId(req.params.id);
|
||||
if (id === null) return res.status(400).json({ error: "Invalid id" });
|
||||
|
||||
try {
|
||||
const deleted = await repo.deleteItem(userId, id);
|
||||
if (!deleted) return res.status(404).json({ error: "Not found" });
|
||||
res.json({ message: "Homepage item deleted" });
|
||||
} catch (err) {
|
||||
logError(
|
||||
ctx,
|
||||
"Failed to delete homepage item",
|
||||
err,
|
||||
"homepage_item_delete_failed",
|
||||
);
|
||||
res.status(500).json({ error: "Failed to delete homepage item" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/homepage/layout:
|
||||
* get:
|
||||
* summary: Get homepage layout
|
||||
* description: Returns the homepage canvas layout (widget positions, pan, zoom) for the authenticated user.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Layout data or null.
|
||||
* 500:
|
||||
* description: Failed to fetch homepage layout.
|
||||
*/
|
||||
router.get("/layout", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
try {
|
||||
const row = await repo.findLayoutByUser(userId);
|
||||
if (!row) return res.json(null);
|
||||
const parsed = JSON.parse(row.layout || "{}");
|
||||
res.json({ ...row, layout: parsed });
|
||||
} catch (err) {
|
||||
logError(
|
||||
ctx,
|
||||
"Failed to fetch homepage layout",
|
||||
err,
|
||||
"homepage_layout_get_failed",
|
||||
);
|
||||
res.status(500).json({ error: "Failed to fetch homepage layout" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/homepage/layout:
|
||||
* put:
|
||||
* summary: Save homepage layout
|
||||
* description: Saves or updates the homepage canvas layout for the authenticated user.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* entries:
|
||||
* type: array
|
||||
* pan:
|
||||
* type: object
|
||||
* zoom:
|
||||
* type: number
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Layout saved.
|
||||
* 500:
|
||||
* description: Failed to save homepage layout.
|
||||
*/
|
||||
router.put("/layout", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
try {
|
||||
const layoutJson = JSON.stringify(req.body ?? {});
|
||||
const now = new Date().toISOString();
|
||||
const updated = await repo.upsertLayout(userId, layoutJson, now);
|
||||
const parsed = JSON.parse(updated.layout);
|
||||
res.json({ ...updated, layout: parsed });
|
||||
} catch (err) {
|
||||
logError(
|
||||
ctx,
|
||||
"Failed to save homepage layout",
|
||||
err,
|
||||
"homepage_layout_save_failed",
|
||||
);
|
||||
res.status(500).json({ error: "Failed to save homepage layout" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/homepage/service-links:
|
||||
* get:
|
||||
* summary: Get service links
|
||||
* description: Returns all dashboard service links for the authenticated user.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of service links.
|
||||
* 500:
|
||||
* description: Failed to fetch service links.
|
||||
*/
|
||||
router.get("/service-links", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
try {
|
||||
const links = await repo.listServiceLinksByUser(userId);
|
||||
res.json(links);
|
||||
} catch (err) {
|
||||
logError(
|
||||
ctx,
|
||||
"Failed to fetch service links",
|
||||
err,
|
||||
"service_links_list_failed",
|
||||
);
|
||||
res.status(500).json({ error: "Failed to fetch service links" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/homepage/service-links:
|
||||
* post:
|
||||
* summary: Create service link
|
||||
* description: Creates a new dashboard service link for the authenticated user.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - label
|
||||
* - url
|
||||
* properties:
|
||||
* label:
|
||||
* type: string
|
||||
* url:
|
||||
* type: string
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Service link created.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 500:
|
||||
* description: Failed to create service link.
|
||||
*/
|
||||
router.post("/service-links", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const { label, url } = req.body ?? {};
|
||||
|
||||
if (!isNonEmptyString(label) || !isNonEmptyString(url)) {
|
||||
return res.status(400).json({ error: "label and url are required" });
|
||||
}
|
||||
const normalizedUrl = normalizeServiceLinkUrl(url);
|
||||
if (!isValidServiceLinkUrl(normalizedUrl)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "url must be a valid http or https URL" });
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await repo.createServiceLink(userId, {
|
||||
label: label.trim(),
|
||||
url: normalizedUrl,
|
||||
});
|
||||
res.status(201).json(created);
|
||||
} catch (err) {
|
||||
logError(
|
||||
ctx,
|
||||
"Failed to create service link",
|
||||
err,
|
||||
"service_link_create_failed",
|
||||
);
|
||||
res.status(500).json({ error: "Failed to create service link" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/homepage/service-links/{id}:
|
||||
* put:
|
||||
* summary: Update service link
|
||||
* description: Updates label or url of a dashboard service link.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* label:
|
||||
* type: string
|
||||
* url:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Service link updated.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 404:
|
||||
* description: Not found.
|
||||
* 500:
|
||||
* description: Failed to update service link.
|
||||
*/
|
||||
router.put("/service-links/:id", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const id = parseId(req.params.id);
|
||||
if (id === null) return res.status(400).json({ error: "Invalid id" });
|
||||
|
||||
const { label, url } = req.body ?? {};
|
||||
if (
|
||||
url !== undefined &&
|
||||
!isValidServiceLinkUrl(normalizeServiceLinkUrl(url))
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "url must be a valid http or https URL" });
|
||||
}
|
||||
|
||||
const updates: { label?: string; url?: string } = {};
|
||||
if (isNonEmptyString(label)) updates.label = label.trim();
|
||||
if (isNonEmptyString(url)) updates.url = normalizeServiceLinkUrl(url);
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return res.status(400).json({ error: "Nothing to update" });
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await repo.updateServiceLink(userId, id, updates);
|
||||
if (!updated) return res.status(404).json({ error: "Not found" });
|
||||
res.json(updated);
|
||||
} catch (err) {
|
||||
logError(
|
||||
ctx,
|
||||
"Failed to update service link",
|
||||
err,
|
||||
"service_link_update_failed",
|
||||
);
|
||||
res.status(500).json({ error: "Failed to update service link" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/homepage/service-links/{id}:
|
||||
* delete:
|
||||
* summary: Delete service link
|
||||
* description: Deletes a dashboard service link by id.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Service link deleted.
|
||||
* 400:
|
||||
* description: Invalid id.
|
||||
* 404:
|
||||
* description: Not found.
|
||||
* 500:
|
||||
* description: Failed to delete service link.
|
||||
*/
|
||||
router.delete("/service-links/:id", async (req: Request, res: Response) => {
|
||||
const userId = actor(ctx);
|
||||
const id = parseId(req.params.id);
|
||||
if (id === null) return res.status(400).json({ error: "Invalid id" });
|
||||
|
||||
try {
|
||||
const deleted = await repo.deleteServiceLink(userId, id);
|
||||
if (!deleted) return res.status(404).json({ error: "Not found" });
|
||||
res.json({ message: "Service link deleted" });
|
||||
} catch (err) {
|
||||
logError(
|
||||
ctx,
|
||||
"Failed to delete service link",
|
||||
err,
|
||||
"service_link_delete_failed",
|
||||
);
|
||||
res.status(500).json({ error: "Failed to delete service link" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import type { HomepageRepository } from "./repository.js";
|
||||
|
||||
/** The "homepage.items" service, version 1. Runs as the caller's user. */
|
||||
export interface HomepageItemsV1 {
|
||||
list: () => Promise<
|
||||
Array<{ id: number; typeId: string; title: string | null }>
|
||||
>;
|
||||
}
|
||||
|
||||
export function createHomepageItemsService(
|
||||
ctx: PluginContext,
|
||||
repo: HomepageRepository,
|
||||
): HomepageItemsV1 {
|
||||
return {
|
||||
list: async () => {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) return [];
|
||||
const rows = await repo.listItemsByUser(userId);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
typeId: row.typeId,
|
||||
title: row.title,
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
adoptLegacyTable,
|
||||
defineTable,
|
||||
id,
|
||||
integer,
|
||||
refUser,
|
||||
text,
|
||||
timestamp,
|
||||
varchar,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
|
||||
/** One homepage widget tile. folderId points at another row when it sits in a folder. */
|
||||
export const homepageItems = adoptLegacyTable(
|
||||
"homepage_items",
|
||||
defineTable(
|
||||
"homepage_items",
|
||||
{
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
typeId: text().notNull(),
|
||||
title: text(),
|
||||
config: text().notNull().default("{}"),
|
||||
folderId: integer(),
|
||||
syncId: varchar().unique(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
{
|
||||
indexes: [{ name: "idx_homepage_items_user_id", columns: ["userId"] }],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/** One canvas layout (widget positions, pan, zoom) per user. */
|
||||
export const homepageLayouts = adoptLegacyTable(
|
||||
"homepage_layouts",
|
||||
defineTable("homepage_layouts", {
|
||||
id: id(),
|
||||
userId: refUser().unique(),
|
||||
layout: text().notNull().default("{}"),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
}),
|
||||
);
|
||||
|
||||
/** Clickable dashboard service link buttons. */
|
||||
export const dashboardServiceLinks = adoptLegacyTable(
|
||||
"dashboard_service_links",
|
||||
defineTable(
|
||||
"dashboard_service_links",
|
||||
{
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
label: text().notNull(),
|
||||
url: text().notNull(),
|
||||
order: integer().notNull().default(0),
|
||||
syncId: varchar().unique(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
{
|
||||
indexes: [
|
||||
{ name: "idx_dashboard_service_links_user_id", columns: ["userId"] },
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const tables = [homepageItems, homepageLayouts, dashboardServiceLinks];
|
||||
@@ -0,0 +1,16 @@
|
||||
export function normalizeServiceLinkUrl(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "";
|
||||
return /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)
|
||||
? trimmed
|
||||
: `http://${trimmed}`;
|
||||
}
|
||||
|
||||
export function isValidServiceLinkUrl(value: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(normalizeServiceLinkUrl(value));
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from "react";
|
||||
import { Check, ExternalLink, Link } from "lucide-react";
|
||||
import { useTranslation, type ShellApi } from "@termix/plugin-sdk/frontend";
|
||||
import { HomepageCanvas } from "./HomepageCanvas.js";
|
||||
|
||||
/** The homepage plugin's contribution to the dashboard's "dashboard.secondaryView" slot. */
|
||||
export function DashboardHomepageView({
|
||||
onOpenSingletonTab,
|
||||
}: {
|
||||
onOpenSingletonTab: ShellApi["openSingletonTab"];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [linkCopied, setLinkCopied] = useState(false);
|
||||
|
||||
const handleCopyLink = () => {
|
||||
navigator.clipboard
|
||||
.writeText(`${window.location.origin}?view=homepage`)
|
||||
.catch(() => {});
|
||||
setLinkCopied(true);
|
||||
setTimeout(() => setLinkCopied(false), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border shrink-0 bg-muted/20">
|
||||
<span className="text-[10px] text-muted-foreground/50 uppercase tracking-widest font-semibold">
|
||||
{t("nav.homepage")}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
||||
onClick={handleCopyLink}
|
||||
>
|
||||
{linkCopied ? (
|
||||
<>
|
||||
<Check size={10} className="text-accent-brand" />
|
||||
<span className="text-accent-brand">
|
||||
{t("homepage.linkCopied")}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link size={10} />
|
||||
{t("homepage.copyLink")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
||||
onClick={() => onOpenSingletonTab("homepage")}
|
||||
>
|
||||
<ExternalLink size={10} />
|
||||
{t("homepage.openFullView")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<HomepageCanvas />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
import type { CanvasWidget, ContextMenuState, WidgetTypeId } from "./types.js";
|
||||
import { GRID_SIZE, MIN_ZOOM, MAX_ZOOM } from "./types.js";
|
||||
import {
|
||||
getHomepageItems,
|
||||
getHomepageLayout,
|
||||
createHomepageItem,
|
||||
updateHomepageItem,
|
||||
deleteHomepageItem,
|
||||
saveHomepageLayout,
|
||||
} from "./api.js";
|
||||
import { snapToGrid } from "./canvas/snapToGrid";
|
||||
import { screenToCanvas } from "./canvas/canvasGeometry";
|
||||
import { useCanvasInput } from "./canvas/useCanvasInput";
|
||||
import { useBoxDrag } from "./canvas/useBoxDrag";
|
||||
import { useBoxResize } from "./canvas/useBoxResize";
|
||||
import { CanvasDotBackground } from "./canvas/CanvasDotBackground";
|
||||
import { WidgetShell } from "./widgets/WidgetShell";
|
||||
import { AddWidgetMenu } from "./dialogs/AddWidgetMenu";
|
||||
import { WidgetEditDialog } from "./dialogs/WidgetEditDialog";
|
||||
import { HomepageToolbar } from "./toolbar/HomepageToolbar";
|
||||
import { getWidgetType } from "./widgets/WidgetRegistry";
|
||||
|
||||
// Side-effect imports so widgets register themselves
|
||||
import "./widgets/ServiceLinkWidget";
|
||||
import "./widgets/ClockWidget";
|
||||
import "./widgets/NotesWidget";
|
||||
import "./widgets/BookmarkListWidget";
|
||||
import "./widgets/HostStatusWidget";
|
||||
import "./widgets/FolderWidget";
|
||||
import "./widgets/WeatherWidget";
|
||||
import "./widgets/IframeWidget";
|
||||
import "./widgets/RssFeedWidget";
|
||||
import "./widgets/HostGridWidget";
|
||||
import "./widgets/PingStatusWidget";
|
||||
import "./widgets/RecentActivityWidget";
|
||||
import "./widgets/TermixUptimeWidget";
|
||||
import "./widgets/SystemOverviewWidget";
|
||||
import "./widgets/SshTerminalWidget";
|
||||
import "./widgets/QuickConnectWidget";
|
||||
import "./widgets/CalendarWidget";
|
||||
import "./widgets/CountdownWidget";
|
||||
import "./widgets/SearchBarWidget";
|
||||
import "./widgets/TextBannerWidget";
|
||||
import "./widgets/ImageWidget";
|
||||
import "./widgets/MarkdownNotesWidget";
|
||||
import "./widgets/CustomApiWidget";
|
||||
import "./widgets/ServiceGridWidget";
|
||||
import "./widgets/DashboardLinksWidget";
|
||||
import "./widgets/SearchLinksWidget";
|
||||
import "./widgets/LinkTreeWidget";
|
||||
|
||||
const CANVAS_SIZE = 100_000;
|
||||
const DEFAULT_PAN = { x: CANVAS_SIZE / 2 - 600, y: CANVAS_SIZE / 2 - 400 };
|
||||
const DEFAULT_ZOOM = 1.0;
|
||||
|
||||
interface HomepageCanvasProps {
|
||||
isReadOnly?: boolean;
|
||||
fitOnLoad?: boolean;
|
||||
onOpenFullscreen?: () => void;
|
||||
}
|
||||
|
||||
function nextZOrder(widgets: CanvasWidget[]): number {
|
||||
const nonFolders = widgets.filter((w) => w.typeId !== "folder");
|
||||
return nonFolders.length === 0
|
||||
? 1
|
||||
: Math.max(...nonFolders.map((w) => w.zOrder)) + 1;
|
||||
}
|
||||
|
||||
export function HomepageCanvas({
|
||||
isReadOnly,
|
||||
fitOnLoad,
|
||||
onOpenFullscreen,
|
||||
}: HomepageCanvasProps) {
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [widgets, setWidgets] = useState<CanvasWidget[]>([]);
|
||||
const [pan, setPan] = useState(DEFAULT_PAN);
|
||||
const [zoom, setZoom] = useState(DEFAULT_ZOOM);
|
||||
const [isLocked, setIsLocked] = useState(
|
||||
() => localStorage.getItem("homepage.locked") === "true",
|
||||
);
|
||||
const [draggingId, setDraggingId] = useState<number | null>(null);
|
||||
const [resizingId, setResizingId] = useState<number | null>(null);
|
||||
const [editingWidget, setEditingWidget] = useState<CanvasWidget | null>(null);
|
||||
const [dialogKey, setDialogKey] = useState(0);
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
|
||||
visible: false,
|
||||
screenX: 0,
|
||||
screenY: 0,
|
||||
canvasX: 0,
|
||||
canvasY: 0,
|
||||
});
|
||||
const [containerSize, setContainerSize] = useState({ w: 800, h: 600 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const panRef = useRef(pan);
|
||||
const zoomRef = useRef(zoom);
|
||||
const widgetsRef = useRef(widgets);
|
||||
panRef.current = pan;
|
||||
zoomRef.current = zoom;
|
||||
widgetsRef.current = widgets;
|
||||
|
||||
const hasFitRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
setContainerSize({ w: el.offsetWidth, h: el.offsetHeight });
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
Promise.all([getHomepageItems(), getHomepageLayout()])
|
||||
.then(([items, layoutRow]) => {
|
||||
if (cancelled) return;
|
||||
const layoutEntries = layoutRow?.layout?.entries ?? [];
|
||||
const merged: CanvasWidget[] = items.map((item) => {
|
||||
const entry = layoutEntries.find((e) => e.itemId === item.id);
|
||||
const typeDef = getWidgetType(item.typeId as WidgetTypeId);
|
||||
return {
|
||||
id: item.id,
|
||||
typeId: item.typeId as WidgetTypeId,
|
||||
title: item.title,
|
||||
config: JSON.parse(item.config || "{}"),
|
||||
x: entry?.x ?? snapToGrid(Math.random() * 600 + 100),
|
||||
y: entry?.y ?? snapToGrid(Math.random() * 400 + 100),
|
||||
w: entry?.w ?? typeDef?.defaultSize.w ?? GRID_SIZE * 8,
|
||||
h: entry?.h ?? typeDef?.defaultSize.h ?? GRID_SIZE * 6,
|
||||
zOrder: entry?.zOrder ?? 0,
|
||||
};
|
||||
});
|
||||
setWidgets(merged);
|
||||
if (layoutRow?.layout?.pan) setPan(layoutRow.layout.pan);
|
||||
if (layoutRow?.layout?.zoom) setZoom(layoutRow.layout.zoom);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scheduleSave = useCallback(
|
||||
(newWidgets?: CanvasWidget[], newPan?: typeof pan, newZoom?: number) => {
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
const ws = newWidgets ?? widgetsRef.current;
|
||||
const p = newPan ?? panRef.current;
|
||||
const z = newZoom ?? zoomRef.current;
|
||||
saveHomepageLayout({
|
||||
entries: ws.map((w) => ({
|
||||
itemId: w.id,
|
||||
x: w.x,
|
||||
y: w.y,
|
||||
w: w.w,
|
||||
h: w.h,
|
||||
zOrder: w.zOrder,
|
||||
})),
|
||||
pan: p,
|
||||
zoom: z,
|
||||
}).catch(() => {});
|
||||
}, 500);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fitOnLoad || loading || widgets.length === 0 || hasFitRef.current)
|
||||
return;
|
||||
const el = containerRef.current;
|
||||
if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) return;
|
||||
hasFitRef.current = true;
|
||||
const vw = el.offsetWidth;
|
||||
const vh = el.offsetHeight;
|
||||
const minX = Math.min(...widgets.map((w) => w.x));
|
||||
const minY = Math.min(...widgets.map((w) => w.y));
|
||||
const maxX = Math.max(...widgets.map((w) => w.x + w.w));
|
||||
const maxY = Math.max(...widgets.map((w) => w.y + w.h));
|
||||
const contentW = maxX - minX;
|
||||
const contentH = maxY - minY;
|
||||
const PADDING = 48;
|
||||
const fz = Math.min(
|
||||
MAX_ZOOM,
|
||||
Math.max(
|
||||
MIN_ZOOM,
|
||||
Math.min((vw - PADDING * 2) / contentW, (vh - PADDING * 2) / contentH),
|
||||
),
|
||||
);
|
||||
setPan({
|
||||
x: (vw - contentW * fz) / 2 - minX * fz,
|
||||
y: (vh - contentH * fz) / 2 - minY * fz,
|
||||
});
|
||||
setZoom(fz);
|
||||
}, [fitOnLoad, loading, widgets, containerSize]);
|
||||
|
||||
const handlePanChange = useCallback(
|
||||
(newPan: typeof pan) => {
|
||||
setPan(newPan);
|
||||
scheduleSave(undefined, newPan, undefined);
|
||||
},
|
||||
[scheduleSave],
|
||||
);
|
||||
|
||||
const handleZoomChange = useCallback(
|
||||
(newZoom: number, newPan: typeof pan) => {
|
||||
setZoom(newZoom);
|
||||
setPan(newPan);
|
||||
scheduleSave(undefined, newPan, newZoom);
|
||||
},
|
||||
[scheduleSave],
|
||||
);
|
||||
|
||||
const canvasInput = useCanvasInput({
|
||||
isDraggingWidget: draggingId !== null,
|
||||
isResizing: resizingId !== null,
|
||||
isContextMenuVisible: contextMenu.visible,
|
||||
onPanChange: handlePanChange,
|
||||
onZoomChange: handleZoomChange,
|
||||
getPan: () => panRef.current,
|
||||
getZoom: () => zoomRef.current,
|
||||
containerRef,
|
||||
});
|
||||
|
||||
const handleWidgetMove = useCallback(
|
||||
(id: number, x: number, y: number) => {
|
||||
setWidgets((prev) => {
|
||||
const next = prev.map((w) => (w.id === id ? { ...w, x, y } : w));
|
||||
scheduleSave(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[scheduleSave],
|
||||
);
|
||||
|
||||
const handleWidgetResize = useCallback(
|
||||
(id: number, w: number, h: number) => {
|
||||
setWidgets((prev) => {
|
||||
const next = prev.map((widget) =>
|
||||
widget.id === id ? { ...widget, w, h } : widget,
|
||||
);
|
||||
scheduleSave(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[scheduleSave],
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(id: number) => {
|
||||
setDraggingId(id);
|
||||
setWidgets((prev) => {
|
||||
const widget = prev.find((w) => w.id === id);
|
||||
if (!widget || widget.typeId === "folder") return prev;
|
||||
const top = nextZOrder(prev);
|
||||
const next = prev.map((w) => (w.id === id ? { ...w, zOrder: top } : w));
|
||||
scheduleSave(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[scheduleSave],
|
||||
);
|
||||
|
||||
const { startDrag } = useBoxDrag({
|
||||
widgets,
|
||||
zoom,
|
||||
getZoom: () => zoomRef.current,
|
||||
onWidgetMove: handleWidgetMove,
|
||||
onDragStart: handleDragStart,
|
||||
onDragEnd: () => setDraggingId(null),
|
||||
});
|
||||
|
||||
const { startResize } = useBoxResize({
|
||||
widgets,
|
||||
getZoom: () => zoomRef.current,
|
||||
onWidgetResize: handleWidgetResize,
|
||||
onResizeStart: setResizingId,
|
||||
onResizeEnd: () => setResizingId(null),
|
||||
});
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (isLocked || isReadOnly) return;
|
||||
if ((e.target as HTMLElement).closest("[data-widget]")) return;
|
||||
e.preventDefault();
|
||||
const rect = containerRef.current!.getBoundingClientRect();
|
||||
const sx = e.clientX - rect.left;
|
||||
const sy = e.clientY - rect.top;
|
||||
const { x, y } = screenToCanvas(sx, sy, panRef.current, zoomRef.current);
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
screenX: e.clientX,
|
||||
screenY: e.clientY,
|
||||
canvasX: snapToGrid(x),
|
||||
canvasY: snapToGrid(y),
|
||||
});
|
||||
},
|
||||
[isLocked, isReadOnly],
|
||||
);
|
||||
|
||||
const handleAddWidget = useCallback(
|
||||
async (typeId: WidgetTypeId, canvasX: number, canvasY: number) => {
|
||||
const typeDef = getWidgetType(typeId);
|
||||
if (!typeDef) return;
|
||||
try {
|
||||
const item = await createHomepageItem({
|
||||
typeId,
|
||||
config: typeDef.defaultConfig as Record<string, unknown>,
|
||||
});
|
||||
setWidgets((prev) => {
|
||||
const zOrder = typeId === "folder" ? 0 : nextZOrder(prev);
|
||||
const newWidget: CanvasWidget = {
|
||||
id: item.id,
|
||||
typeId,
|
||||
title: item.title,
|
||||
config: typeDef.defaultConfig as Record<string, unknown>,
|
||||
x: canvasX,
|
||||
y: canvasY,
|
||||
w: typeDef.defaultSize.w,
|
||||
h: typeDef.defaultSize.h,
|
||||
zOrder,
|
||||
};
|
||||
const next = [...prev, newWidget];
|
||||
scheduleSave(next);
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
// A bad layout write is dropped; the canvas keeps what it shows.
|
||||
}
|
||||
},
|
||||
[scheduleSave],
|
||||
);
|
||||
|
||||
const handleOpenAddMenu = useCallback(
|
||||
(anchorRect: {
|
||||
top: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
right: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}) => {
|
||||
const w = containerSize.w;
|
||||
const h = containerSize.h;
|
||||
const { x, y } = screenToCanvas(
|
||||
w / 2,
|
||||
h / 2,
|
||||
panRef.current,
|
||||
zoomRef.current,
|
||||
);
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
screenX: anchorRect.left,
|
||||
screenY: anchorRect.top,
|
||||
canvasX: snapToGrid(x),
|
||||
canvasY: snapToGrid(y),
|
||||
anchorRect,
|
||||
});
|
||||
},
|
||||
[containerSize],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (id: number) => {
|
||||
try {
|
||||
await deleteHomepageItem(id);
|
||||
setWidgets((prev) => {
|
||||
const next = prev.filter((w) => w.id !== id);
|
||||
scheduleSave(next);
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
// A bad layout write is dropped; the canvas keeps what it shows.
|
||||
}
|
||||
},
|
||||
[scheduleSave],
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(id: number) => {
|
||||
const w = widgets.find((x) => x.id === id);
|
||||
if (w) {
|
||||
setEditingWidget(w);
|
||||
setDialogKey((k) => k + 1);
|
||||
}
|
||||
},
|
||||
[widgets],
|
||||
);
|
||||
|
||||
const handleSaveEdit = useCallback(
|
||||
async (
|
||||
id: number,
|
||||
title: string | null,
|
||||
config: Record<string, unknown>,
|
||||
) => {
|
||||
try {
|
||||
await updateHomepageItem(id, { title, config });
|
||||
setWidgets((prev) =>
|
||||
prev.map((w) => (w.id === id ? { ...w, title, config } : w)),
|
||||
);
|
||||
} catch {
|
||||
// A bad layout write is dropped; the canvas keeps what it shows.
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const resetView = useCallback(() => {
|
||||
const ws = widgetsRef.current;
|
||||
const el = containerRef.current;
|
||||
const vw = el?.offsetWidth ?? 800;
|
||||
const vh = el?.offsetHeight ?? 600;
|
||||
|
||||
if (ws.length === 0) {
|
||||
setPan(DEFAULT_PAN);
|
||||
setZoom(DEFAULT_ZOOM);
|
||||
return;
|
||||
}
|
||||
|
||||
const minX = Math.min(...ws.map((w) => w.x));
|
||||
const minY = Math.min(...ws.map((w) => w.y));
|
||||
const maxX = Math.max(...ws.map((w) => w.x + w.w));
|
||||
const maxY = Math.max(...ws.map((w) => w.y + w.h));
|
||||
const contentW = maxX - minX;
|
||||
const contentH = maxY - minY;
|
||||
|
||||
const PADDING = 48;
|
||||
const fitZoom = Math.min(
|
||||
MAX_ZOOM,
|
||||
Math.max(
|
||||
MIN_ZOOM,
|
||||
Math.min((vw - PADDING * 2) / contentW, (vh - PADDING * 2) / contentH),
|
||||
),
|
||||
);
|
||||
|
||||
// Place the content bbox center at the viewport center
|
||||
const newPan = {
|
||||
x: (vw - contentW * fitZoom) / 2 - minX * fitZoom,
|
||||
y: (vh - contentH * fitZoom) / 2 - minY * fitZoom,
|
||||
};
|
||||
|
||||
setZoom(fitZoom);
|
||||
setPan(newPan);
|
||||
scheduleSave(undefined, newPan, fitZoom);
|
||||
}, [scheduleSave]);
|
||||
|
||||
// Folders always render first (lowest z), non-folders sorted by zOrder ascending
|
||||
const sortedWidgets = [...widgets].sort((a, b) => {
|
||||
const aFolder = a.typeId === "folder";
|
||||
const bFolder = b.typeId === "folder";
|
||||
if (aFolder && !bFolder) return -1;
|
||||
if (!aFolder && bFolder) return 1;
|
||||
return a.zOrder - b.zOrder;
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-muted-foreground text-sm">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full h-full overflow-hidden bg-background"
|
||||
onMouseDown={editingWidget ? undefined : canvasInput.handleMouseDown}
|
||||
onMouseMove={canvasInput.handleMouseMove}
|
||||
onMouseUp={canvasInput.handleMouseUp}
|
||||
onMouseLeave={canvasInput.handleMouseUp}
|
||||
onWheel={canvasInput.handleWheel}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ cursor: draggingId ? "grabbing" : "default" }}
|
||||
>
|
||||
<CanvasDotBackground pan={pan} zoom={zoom} />
|
||||
|
||||
<div
|
||||
className="absolute origin-top-left"
|
||||
style={{
|
||||
width: CANVAS_SIZE,
|
||||
height: CANVAS_SIZE,
|
||||
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
||||
willChange: "transform",
|
||||
}}
|
||||
>
|
||||
{sortedWidgets.map((widget) => (
|
||||
<WidgetShell
|
||||
key={widget.id}
|
||||
widget={widget}
|
||||
isLocked={isLocked}
|
||||
isReadOnly={isReadOnly}
|
||||
isDragging={draggingId === widget.id}
|
||||
isResizing={resizingId === widget.id}
|
||||
onStartDrag={startDrag}
|
||||
onStartResize={startResize}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onConfigUpdate={(config) =>
|
||||
handleSaveEdit(widget.id, widget.title, config)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{widgets.length === 0 && !isReadOnly && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none select-none">
|
||||
<div className="flex items-center gap-2 bg-card border border-border px-3 py-2 shadow-sm">
|
||||
<LayoutGrid className="size-3.5 text-muted-foreground/50 shrink-0" />
|
||||
<span className="text-xs text-muted-foreground/70">
|
||||
{t("homepage.noWidgets")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isReadOnly && onOpenFullscreen && (
|
||||
<button
|
||||
className="absolute top-2 right-2 z-20 text-xs text-muted-foreground hover:text-foreground bg-card/80 border border-border px-2 py-1"
|
||||
onClick={onOpenFullscreen}
|
||||
>
|
||||
{t("homepage.openFullView")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isReadOnly && (
|
||||
<HomepageToolbar
|
||||
zoom={zoom}
|
||||
isLocked={isLocked}
|
||||
pan={pan}
|
||||
containerSize={containerSize}
|
||||
onZoomChange={handleZoomChange}
|
||||
onLockToggle={() =>
|
||||
setIsLocked((v) => {
|
||||
const next = !v;
|
||||
localStorage.setItem("homepage.locked", String(next));
|
||||
return next;
|
||||
})
|
||||
}
|
||||
onResetView={resetView}
|
||||
onAddWidget={handleOpenAddMenu}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AddWidgetMenu
|
||||
state={contextMenu}
|
||||
onAdd={handleAddWidget}
|
||||
onClose={() => setContextMenu((s) => ({ ...s, visible: false }))}
|
||||
/>
|
||||
|
||||
<WidgetEditDialog
|
||||
key={dialogKey}
|
||||
widget={editingWidget}
|
||||
onSave={handleSaveEdit}
|
||||
onClose={() => setEditingWidget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
import {
|
||||
useTranslation,
|
||||
type DashboardCardProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { Card } from "@termix/plugin-sdk/ui";
|
||||
import { HomepageCanvas } from "./HomepageCanvas.js";
|
||||
|
||||
export function HomepagePreviewCard({ shell }: DashboardCardProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card className="relative overflow-hidden w-full h-full flex flex-col p-0 gap-0">
|
||||
<div className="flex items-center gap-2 px-3 py-2 shrink-0 border-b border-border">
|
||||
<LayoutGrid className="size-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("homepage.previewTitle")}
|
||||
</span>
|
||||
<button
|
||||
className="ml-auto text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => shell.openSingletonTab("homepage")}
|
||||
>
|
||||
{t("homepage.openFullView")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
<HomepageCanvas isReadOnly={true} fitOnLoad={true} />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ExternalLink, Link, Trash2 } from "lucide-react";
|
||||
import {
|
||||
usePluginApi,
|
||||
useTranslation,
|
||||
type DashboardCardProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { Button, Card } from "@termix/plugin-sdk/ui";
|
||||
import type { ServiceLinkRecord } from "./types.js";
|
||||
import { isValidServiceLinkUrl, normalizeServiceLinkUrl } from "./url.js";
|
||||
|
||||
export function ServiceLinksCard({ isVisible }: DashboardCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const api = usePluginApi();
|
||||
const [links, setLinks] = useState<ServiceLinkRecord[]>([]);
|
||||
const [label, setLabel] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [urlError, setUrlError] = useState(false);
|
||||
const [addError, setAddError] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
api
|
||||
.get<ServiceLinkRecord[]>("/service-links")
|
||||
.then((res) => setLinks(res.data))
|
||||
.catch(() => {});
|
||||
}, [isVisible]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
const normalizedUrl = normalizeServiceLinkUrl(url);
|
||||
if (!isValidServiceLinkUrl(normalizedUrl)) {
|
||||
setUrlError(true);
|
||||
setAddError("");
|
||||
return;
|
||||
}
|
||||
setUrlError(false);
|
||||
setAddError("");
|
||||
setAdding(true);
|
||||
try {
|
||||
const res = await api.post<ServiceLinkRecord>("/service-links", {
|
||||
label: label.trim(),
|
||||
url: normalizedUrl,
|
||||
});
|
||||
setLinks((prev) => [...prev, res.data]);
|
||||
setLabel("");
|
||||
setUrl("");
|
||||
} catch (error) {
|
||||
setAddError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("dashboardTab.serviceLinksAddFailed"),
|
||||
);
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
await api.delete(`/service-links/${id}`);
|
||||
setLinks((prev) => prev.filter((l) => l.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col overflow-hidden w-full h-full py-0 gap-0">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border shrink-0">
|
||||
<Link className="size-3.5 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
{t("dashboardTab.serviceLinksTitle")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col overflow-auto flex-1">
|
||||
{links.length === 0 && (
|
||||
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground/40 py-4">
|
||||
{t("dashboardTab.serviceLinksEmpty")}
|
||||
</div>
|
||||
)}
|
||||
{links.map((link) => (
|
||||
<div
|
||||
key={link.id}
|
||||
className="flex items-center justify-between px-4 py-2 border-b border-border last:border-0 group/link"
|
||||
>
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="flex items-center gap-2 min-w-0 flex-1 hover:text-accent-brand transition-colors"
|
||||
>
|
||||
<ExternalLink className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs font-semibold truncate">
|
||||
{link.label}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
{link.url}
|
||||
</span>
|
||||
</a>
|
||||
<button
|
||||
onClick={() => handleDelete(link.id)}
|
||||
className="ml-2 opacity-0 group-hover/link:opacity-100 transition-opacity size-5 flex items-center justify-center hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-t border-border shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder={t("dashboardTab.serviceLinksLabelPlaceholder")}
|
||||
className="flex-1 min-w-0 text-xs bg-transparent border border-border px-2 py-1 focus:outline-none focus:border-accent-brand/60"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
setUrlError(false);
|
||||
setAddError("");
|
||||
}}
|
||||
placeholder={t("dashboardTab.serviceLinksUrlPlaceholder")}
|
||||
className={`flex-[2] min-w-0 text-xs bg-transparent border px-2 py-1 focus:outline-none ${urlError ? "border-destructive" : "border-border focus:border-accent-brand/60"}`}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
className="text-xs bg-accent-brand hover:bg-accent-brand/90 text-white h-6 px-2 shrink-0"
|
||||
onClick={handleAdd}
|
||||
disabled={!label.trim() || !url.trim() || adding}
|
||||
>
|
||||
{t("dashboardTab.serviceLinksAdd")}
|
||||
</Button>
|
||||
</div>
|
||||
{urlError && (
|
||||
<div className="px-4 pb-2 text-[10px] text-destructive shrink-0">
|
||||
{t("dashboardTab.serviceLinksInvalidUrl")}
|
||||
</div>
|
||||
)}
|
||||
{addError && (
|
||||
<div className="px-4 pb-2 text-[10px] text-destructive shrink-0">
|
||||
{addError}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
import type {
|
||||
HomepageItemRow,
|
||||
HomepageLayoutData,
|
||||
HomepageLayoutRow,
|
||||
WidgetTypeId,
|
||||
} from "./types.js";
|
||||
|
||||
let api: PluginApiClient | null = null;
|
||||
|
||||
/** Set once from activate(app); the widget canvas calls the functions below. */
|
||||
export function setHomepageApi(client: PluginApiClient | null): void {
|
||||
api = client;
|
||||
}
|
||||
|
||||
function client(): PluginApiClient {
|
||||
if (!api) throw new Error("Homepage plugin API is not ready");
|
||||
return api;
|
||||
}
|
||||
|
||||
export async function getHomepageItems(): Promise<HomepageItemRow[]> {
|
||||
const res = await client().get<HomepageItemRow[]>("/items");
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createHomepageItem(data: {
|
||||
typeId: WidgetTypeId;
|
||||
title?: string | null;
|
||||
config?: Record<string, unknown>;
|
||||
}): Promise<HomepageItemRow> {
|
||||
const res = await client().post<HomepageItemRow>("/items", data);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateHomepageItem(
|
||||
id: number,
|
||||
data: { title?: string | null; config?: Record<string, unknown> },
|
||||
): Promise<HomepageItemRow> {
|
||||
const res = await client().put<HomepageItemRow>(`/items/${id}`, data);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteHomepageItem(id: number): Promise<void> {
|
||||
await client().delete(`/items/${id}`);
|
||||
}
|
||||
|
||||
export async function getHomepageLayout(): Promise<HomepageLayoutRow | null> {
|
||||
const res = await client().get<HomepageLayoutRow | null>("/layout");
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function saveHomepageLayout(
|
||||
layout: HomepageLayoutData,
|
||||
): Promise<HomepageLayoutRow> {
|
||||
const res = await client().put<HomepageLayoutRow>("/layout", layout);
|
||||
return res.data;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface Props {
|
||||
pan: { x: number; y: number };
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
export function CanvasDotBackground({ pan, zoom }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = canvas.offsetWidth;
|
||||
const h = canvas.offsetHeight;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Read dot color from CSS variable for theme support
|
||||
const dotColor =
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--color-muted-foreground")
|
||||
.trim() || "#6b7280";
|
||||
|
||||
// Effective grid spacing in screen pixels
|
||||
const spacing = 30 * zoom;
|
||||
|
||||
// Skip rendering dots that are too close together or too far apart
|
||||
if (spacing < 8 || spacing > 200) return;
|
||||
|
||||
// Offset so dots align with the canvas pan position
|
||||
const offsetX = ((pan.x % spacing) + spacing) % spacing;
|
||||
const offsetY = ((pan.y % spacing) + spacing) % spacing;
|
||||
|
||||
const dotRadius = Math.max(0.8, Math.min(1.5, zoom * 1.2));
|
||||
|
||||
ctx.fillStyle = dotColor;
|
||||
ctx.globalAlpha = 0.35;
|
||||
|
||||
for (let x = offsetX; x < w; x += spacing) {
|
||||
for (let y = offsetY; y < h; y += spacing) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, dotRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
}, [pan, zoom]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||
style={{ zIndex: 0 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export function screenToCanvas(
|
||||
screenX: number,
|
||||
screenY: number,
|
||||
pan: { x: number; y: number },
|
||||
zoom: number,
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: (screenX - pan.x) / zoom,
|
||||
y: (screenY - pan.y) / zoom,
|
||||
};
|
||||
}
|
||||
|
||||
export function canvasToScreen(
|
||||
canvasX: number,
|
||||
canvasY: number,
|
||||
pan: { x: number; y: number },
|
||||
zoom: number,
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: canvasX * zoom + pan.x,
|
||||
y: canvasY * zoom + pan.y,
|
||||
};
|
||||
}
|
||||
|
||||
/** Pan value that keeps the canvas point (gridX, gridY) under (mouseX, mouseY) after zoom changes. */
|
||||
export function zoomAroundPoint(
|
||||
mouseX: number,
|
||||
mouseY: number,
|
||||
pan: { x: number; y: number },
|
||||
oldZoom: number,
|
||||
newZoom: number,
|
||||
): { x: number; y: number } {
|
||||
const gridX = (mouseX - pan.x) / oldZoom;
|
||||
const gridY = (mouseY - pan.y) / oldZoom;
|
||||
return {
|
||||
x: mouseX - gridX * newZoom,
|
||||
y: mouseY - gridY * newZoom,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { GRID_SIZE } from "../types.js";
|
||||
|
||||
export function snapToGrid(value: number): number {
|
||||
return Math.round(value / GRID_SIZE) * GRID_SIZE;
|
||||
}
|
||||
|
||||
export function snapToGridFloor(value: number): number {
|
||||
return Math.floor(value / GRID_SIZE) * GRID_SIZE;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import type { CanvasWidget, DragState } from "../types.js";
|
||||
import { snapToGrid } from "./snapToGrid";
|
||||
|
||||
interface UseBoxDragOptions {
|
||||
widgets: CanvasWidget[];
|
||||
zoom: number;
|
||||
getZoom: () => number;
|
||||
onWidgetMove: (id: number, x: number, y: number) => void;
|
||||
onDragStart: (id: number) => void;
|
||||
onDragEnd: () => void;
|
||||
}
|
||||
|
||||
export function useBoxDrag({
|
||||
widgets,
|
||||
getZoom,
|
||||
onWidgetMove,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
}: UseBoxDragOptions) {
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
|
||||
const startDrag = useCallback(
|
||||
(e: React.MouseEvent, widget: CanvasWidget) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
dragRef.current = {
|
||||
widgetId: widget.id,
|
||||
startMouseX: e.clientX,
|
||||
startMouseY: e.clientY,
|
||||
startWidgetX: widget.x,
|
||||
startWidgetY: widget.y,
|
||||
};
|
||||
onDragStart(widget.id);
|
||||
|
||||
const handleMove = (ev: MouseEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
const zoom = getZoom();
|
||||
const dx = (ev.clientX - dragRef.current.startMouseX) / zoom;
|
||||
const dy = (ev.clientY - dragRef.current.startMouseY) / zoom;
|
||||
const newX = snapToGrid(dragRef.current.startWidgetX + dx);
|
||||
const newY = snapToGrid(dragRef.current.startWidgetY + dy);
|
||||
|
||||
const moving = widgets.find((w) => w.id === dragRef.current!.widgetId);
|
||||
if (!moving) return;
|
||||
|
||||
if (newX !== moving.x || newY !== moving.y) {
|
||||
onWidgetMove(moving.id, newX, newY);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
dragRef.current = null;
|
||||
onDragEnd();
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
},
|
||||
[widgets, getZoom, onWidgetMove, onDragStart, onDragEnd],
|
||||
);
|
||||
|
||||
return { startDrag };
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { GRID_SIZE, type CanvasWidget, type ResizeState } from "../types.js";
|
||||
import { snapToGrid } from "./snapToGrid";
|
||||
import { getWidgetType } from "../widgets/WidgetRegistry";
|
||||
|
||||
interface UseBoxResizeOptions {
|
||||
widgets: CanvasWidget[];
|
||||
getZoom: () => number;
|
||||
onWidgetResize: (id: number, w: number, h: number) => void;
|
||||
onResizeStart: (id: number) => void;
|
||||
onResizeEnd: () => void;
|
||||
}
|
||||
|
||||
export function useBoxResize({
|
||||
widgets,
|
||||
getZoom,
|
||||
onWidgetResize,
|
||||
onResizeStart,
|
||||
onResizeEnd,
|
||||
}: UseBoxResizeOptions) {
|
||||
const resizeRef = useRef<ResizeState | null>(null);
|
||||
|
||||
const startResize = useCallback(
|
||||
(e: React.MouseEvent, widget: CanvasWidget) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
resizeRef.current = {
|
||||
widgetId: widget.id,
|
||||
startMouseX: e.clientX,
|
||||
startMouseY: e.clientY,
|
||||
startW: widget.w,
|
||||
startH: widget.h,
|
||||
};
|
||||
onResizeStart(widget.id);
|
||||
|
||||
const handleMove = (ev: MouseEvent) => {
|
||||
if (!resizeRef.current) return;
|
||||
const zoom = getZoom();
|
||||
const dx = (ev.clientX - resizeRef.current.startMouseX) / zoom;
|
||||
const dy = (ev.clientY - resizeRef.current.startMouseY) / zoom;
|
||||
const newW = Math.max(
|
||||
GRID_SIZE * 2,
|
||||
snapToGrid(resizeRef.current.startW + dx),
|
||||
);
|
||||
const newH = Math.max(
|
||||
GRID_SIZE * 2,
|
||||
snapToGrid(resizeRef.current.startH + dy),
|
||||
);
|
||||
|
||||
const moving = widgets.find(
|
||||
(w) => w.id === resizeRef.current!.widgetId,
|
||||
);
|
||||
if (!moving) return;
|
||||
|
||||
const typeDef = getWidgetType(moving.typeId);
|
||||
const minW = typeDef?.minSize.w ?? GRID_SIZE * 4;
|
||||
const minH = typeDef?.minSize.h ?? GRID_SIZE * 4;
|
||||
const clampedW = Math.max(minW, newW);
|
||||
const clampedH = Math.max(minH, newH);
|
||||
|
||||
if (clampedW !== moving.w || clampedH !== moving.h) {
|
||||
onWidgetResize(moving.id, clampedW, clampedH);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
resizeRef.current = null;
|
||||
onResizeEnd();
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
},
|
||||
[widgets, getZoom, onWidgetResize, onResizeStart, onResizeEnd],
|
||||
);
|
||||
|
||||
return { startResize };
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { MIN_ZOOM, MAX_ZOOM } from "../types.js";
|
||||
import { zoomAroundPoint } from "./canvasGeometry";
|
||||
|
||||
interface UseCanvasInputOptions {
|
||||
isDraggingWidget: boolean;
|
||||
isResizing: boolean;
|
||||
isContextMenuVisible: boolean;
|
||||
onPanChange: (pan: { x: number; y: number }) => void;
|
||||
onZoomChange: (zoom: number, pan: { x: number; y: number }) => void;
|
||||
getPan: () => { x: number; y: number };
|
||||
getZoom: () => number;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export function useCanvasInput({
|
||||
isDraggingWidget,
|
||||
isResizing,
|
||||
isContextMenuVisible,
|
||||
onPanChange,
|
||||
onZoomChange,
|
||||
getPan,
|
||||
getZoom,
|
||||
containerRef,
|
||||
}: UseCanvasInputOptions) {
|
||||
const isPanningRef = useRef(false);
|
||||
const lastMouseRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Only pan on the background (not on widgets), left button
|
||||
if (e.button !== 0) return;
|
||||
if ((e.target as HTMLElement).closest("[data-widget]")) return;
|
||||
if (isDraggingWidget || isResizing || isContextMenuVisible) return;
|
||||
|
||||
isPanningRef.current = true;
|
||||
lastMouseRef.current = { x: e.clientX, y: e.clientY };
|
||||
e.preventDefault();
|
||||
},
|
||||
[isDraggingWidget, isResizing, isContextMenuVisible],
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!isPanningRef.current) return;
|
||||
const dx = e.clientX - lastMouseRef.current.x;
|
||||
const dy = e.clientY - lastMouseRef.current.y;
|
||||
lastMouseRef.current = { x: e.clientX, y: e.clientY };
|
||||
const pan = getPan();
|
||||
onPanChange({ x: pan.x + dx, y: pan.y + dy });
|
||||
},
|
||||
[getPan, onPanChange],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
isPanningRef.current = false;
|
||||
}, []);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const zoom = getZoom();
|
||||
const pan = getPan();
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
// Multiplicative zoom so each step is proportional regardless of current zoom level.
|
||||
// Normalize deltaY to avoid trackpad sending huge values.
|
||||
const rawDelta = Math.sign(e.deltaY) * Math.min(Math.abs(e.deltaY), 100);
|
||||
const factor = 1 - rawDelta * 0.001;
|
||||
const newZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom * factor));
|
||||
if (newZoom === zoom) return;
|
||||
|
||||
const newPan = zoomAroundPoint(mouseX, mouseY, pan, zoom, newZoom);
|
||||
onZoomChange(newZoom, newPan);
|
||||
},
|
||||
[getZoom, getPan, containerRef, onZoomChange],
|
||||
);
|
||||
|
||||
return { handleMouseDown, handleMouseMove, handleMouseUp, handleWheel };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/** IANA zone names use underscores, but people type and paste spaces. */
|
||||
export function normalizeTimezone(timezone: string): string {
|
||||
return timezone.trim().replace(/\s+/g, "_");
|
||||
}
|
||||
|
||||
export function isValidTimezone(timezone: string): boolean {
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: timezone });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes the timezone a user typed and reports whether it is usable.
|
||||
* A blank field is valid: it means "use local time".
|
||||
*/
|
||||
export function validateClockTimezone(timezone: unknown): {
|
||||
timezone?: string;
|
||||
valid: boolean;
|
||||
} {
|
||||
if (typeof timezone !== "string" || timezone.trim() === "") {
|
||||
return { timezone: undefined, valid: true };
|
||||
}
|
||||
const normalized = normalizeTimezone(timezone);
|
||||
return { timezone: normalized, valid: isValidTimezone(normalized) };
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { useWidgetTypes } from "../widgets/WidgetRegistry";
|
||||
import type { ContextMenuState, WidgetTypeId } from "../types.js";
|
||||
|
||||
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 = useWidgetTypes();
|
||||
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">
|
||||
{t("homepage.noWidgetsInCategory")}
|
||||
</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.labelComponent ? (
|
||||
<type.labelComponent part="name" />
|
||||
) : (
|
||||
type.name
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
{type.labelComponent ? (
|
||||
<type.labelComponent part="description" />
|
||||
) : (
|
||||
type.description
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Input, Button } from "@termix/plugin-sdk/ui";
|
||||
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import type {
|
||||
BookmarkListConfig,
|
||||
BookmarkLink,
|
||||
WidgetEditFormProps,
|
||||
} from "../types.js";
|
||||
|
||||
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,38 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Input } from "@termix/plugin-sdk/ui";
|
||||
import type { CalendarConfig, WidgetEditFormProps } from "../types.js";
|
||||
|
||||
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,60 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Input, Switch } from "@termix/plugin-sdk/ui";
|
||||
|
||||
import type { ClockConfig, WidgetEditFormProps } from "../types.js";
|
||||
import { validateClockTimezone } from "../clock-timezone";
|
||||
|
||||
export function ClockEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<ClockConfig>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const invalidTimezone = !validateClockTimezone(config.timezone).valid;
|
||||
|
||||
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)"
|
||||
aria-invalid={invalidTimezone}
|
||||
className={
|
||||
invalidTimezone ? "h-8 text-sm border-destructive" : "h-8 text-sm"
|
||||
}
|
||||
/>
|
||||
{invalidTimezone && (
|
||||
<p className="text-[10px] text-destructive">
|
||||
{t("homepage.invalidTimezone")}
|
||||
</p>
|
||||
)}
|
||||
</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,54 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Input } from "@termix/plugin-sdk/ui";
|
||||
import type { CountdownConfig, WidgetEditFormProps } from "../types.js";
|
||||
|
||||
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 "@termix/plugin-sdk/frontend";
|
||||
import { Input } from "@termix/plugin-sdk/ui";
|
||||
import type {
|
||||
CustomApiConfig,
|
||||
CustomApiDisplayMode,
|
||||
WidgetEditFormProps,
|
||||
} from "../types.js";
|
||||
|
||||
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,61 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Input } from "@termix/plugin-sdk/ui";
|
||||
import type { DashboardLinksConfig, WidgetEditFormProps } from "../types.js";
|
||||
|
||||
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,44 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Input } from "@termix/plugin-sdk/ui";
|
||||
import type { FolderConfig, WidgetEditFormProps } from "../types.js";
|
||||
|
||||
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,82 @@
|
||||
import { useHosts, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import type { HostGridConfig, WidgetEditFormProps } from "../types.js";
|
||||
|
||||
export function HostGridEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<HostGridConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { hosts } = useHosts();
|
||||
|
||||
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) => {
|
||||
const id = Number(h.id);
|
||||
return (
|
||||
<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(id)}
|
||||
onChange={() => toggleHost(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,85 @@
|
||||
import { useHosts, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import type {
|
||||
HostMetricKey,
|
||||
HostStatusConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "../types.js";
|
||||
import { Select2 } from "@termix/plugin-sdk/ui";
|
||||
|
||||
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" },
|
||||
];
|
||||
|
||||
export function HostStatusEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<HostStatusConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { hosts: allHosts } = useHosts();
|
||||
const hosts = allHosts.filter((h) => h.statusCheckEnabled !== false);
|
||||
|
||||
const shownMetrics: HostMetricKey[] = config.shownMetrics?.length
|
||||
? config.shownMetrics
|
||||
: config.showMetrics !== false
|
||||
? ["cpu", "memory", ...(config.showDisk ? ["disk" as HostMetricKey] : [])]
|
||||
: [];
|
||||
|
||||
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("common.host")}
|
||||
</label>
|
||||
<Select2
|
||||
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("common.selectHost")}</option>
|
||||
{hosts.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.name}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</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 "@termix/plugin-sdk/frontend";
|
||||
import { Input, Switch } from "@termix/plugin-sdk/ui";
|
||||
|
||||
import type { IframeConfig, WidgetEditFormProps } from "../types.js";
|
||||
|
||||
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,68 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Input } from "@termix/plugin-sdk/ui";
|
||||
import type { ImageWidgetConfig, WidgetEditFormProps } from "../types.js";
|
||||
|
||||
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 "@termix/plugin-sdk/frontend";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import { Input, Button } from "@termix/plugin-sdk/ui";
|
||||
|
||||
import type {
|
||||
LinkTreeConfig,
|
||||
LinkTreeSection,
|
||||
LinkTreeLink,
|
||||
WidgetEditFormProps,
|
||||
} from "../types.js";
|
||||
|
||||
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,73 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Input, Textarea } from "@termix/plugin-sdk/ui";
|
||||
|
||||
import type { MarkdownNotesConfig, WidgetEditFormProps } from "../types.js";
|
||||
|
||||
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,25 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Textarea } from "@termix/plugin-sdk/ui";
|
||||
import type { NotesConfig, WidgetEditFormProps } from "../types.js";
|
||||
|
||||
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 "@termix/plugin-sdk/frontend";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import { Input, Button } from "@termix/plugin-sdk/ui";
|
||||
|
||||
import type {
|
||||
PingStatusConfig,
|
||||
PingUrl,
|
||||
WidgetEditFormProps,
|
||||
} from "../types.js";
|
||||
|
||||
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,132 @@
|
||||
import {
|
||||
useHostActions,
|
||||
useHosts,
|
||||
useTranslation,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import type {
|
||||
QuickConnectConfig,
|
||||
QuickConnectType,
|
||||
WidgetEditFormProps,
|
||||
} from "../types.js";
|
||||
import { quickConnectTargets } from "../quick-connect-targets.js";
|
||||
|
||||
export function QuickConnectEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<QuickConnectConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { hosts } = useHosts();
|
||||
const targets = quickConnectTargets(useHostActions());
|
||||
|
||||
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) => {
|
||||
const id = Number(h.id);
|
||||
return (
|
||||
<label
|
||||
key={h.id}
|
||||
className="flex items-center gap-2 text-xs cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.hostIds.includes(id)}
|
||||
onChange={() => toggleHost(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">
|
||||
{targets.map(({ type, labelKey }) => (
|
||||
<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(labelKey, 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,83 @@
|
||||
import { useActivityTypes, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Input } from "@termix/plugin-sdk/ui";
|
||||
import type {
|
||||
RecentActivityConfig,
|
||||
ActivityType,
|
||||
WidgetEditFormProps,
|
||||
} from "../types.js";
|
||||
|
||||
export function RecentActivityEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<RecentActivityConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const activityTypes = useActivityTypes();
|
||||
|
||||
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">
|
||||
{activityTypes.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,53 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Input, Switch } from "@termix/plugin-sdk/ui";
|
||||
|
||||
import type { RssFeedConfig, WidgetEditFormProps } from "../types.js";
|
||||
|
||||
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 "@termix/plugin-sdk/frontend";
|
||||
import { Input } from "@termix/plugin-sdk/ui";
|
||||
import type {
|
||||
SearchBarConfig,
|
||||
SearchEngine,
|
||||
WidgetEditFormProps,
|
||||
} from "../types.js";
|
||||
|
||||
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 "@termix/plugin-sdk/frontend";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import { Input, Button } from "@termix/plugin-sdk/ui";
|
||||
|
||||
import type {
|
||||
SearchLinksConfig,
|
||||
SearchLinkShortcut,
|
||||
WidgetEditFormProps,
|
||||
} from "../types.js";
|
||||
|
||||
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 "@termix/plugin-sdk/frontend";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import { Input, Button } from "@termix/plugin-sdk/ui";
|
||||
|
||||
import type {
|
||||
ServiceGridConfig,
|
||||
ServiceGridItem,
|
||||
WidgetEditFormProps,
|
||||
} from "../types.js";
|
||||
|
||||
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,98 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Input, Switch } from "@termix/plugin-sdk/ui";
|
||||
|
||||
import type { ServiceLinkConfig, WidgetEditFormProps } from "../types.js";
|
||||
|
||||
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={t("homepage.serviceNamePlaceholder")}
|
||||
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,42 @@
|
||||
import {
|
||||
useTranslation,
|
||||
useHosts,
|
||||
type PluginHostRecord,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { Select2 } from "@termix/plugin-sdk/ui";
|
||||
|
||||
interface SingleHostEditFormProps {
|
||||
hostId: number;
|
||||
onChange: (hostId: number) => void;
|
||||
filter?: (host: PluginHostRecord) => boolean;
|
||||
}
|
||||
|
||||
export function SingleHostEditForm({
|
||||
hostId,
|
||||
onChange,
|
||||
filter,
|
||||
}: SingleHostEditFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const { hosts: allHosts } = useHosts();
|
||||
const hosts = filter ? allHosts.filter(filter) : allHosts;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("common.host")}
|
||||
</label>
|
||||
<Select2
|
||||
value={hostId || ""}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="h-8 text-xs border border-border bg-background px-2"
|
||||
>
|
||||
<option value="">{t("common.selectHost")}</option>
|
||||
{hosts.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.name || h.ip}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import type { SshTerminalConfig, WidgetEditFormProps } from "../types.js";
|
||||
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}
|
||||
/>
|
||||
<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,46 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import type { SystemOverviewConfig, WidgetEditFormProps } from "../types.js";
|
||||
|
||||
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,24 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import type { TermixUptimeConfig, WidgetEditFormProps } from "../types.js";
|
||||
|
||||
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 "@termix/plugin-sdk/frontend";
|
||||
import { Input, Textarea } from "@termix/plugin-sdk/ui";
|
||||
|
||||
import type {
|
||||
TextBannerConfig,
|
||||
TextBannerFontSize,
|
||||
TextBannerAlign,
|
||||
TextBannerWeight,
|
||||
WidgetEditFormProps,
|
||||
} from "../types.js";
|
||||
|
||||
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,56 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Input, Switch } from "@termix/plugin-sdk/ui";
|
||||
|
||||
import type { WeatherConfig, WidgetEditFormProps } from "../types.js";
|
||||
|
||||
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,353 @@
|
||||
import { useState } from "react";
|
||||
import { getWidgetType } from "../widgets/WidgetRegistry";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
Input,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
|
||||
import type {
|
||||
CanvasWidget,
|
||||
ServiceLinkConfig,
|
||||
FolderConfig,
|
||||
NotesConfig,
|
||||
BookmarkListConfig,
|
||||
ClockConfig,
|
||||
HostStatusConfig,
|
||||
WeatherConfig,
|
||||
IframeConfig,
|
||||
RssFeedConfig,
|
||||
HostGridConfig,
|
||||
PingStatusConfig,
|
||||
RecentActivityConfig,
|
||||
TermixUptimeConfig,
|
||||
SystemOverviewConfig,
|
||||
SshTerminalConfig,
|
||||
QuickConnectConfig,
|
||||
CalendarConfig,
|
||||
CountdownConfig,
|
||||
SearchBarConfig,
|
||||
TextBannerConfig,
|
||||
ImageWidgetConfig,
|
||||
MarkdownNotesConfig,
|
||||
CustomApiConfig,
|
||||
ServiceGridConfig,
|
||||
DashboardLinksConfig,
|
||||
SearchLinksConfig,
|
||||
LinkTreeConfig,
|
||||
} from "../types.js";
|
||||
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 { HostGridEditForm } from "./HostGridEditForm";
|
||||
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 { 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";
|
||||
import { validateClockTimezone } from "../clock-timezone";
|
||||
|
||||
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;
|
||||
|
||||
// Configs that need checking before they can be stored. The clock keeps the
|
||||
// normalized zone rather than the raw input, so "America/New York" is filed
|
||||
// as "America/New_York"; the form flags an unusable one and Save stays off.
|
||||
const clockTimezone =
|
||||
widget.typeId === "clock" ? validateClockTimezone(config.timezone) : null;
|
||||
const configInvalid = clockTimezone ? !clockTimezone.valid : false;
|
||||
|
||||
const handleSave = () => {
|
||||
if (configInvalid) return;
|
||||
onSave(
|
||||
widget.id,
|
||||
title || null,
|
||||
clockTimezone ? { ...config, timezone: clockTimezone.timezone } : config,
|
||||
);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const renderForm = () => {
|
||||
switch (widget.typeId) {
|
||||
case "service_link":
|
||||
return (
|
||||
<ServiceLinkEditForm
|
||||
config={config as unknown as ServiceLinkConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "folder":
|
||||
return (
|
||||
<FolderEditForm
|
||||
config={config as unknown as FolderConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "notes":
|
||||
return (
|
||||
<NotesEditForm
|
||||
config={config as unknown as NotesConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "bookmark_list":
|
||||
return (
|
||||
<BookmarkListEditForm
|
||||
config={config as unknown as BookmarkListConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "clock":
|
||||
return (
|
||||
<ClockEditForm
|
||||
config={config as unknown as ClockConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "host_status":
|
||||
return (
|
||||
<HostStatusEditForm
|
||||
config={config as unknown as HostStatusConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "weather":
|
||||
return (
|
||||
<WeatherEditForm
|
||||
config={config as unknown as WeatherConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "iframe_embed":
|
||||
return (
|
||||
<IframeEditForm
|
||||
config={config as unknown as IframeConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "rss_feed":
|
||||
return (
|
||||
<RssFeedEditForm
|
||||
config={config as unknown as RssFeedConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "host_grid":
|
||||
return (
|
||||
<HostGridEditForm
|
||||
config={config as unknown as HostGridConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "ping_status":
|
||||
return (
|
||||
<PingStatusEditForm
|
||||
config={config as unknown as PingStatusConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "recent_activity":
|
||||
return (
|
||||
<RecentActivityEditForm
|
||||
config={config as unknown as RecentActivityConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "termix_uptime":
|
||||
return (
|
||||
<TermixUptimeEditForm
|
||||
config={config as unknown as TermixUptimeConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "system_overview":
|
||||
return (
|
||||
<SystemOverviewEditForm
|
||||
config={config as unknown as SystemOverviewConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "ssh_terminal":
|
||||
return (
|
||||
<SshTerminalEditForm
|
||||
config={config as unknown as SshTerminalConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "quick_connect":
|
||||
return (
|
||||
<QuickConnectEditForm
|
||||
config={config as unknown as QuickConnectConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "calendar":
|
||||
return (
|
||||
<CalendarEditForm
|
||||
config={config as unknown as CalendarConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "countdown":
|
||||
return (
|
||||
<CountdownEditForm
|
||||
config={config as unknown as CountdownConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "search_bar":
|
||||
return (
|
||||
<SearchBarEditForm
|
||||
config={config as unknown as SearchBarConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "text_banner":
|
||||
return (
|
||||
<TextBannerEditForm
|
||||
config={config as unknown as TextBannerConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "image_widget":
|
||||
return (
|
||||
<ImageWidgetEditForm
|
||||
config={config as unknown as ImageWidgetConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "markdown_notes":
|
||||
return (
|
||||
<MarkdownNotesEditForm
|
||||
config={config as unknown as MarkdownNotesConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "custom_api":
|
||||
return (
|
||||
<CustomApiEditForm
|
||||
config={config as unknown as CustomApiConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "service_grid":
|
||||
return (
|
||||
<ServiceGridEditForm
|
||||
config={config as unknown as ServiceGridConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "dashboard_links":
|
||||
return (
|
||||
<DashboardLinksEditForm
|
||||
config={config as unknown as DashboardLinksConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "search_links":
|
||||
return (
|
||||
<SearchLinksEditForm
|
||||
config={config as unknown as SearchLinksConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "link_tree":
|
||||
return (
|
||||
<LinkTreeEditForm
|
||||
config={config as unknown as LinkTreeConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
default: {
|
||||
// Plugin widgets bring their own form.
|
||||
const EditForm = getWidgetType(widget.typeId)?.editFormComponent;
|
||||
return EditForm ? (
|
||||
<EditForm config={config} onChange={setConfig} />
|
||||
) : 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} disabled={configInvalid}>
|
||||
{t("homepage.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
import type { ComponentType } from "react";
|
||||
import type {
|
||||
StandaloneViewProps,
|
||||
TabProps,
|
||||
TermixApp,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { HomepageCanvas } from "./HomepageCanvas.js";
|
||||
import { HomepagePreviewCard } from "./HomepagePreviewCard.js";
|
||||
import { ServiceLinksCard } from "./ServiceLinksCard.js";
|
||||
import { DashboardHomepageView } from "./DashboardHomepageView.js";
|
||||
import { setHomepageApi } from "./api.js";
|
||||
import {
|
||||
WIDGET_POINT,
|
||||
drainQueuedWidgets,
|
||||
toExtension,
|
||||
} from "./widgets/WidgetRegistry.js";
|
||||
|
||||
// Side-effect imports so widgets register themselves.
|
||||
import "./widgets/ServiceLinkWidget";
|
||||
import "./widgets/ClockWidget";
|
||||
import "./widgets/NotesWidget";
|
||||
import "./widgets/BookmarkListWidget";
|
||||
import "./widgets/HostStatusWidget";
|
||||
import "./widgets/FolderWidget";
|
||||
import "./widgets/WeatherWidget";
|
||||
import "./widgets/IframeWidget";
|
||||
import "./widgets/RssFeedWidget";
|
||||
import "./widgets/HostGridWidget";
|
||||
import "./widgets/PingStatusWidget";
|
||||
import "./widgets/RecentActivityWidget";
|
||||
import "./widgets/TermixUptimeWidget";
|
||||
import "./widgets/SystemOverviewWidget";
|
||||
import "./widgets/SshTerminalWidget";
|
||||
import "./widgets/QuickConnectWidget";
|
||||
import "./widgets/CalendarWidget";
|
||||
import "./widgets/CountdownWidget";
|
||||
import "./widgets/SearchBarWidget";
|
||||
import "./widgets/TextBannerWidget";
|
||||
import "./widgets/ImageWidget";
|
||||
import "./widgets/MarkdownNotesWidget";
|
||||
import "./widgets/CustomApiWidget";
|
||||
import "./widgets/ServiceGridWidget";
|
||||
import "./widgets/DashboardLinksWidget";
|
||||
import "./widgets/SearchLinksWidget";
|
||||
import "./widgets/LinkTreeWidget";
|
||||
|
||||
function HomepageTab(_props: TabProps) {
|
||||
return <HomepageCanvas />;
|
||||
}
|
||||
|
||||
function HomepageStandalone(_props: StandaloneViewProps) {
|
||||
return <HomepageCanvas fitOnLoad={true} />;
|
||||
}
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
setHomepageApi(app.api);
|
||||
app.onDispose(() => setHomepageApi(null));
|
||||
|
||||
for (const widget of drainQueuedWidgets()) {
|
||||
app.registerExtension(WIDGET_POINT, toExtension(widget));
|
||||
}
|
||||
|
||||
app.registerTab("homepage", HomepageTab as ComponentType<TabProps>, {
|
||||
icon: LayoutGrid,
|
||||
titleKey: "nav.homepage",
|
||||
singleton: true,
|
||||
hostless: true,
|
||||
standalone: HomepageStandalone,
|
||||
});
|
||||
|
||||
app.registerPaletteEntry({
|
||||
id: "homepage.open",
|
||||
titleKey: "nav.homepage",
|
||||
icon: LayoutGrid,
|
||||
keywords: ["homepage", "widgets", "canvas"],
|
||||
scope: "global",
|
||||
run: (shell) => shell.openSingletonTab("homepage"),
|
||||
});
|
||||
|
||||
app.registerDashboardCard({
|
||||
id: "service_links",
|
||||
titleKey: "dashboard.serviceLinks",
|
||||
defaultHeight: 200,
|
||||
defaultPanel: "side",
|
||||
component: ServiceLinksCard,
|
||||
});
|
||||
|
||||
app.registerDashboardCard({
|
||||
id: "homepage_preview",
|
||||
titleKey: "dashboard.homepagePreview",
|
||||
defaultHeight: 320,
|
||||
component: HomepagePreviewCard,
|
||||
});
|
||||
|
||||
app.registerSlotContribution("dashboard.secondaryView", {
|
||||
actionId: "homepage",
|
||||
titleKey: "nav.homepage",
|
||||
kind: "component",
|
||||
component: DashboardHomepageView as ComponentType<Record<string, unknown>>,
|
||||
});
|
||||
}
|
||||
|
||||
export function deactivate(): void {
|
||||
// Everything above was registered through app and is disposed by core.
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type {
|
||||
HostActionContribution,
|
||||
PluginHostRecord,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
|
||||
export interface QuickConnectTarget {
|
||||
/** Tab type, and the value stored in a widget's config. */
|
||||
type: string;
|
||||
icon: ComponentType<{ size?: number | string; className?: string }>;
|
||||
/** i18n key, or a literal label from a plugin action. */
|
||||
labelKey: string;
|
||||
enabled: (host: PluginHostRecord) => boolean;
|
||||
}
|
||||
|
||||
/** What a quick connect widget can open: the tools plugins registered. */
|
||||
export function quickConnectTargets(
|
||||
actions: HostActionContribution[],
|
||||
): QuickConnectTarget[] {
|
||||
const seen = new Set<string>();
|
||||
const fromPlugins: QuickConnectTarget[] = [];
|
||||
for (const action of actions) {
|
||||
if (!action.tabType || seen.has(action.tabType)) continue;
|
||||
seen.add(action.tabType);
|
||||
fromPlugins.push({
|
||||
type: action.tabType,
|
||||
icon: action.icon,
|
||||
labelKey: action.titleKey,
|
||||
enabled: (h) => {
|
||||
try {
|
||||
return action.when(h);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
return fromPlugins;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
Lock,
|
||||
LockOpen,
|
||||
Minus,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
PlusCircle,
|
||||
} from "lucide-react";
|
||||
import { MIN_ZOOM, MAX_ZOOM, ZOOM_STEP } from "../types.js";
|
||||
import { zoomAroundPoint } from "../canvas/canvasGeometry";
|
||||
|
||||
interface HomepageToolbarProps {
|
||||
zoom: number;
|
||||
isLocked: boolean;
|
||||
pan: { x: number; y: number };
|
||||
containerSize: { w: number; h: number };
|
||||
onZoomChange: (zoom: number, pan: { x: number; y: number }) => void;
|
||||
onLockToggle: () => void;
|
||||
onResetView: () => void;
|
||||
onAddWidget: (anchorRect: {
|
||||
top: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
right: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
function ToolButton({
|
||||
onClick,
|
||||
title,
|
||||
disabled,
|
||||
children,
|
||||
}: {
|
||||
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
title: string;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-muted/60 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomepageToolbar({
|
||||
zoom,
|
||||
isLocked,
|
||||
pan,
|
||||
containerSize,
|
||||
onZoomChange,
|
||||
onLockToggle,
|
||||
onResetView,
|
||||
onAddWidget,
|
||||
}: HomepageToolbarProps) {
|
||||
const { t } = useTranslation();
|
||||
const cx = containerSize.w / 2;
|
||||
const cy = containerSize.h / 2;
|
||||
|
||||
const zoomIn = () => {
|
||||
const next = Math.min(MAX_ZOOM, parseFloat((zoom + ZOOM_STEP).toFixed(1)));
|
||||
onZoomChange(next, zoomAroundPoint(cx, cy, pan, zoom, next));
|
||||
};
|
||||
const zoomOut = () => {
|
||||
const next = Math.max(MIN_ZOOM, parseFloat((zoom - ZOOM_STEP).toFixed(1)));
|
||||
onZoomChange(next, zoomAroundPoint(cx, cy, pan, zoom, next));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-4 right-4 z-30 flex items-center gap-0 bg-card border border-border shadow-lg">
|
||||
<ToolButton
|
||||
onClick={(e) => onAddWidget(e.currentTarget.getBoundingClientRect())}
|
||||
title={t("homepage.addWidget")}
|
||||
disabled={isLocked}
|
||||
>
|
||||
<PlusCircle size={15} className={isLocked ? "" : "text-accent-brand"} />
|
||||
</ToolButton>
|
||||
|
||||
<div className="w-px h-5 bg-border" />
|
||||
|
||||
<ToolButton
|
||||
onClick={zoomOut}
|
||||
title={t("homepage.zoomOut")}
|
||||
disabled={zoom <= MIN_ZOOM}
|
||||
>
|
||||
<Minus size={13} />
|
||||
</ToolButton>
|
||||
|
||||
<span className="text-[11px] font-mono text-foreground tabular-nums w-10 text-center select-none">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
|
||||
<ToolButton
|
||||
onClick={zoomIn}
|
||||
title={t("homepage.zoomIn")}
|
||||
disabled={zoom >= MAX_ZOOM}
|
||||
>
|
||||
<Plus size={13} />
|
||||
</ToolButton>
|
||||
|
||||
<div className="w-px h-5 bg-border" />
|
||||
|
||||
<ToolButton onClick={onResetView} title={t("homepage.resetView")}>
|
||||
<RotateCcw size={13} />
|
||||
</ToolButton>
|
||||
|
||||
<ToolButton
|
||||
onClick={onLockToggle}
|
||||
title={isLocked ? t("homepage.unlockLayout") : t("homepage.lockLayout")}
|
||||
>
|
||||
{isLocked ? (
|
||||
<Lock size={13} className="text-accent-brand" />
|
||||
) : (
|
||||
<LockOpen size={13} />
|
||||
)}
|
||||
</ToolButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
export const GRID_SIZE = 30;
|
||||
export const MIN_ZOOM = 0.4;
|
||||
export const MAX_ZOOM = 2.0;
|
||||
export const ZOOM_STEP = 0.1;
|
||||
|
||||
export type WidgetTypeId =
|
||||
| "service_link"
|
||||
| "folder"
|
||||
| "clock"
|
||||
| "notes"
|
||||
| "host_status"
|
||||
| "bookmark_list"
|
||||
| "weather"
|
||||
| "iframe_embed"
|
||||
| "rss_feed"
|
||||
| "metrics_chart"
|
||||
| "host_grid"
|
||||
| "ping_status"
|
||||
| "recent_activity"
|
||||
| "termix_uptime"
|
||||
| "system_overview"
|
||||
| "ssh_terminal"
|
||||
| "quick_connect"
|
||||
| "file_manager_widget"
|
||||
| "tunnel_widget"
|
||||
| "calendar"
|
||||
| "countdown"
|
||||
| "search_bar"
|
||||
| "text_banner"
|
||||
| "image_widget"
|
||||
| "markdown_notes"
|
||||
| "custom_api"
|
||||
| "service_grid"
|
||||
| "dashboard_links"
|
||||
| "search_links"
|
||||
| "link_tree"
|
||||
| "ssh_quick_connect"
|
||||
| (string & {});
|
||||
|
||||
export interface HomepageItemRow {
|
||||
id: number;
|
||||
userId: string;
|
||||
typeId: WidgetTypeId;
|
||||
title: string | null;
|
||||
config: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface HomepageLayoutEntry {
|
||||
itemId: number;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
zOrder?: number;
|
||||
}
|
||||
|
||||
export interface HomepageLayoutData {
|
||||
entries: HomepageLayoutEntry[];
|
||||
pan: { x: number; y: number };
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
export interface HomepageLayoutRow {
|
||||
id: number;
|
||||
userId: string;
|
||||
layout: HomepageLayoutData;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ServiceLinkRecord {
|
||||
id: number;
|
||||
userId: string;
|
||||
label: string;
|
||||
url: string;
|
||||
order: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CanvasWidget {
|
||||
id: number;
|
||||
typeId: WidgetTypeId;
|
||||
title: string | null;
|
||||
config: Record<string, unknown>;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
zOrder: number;
|
||||
}
|
||||
|
||||
// ---- Per-widget config shapes ----
|
||||
|
||||
export interface ServiceLinkConfig {
|
||||
url: string;
|
||||
description?: string;
|
||||
accentColor?: string;
|
||||
imageUrl?: string;
|
||||
showImage?: boolean;
|
||||
}
|
||||
|
||||
export interface FolderConfig {
|
||||
color?: string;
|
||||
isExpanded: boolean;
|
||||
}
|
||||
|
||||
export interface ClockConfig {
|
||||
timezone?: string;
|
||||
showSeconds: boolean;
|
||||
format: "12h" | "24h";
|
||||
}
|
||||
|
||||
export interface NotesConfig {
|
||||
content: string;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export type HostMetricKey =
|
||||
"cpu" | "memory" | "disk" | "uptime" | "network" | "system" | "processes";
|
||||
|
||||
export interface HostStatusConfig {
|
||||
hostId: number;
|
||||
/** @deprecated use shownMetrics instead */
|
||||
showMetrics?: boolean;
|
||||
/** @deprecated use shownMetrics instead */
|
||||
showDisk?: boolean;
|
||||
shownMetrics: HostMetricKey[];
|
||||
showSparkline?: boolean;
|
||||
}
|
||||
|
||||
export interface BookmarkLink {
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface BookmarkListConfig {
|
||||
links: BookmarkLink[];
|
||||
}
|
||||
|
||||
export interface WeatherConfig {
|
||||
location: string;
|
||||
unit: "C" | "F";
|
||||
showForecast: boolean;
|
||||
}
|
||||
|
||||
export interface IframeConfig {
|
||||
url: string;
|
||||
scrolling: boolean;
|
||||
}
|
||||
|
||||
export interface RssFeedConfig {
|
||||
feedUrl: string;
|
||||
maxItems: number;
|
||||
showDescription: boolean;
|
||||
}
|
||||
|
||||
// ---- New widget configs ----
|
||||
|
||||
export type MetricsChartMetric =
|
||||
"cpu" | "memory" | "disk" | "net_rx" | "net_tx";
|
||||
export type MetricsChartRange = "15m" | "1h" | "6h" | "24h";
|
||||
|
||||
export interface MetricsChartConfig {
|
||||
hostId: number;
|
||||
metric: MetricsChartMetric;
|
||||
range: MetricsChartRange;
|
||||
showCurrentValue: boolean;
|
||||
}
|
||||
|
||||
export interface HostGridConfig {
|
||||
hostIds: number[];
|
||||
showIp: boolean;
|
||||
columns: 2 | 3 | 4;
|
||||
}
|
||||
|
||||
export interface PingUrl {
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PingStatusConfig {
|
||||
urls: PingUrl[];
|
||||
refreshInterval: number;
|
||||
showLatency: boolean;
|
||||
}
|
||||
|
||||
/** Core activity types; plugin tabs record their own. */
|
||||
export type ActivityType = "file_manager" | "tunnel" | (string & {});
|
||||
|
||||
export interface RecentActivityConfig {
|
||||
maxItems: number;
|
||||
filterTypes: ActivityType[];
|
||||
showTimestamp: boolean;
|
||||
}
|
||||
|
||||
export interface DockerActivityConfig {
|
||||
maxItems: number;
|
||||
showHostName: boolean;
|
||||
}
|
||||
|
||||
export interface SshQuickConnectConfig {
|
||||
hostIds: number[];
|
||||
connectionType: QuickConnectType;
|
||||
showStatus: boolean;
|
||||
layout: "grid" | "list";
|
||||
}
|
||||
|
||||
export interface TermixUptimeConfig {
|
||||
showDetailed: boolean;
|
||||
}
|
||||
|
||||
export interface SystemOverviewConfig {
|
||||
showVersion: boolean;
|
||||
showDbHealth: boolean;
|
||||
showUptime: boolean;
|
||||
}
|
||||
|
||||
export interface FileManagerWidgetConfig {
|
||||
hostId: number;
|
||||
}
|
||||
|
||||
export interface SshTerminalConfig {
|
||||
hostId: number;
|
||||
autoConnect: boolean;
|
||||
}
|
||||
|
||||
/** Tab types a quick connect widget opens: core tools or plugin actions. */
|
||||
export type QuickConnectType = "files" | "tunnel" | (string & {});
|
||||
|
||||
export interface QuickConnectConfig {
|
||||
hostIds: number[];
|
||||
connectionTypes: QuickConnectType[];
|
||||
showStatus: boolean;
|
||||
layout: "grid" | "list";
|
||||
}
|
||||
|
||||
export interface CalendarConfig {
|
||||
timezone?: string;
|
||||
startOnMonday: boolean;
|
||||
}
|
||||
|
||||
export interface CountdownConfig {
|
||||
targetDate: string;
|
||||
label: string;
|
||||
showDays: boolean;
|
||||
showHours: boolean;
|
||||
}
|
||||
|
||||
export type SearchEngine = "google" | "duckduckgo" | "bing" | "custom";
|
||||
|
||||
export interface SearchBarConfig {
|
||||
engine: SearchEngine;
|
||||
customUrl?: string;
|
||||
placeholder?: string;
|
||||
openInNewTab: boolean;
|
||||
}
|
||||
|
||||
export type TextBannerFontSize = "sm" | "md" | "lg" | "xl";
|
||||
export type TextBannerAlign = "left" | "center" | "right";
|
||||
export type TextBannerWeight = "normal" | "semibold" | "bold";
|
||||
|
||||
export interface TextBannerConfig {
|
||||
text: string;
|
||||
fontSize: TextBannerFontSize;
|
||||
textAlign: TextBannerAlign;
|
||||
fontWeight: TextBannerWeight;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export interface ImageWidgetConfig {
|
||||
imageUrl: string;
|
||||
fit: "contain" | "cover" | "fill";
|
||||
alt?: string;
|
||||
linkUrl?: string;
|
||||
}
|
||||
|
||||
export interface MarkdownNotesConfig {
|
||||
content: string;
|
||||
backgroundColor?: string;
|
||||
renderMarkdown: boolean;
|
||||
}
|
||||
|
||||
export type CustomApiDisplayMode = "value" | "json" | "table";
|
||||
|
||||
export interface CustomApiConfig {
|
||||
url: string;
|
||||
displayField?: string;
|
||||
label?: string;
|
||||
unit?: string;
|
||||
refreshInterval: number;
|
||||
displayMode: CustomApiDisplayMode;
|
||||
jsonPath?: string;
|
||||
}
|
||||
|
||||
export interface ServiceGridItem {
|
||||
label: string;
|
||||
url: string;
|
||||
imageUrl?: string;
|
||||
accentColor?: string;
|
||||
}
|
||||
|
||||
export interface ServiceGridConfig {
|
||||
services: ServiceGridItem[];
|
||||
columns: 2 | 3 | 4;
|
||||
showLabels: boolean;
|
||||
iconSize: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
export interface DashboardLinksConfig {
|
||||
showIcons: boolean;
|
||||
columns: 1 | 2 | 3;
|
||||
maxItems?: number;
|
||||
}
|
||||
|
||||
export interface SearchLinkShortcut {
|
||||
label: string;
|
||||
queryTemplate: string;
|
||||
icon?: string;
|
||||
accentColor?: string;
|
||||
}
|
||||
|
||||
export interface SearchLinksConfig {
|
||||
shortcuts: SearchLinkShortcut[];
|
||||
}
|
||||
|
||||
export interface LinkTreeLink {
|
||||
label: string;
|
||||
url: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface LinkTreeSection {
|
||||
heading: string;
|
||||
links: LinkTreeLink[];
|
||||
}
|
||||
|
||||
export interface LinkTreeConfig {
|
||||
sections: LinkTreeSection[];
|
||||
compact: boolean;
|
||||
}
|
||||
|
||||
// ---- Widget registry types ----
|
||||
|
||||
export interface WidgetComponentProps<C = Record<string, unknown>> {
|
||||
widget: CanvasWidget;
|
||||
config: C;
|
||||
isReadOnly?: boolean;
|
||||
onConfigUpdate?: (config: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export interface WidgetEditFormProps<C = Record<string, unknown>> {
|
||||
config: C;
|
||||
onChange: (config: C) => void;
|
||||
}
|
||||
|
||||
export interface WidgetTypeDefinition<C = Record<string, unknown>> {
|
||||
id: WidgetTypeId;
|
||||
name: string;
|
||||
description: string;
|
||||
category: "links" | "info" | "system" | "monitoring";
|
||||
icon: React.ReactNode;
|
||||
defaultConfig: C;
|
||||
defaultSize: { w: number; h: number };
|
||||
minSize: { w: number; h: number };
|
||||
component: React.ComponentType<WidgetComponentProps<C>>;
|
||||
editFormComponent?: React.ComponentType<WidgetEditFormProps<C>>;
|
||||
/** Renders the translated name or description in the add menu. */
|
||||
labelComponent?: React.ComponentType<{ part: "name" | "description" }>;
|
||||
}
|
||||
|
||||
export interface DragState {
|
||||
widgetId: number;
|
||||
startMouseX: number;
|
||||
startMouseY: number;
|
||||
startWidgetX: number;
|
||||
startWidgetY: number;
|
||||
}
|
||||
|
||||
export interface ResizeState {
|
||||
widgetId: number;
|
||||
startMouseX: number;
|
||||
startMouseY: number;
|
||||
startW: number;
|
||||
startH: number;
|
||||
}
|
||||
|
||||
export interface ContextMenuAnchor {
|
||||
top: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
right: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ContextMenuState {
|
||||
visible: boolean;
|
||||
screenX: number;
|
||||
screenY: number;
|
||||
canvasX: number;
|
||||
canvasY: number;
|
||||
anchorRect?: ContextMenuAnchor;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export function normalizeServiceLinkUrl(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "";
|
||||
return /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)
|
||||
? trimmed
|
||||
: `http://${trimmed}`;
|
||||
}
|
||||
|
||||
export function isValidServiceLinkUrl(value: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(normalizeServiceLinkUrl(value));
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
function isDocumentVisible(): boolean {
|
||||
if (typeof document === "undefined") return true;
|
||||
return document.visibilityState !== "hidden";
|
||||
}
|
||||
|
||||
type Options = {
|
||||
/** Run callback once when the effect mounts (if visible). Default true. */
|
||||
runOnMount?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs `callback` on `intervalMs` while the page is visible.
|
||||
* Pauses when the tab is backgrounded; fires once on resume.
|
||||
*/
|
||||
export function usePageVisibleInterval(
|
||||
callback: () => void,
|
||||
intervalMs: number,
|
||||
enabled = true,
|
||||
options: Options = {},
|
||||
): void {
|
||||
const { runOnMount = true } = options;
|
||||
const callbackRef = useRef(callback);
|
||||
callbackRef.current = callback;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || intervalMs <= 0) return;
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const tick = () => {
|
||||
callbackRef.current();
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
if (intervalId !== null) return;
|
||||
intervalId = setInterval(tick, intervalMs);
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (intervalId === null) return;
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
};
|
||||
|
||||
const onVisibility = () => {
|
||||
if (isDocumentVisible()) {
|
||||
tick();
|
||||
start();
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
};
|
||||
|
||||
if (isDocumentVisible()) {
|
||||
if (runOnMount) tick();
|
||||
start();
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => {
|
||||
stop();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [intervalMs, enabled, runOnMount]);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Bookmark, ExternalLink } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type { BookmarkListConfig, WidgetComponentProps } from "../types.js";
|
||||
import { GRID_SIZE } from "../types.js";
|
||||
|
||||
function BookmarkListWidget({
|
||||
config,
|
||||
widget,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<BookmarkListConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { links } = config;
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col overflow-hidden">
|
||||
{widget.title && (
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-border shrink-0">
|
||||
<Bookmark size={11} className="text-accent-brand shrink-0" />
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider truncate">
|
||||
{widget.title}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto p-3 gap-1 flex flex-col">
|
||||
{links.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground italic">
|
||||
{t("homepage.noBookmarks")}
|
||||
</span>
|
||||
)}
|
||||
{links.map((link, i) =>
|
||||
isReadOnly ? (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 text-xs text-foreground py-1"
|
||||
>
|
||||
<Bookmark size={10} className="text-muted-foreground shrink-0" />
|
||||
<span className="truncate">{link.label || link.url}</span>
|
||||
</div>
|
||||
) : (
|
||||
<a
|
||||
key={i}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-xs text-foreground hover:text-primary py-1 no-underline group"
|
||||
>
|
||||
<Bookmark size={10} className="text-muted-foreground shrink-0" />
|
||||
<span className="truncate flex-1">{link.label || link.url}</span>
|
||||
<ExternalLink
|
||||
size={8}
|
||||
className="text-muted-foreground/0 group-hover:text-muted-foreground/60 shrink-0"
|
||||
/>
|
||||
</a>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<BookmarkListConfig>({
|
||||
id: "bookmark_list",
|
||||
name: "Bookmarks",
|
||||
description: "A list of quick links",
|
||||
category: "links",
|
||||
icon: <Bookmark size={14} />,
|
||||
defaultConfig: { links: [] },
|
||||
defaultSize: { w: GRID_SIZE * 9, h: GRID_SIZE * 10 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: BookmarkListWidget,
|
||||
});
|
||||
|
||||
export { BookmarkListWidget };
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type { CalendarConfig, WidgetComponentProps } from "../types.js";
|
||||
import { GRID_SIZE } from "../types.js";
|
||||
|
||||
import { WidgetTitle, runVisibleInterval } from "@termix/plugin-sdk/ui";
|
||||
|
||||
function getMonthData(year: number, month: number, startOnMonday: boolean) {
|
||||
const firstDay = new Date(year, month, 1).getDay();
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const offset = startOnMonday ? (firstDay === 0 ? 6 : firstDay - 1) : firstDay;
|
||||
return { daysInMonth, offset };
|
||||
}
|
||||
|
||||
function CalendarWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<CalendarConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { startOnMonday } = config;
|
||||
const [now, setNow] = useState(new Date());
|
||||
const [viewYear, setViewYear] = useState(now.getFullYear());
|
||||
const [viewMonth, setViewMonth] = useState(now.getMonth());
|
||||
|
||||
useEffect(() => {
|
||||
return runVisibleInterval(() => setNow(new Date()), 60_000);
|
||||
}, []);
|
||||
|
||||
const { daysInMonth, offset } = getMonthData(
|
||||
viewYear,
|
||||
viewMonth,
|
||||
startOnMonday,
|
||||
);
|
||||
const today = now;
|
||||
const isCurrentMonth =
|
||||
viewYear === today.getFullYear() && viewMonth === today.getMonth();
|
||||
|
||||
const dayNames = startOnMonday
|
||||
? [
|
||||
t("homepage.calMon"),
|
||||
t("homepage.calTue"),
|
||||
t("homepage.calWed"),
|
||||
t("homepage.calThu"),
|
||||
t("homepage.calFri"),
|
||||
t("homepage.calSat"),
|
||||
t("homepage.calSun"),
|
||||
]
|
||||
: [
|
||||
t("homepage.calSun"),
|
||||
t("homepage.calMon"),
|
||||
t("homepage.calTue"),
|
||||
t("homepage.calWed"),
|
||||
t("homepage.calThu"),
|
||||
t("homepage.calFri"),
|
||||
t("homepage.calSat"),
|
||||
];
|
||||
|
||||
const monthLabel = new Intl.DateTimeFormat("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(new Date(viewYear, viewMonth, 1));
|
||||
|
||||
const cells: (number | null)[] = [
|
||||
...Array(offset).fill(null),
|
||||
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
|
||||
];
|
||||
|
||||
const prevMonth = () => {
|
||||
if (viewMonth === 0) {
|
||||
setViewMonth(11);
|
||||
setViewYear((y) => y - 1);
|
||||
} else setViewMonth((m) => m - 1);
|
||||
};
|
||||
const nextMonth = () => {
|
||||
if (viewMonth === 11) {
|
||||
setViewMonth(0);
|
||||
setViewYear((y) => y + 1);
|
||||
} else setViewMonth((m) => m + 1);
|
||||
};
|
||||
|
||||
const accent =
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden select-none">
|
||||
<WidgetTitle title={widget.title} icon={<CalendarDays size={11} />} />
|
||||
<div className="flex flex-col flex-1 p-2 gap-1 overflow-hidden">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<button
|
||||
onClick={prevMonth}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronLeft size={12} />
|
||||
</button>
|
||||
<span className="text-[10px] font-semibold text-foreground">
|
||||
{monthLabel}
|
||||
</span>
|
||||
<button
|
||||
onClick={nextMonth}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronRight size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-0 shrink-0">
|
||||
{dayNames.map((d) => (
|
||||
<div
|
||||
key={d}
|
||||
className="text-center text-[8px] font-semibold text-muted-foreground/70 uppercase py-0.5"
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-0 flex-1 content-start">
|
||||
{cells.map((day, i) => {
|
||||
if (day === null) return <div key={`e-${i}`} />;
|
||||
const isToday = isCurrentMonth && day === today.getDate();
|
||||
return (
|
||||
<div
|
||||
key={`d-${day}`}
|
||||
className="flex items-center justify-center aspect-square"
|
||||
>
|
||||
<span
|
||||
className="text-[9px] font-medium w-5 h-5 flex items-center justify-center rounded-full"
|
||||
style={
|
||||
isToday
|
||||
? { background: accent, color: "#fff" }
|
||||
: { color: "var(--text-primary, currentColor)" }
|
||||
}
|
||||
>
|
||||
{day}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<CalendarConfig>({
|
||||
id: "calendar",
|
||||
name: "Calendar",
|
||||
description: "A month calendar with today highlighted",
|
||||
category: "info",
|
||||
icon: <CalendarDays size={14} />,
|
||||
defaultConfig: { startOnMonday: true },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 9 },
|
||||
minSize: { w: GRID_SIZE * 7, h: GRID_SIZE * 7 },
|
||||
component: CalendarWidget,
|
||||
});
|
||||
|
||||
export { CalendarWidget };
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } from "react";
|
||||
import { Clock } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import {
|
||||
GRID_SIZE,
|
||||
type ClockConfig,
|
||||
type WidgetComponentProps,
|
||||
} from "../types.js";
|
||||
|
||||
import { usePageVisibleInterval } from "../use-page-visible-interval.js";
|
||||
import { isValidTimezone } from "../clock-timezone";
|
||||
import { WidgetTitle } from "@termix/plugin-sdk/ui";
|
||||
|
||||
function ClockWidget({ widget, config }: WidgetComponentProps<ClockConfig>) {
|
||||
const { showSeconds, format } = config;
|
||||
// Configs saved before the edit dialog validated timezones can still hold an
|
||||
// unusable one (e.g. "America/New York"), which makes toLocaleTimeString throw.
|
||||
const timezone =
|
||||
config.timezone && isValidTimezone(config.timezone)
|
||||
? config.timezone
|
||||
: undefined;
|
||||
const [now, setNow] = useState(new Date());
|
||||
|
||||
// Minute-level is enough without seconds; pause when the tab is backgrounded.
|
||||
usePageVisibleInterval(
|
||||
() => setNow(new Date()),
|
||||
showSeconds ? 1_000 : 30_000,
|
||||
);
|
||||
|
||||
const opts: Intl.DateTimeFormatOptions = {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
...(showSeconds ? { second: "2-digit" } : {}),
|
||||
hour12: format === "12h",
|
||||
...(timezone ? { timeZone: timezone } : {}),
|
||||
};
|
||||
|
||||
const dateOpts: Intl.DateTimeFormatOptions = {
|
||||
weekday: "long",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
...(timezone ? { timeZone: timezone } : {}),
|
||||
};
|
||||
|
||||
const timeStr = now.toLocaleTimeString(undefined, opts);
|
||||
const dateStr = now.toLocaleDateString(undefined, dateOpts);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden select-none">
|
||||
<WidgetTitle title={widget.title} icon={<Clock size={11} />} />
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-1 p-3 overflow-auto">
|
||||
<span className="text-2xl font-bold tabular-nums text-accent-brand">
|
||||
{timeStr}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{dateStr}</span>
|
||||
{timezone && (
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
{timezone}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<ClockConfig>({
|
||||
id: "clock",
|
||||
name: "Clock",
|
||||
description: "A live clock with configurable timezone",
|
||||
category: "info",
|
||||
icon: <Clock size={14} />,
|
||||
defaultConfig: { showSeconds: true, format: "24h" },
|
||||
defaultSize: { w: GRID_SIZE * 8, h: GRID_SIZE * 5 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: ClockWidget,
|
||||
});
|
||||
|
||||
export { ClockWidget };
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Timer } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type { CountdownConfig, WidgetComponentProps } from "../types.js";
|
||||
import { GRID_SIZE } from "../types.js";
|
||||
import { WidgetTitle } from "@termix/plugin-sdk/ui";
|
||||
|
||||
function getAccentColor(): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145"
|
||||
);
|
||||
}
|
||||
|
||||
function decompose(ms: number): {
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
} {
|
||||
const total = Math.max(0, Math.floor(ms / 1000));
|
||||
const days = Math.floor(total / 86400);
|
||||
const hours = Math.floor((total % 86400) / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const seconds = total % 60;
|
||||
return { days, hours, minutes, seconds };
|
||||
}
|
||||
|
||||
function UnitBlock({ value, label }: { value: number; label: string }) {
|
||||
const accent = getAccentColor();
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<span
|
||||
className="text-2xl font-bold tabular-nums leading-none"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{String(value).padStart(2, "0")}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted-foreground uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CountdownWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<CountdownConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { targetDate, label, showDays, showHours } = config;
|
||||
const [remaining, setRemaining] = useState(0);
|
||||
const [past, setPast] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const diff = new Date(targetDate).getTime() - Date.now();
|
||||
if (diff <= 0) {
|
||||
setRemaining(0);
|
||||
setPast(true);
|
||||
} else {
|
||||
setRemaining(diff);
|
||||
setPast(false);
|
||||
}
|
||||
};
|
||||
update();
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
const start = () => {
|
||||
if (intervalId !== null) return;
|
||||
intervalId = setInterval(update, 1000);
|
||||
};
|
||||
const stop = () => {
|
||||
if (intervalId === null) return;
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
update();
|
||||
start();
|
||||
};
|
||||
|
||||
if (document.visibilityState !== "hidden") start();
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => {
|
||||
stop();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [targetDate]);
|
||||
|
||||
const titleBar = (
|
||||
<WidgetTitle title={widget.title} icon={<Timer size={11} />} />
|
||||
);
|
||||
|
||||
if (!targetDate) {
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex items-center justify-center flex-1 text-xs text-muted-foreground">
|
||||
{t("homepage.countdownNoDate")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (past) {
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-1">
|
||||
<Timer size={16} className="text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("homepage.countdownPast")}
|
||||
</span>
|
||||
{label && (
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { days, hours, minutes, seconds } = decompose(remaining);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-2 p-2">
|
||||
{label && (
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-3 flex-wrap justify-center">
|
||||
{showDays && (
|
||||
<UnitBlock value={days} label={t("homepage.countdownDays")} />
|
||||
)}
|
||||
{showHours && (
|
||||
<UnitBlock value={hours} label={t("homepage.countdownHours")} />
|
||||
)}
|
||||
<UnitBlock value={minutes} label={t("homepage.countdownMinutes")} />
|
||||
<UnitBlock value={seconds} label={t("homepage.countdownSeconds")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<CountdownConfig>({
|
||||
id: "countdown",
|
||||
name: "Countdown",
|
||||
description: "Counts down to a target date and time",
|
||||
category: "info",
|
||||
icon: <Timer size={14} />,
|
||||
defaultConfig: { targetDate: "", label: "", showDays: true, showHours: true },
|
||||
defaultSize: { w: GRID_SIZE * 8, h: GRID_SIZE * 5 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: CountdownWidget,
|
||||
});
|
||||
|
||||
export { CountdownWidget };
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Braces } from "lucide-react";
|
||||
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type { CustomApiConfig, WidgetComponentProps } from "../types.js";
|
||||
import { GRID_SIZE } from "../types.js";
|
||||
import { WidgetTitle, runVisibleInterval } from "@termix/plugin-sdk/ui";
|
||||
|
||||
function resolvePath(obj: unknown, path: string): unknown {
|
||||
return path.split(".").reduce((acc: unknown, key) => {
|
||||
if (acc && typeof acc === "object")
|
||||
return (acc as Record<string, unknown>)[key];
|
||||
return undefined;
|
||||
}, obj);
|
||||
}
|
||||
|
||||
function resolveArray(
|
||||
obj: unknown,
|
||||
path: string | undefined,
|
||||
): unknown[] | null {
|
||||
if (!path) {
|
||||
if (Array.isArray(obj)) return obj as unknown[];
|
||||
return null;
|
||||
}
|
||||
const val = resolvePath(obj, path);
|
||||
return Array.isArray(val) ? (val as unknown[]) : null;
|
||||
}
|
||||
|
||||
function CustomApiWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<CustomApiConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const api = usePluginApi();
|
||||
const {
|
||||
url,
|
||||
displayField,
|
||||
label,
|
||||
unit,
|
||||
refreshInterval,
|
||||
displayMode,
|
||||
jsonPath,
|
||||
} = config;
|
||||
const [data, setData] = useState<unknown>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const interval = Math.max(10, refreshInterval || 60);
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!url) return;
|
||||
try {
|
||||
const res = await api.get("/proxy", { params: { url, ttl: interval } });
|
||||
setData(res.data);
|
||||
setError(false);
|
||||
} catch {
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
return runVisibleInterval(() => {
|
||||
void fetchData();
|
||||
}, interval * 1000);
|
||||
}, [url, interval]);
|
||||
|
||||
const accent =
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145";
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.customApiNoUrl")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-red-400">
|
||||
{t("homepage.customApiError")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const titleBar = (
|
||||
<WidgetTitle title={widget.title} icon={<Braces size={11} />} />
|
||||
);
|
||||
|
||||
if (displayMode === "value") {
|
||||
const val = displayField ? resolvePath(data, displayField) : data;
|
||||
const display =
|
||||
typeof val === "object" ? JSON.stringify(val) : String(val ?? "—");
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-1 p-2">
|
||||
{label && (
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="text-2xl font-bold tabular-nums"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{display}
|
||||
</span>
|
||||
{unit && (
|
||||
<span className="text-[10px] text-muted-foreground">{unit}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (displayMode === "table") {
|
||||
const arr = resolveArray(data, jsonPath);
|
||||
if (!arr) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.customApiNotArray")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
<table className="w-full text-[9px]">
|
||||
<tbody>
|
||||
{arr.slice(0, 50).map((row, i) => {
|
||||
if (typeof row !== "object" || row === null) {
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td className="py-0.5 pr-2 text-foreground">
|
||||
{String(row)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<tr key={i} className="border-b border-border/30">
|
||||
{Object.entries(row as Record<string, unknown>)
|
||||
.slice(0, 4)
|
||||
.map(([k, v]) => (
|
||||
<td key={k} className="py-0.5 pr-2">
|
||||
<span className="text-muted-foreground">{k}: </span>
|
||||
<span className="text-foreground">
|
||||
{String(v ?? "")}
|
||||
</span>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// json mode
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
<pre className="text-[9px] text-foreground whitespace-pre-wrap break-all leading-relaxed font-mono">
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<CustomApiConfig>({
|
||||
id: "custom_api",
|
||||
name: "Custom API",
|
||||
description: "Fetch and display data from any JSON API",
|
||||
category: "info",
|
||||
icon: <Braces size={14} />,
|
||||
defaultConfig: { url: "", refreshInterval: 60, displayMode: "value" },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 7 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: CustomApiWidget,
|
||||
});
|
||||
|
||||
export { CustomApiWidget };
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { Link, ExternalLink } from "lucide-react";
|
||||
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
DashboardLinksConfig,
|
||||
ServiceLinkRecord,
|
||||
WidgetComponentProps,
|
||||
} from "../types.js";
|
||||
import { GRID_SIZE } from "../types.js";
|
||||
import { WidgetTitle } from "@termix/plugin-sdk/ui";
|
||||
|
||||
function getFaviconUrl(url: string): string {
|
||||
try {
|
||||
const domain = new URL(url).hostname;
|
||||
return `https://www.google.com/s2/favicons?sz=32&domain_url=${encodeURIComponent(domain)}`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function LinkTile({
|
||||
link,
|
||||
showIcons,
|
||||
isReadOnly,
|
||||
}: {
|
||||
link: ServiceLinkRecord;
|
||||
showIcons: boolean;
|
||||
isReadOnly?: boolean;
|
||||
}) {
|
||||
const [imgFailed, setImgFailed] = useState(false);
|
||||
const mouseDownRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const faviconUrl = showIcons ? getFaviconUrl(link.url) : null;
|
||||
|
||||
const content = (
|
||||
<div className="group flex items-center gap-2 px-2 py-1.5 hover:bg-muted/40 transition-colors overflow-hidden border-b border-border/30">
|
||||
{showIcons &&
|
||||
(faviconUrl && !imgFailed ? (
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt=""
|
||||
className="w-4 h-4 shrink-0 object-contain"
|
||||
onError={() => setImgFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<ExternalLink size={12} className="shrink-0 text-muted-foreground" />
|
||||
))}
|
||||
<span className="text-[10px] font-medium text-foreground truncate flex-1">
|
||||
{link.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isReadOnly || !link.url) return content;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block no-underline"
|
||||
onMouseDown={(e) => {
|
||||
mouseDownRef.current = { x: e.clientX, y: e.clientY };
|
||||
}}
|
||||
onClick={(e) => {
|
||||
const s = mouseDownRef.current;
|
||||
if (
|
||||
s &&
|
||||
(Math.abs(e.clientX - s.x) > 4 || Math.abs(e.clientY - s.y) > 4)
|
||||
)
|
||||
e.preventDefault();
|
||||
mouseDownRef.current = null;
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardLinksWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<DashboardLinksConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const api = usePluginApi();
|
||||
const { showIcons, columns, maxItems } = config;
|
||||
const [links, setLinks] = useState<ServiceLinkRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get("/service-links")
|
||||
.then((res) => {
|
||||
const data = res.data as ServiceLinkRecord[];
|
||||
setLinks(maxItems ? data.slice(0, maxItems) : data);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [maxItems]);
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
);
|
||||
if (links.length === 0)
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noDashboardLinks")}
|
||||
</div>
|
||||
);
|
||||
|
||||
const colClass =
|
||||
columns === 1
|
||||
? "grid-cols-1"
|
||||
: columns === 2
|
||||
? "grid-cols-2"
|
||||
: "grid-cols-3";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Link size={11} />} />
|
||||
<div className={`grid ${colClass} flex-1 overflow-auto content-start`}>
|
||||
{links.map((link) => (
|
||||
<LinkTile
|
||||
key={link.id}
|
||||
link={link}
|
||||
showIcons={showIcons}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<DashboardLinksConfig>({
|
||||
id: "dashboard_links",
|
||||
name: "Dashboard Links",
|
||||
description: "Shows your saved dashboard service links",
|
||||
category: "links",
|
||||
icon: <Link size={14} />,
|
||||
defaultConfig: { showIcons: true, columns: 1 },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: DashboardLinksWidget,
|
||||
});
|
||||
|
||||
export { DashboardLinksWidget };
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Folder } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type { FolderConfig, WidgetComponentProps } from "../types.js";
|
||||
import { GRID_SIZE } from "../types.js";
|
||||
|
||||
function FolderWidget({ widget, config }: WidgetComponentProps<FolderConfig>) {
|
||||
const accentColor =
|
||||
config.color ||
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() ||
|
||||
"#f59145";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full flex flex-col"
|
||||
style={{
|
||||
background: `${accentColor}10`,
|
||||
borderTop: `2px solid ${accentColor}60`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 select-none">
|
||||
<Folder size={11} style={{ color: accentColor }} className="shrink-0" />
|
||||
<span className="text-xs font-semibold text-foreground truncate">
|
||||
{widget.title || "Folder"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<FolderConfig>({
|
||||
id: "folder",
|
||||
name: "Folder",
|
||||
description: "A background card for grouping widgets visually",
|
||||
category: "links",
|
||||
icon: <Folder size={14} />,
|
||||
defaultConfig: { isExpanded: true },
|
||||
defaultSize: { w: GRID_SIZE * 14, h: GRID_SIZE * 12 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: FolderWidget as unknown as React.ComponentType<
|
||||
WidgetComponentProps<FolderConfig>
|
||||
>,
|
||||
});
|
||||
|
||||
export { FolderWidget };
|
||||
@@ -0,0 +1,125 @@
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
import {
|
||||
useTranslation,
|
||||
useHostStatus,
|
||||
useHosts,
|
||||
type PluginHostRecord,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type { HostGridConfig, WidgetComponentProps } from "../types.js";
|
||||
import { GRID_SIZE } from "../types.js";
|
||||
import { WidgetTitle } from "@termix/plugin-sdk/ui";
|
||||
|
||||
function getAccentColor(): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145"
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDot({ status }: { status: string }) {
|
||||
const color =
|
||||
status === "online"
|
||||
? getAccentColor()
|
||||
: status === "reachable"
|
||||
? "#fbbf24"
|
||||
: status === "offline"
|
||||
? "#ef4444"
|
||||
: "#6b7280";
|
||||
return (
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full shrink-0 inline-block"
|
||||
style={{ background: color }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HostGridRow({
|
||||
host,
|
||||
showIp,
|
||||
}: {
|
||||
host: PluginHostRecord;
|
||||
showIp: boolean;
|
||||
}) {
|
||||
const status = useHostStatus(Number(host.id));
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 p-1.5 bg-muted/30 border border-border/40 overflow-hidden">
|
||||
<StatusDot status={status?.status ?? "unknown"} />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-[10px] font-medium text-foreground truncate">
|
||||
{host.name}
|
||||
</span>
|
||||
{showIp && (
|
||||
<span className="text-[9px] text-muted-foreground truncate">
|
||||
{host.ip}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HostGridWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<HostGridConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { hostIds, showIp, columns } = config;
|
||||
const { hosts: allHosts, loaded } = useHosts();
|
||||
const hosts =
|
||||
hostIds.length > 0
|
||||
? allHosts.filter((h) => hostIds.includes(Number(h.id)))
|
||||
: allHosts;
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hosts.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noHosts")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const gridCols =
|
||||
columns === 2
|
||||
? "grid-cols-2"
|
||||
: columns === 3
|
||||
? "grid-cols-3"
|
||||
: "grid-cols-4";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<LayoutGrid size={11} />} />
|
||||
<div
|
||||
className={`grid ${gridCols} gap-1.5 p-2 flex-1 content-start overflow-auto`}
|
||||
>
|
||||
{hosts.map((host) => (
|
||||
<HostGridRow key={String(host.id)} host={host} showIp={showIp} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<HostGridConfig>({
|
||||
id: "host_grid",
|
||||
name: "Host Grid",
|
||||
description: "Live status overview grid for all your SSH hosts",
|
||||
category: "monitoring",
|
||||
icon: <LayoutGrid size={14} />,
|
||||
defaultConfig: { hostIds: [], showIp: false, columns: 3 },
|
||||
defaultSize: { w: GRID_SIZE * 12, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: HostGridWidget,
|
||||
});
|
||||
|
||||
export { HostGridWidget };
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Server } from "lucide-react";
|
||||
import {
|
||||
useTranslation,
|
||||
useHost,
|
||||
useHostStatus,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
|
||||
import { ComponentSlot, WidgetTitle } from "@termix/plugin-sdk/ui";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
HostStatusConfig,
|
||||
HostMetricKey,
|
||||
WidgetComponentProps,
|
||||
} from "../types.js";
|
||||
import { GRID_SIZE } from "../types.js";
|
||||
|
||||
function getAccentColor(): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145"
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_METRICS: HostMetricKey[] = ["cpu", "memory"];
|
||||
|
||||
function migrateConfig(config: HostStatusConfig): HostMetricKey[] {
|
||||
if (config.shownMetrics?.length) return config.shownMetrics;
|
||||
if (config.showMetrics === false) return [];
|
||||
const metrics: HostMetricKey[] = ["cpu", "memory"];
|
||||
if (config.showDisk) metrics.push("disk");
|
||||
return metrics;
|
||||
}
|
||||
|
||||
function HostStatusWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<HostStatusConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const shownMetrics = migrateConfig(config);
|
||||
const { hostId } = config;
|
||||
const host = useHost(hostId || undefined);
|
||||
const statusInfo = useHostStatus(hostId || undefined);
|
||||
|
||||
const needsMetrics = shownMetrics.length > 0;
|
||||
|
||||
if (!hostId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noHostSelected")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const online = statusInfo?.status === "online";
|
||||
const reachable = statusInfo?.status === "reachable";
|
||||
|
||||
const onlineColor = !statusInfo
|
||||
? "#6b7280"
|
||||
: online
|
||||
? getAccentColor()
|
||||
: reachable
|
||||
? "#fbbf24"
|
||||
: "#ef4444";
|
||||
const onlineLabel = !statusInfo
|
||||
? t("common.unknown")
|
||||
: online
|
||||
? t("common.online")
|
||||
: reachable
|
||||
? t("common.signInFailed")
|
||||
: t("common.offline");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Server size={11} />} />
|
||||
<div
|
||||
className={`flex flex-col gap-2.5 p-3 flex-1 overflow-auto ${!needsMetrics ? "justify-center" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Server size={14} className="text-muted-foreground shrink-0" />
|
||||
<span className="text-xs font-semibold text-foreground truncate flex-1">
|
||||
{host?.name ?? `Host #${hostId}`}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 shrink-0">
|
||||
<span
|
||||
className="w-1.5 h-1.5 shrink-0 rounded-full"
|
||||
style={{ background: onlineColor }}
|
||||
/>
|
||||
<span
|
||||
className="text-[10px] font-medium"
|
||||
style={{ color: onlineColor }}
|
||||
>
|
||||
{onlineLabel}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Plugins such as host metrics fill in the live numbers. */}
|
||||
{needsMetrics && (
|
||||
<ComponentSlot
|
||||
slotId="homepage.hostMetrics"
|
||||
props={{ hostId, shownMetrics, online }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<HostStatusConfig>({
|
||||
id: "host_status",
|
||||
name: "Host Status",
|
||||
description: "Shows live status and metrics for an SSH host",
|
||||
category: "system",
|
||||
icon: <Server size={14} />,
|
||||
defaultConfig: { hostId: 0, shownMetrics: DEFAULT_METRICS },
|
||||
defaultSize: { w: GRID_SIZE * 9, h: GRID_SIZE * 6 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: HostStatusWidget,
|
||||
});
|
||||
|
||||
export { HostStatusWidget };
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Globe } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type { IframeConfig, WidgetComponentProps } from "../types.js";
|
||||
import { GRID_SIZE } from "../types.js";
|
||||
import { WidgetTitle } from "@termix/plugin-sdk/ui";
|
||||
|
||||
function IframeWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<IframeConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { url, scrolling } = config;
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/50">
|
||||
<Globe size={24} />
|
||||
<span className="text-xs">{t("homepage.configureUrl")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Globe size={11} />} />
|
||||
<div className="relative flex-1">
|
||||
<iframe
|
||||
src={url}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
scrolling={scrolling ? "yes" : "no"}
|
||||
className="w-full h-full border-0"
|
||||
title={t("homepage.embeddedContent")}
|
||||
/>
|
||||
{/* Block interaction in read-only mode so the canvas can still pan */}
|
||||
{isReadOnly && <div className="absolute inset-0 pointer-events-none" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<IframeConfig>({
|
||||
id: "iframe_embed",
|
||||
name: "iFrame Embed",
|
||||
description: "Embed any URL in an iframe",
|
||||
category: "info",
|
||||
icon: <Globe size={14} />,
|
||||
defaultConfig: { url: "", scrolling: true },
|
||||
defaultSize: { w: GRID_SIZE * 14, h: GRID_SIZE * 12 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: IframeWidget,
|
||||
});
|
||||
|
||||
export { IframeWidget };
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { ImageIcon } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type { ImageWidgetConfig, WidgetComponentProps } from "../types.js";
|
||||
import { GRID_SIZE } from "../types.js";
|
||||
import { WidgetTitle } from "@termix/plugin-sdk/ui";
|
||||
|
||||
const FIT_MAP = {
|
||||
contain: "object-contain",
|
||||
cover: "object-cover",
|
||||
fill: "object-fill",
|
||||
};
|
||||
|
||||
function ImageWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<ImageWidgetConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { imageUrl, fit, alt, linkUrl } = config;
|
||||
const [failed, setFailed] = useState(false);
|
||||
const mouseDownRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const titleBar = widget.title ? (
|
||||
<WidgetTitle title={widget.title} icon={<ImageIcon size={11} />} />
|
||||
) : null;
|
||||
|
||||
const content = (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
{titleBar}
|
||||
<div className="flex-1 flex items-center justify-center overflow-hidden">
|
||||
{imageUrl && !failed ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={alt ?? ""}
|
||||
className={`w-full h-full ${FIT_MAP[fit]}`}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1 text-muted-foreground/40">
|
||||
<ImageIcon size={24} />
|
||||
<span className="text-[10px]">{t("homepage.noImage")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isReadOnly || !linkUrl) return content;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={linkUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full h-full"
|
||||
onMouseDown={(e) => {
|
||||
mouseDownRef.current = { x: e.clientX, y: e.clientY };
|
||||
}}
|
||||
onClick={(e) => {
|
||||
const s = mouseDownRef.current;
|
||||
if (
|
||||
s &&
|
||||
(Math.abs(e.clientX - s.x) > 4 || Math.abs(e.clientY - s.y) > 4)
|
||||
)
|
||||
e.preventDefault();
|
||||
mouseDownRef.current = null;
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<ImageWidgetConfig>({
|
||||
id: "image_widget",
|
||||
name: "Image",
|
||||
description: "Display any image from a URL",
|
||||
category: "info",
|
||||
icon: <ImageIcon size={14} />,
|
||||
defaultConfig: { imageUrl: "", fit: "contain" },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: ImageWidget,
|
||||
});
|
||||
|
||||
export { ImageWidget };
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { useRef } from "react";
|
||||
import { List, ExternalLink } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
LinkTreeConfig,
|
||||
LinkTreeLink,
|
||||
WidgetComponentProps,
|
||||
} from "../types.js";
|
||||
import { GRID_SIZE } from "../types.js";
|
||||
import { WidgetTitle } from "@termix/plugin-sdk/ui";
|
||||
|
||||
function TreeLink({
|
||||
link,
|
||||
compact,
|
||||
isReadOnly,
|
||||
}: {
|
||||
link: LinkTreeLink;
|
||||
compact: boolean;
|
||||
isReadOnly?: boolean;
|
||||
}) {
|
||||
const mouseDownRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={`group flex items-center gap-1.5 ${compact ? "py-0.5" : "py-1"} pl-2 pr-1 hover:bg-muted/40 transition-colors overflow-hidden`}
|
||||
>
|
||||
<ExternalLink
|
||||
size={9}
|
||||
className="shrink-0 text-muted-foreground group-hover:text-accent-brand transition-colors"
|
||||
/>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-[10px] font-medium text-foreground truncate">
|
||||
{link.label}
|
||||
</span>
|
||||
{!compact && link.description && (
|
||||
<span className="text-[9px] text-muted-foreground truncate">
|
||||
{link.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isReadOnly || !link.url) return content;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block no-underline"
|
||||
onMouseDown={(e) => {
|
||||
mouseDownRef.current = { x: e.clientX, y: e.clientY };
|
||||
}}
|
||||
onClick={(e) => {
|
||||
const s = mouseDownRef.current;
|
||||
if (
|
||||
s &&
|
||||
(Math.abs(e.clientX - s.x) > 4 || Math.abs(e.clientY - s.y) > 4)
|
||||
)
|
||||
e.preventDefault();
|
||||
mouseDownRef.current = null;
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkTreeWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<LinkTreeConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { sections, compact } = config;
|
||||
|
||||
if (sections.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noSections")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<List size={11} />} />
|
||||
<div className="flex-1 overflow-auto">
|
||||
{sections.map((section, si) => (
|
||||
<div key={si}>
|
||||
{section.heading && (
|
||||
<div className="flex items-center gap-1.5 px-2 pt-2 pb-0.5 border-b border-border/30">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider truncate">
|
||||
{section.heading}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{section.links.map((link, li) => (
|
||||
<TreeLink
|
||||
key={li}
|
||||
link={link}
|
||||
compact={compact}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<LinkTreeConfig>({
|
||||
id: "link_tree",
|
||||
name: "Link Tree",
|
||||
description: "Grouped links organized into labeled sections",
|
||||
category: "links",
|
||||
icon: <List size={14} />,
|
||||
defaultConfig: { sections: [], compact: false },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 12 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 4 },
|
||||
component: LinkTreeWidget,
|
||||
});
|
||||
|
||||
export { LinkTreeWidget };
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { FileText } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type { MarkdownNotesConfig, WidgetComponentProps } from "../types.js";
|
||||
import { GRID_SIZE } from "../types.js";
|
||||
import { updateHomepageItem } from "../api.js";
|
||||
import { WidgetTitle } from "@termix/plugin-sdk/ui";
|
||||
|
||||
function renderMarkdown(src: string): string {
|
||||
return src
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
if (/^### /.test(line))
|
||||
return `<h3 class="text-[11px] font-bold mt-2 mb-0.5 text-foreground">${esc(line.slice(4))}</h3>`;
|
||||
if (/^## /.test(line))
|
||||
return `<h2 class="text-xs font-bold mt-2 mb-0.5 text-foreground">${esc(line.slice(3))}</h2>`;
|
||||
if (/^# /.test(line))
|
||||
return `<h1 class="text-sm font-bold mt-2 mb-0.5 text-foreground">${esc(line.slice(2))}</h1>`;
|
||||
if (/^- /.test(line))
|
||||
return `<li class="text-[10px] ml-3 list-disc text-foreground">${inlineFormat(line.slice(2))}</li>`;
|
||||
if (/^\d+\. /.test(line))
|
||||
return `<li class="text-[10px] ml-3 list-decimal text-foreground">${inlineFormat(line.replace(/^\d+\. /, ""))}</li>`;
|
||||
if (line.trim() === "") return "<br/>";
|
||||
return `<p class="text-[10px] text-foreground leading-relaxed">${inlineFormat(line)}</p>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function inlineFormat(s: string): string {
|
||||
return esc(s)
|
||||
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/\*(.+?)\*/g, "<em>$1</em>")
|
||||
.replace(
|
||||
/`(.+?)`/g,
|
||||
'<code class="bg-muted px-0.5 font-mono text-[9px]">$1</code>',
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownNotesWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
onConfigUpdate,
|
||||
}: WidgetComponentProps<MarkdownNotesConfig>) {
|
||||
const { content, backgroundColor, renderMarkdown: doRender } = config;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(content);
|
||||
|
||||
const commitEdit = useCallback(async () => {
|
||||
setEditing(false);
|
||||
if (draft === content) return;
|
||||
try {
|
||||
await updateHomepageItem(widget.id, {
|
||||
config: { ...config, content: draft } as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
});
|
||||
onConfigUpdate?.({ ...config, content: draft } as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [draft, content, widget.id, config, onConfigUpdate]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col w-full h-full overflow-hidden"
|
||||
style={backgroundColor ? { background: backgroundColor } : undefined}
|
||||
>
|
||||
<WidgetTitle title={widget.title} icon={<FileText size={11} />} />
|
||||
<div className="flex-1 overflow-auto p-2 relative">
|
||||
{editing ? (
|
||||
<textarea
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commitEdit}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="w-full h-full bg-transparent text-[10px] text-foreground resize-none outline-none border-none leading-relaxed"
|
||||
style={{ minHeight: "100%" }}
|
||||
/>
|
||||
) : doRender ? (
|
||||
<div
|
||||
className="text-[10px] leading-relaxed cursor-text"
|
||||
onDoubleClick={() => {
|
||||
if (!isReadOnly) setEditing(true);
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(content || "") }}
|
||||
/>
|
||||
) : (
|
||||
<pre
|
||||
className="text-[10px] text-foreground leading-relaxed whitespace-pre-wrap break-words font-sans cursor-text"
|
||||
onDoubleClick={() => {
|
||||
if (!isReadOnly) setEditing(true);
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<MarkdownNotesConfig>({
|
||||
id: "markdown_notes",
|
||||
name: "Markdown Notes",
|
||||
description: "Notes with optional Markdown rendering and inline editing",
|
||||
category: "info",
|
||||
icon: <FileText size={14} />,
|
||||
defaultConfig: { content: "", renderMarkdown: true },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 3, h: GRID_SIZE * 3 },
|
||||
component: MarkdownNotesWidget,
|
||||
});
|
||||
|
||||
export { MarkdownNotesWidget };
|
||||
@@ -0,0 +1,42 @@
|
||||
import { StickyNote } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import {
|
||||
GRID_SIZE,
|
||||
type NotesConfig,
|
||||
type WidgetComponentProps,
|
||||
} from "../types.js";
|
||||
import { WidgetTitle } from "@termix/plugin-sdk/ui";
|
||||
|
||||
function NotesWidget({ widget, config }: WidgetComponentProps<NotesConfig>) {
|
||||
const { content, backgroundColor } = config;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col w-full h-full overflow-hidden"
|
||||
style={{ background: backgroundColor || undefined }}
|
||||
>
|
||||
<WidgetTitle title={widget.title} icon={<StickyNote size={11} />} />
|
||||
<div className="flex-1 overflow-auto p-3">
|
||||
<pre className="text-xs text-foreground whitespace-pre-wrap break-words font-sans leading-relaxed select-text w-full">
|
||||
{content || (
|
||||
<span className="text-muted-foreground italic">Empty note...</span>
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<NotesConfig>({
|
||||
id: "notes",
|
||||
name: "Notes",
|
||||
description: "A text notes widget",
|
||||
category: "info",
|
||||
icon: <StickyNote size={14} />,
|
||||
defaultConfig: { content: "" },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: NotesWidget,
|
||||
});
|
||||
|
||||
export { NotesWidget };
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Wifi } from "lucide-react";
|
||||
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type { PingStatusConfig, WidgetComponentProps } from "../types.js";
|
||||
import { GRID_SIZE } from "../types.js";
|
||||
import { WidgetTitle, runVisibleInterval } from "@termix/plugin-sdk/ui";
|
||||
|
||||
interface PingResult {
|
||||
ok: boolean;
|
||||
statusCode: number | null;
|
||||
latencyMs: number;
|
||||
}
|
||||
|
||||
function StatusDot({ ok, pending }: { ok: boolean | null; pending: boolean }) {
|
||||
if (pending)
|
||||
return (
|
||||
<span className="w-2 h-2 rounded-full bg-muted-foreground/40 shrink-0 inline-block" />
|
||||
);
|
||||
return (
|
||||
<span
|
||||
className="w-2 h-2 rounded-full shrink-0 inline-block"
|
||||
style={{ background: ok ? "var(--accent-brand, #f59145)" : "#ef4444" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PingStatusWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<PingStatusConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const api = usePluginApi();
|
||||
const { urls, refreshInterval, showLatency } = config;
|
||||
const [results, setResults] = useState<Record<string, PingResult | null>>({});
|
||||
const [pending, setPending] = useState<Record<string, boolean>>({});
|
||||
|
||||
const fetchAll = async () => {
|
||||
if (urls.length === 0) return;
|
||||
const newPending: Record<string, boolean> = {};
|
||||
urls.forEach((u) => {
|
||||
newPending[u.url] = true;
|
||||
});
|
||||
setPending(newPending);
|
||||
|
||||
await Promise.all(
|
||||
urls.map(async (u) => {
|
||||
try {
|
||||
const res = await api.get("/ping", { params: { url: u.url } });
|
||||
const r = res.data as PingResult;
|
||||
setResults((prev) => ({ ...prev, [u.url]: r }));
|
||||
} catch {
|
||||
setResults((prev) => ({
|
||||
...prev,
|
||||
[u.url]: { ok: false, statusCode: null, latencyMs: 0 },
|
||||
}));
|
||||
} finally {
|
||||
setPending((prev) => ({ ...prev, [u.url]: false }));
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const interval = Math.max(10, refreshInterval || 30);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAll();
|
||||
return runVisibleInterval(() => {
|
||||
void fetchAll();
|
||||
}, interval * 1000);
|
||||
}, [JSON.stringify(urls), interval]);
|
||||
|
||||
if (urls.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noPingUrls")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle
|
||||
title={widget.title || t("homepage.widgetPingStatusName")}
|
||||
icon={<Wifi size={11} />}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{urls.map((u) => {
|
||||
const r = results[u.url];
|
||||
const isPending = pending[u.url] ?? false;
|
||||
return (
|
||||
<div
|
||||
key={u.url}
|
||||
className="flex items-center gap-2 px-2 py-1.5 border-b border-border/30"
|
||||
>
|
||||
<StatusDot ok={r?.ok ?? null} pending={isPending} />
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-[10px] font-medium text-foreground truncate">
|
||||
{u.label || u.url}
|
||||
</span>
|
||||
{r && (
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
{r.statusCode ?? (r.ok ? "OK" : "ERR")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showLatency && r && r.latencyMs > 0 && (
|
||||
<span className="text-[9px] text-muted-foreground shrink-0 tabular-nums">
|
||||
{r.latencyMs}ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<PingStatusConfig>({
|
||||
id: "ping_status",
|
||||
name: "Ping Status",
|
||||
description: "Check if URLs or services are reachable",
|
||||
category: "monitoring",
|
||||
icon: <Wifi size={14} />,
|
||||
defaultConfig: { urls: [], refreshInterval: 30, showLatency: true },
|
||||
defaultSize: { w: GRID_SIZE * 9, h: GRID_SIZE * 7 },
|
||||
minSize: { w: GRID_SIZE * 3, h: GRID_SIZE * 2 },
|
||||
component: PingStatusWidget,
|
||||
});
|
||||
|
||||
export { PingStatusWidget };
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Zap } from "lucide-react";
|
||||
import {
|
||||
useTranslation,
|
||||
useHostActions,
|
||||
useHostStatus,
|
||||
useHosts,
|
||||
useTabs,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
QuickConnectConfig,
|
||||
QuickConnectType,
|
||||
WidgetComponentProps,
|
||||
} from "../types.js";
|
||||
import { GRID_SIZE } from "../types.js";
|
||||
|
||||
import { quickConnectTargets } from "../quick-connect-targets.js";
|
||||
import { WidgetTitle } from "@termix/plugin-sdk/ui";
|
||||
|
||||
function StatusDot({ hostId }: { hostId: number }) {
|
||||
const status = useHostStatus(hostId);
|
||||
const cls =
|
||||
status?.status === "online"
|
||||
? "bg-green-500"
|
||||
: status?.status === "offline"
|
||||
? "bg-red-500"
|
||||
: "bg-muted-foreground/30";
|
||||
return <span className={`size-1.5 rounded-full shrink-0 ${cls}`} />;
|
||||
}
|
||||
|
||||
function QuickConnectWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<QuickConnectConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { hosts: allHosts, loaded } = useHosts();
|
||||
const tabs = useTabs();
|
||||
const targets = quickConnectTargets(useHostActions());
|
||||
|
||||
const hosts =
|
||||
config.hostIds.length > 0
|
||||
? allHosts.filter((h) => config.hostIds.includes(Number(h.id)))
|
||||
: allHosts;
|
||||
|
||||
// An empty list means the first tools plugins offer, in their order.
|
||||
const types =
|
||||
config.connectionTypes.length > 0
|
||||
? config.connectionTypes
|
||||
: (targets
|
||||
.slice(0, 2)
|
||||
.map((target) => target.type) as QuickConnectType[]);
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hosts.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/60">
|
||||
<Zap size={18} />
|
||||
<span className="text-xs">{t("homepage.noHosts")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Zap size={11} />} />
|
||||
<div className="flex-1 overflow-y-auto p-2 flex flex-col gap-1">
|
||||
{hosts.map((host) => {
|
||||
const availableTypes = types.flatMap((type) => {
|
||||
const target = targets.find((item) => item.type === type);
|
||||
return target && target.enabled(host) ? [target] : [];
|
||||
});
|
||||
if (availableTypes.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
key={String(host.id)}
|
||||
className={`flex items-center gap-2 py-1.5 border-b border-border/20 last:border-0 ${config.layout === "grid" ? "flex-col items-start" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||
{config.showStatus && <StatusDot hostId={Number(host.id)} />}
|
||||
<span className="text-xs font-medium text-foreground truncate">
|
||||
{host.name || host.ip}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0 flex-wrap">
|
||||
{availableTypes.map((target) => (
|
||||
<button
|
||||
key={target.type}
|
||||
title={t(target.labelKey, target.type)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
tabs.connectHost(host, target.type);
|
||||
}}
|
||||
className="p-1.5 text-muted-foreground hover:text-accent-brand hover:bg-accent-brand/10 transition-colors border border-transparent hover:border-accent-brand/20"
|
||||
>
|
||||
<target.icon size={12} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<QuickConnectConfig>({
|
||||
id: "quick_connect",
|
||||
name: "Quick Connect",
|
||||
description:
|
||||
"Launch terminal, files, docker, tunnels, and more for your hosts",
|
||||
category: "system",
|
||||
icon: <Zap size={14} />,
|
||||
defaultConfig: {
|
||||
hostIds: [],
|
||||
connectionTypes: [],
|
||||
showStatus: true,
|
||||
layout: "list",
|
||||
},
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: QuickConnectWidget,
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Activity } from "lucide-react";
|
||||
import { useTranslation, activityTarget } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
import {
|
||||
WidgetTitle,
|
||||
getRecentActivity,
|
||||
runVisibleInterval,
|
||||
type RecentActivityItem,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
RecentActivityConfig,
|
||||
ActivityType,
|
||||
WidgetComponentProps,
|
||||
} from "../types.js";
|
||||
import { GRID_SIZE } from "../types.js";
|
||||
|
||||
function relativeTime(ts: string): string {
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
const m = Math.floor(diff / 60_000);
|
||||
if (m < 1) return "just now";
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
function ActivityIcon({ type }: { type: string }) {
|
||||
const Icon = activityTarget(type)?.icon ?? Activity;
|
||||
return <Icon className="size-2.5 shrink-0 text-accent-brand" />;
|
||||
}
|
||||
|
||||
function RecentActivityWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<RecentActivityConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { maxItems, filterTypes, showTimestamp } = config;
|
||||
const [items, setItems] = useState<RecentActivityItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const data = await getRecentActivity(maxItems * 3);
|
||||
const filtered =
|
||||
filterTypes.length > 0
|
||||
? data.filter((i) => filterTypes.includes(i.type as ActivityType))
|
||||
: data;
|
||||
setItems(filtered.slice(0, maxItems));
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
return runVisibleInterval(() => {
|
||||
void fetchData();
|
||||
}, 60_000);
|
||||
}, [maxItems, filterTypes.join(",")]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noActivity")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle
|
||||
title={widget.title || t("homepage.widgetRecentActivityName")}
|
||||
icon={<Activity size={11} />}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 border-b border-border/30"
|
||||
>
|
||||
<ActivityIcon type={item.type} />
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-[10px] font-medium text-foreground truncate">
|
||||
{item.hostName}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted-foreground capitalize">
|
||||
{item.type.replace("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
{showTimestamp && (
|
||||
<span className="text-[9px] text-muted-foreground shrink-0">
|
||||
{relativeTime(item.timestamp)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<RecentActivityConfig>({
|
||||
id: "recent_activity",
|
||||
name: "Recent Activity",
|
||||
description: "Shows recent terminal, Docker, file, and tunnel activity",
|
||||
category: "system",
|
||||
icon: <Activity size={14} />,
|
||||
defaultConfig: { maxItems: 10, filterTypes: [], showTimestamp: true },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 9 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: RecentActivityWidget,
|
||||
});
|
||||
|
||||
export { RecentActivityWidget };
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Rss, ExternalLink } from "lucide-react";
|
||||
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type { RssFeedConfig, WidgetComponentProps } from "../types.js";
|
||||
import { GRID_SIZE } from "../types.js";
|
||||
import { WidgetTitle } from "@termix/plugin-sdk/ui";
|
||||
|
||||
interface RssItem {
|
||||
title: string;
|
||||
link: string;
|
||||
pubDate: string | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return "";
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
const diffMs = Date.now() - d.getTime();
|
||||
const diffH = Math.floor(diffMs / 3_600_000);
|
||||
if (diffH < 1) return "just now";
|
||||
if (diffH < 24) return `${diffH}h ago`;
|
||||
const diffD = Math.floor(diffH / 24);
|
||||
if (diffD < 7) return `${diffD}d ago`;
|
||||
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function RssFeedWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<RssFeedConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const api = usePluginApi();
|
||||
const { feedUrl, maxItems, showDescription } = config;
|
||||
const [items, setItems] = useState<RssItem[]>([]);
|
||||
const [error, setError] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!feedUrl) return;
|
||||
let cancelled = false;
|
||||
const fetchFeed = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
const res = await api.get<RssItem[]>("/rss", {
|
||||
params: { url: feedUrl, max: maxItems ?? 10 },
|
||||
});
|
||||
if (!cancelled) setItems(res.data);
|
||||
} catch {
|
||||
if (!cancelled) setError(true);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchFeed();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [feedUrl, maxItems]);
|
||||
|
||||
if (!feedUrl) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/50">
|
||||
<Rss size={24} />
|
||||
<span className="text-xs">{t("homepage.rssNoFeedUrl")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading && items.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/50">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-destructive/70 p-3 text-center">
|
||||
{t("homepage.rssLoadError")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle
|
||||
title={widget.title || t("homepage.widgetRssName")}
|
||||
icon={<Rss size={11} />}
|
||||
/>
|
||||
|
||||
{/* Items */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{items.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-xs text-muted-foreground/50">
|
||||
{t("homepage.rssNoItems")}
|
||||
</div>
|
||||
) : (
|
||||
items.map((item, i) => (
|
||||
<div key={i} className="border-b border-border/60 last:border-0">
|
||||
<a
|
||||
href={item.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`flex flex-col px-3 py-2 gap-0.5 hover:bg-muted/40 transition-colors no-underline ${isReadOnly ? "pointer-events-none" : ""}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-xs font-medium text-foreground line-clamp-2 leading-snug flex-1">
|
||||
{item.title}
|
||||
</span>
|
||||
<ExternalLink
|
||||
size={9}
|
||||
className="text-muted-foreground/40 shrink-0 mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
{showDescription && item.description && (
|
||||
<span className="text-[10px] text-muted-foreground/70 line-clamp-2">
|
||||
{item.description.replace(/<[^>]+>/g, "")}
|
||||
</span>
|
||||
)}
|
||||
{item.pubDate && (
|
||||
<span className="text-[9px] text-muted-foreground/50">
|
||||
{formatDate(item.pubDate)}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<RssFeedConfig>({
|
||||
id: "rss_feed",
|
||||
name: "RSS Feed",
|
||||
description: "Display items from an RSS or Atom feed",
|
||||
category: "links",
|
||||
icon: <Rss size={14} />,
|
||||
defaultConfig: { feedUrl: "", maxItems: 10, showDescription: false },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 10 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: RssFeedWidget,
|
||||
});
|
||||
|
||||
export { RssFeedWidget };
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
SearchBarConfig,
|
||||
SearchEngine,
|
||||
WidgetComponentProps,
|
||||
} from "../types.js";
|
||||
import { GRID_SIZE } from "../types.js";
|
||||
import { WidgetTitle } from "@termix/plugin-sdk/ui";
|
||||
|
||||
const ENGINE_URLS: Record<SearchEngine, string> = {
|
||||
google: "https://www.google.com/search?q={q}",
|
||||
duckduckgo: "https://duckduckgo.com/?q={q}",
|
||||
bing: "https://www.bing.com/search?q={q}",
|
||||
custom: "",
|
||||
};
|
||||
|
||||
const ENGINE_LABELS: Record<SearchEngine, string> = {
|
||||
google: "Google",
|
||||
duckduckgo: "DuckDuckGo",
|
||||
bing: "Bing",
|
||||
custom: "Custom",
|
||||
};
|
||||
|
||||
function buildUrl(
|
||||
engine: SearchEngine,
|
||||
customUrl: string | undefined,
|
||||
query: string,
|
||||
): string {
|
||||
const template =
|
||||
engine === "custom" ? (customUrl ?? "") : ENGINE_URLS[engine];
|
||||
return template.replace("{q}", encodeURIComponent(query));
|
||||
}
|
||||
|
||||
function SearchBarWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<SearchBarConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { engine, customUrl, placeholder, openInNewTab } = config;
|
||||
const [query, setQuery] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!query.trim()) return;
|
||||
const url = buildUrl(engine, customUrl, query.trim());
|
||||
if (!url) return;
|
||||
if (openInNewTab) window.open(url, "_blank", "noopener,noreferrer");
|
||||
else window.location.href = url;
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Search size={11} />} />
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex items-center gap-1.5 flex-1 px-2"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Search size={12} className="text-muted-foreground shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={
|
||||
placeholder ||
|
||||
`${t("homepage.searchPlaceholder")} ${ENGINE_LABELS[engine]}...`
|
||||
}
|
||||
disabled={isReadOnly}
|
||||
className="flex-1 bg-transparent text-xs text-foreground placeholder:text-muted-foreground/60 outline-none border-none min-w-0"
|
||||
/>
|
||||
{query.trim() && (
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 text-[10px] font-medium text-accent-brand hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{t("homepage.searchGo")}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<SearchBarConfig>({
|
||||
id: "search_bar",
|
||||
name: "Search Bar",
|
||||
description: "Quick search with Google, DuckDuckGo, Bing, or a custom engine",
|
||||
category: "info",
|
||||
icon: <Search size={14} />,
|
||||
defaultConfig: { engine: "google", openInNewTab: true },
|
||||
defaultSize: { w: GRID_SIZE * 12, h: GRID_SIZE * 3 },
|
||||
minSize: { w: GRID_SIZE * 6, h: GRID_SIZE * 2 },
|
||||
component: SearchBarWidget,
|
||||
});
|
||||
|
||||
export { SearchBarWidget };
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useState } from "react";
|
||||
import { SearchCode } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
SearchLinksConfig,
|
||||
SearchLinkShortcut,
|
||||
WidgetComponentProps,
|
||||
} from "../types.js";
|
||||
import { GRID_SIZE } from "../types.js";
|
||||
import { WidgetTitle } from "@termix/plugin-sdk/ui";
|
||||
|
||||
function SearchLinksWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<SearchLinksConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { shortcuts } = config;
|
||||
const [activeIdx, setActiveIdx] = useState<number | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const handleOpen = (shortcut: SearchLinkShortcut) => {
|
||||
if (!query.trim()) return;
|
||||
const url = shortcut.queryTemplate.replace(
|
||||
"{q}",
|
||||
encodeURIComponent(query.trim()),
|
||||
);
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
setQuery("");
|
||||
setActiveIdx(null);
|
||||
};
|
||||
|
||||
const handleButtonClick = (i: number) => {
|
||||
if (isReadOnly) return;
|
||||
if (activeIdx === i) {
|
||||
setActiveIdx(null);
|
||||
setQuery("");
|
||||
} else {
|
||||
setActiveIdx(i);
|
||||
setQuery("");
|
||||
}
|
||||
};
|
||||
|
||||
if (shortcuts.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noSearchShortcuts")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<SearchCode size={11} />} />
|
||||
<div className="flex flex-col flex-1 p-2 gap-1.5 overflow-auto">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{shortcuts.map((s, i) => {
|
||||
const accent =
|
||||
s.accentColor ||
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() ||
|
||||
"#f59145";
|
||||
const isActive = activeIdx === i;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => handleButtonClick(i)}
|
||||
className="flex items-center gap-1 px-2 py-1 text-[10px] font-medium border transition-colors"
|
||||
style={
|
||||
isActive
|
||||
? { background: accent, borderColor: accent, color: "#fff" }
|
||||
: {
|
||||
borderColor: `${accent}50`,
|
||||
color: "var(--text-primary, currentColor)",
|
||||
}
|
||||
}
|
||||
>
|
||||
{s.icon && <span className="text-xs">{s.icon}</span>}
|
||||
{s.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{activeIdx !== null && (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleOpen(shortcuts[activeIdx]);
|
||||
}}
|
||||
className="flex items-center gap-1.5 border border-border/60 px-2 py-1"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("homepage.searchQueryPlaceholder")}
|
||||
className="flex-1 bg-transparent text-[10px] text-foreground placeholder:text-muted-foreground/60 outline-none border-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="text-[9px] font-medium text-accent-brand hover:opacity-80"
|
||||
>
|
||||
{t("homepage.searchGo")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<SearchLinksConfig>({
|
||||
id: "search_links",
|
||||
name: "Search Shortcuts",
|
||||
description: "Quick buttons to search different engines",
|
||||
category: "links",
|
||||
icon: <SearchCode size={14} />,
|
||||
defaultConfig: { shortcuts: [] },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 7 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: SearchLinksWidget,
|
||||
});
|
||||
|
||||
export { SearchLinksWidget };
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { Grid3x3, ExternalLink } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
ServiceGridConfig,
|
||||
ServiceGridItem,
|
||||
WidgetComponentProps,
|
||||
} from "../types.js";
|
||||
import { GRID_SIZE } from "../types.js";
|
||||
import { WidgetTitle } from "@termix/plugin-sdk/ui";
|
||||
|
||||
function getAccentColor(): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145"
|
||||
);
|
||||
}
|
||||
|
||||
function getFaviconUrl(url: string): string {
|
||||
try {
|
||||
const domain = new URL(url).hostname;
|
||||
return `https://www.google.com/s2/favicons?sz=64&domain_url=${encodeURIComponent(domain)}`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
const ICON_SIZE_MAP = { sm: 16, md: 24, lg: 32 };
|
||||
|
||||
function ServiceTile({
|
||||
item,
|
||||
iconSize,
|
||||
showLabels,
|
||||
isReadOnly,
|
||||
}: {
|
||||
item: ServiceGridItem;
|
||||
iconSize: number;
|
||||
showLabels: boolean;
|
||||
isReadOnly?: boolean;
|
||||
}) {
|
||||
const [imgFailed, setImgFailed] = useState(false);
|
||||
const mouseDownRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setImgFailed(false);
|
||||
}, [item.imageUrl, item.url]);
|
||||
|
||||
const accent = item.accentColor || getAccentColor();
|
||||
const imageUrl = item.imageUrl || (item.url ? getFaviconUrl(item.url) : null);
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className="group flex flex-col items-center justify-center gap-1 p-1.5 w-full h-full border border-border/40 border-t-2 hover:bg-muted/40 transition-colors overflow-hidden"
|
||||
style={{ borderTopColor: `${accent}60`, background: "var(--color-card)" }}
|
||||
>
|
||||
{imageUrl && !imgFailed ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
style={{ width: iconSize, height: iconSize }}
|
||||
className="object-contain shrink-0"
|
||||
onError={() => setImgFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<ExternalLink
|
||||
size={iconSize * 0.6}
|
||||
className="text-accent-brand shrink-0"
|
||||
/>
|
||||
)}
|
||||
{showLabels && (
|
||||
<span className="text-[9px] font-medium text-foreground text-center truncate w-full leading-tight">
|
||||
{item.label || item.url}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isReadOnly || !item.url) return content;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full h-full no-underline"
|
||||
onMouseDown={(e) => {
|
||||
mouseDownRef.current = { x: e.clientX, y: e.clientY };
|
||||
}}
|
||||
onClick={(e) => {
|
||||
const s = mouseDownRef.current;
|
||||
if (
|
||||
s &&
|
||||
(Math.abs(e.clientX - s.x) > 4 || Math.abs(e.clientY - s.y) > 4)
|
||||
)
|
||||
e.preventDefault();
|
||||
mouseDownRef.current = null;
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceGridWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<ServiceGridConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { services, columns, showLabels, iconSize } = config;
|
||||
|
||||
if (services.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noServices")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const gridCols =
|
||||
columns === 2
|
||||
? "grid-cols-2"
|
||||
: columns === 3
|
||||
? "grid-cols-3"
|
||||
: "grid-cols-4";
|
||||
const size = ICON_SIZE_MAP[iconSize];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Grid3x3 size={11} />} />
|
||||
<div
|
||||
className={`grid ${gridCols} gap-1.5 p-1.5 flex-1 overflow-auto content-start`}
|
||||
>
|
||||
{services.map((s, i) => (
|
||||
<ServiceTile
|
||||
key={i}
|
||||
item={s}
|
||||
iconSize={size}
|
||||
showLabels={showLabels}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<ServiceGridConfig>({
|
||||
id: "service_grid",
|
||||
name: "Service Grid",
|
||||
description: "A grid of service tiles with icons and links",
|
||||
category: "links",
|
||||
icon: <Grid3x3 size={14} />,
|
||||
defaultConfig: { services: [], columns: 3, showLabels: true, iconSize: "md" },
|
||||
defaultSize: { w: GRID_SIZE * 14, h: GRID_SIZE * 10 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 4 },
|
||||
component: ServiceGridWidget,
|
||||
});
|
||||
|
||||
export { ServiceGridWidget };
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type { ServiceLinkConfig, WidgetComponentProps } from "../types.js";
|
||||
import { GRID_SIZE } from "../types.js";
|
||||
|
||||
function getAccentColor(): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145"
|
||||
);
|
||||
}
|
||||
|
||||
function getFaviconUrl(url: string): string {
|
||||
try {
|
||||
const domain = new URL(url).hostname;
|
||||
return `https://www.google.com/s2/favicons?sz=64&domain_url=${encodeURIComponent(domain)}`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function ServiceLinkWidget({
|
||||
widget,
|
||||
config,
|
||||
isReadOnly,
|
||||
}: WidgetComponentProps<ServiceLinkConfig>) {
|
||||
const { url, description, accentColor, imageUrl, showImage = true } = config;
|
||||
const resolvedImageUrl = showImage
|
||||
? imageUrl || (url ? getFaviconUrl(url) : null)
|
||||
: null;
|
||||
const mouseDownPosRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const [imgFailed, setImgFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setImgFailed(false);
|
||||
}, [resolvedImageUrl]);
|
||||
|
||||
let displayLabel = widget.title;
|
||||
if (!displayLabel && url) {
|
||||
try {
|
||||
displayLabel = new URL(url).hostname;
|
||||
} catch {
|
||||
displayLabel = url;
|
||||
}
|
||||
}
|
||||
|
||||
const accentBorder = accentColor || getAccentColor();
|
||||
const imgSize = Math.min(48, widget.h * 0.3);
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className="group flex flex-col items-center justify-center gap-1.5 w-full h-full p-2 select-none border-t-2 relative overflow-hidden"
|
||||
style={{ borderTopColor: `${accentBorder}80` }}
|
||||
>
|
||||
{resolvedImageUrl && !imgFailed ? (
|
||||
<img
|
||||
src={resolvedImageUrl}
|
||||
alt=""
|
||||
className="shrink-0 transition-transform duration-150 group-hover:scale-110 object-contain"
|
||||
style={{ width: imgSize, height: imgSize }}
|
||||
onError={() => setImgFailed(true)}
|
||||
/>
|
||||
) : showImage ? (
|
||||
<ExternalLink
|
||||
className="shrink-0 text-accent-brand transition-transform duration-150 group-hover:scale-110"
|
||||
style={{ width: imgSize * 0.75, height: imgSize * 0.75 }}
|
||||
/>
|
||||
) : null}
|
||||
<span className="text-xs font-semibold text-foreground text-center leading-tight w-full truncate px-1">
|
||||
{displayLabel}
|
||||
</span>
|
||||
{description && (
|
||||
<span className="text-[10px] text-muted-foreground text-center w-full truncate px-1">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
{!isReadOnly && url && (
|
||||
<ExternalLink
|
||||
size={10}
|
||||
className="absolute top-1.5 right-1.5 text-muted-foreground/0 group-hover:text-muted-foreground/60 transition-colors duration-150"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isReadOnly || !url) return content;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full h-full no-underline"
|
||||
onMouseDown={(e) => {
|
||||
mouseDownPosRef.current = { x: e.clientX, y: e.clientY };
|
||||
}}
|
||||
onClick={(e) => {
|
||||
const start = mouseDownPosRef.current;
|
||||
if (
|
||||
start &&
|
||||
(Math.abs(e.clientX - start.x) > 4 ||
|
||||
Math.abs(e.clientY - start.y) > 4)
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
mouseDownPosRef.current = null;
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<ServiceLinkConfig>({
|
||||
id: "service_link",
|
||||
name: "Service Link",
|
||||
description: "A clickable tile linking to a service URL",
|
||||
category: "links",
|
||||
icon: <ExternalLink size={14} />,
|
||||
defaultConfig: { url: "", description: "", showImage: true },
|
||||
defaultSize: { w: GRID_SIZE * 8, h: GRID_SIZE * 6 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: ServiceLinkWidget,
|
||||
});
|
||||
|
||||
export { ServiceLinkWidget };
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Terminal as TerminalIcon, Play } from "lucide-react";
|
||||
import {
|
||||
useTranslation,
|
||||
useHost,
|
||||
type TabHandle as TerminalHandle,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
|
||||
import { PluginComponent, WidgetTitle } from "@termix/plugin-sdk/ui";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type { SshTerminalConfig, WidgetComponentProps } from "../types.js";
|
||||
import { GRID_SIZE } from "../types.js";
|
||||
|
||||
function SshTerminalWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<SshTerminalConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const host = useHost(config.hostId || undefined);
|
||||
const [started, setStarted] = useState(false);
|
||||
const terminalRef = useRef<TerminalHandle>(null);
|
||||
const terminalWrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = terminalWrapRef.current;
|
||||
if (!el || !started) return;
|
||||
const stopWheel = (e: WheelEvent) => e.stopPropagation();
|
||||
el.addEventListener("wheel", stopWheel, { capture: true, passive: true });
|
||||
return () => el.removeEventListener("wheel", stopWheel, { capture: true });
|
||||
}, [started]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!started) return;
|
||||
const blurOnOutsideClick = (e: MouseEvent) => {
|
||||
if (!terminalWrapRef.current?.contains(e.target as Node)) {
|
||||
const textarea = terminalWrapRef.current?.querySelector("textarea");
|
||||
textarea?.blur();
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", blurOnOutsideClick, {
|
||||
capture: true,
|
||||
});
|
||||
return () =>
|
||||
document.removeEventListener("mousedown", blurOnOutsideClick, {
|
||||
capture: true,
|
||||
});
|
||||
}, [started]);
|
||||
|
||||
// auto-connect after the host loads if configured
|
||||
useEffect(() => {
|
||||
if (host && config.autoConnect) setStarted(true);
|
||||
}, [host, config.autoConnect]);
|
||||
|
||||
if (!config.hostId) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/60">
|
||||
<TerminalIcon size={20} />
|
||||
<span className="text-xs">{t("homepage.sshTerminalNoHost")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!host) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
|
||||
{t("homepage.sshTerminalNoHost")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hostConfig = {
|
||||
id: Number(host.id),
|
||||
ip: host.ip,
|
||||
port: (host.sshPort as number | undefined) ?? host.port ?? 22,
|
||||
username: host.username,
|
||||
authType: host.authType,
|
||||
};
|
||||
|
||||
if (!started) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center w-full h-full gap-3"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<TerminalIcon size={16} className="text-muted-foreground/60" />
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{host.name || host.ip}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/50">
|
||||
{host.username}@{host.ip}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-accent-brand text-white text-xs font-medium hover:opacity-90 transition-opacity"
|
||||
onClick={() => setStarted(true)}
|
||||
>
|
||||
<Play size={11} />
|
||||
{t("homepage.sshTerminalConnect")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={terminalWrapRef}
|
||||
className="flex flex-col w-full h-full overflow-hidden"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<WidgetTitle title={widget.title} icon={<TerminalIcon size={11} />} />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<PluginComponent
|
||||
id="terminal.view"
|
||||
handleRef={terminalRef}
|
||||
hostConfig={hostConfig}
|
||||
isVisible={true}
|
||||
showTitle={false}
|
||||
splitScreen={false}
|
||||
disableAutoFocus={true}
|
||||
onClose={() => setStarted(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<SshTerminalConfig>({
|
||||
id: "ssh_terminal",
|
||||
name: "SSH Terminal",
|
||||
description: "An inline SSH terminal connected to a configured host",
|
||||
category: "system",
|
||||
icon: <TerminalIcon size={14} />,
|
||||
defaultConfig: {
|
||||
hostId: 0,
|
||||
autoConnect: false,
|
||||
},
|
||||
defaultSize: { w: GRID_SIZE * 16, h: GRID_SIZE * 12 },
|
||||
minSize: { w: GRID_SIZE * 8, h: GRID_SIZE * 6 },
|
||||
component: SshTerminalWidget,
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Server } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type { SystemOverviewConfig, WidgetComponentProps } from "../types.js";
|
||||
import { GRID_SIZE } from "../types.js";
|
||||
import {
|
||||
WidgetTitle,
|
||||
getDatabaseHealth,
|
||||
getUptime,
|
||||
getVersionInfo,
|
||||
runVisibleInterval,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
|
||||
interface InfoRowProps {
|
||||
label: string;
|
||||
value: string | null | undefined;
|
||||
valueColor?: string;
|
||||
}
|
||||
|
||||
function InfoRow({ label, value, valueColor }: InfoRowProps) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="flex justify-between text-[10px] gap-2">
|
||||
<span className="text-muted-foreground shrink-0">{label}</span>
|
||||
<span
|
||||
className="text-foreground truncate text-right font-medium"
|
||||
style={valueColor ? { color: valueColor } : undefined}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemOverviewWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<SystemOverviewConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { showVersion, showDbHealth, showUptime } = config;
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
const [dbStatus, setDbStatus] = useState<string | null>(null);
|
||||
const [uptimeFormatted, setUptimeFormatted] = useState<string | null>(null);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
if (showVersion) {
|
||||
// `checkRemote=false` makes /version return early with just
|
||||
// {localVersion, status: "update_check_disabled"}, and the response has
|
||||
// no `updateAvailable` field in either mode -- so the previous read was
|
||||
// false twice over. `status` is what the endpoint actually answers with,
|
||||
// and what the dashboard and profile badges already read.
|
||||
const info = await getVersionInfo();
|
||||
setVersion((info?.localVersion as string) ?? null);
|
||||
setUpdateAvailable(info?.status === "requires_update");
|
||||
}
|
||||
if (showDbHealth) {
|
||||
const health = await getDatabaseHealth();
|
||||
setDbStatus((health?.status as string) ?? "unknown");
|
||||
}
|
||||
if (showUptime) {
|
||||
const up = await getUptime();
|
||||
setUptimeFormatted(up.formatted);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
return runVisibleInterval(() => {
|
||||
void fetchData();
|
||||
}, 300_000);
|
||||
}, [showVersion, showDbHealth, showUptime]);
|
||||
|
||||
const accent =
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle
|
||||
title={widget.title}
|
||||
icon={<Server size={11} />}
|
||||
fallback="Termix"
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5 p-3 flex-1 overflow-auto">
|
||||
{showVersion && (
|
||||
<InfoRow
|
||||
label={t("homepage.overviewVersion")}
|
||||
value={version ?? t("common.loading")}
|
||||
valueColor={updateAvailable ? "#f97316" : undefined}
|
||||
/>
|
||||
)}
|
||||
{showVersion && updateAvailable && (
|
||||
<InfoRow
|
||||
label={t("homepage.overviewUpdateLabel")}
|
||||
value={t("homepage.overviewUpdateAvailable")}
|
||||
valueColor="#f97316"
|
||||
/>
|
||||
)}
|
||||
{showDbHealth && (
|
||||
<InfoRow
|
||||
label={t("homepage.overviewDatabase")}
|
||||
value={dbStatus ? dbStatus.toUpperCase() : t("common.loading")}
|
||||
valueColor={
|
||||
dbStatus === "ok" || dbStatus === "healthy"
|
||||
? accent
|
||||
: dbStatus
|
||||
? "#ef4444"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showUptime && (
|
||||
<InfoRow
|
||||
label={t("homepage.overviewUptime")}
|
||||
value={uptimeFormatted ?? t("common.loading")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<SystemOverviewConfig>({
|
||||
id: "system_overview",
|
||||
name: "System Overview",
|
||||
description: "Termix version, database health, and uptime at a glance",
|
||||
category: "system",
|
||||
icon: <Server size={14} />,
|
||||
defaultConfig: { showVersion: true, showDbHealth: true, showUptime: true },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 6 },
|
||||
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
|
||||
component: SystemOverviewWidget,
|
||||
});
|
||||
|
||||
export { SystemOverviewWidget };
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { Clock4 } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type { TermixUptimeConfig, WidgetComponentProps } from "../types.js";
|
||||
import { GRID_SIZE } from "../types.js";
|
||||
import {
|
||||
WidgetTitle,
|
||||
getUptime,
|
||||
runVisibleInterval,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
|
||||
function formatUptime(seconds: number): {
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
secs: number;
|
||||
} {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return { days, hours, minutes, secs };
|
||||
}
|
||||
|
||||
function TermixUptimeWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<TermixUptimeConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { showDetailed } = config;
|
||||
const [seconds, setSeconds] = useState<number | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const baseRef = useRef<number | null>(null);
|
||||
const startRef = useRef<number>(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
getUptime()
|
||||
.then((info) => {
|
||||
baseRef.current = info.uptimeSeconds;
|
||||
startRef.current = Date.now();
|
||||
setSeconds(info.uptimeSeconds);
|
||||
})
|
||||
.catch(() => setError(true));
|
||||
|
||||
return runVisibleInterval(() => {
|
||||
if (baseRef.current !== null) {
|
||||
const elapsed = (Date.now() - startRef.current) / 1000;
|
||||
setSeconds(Math.floor(baseRef.current + elapsed));
|
||||
}
|
||||
}, 1000);
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.uptimeUnavailable")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (seconds === null) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { days, hours, minutes, secs } = formatUptime(seconds);
|
||||
const accent =
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Clock4 size={11} />} />
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-1 p-2">
|
||||
<Clock4 size={14} className="text-muted-foreground" />
|
||||
<div className="flex items-baseline gap-1 flex-wrap justify-center">
|
||||
{days > 0 && (
|
||||
<span
|
||||
className="text-xl font-bold tabular-nums"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{days}
|
||||
<span className="text-xs font-normal text-muted-foreground ml-0.5">
|
||||
d
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="text-xl font-bold tabular-nums"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{String(hours).padStart(2, "0")}
|
||||
<span className="text-xs font-normal text-muted-foreground ml-0.5">
|
||||
h
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="text-xl font-bold tabular-nums"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{String(minutes).padStart(2, "0")}
|
||||
<span className="text-xs font-normal text-muted-foreground ml-0.5">
|
||||
m
|
||||
</span>
|
||||
</span>
|
||||
{showDetailed && (
|
||||
<span
|
||||
className="text-xl font-bold tabular-nums"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{String(secs).padStart(2, "0")}
|
||||
<span className="text-xs font-normal text-muted-foreground ml-0.5">
|
||||
s
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("homepage.uptimeLabel")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<TermixUptimeConfig>({
|
||||
id: "termix_uptime",
|
||||
name: "Termix Uptime",
|
||||
description: "Shows how long the Termix server has been running",
|
||||
category: "system",
|
||||
icon: <Clock4 size={14} />,
|
||||
defaultConfig: { showDetailed: false },
|
||||
defaultSize: { w: GRID_SIZE * 8, h: GRID_SIZE * 4 },
|
||||
minSize: { w: GRID_SIZE * 3, h: GRID_SIZE * 2 },
|
||||
component: TermixUptimeWidget,
|
||||
});
|
||||
|
||||
export { TermixUptimeWidget };
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Type } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type { TextBannerConfig, WidgetComponentProps } from "../types.js";
|
||||
import { GRID_SIZE } from "../types.js";
|
||||
|
||||
const FONT_SIZE_MAP = {
|
||||
sm: "text-xs",
|
||||
md: "text-sm",
|
||||
lg: "text-base",
|
||||
xl: "text-xl",
|
||||
};
|
||||
|
||||
const FONT_WEIGHT_MAP = {
|
||||
normal: "font-normal",
|
||||
semibold: "font-semibold",
|
||||
bold: "font-bold",
|
||||
};
|
||||
|
||||
const TEXT_ALIGN_MAP = {
|
||||
left: "text-left",
|
||||
center: "text-center",
|
||||
right: "text-right",
|
||||
};
|
||||
|
||||
function TextBannerWidget({ config }: WidgetComponentProps<TextBannerConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { text, fontSize, textAlign, fontWeight, backgroundColor } = config;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center w-full h-full px-3 py-2 overflow-hidden ${FONT_SIZE_MAP[fontSize]} ${FONT_WEIGHT_MAP[fontWeight]} ${TEXT_ALIGN_MAP[textAlign]}`}
|
||||
style={backgroundColor ? { background: backgroundColor } : undefined}
|
||||
>
|
||||
<span className="whitespace-pre-wrap break-words w-full text-foreground leading-tight">
|
||||
{text || t("homepage.bannerTextPlaceholder")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<TextBannerConfig>({
|
||||
id: "text_banner",
|
||||
name: "Text Banner",
|
||||
description: "A styled text label for sections and headings",
|
||||
category: "info",
|
||||
icon: <Type size={14} />,
|
||||
defaultConfig: {
|
||||
text: "",
|
||||
fontSize: "lg",
|
||||
textAlign: "center",
|
||||
fontWeight: "semibold",
|
||||
},
|
||||
defaultSize: { w: GRID_SIZE * 14, h: GRID_SIZE * 3 },
|
||||
minSize: { w: GRID_SIZE * 3, h: GRID_SIZE * 2 },
|
||||
component: TextBannerWidget,
|
||||
});
|
||||
|
||||
export { TextBannerWidget };
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Cloud,
|
||||
CloudRain,
|
||||
Sun,
|
||||
CloudSnow,
|
||||
Wind,
|
||||
Thermometer,
|
||||
} from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type { WeatherConfig, WidgetComponentProps } from "../types.js";
|
||||
import { GRID_SIZE } from "../types.js";
|
||||
|
||||
import { WidgetTitle, runVisibleInterval } from "@termix/plugin-sdk/ui";
|
||||
|
||||
interface WttrCurrent {
|
||||
temp_C: string;
|
||||
FeelsLikeC: string;
|
||||
weatherDesc: { value: string }[];
|
||||
weatherCode: string;
|
||||
}
|
||||
|
||||
interface WttrDay {
|
||||
mintempC: string;
|
||||
maxtempC: string;
|
||||
hourly: { weatherDesc: { value: string }[]; weatherCode: string }[];
|
||||
}
|
||||
|
||||
interface WttrResponse {
|
||||
nearest_area: {
|
||||
areaName: { value: string }[];
|
||||
country: { value: string }[];
|
||||
}[];
|
||||
current_condition: WttrCurrent[];
|
||||
weather: WttrDay[];
|
||||
}
|
||||
|
||||
function weatherIcon(code: string, size = 20) {
|
||||
const n = Number(code);
|
||||
if (n === 113) return <Sun size={size} className="text-yellow-400" />;
|
||||
if (n <= 119) return <Cloud size={size} className="text-gray-400" />;
|
||||
if (n <= 143) return <Cloud size={size} className="text-gray-400" />;
|
||||
if (n <= 266) return <CloudRain size={size} className="text-blue-400" />;
|
||||
if (n <= 335) return <CloudSnow size={size} className="text-blue-200" />;
|
||||
if (n <= 389) return <CloudRain size={size} className="text-blue-500" />;
|
||||
return <Wind size={size} className="text-muted-foreground" />;
|
||||
}
|
||||
|
||||
function celsiusToF(c: number) {
|
||||
return Math.round((c * 9) / 5 + 32);
|
||||
}
|
||||
|
||||
function WeatherWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<WeatherConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { location, unit, showForecast } = config;
|
||||
const [data, setData] = useState<WttrResponse | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!location) return;
|
||||
let cancelled = false;
|
||||
const fetchWeather = async () => {
|
||||
try {
|
||||
setError(false);
|
||||
const res = await fetch(
|
||||
`https://wttr.in/${encodeURIComponent(location)}?format=j1`,
|
||||
);
|
||||
if (!res.ok) throw new Error("bad response");
|
||||
const json: WttrResponse = await res.json();
|
||||
if (!cancelled) setData(json);
|
||||
} catch {
|
||||
if (!cancelled) setError(true);
|
||||
}
|
||||
};
|
||||
fetchWeather();
|
||||
const stop = runVisibleInterval(
|
||||
() => {
|
||||
void fetchWeather();
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
stop();
|
||||
};
|
||||
}, [location]);
|
||||
|
||||
if (!location) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60 p-3 text-center">
|
||||
{t("homepage.configureLocation")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-destructive/70 p-3 text-center">
|
||||
Could not load weather for "{location}"
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/50">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const current = data.current_condition[0];
|
||||
const area = data.nearest_area[0];
|
||||
const cityName = area?.areaName[0]?.value ?? location;
|
||||
const tempC = Number(current.temp_C);
|
||||
const feelsC = Number(current.FeelsLikeC);
|
||||
const displayTemp = unit === "F" ? `${celsiusToF(tempC)}°F` : `${tempC}°C`;
|
||||
const displayFeels = unit === "F" ? `${celsiusToF(feelsC)}°F` : `${feelsC}°C`;
|
||||
const desc = current.weatherDesc[0]?.value ?? "";
|
||||
const code = current.weatherCode;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Sun size={11} />} />
|
||||
<div className="flex flex-col flex-1 p-3 gap-2 overflow-auto">
|
||||
{/* Current conditions */}
|
||||
<div className="flex items-center gap-3 flex-1 min-h-0">
|
||||
<div className="shrink-0">{weatherIcon(code, 32)}</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-2xl font-bold text-foreground leading-none">
|
||||
{displayTemp}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
{desc}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/70">
|
||||
Feels like {displayFeels}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Thermometer
|
||||
size={10}
|
||||
className="text-muted-foreground/60 shrink-0"
|
||||
/>
|
||||
<span className="text-[11px] font-semibold text-foreground truncate">
|
||||
{cityName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 3-day forecast */}
|
||||
{showForecast && data.weather.length >= 3 && (
|
||||
<div className="flex gap-1 border-t border-border pt-2 mt-auto shrink-0">
|
||||
{data.weather.slice(0, 3).map((day, i) => {
|
||||
const hourly = day.hourly[4] ?? day.hourly[0];
|
||||
const minC = Number(day.mintempC);
|
||||
const maxC = Number(day.maxtempC);
|
||||
const lo = unit === "F" ? `${celsiusToF(minC)}°` : `${minC}°`;
|
||||
const hi = unit === "F" ? `${celsiusToF(maxC)}°` : `${maxC}°`;
|
||||
const dayLabel = i === 0 ? "Today" : i === 1 ? "Tmrw" : "+2";
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 flex flex-col items-center gap-0.5"
|
||||
>
|
||||
<span className="text-[9px] text-muted-foreground/70">
|
||||
{dayLabel}
|
||||
</span>
|
||||
{weatherIcon(hourly?.weatherCode ?? "113", 12)}
|
||||
<span className="text-[9px] text-foreground">{hi}</span>
|
||||
<span className="text-[9px] text-muted-foreground/60">
|
||||
{lo}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<WeatherConfig>({
|
||||
id: "weather",
|
||||
name: "Weather",
|
||||
description: "Live weather for any location",
|
||||
category: "info",
|
||||
icon: <Sun size={14} />,
|
||||
defaultConfig: { location: "", unit: "C", showForecast: true },
|
||||
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 7 },
|
||||
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
|
||||
component: WeatherWidget,
|
||||
});
|
||||
|
||||
export { WeatherWidget };
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
getExtension,
|
||||
useExtensions,
|
||||
type ExtensionContribution,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { useMemo } from "react";
|
||||
import type { WidgetTypeDefinition, WidgetTypeId } from "../types.js";
|
||||
|
||||
/**
|
||||
* Other plugins add widgets with app.registerExtension(WIDGET_POINT, ...),
|
||||
* putting the widget under components.view and its edit form under
|
||||
* components.editForm. Core only stores the list, so a plugin's widgets
|
||||
* stay registered while this plugin is off.
|
||||
*/
|
||||
export const WIDGET_POINT = "homepage.widgets";
|
||||
|
||||
/**
|
||||
* Each widget module calls this at import time (before activate(app) runs),
|
||||
* so the definitions are queued here and registered from activate.
|
||||
*/
|
||||
const queued: WidgetTypeDefinition[] = [];
|
||||
|
||||
export function registerWidget<C>(def: WidgetTypeDefinition<C>): void {
|
||||
queued.push(def as unknown as WidgetTypeDefinition);
|
||||
}
|
||||
|
||||
/** Called once from activate(app); drains the queue. */
|
||||
export function drainQueuedWidgets(): WidgetTypeDefinition[] {
|
||||
return queued.splice(0, queued.length);
|
||||
}
|
||||
|
||||
export function toExtension(def: WidgetTypeDefinition): ExtensionContribution {
|
||||
const { component, editFormComponent, ...rest } = def;
|
||||
return {
|
||||
...rest,
|
||||
components: editFormComponent
|
||||
? { view: component, editForm: editFormComponent }
|
||||
: { view: component },
|
||||
};
|
||||
}
|
||||
|
||||
function toDefinition(
|
||||
extension: ExtensionContribution | undefined,
|
||||
): WidgetTypeDefinition | undefined {
|
||||
const view = extension?.components?.view;
|
||||
if (!extension || !view) return undefined;
|
||||
const { components, ...rest } = extension;
|
||||
return {
|
||||
...(rest as unknown as WidgetTypeDefinition),
|
||||
component: view,
|
||||
editFormComponent: components?.editForm,
|
||||
labelComponent: components?.label,
|
||||
};
|
||||
}
|
||||
|
||||
export function getWidgetType(
|
||||
id: WidgetTypeId,
|
||||
): WidgetTypeDefinition | undefined {
|
||||
return toDefinition(getExtension(WIDGET_POINT, id));
|
||||
}
|
||||
|
||||
export function useWidgetTypes(): WidgetTypeDefinition[] {
|
||||
const extensions = useExtensions(WIDGET_POINT);
|
||||
return useMemo(
|
||||
() =>
|
||||
extensions
|
||||
.map(toDefinition)
|
||||
.filter((def): def is WidgetTypeDefinition => !!def),
|
||||
[extensions],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useState } from "react";
|
||||
import { Pencil, Trash2, GripVertical } from "lucide-react";
|
||||
import type { CanvasWidget } from "../types.js";
|
||||
import { getWidgetType, useWidgetTypes } from "./WidgetRegistry";
|
||||
import { PluginViewPlaceholder } from "@termix/plugin-sdk/ui";
|
||||
|
||||
interface WidgetShellProps {
|
||||
widget: CanvasWidget;
|
||||
isLocked?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
isDragging?: boolean;
|
||||
isResizing?: boolean;
|
||||
onStartDrag?: (e: React.MouseEvent, widget: CanvasWidget) => void;
|
||||
onStartResize?: (e: React.MouseEvent, widget: CanvasWidget) => void;
|
||||
onDelete?: (id: number) => void;
|
||||
onEdit?: (id: number) => void;
|
||||
onConfigUpdate?: (config: Record<string, unknown>) => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function WidgetShell({
|
||||
widget,
|
||||
isLocked,
|
||||
isReadOnly,
|
||||
isDragging,
|
||||
isResizing,
|
||||
onStartDrag,
|
||||
onStartResize,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onConfigUpdate,
|
||||
children,
|
||||
}: WidgetShellProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
// A plugin widget appears once its plugin starts.
|
||||
useWidgetTypes();
|
||||
const typeDef = getWidgetType(widget.typeId);
|
||||
const showControls =
|
||||
hovered && !isLocked && !isReadOnly && !isDragging && !isResizing;
|
||||
|
||||
const WidgetComponent = typeDef?.component;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-widget={widget.id}
|
||||
className={`absolute overflow-hidden select-none transition-shadow duration-150 ${
|
||||
hovered && !isLocked && !isReadOnly
|
||||
? "ring-1 ring-accent-brand/30 shadow-md"
|
||||
: "ring-1 ring-border/60"
|
||||
}`}
|
||||
style={{
|
||||
left: widget.x,
|
||||
top: widget.y,
|
||||
width: widget.w,
|
||||
height: widget.h,
|
||||
background: "var(--color-card)",
|
||||
opacity: isDragging ? 0.85 : 1,
|
||||
transform: isDragging ? "scale(1.02)" : undefined,
|
||||
boxShadow:
|
||||
isDragging || isResizing ? "0 8px 32px rgba(0,0,0,0.25)" : undefined,
|
||||
cursor:
|
||||
isLocked || isReadOnly ? "default" : isDragging ? "grabbing" : "grab",
|
||||
}}
|
||||
onWheel={(e) => {
|
||||
// Find the nearest scrollable ancestor within this widget
|
||||
let el = e.target as HTMLElement | null;
|
||||
while (el && el !== e.currentTarget) {
|
||||
const canScrollY = el.scrollHeight > el.clientHeight;
|
||||
const canScrollX = el.scrollWidth > el.clientWidth;
|
||||
if (canScrollY || canScrollX) {
|
||||
e.stopPropagation();
|
||||
break;
|
||||
}
|
||||
el = el.parentElement;
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onMouseDown={
|
||||
!isLocked && !isReadOnly ? (e) => onStartDrag?.(e, widget) : undefined
|
||||
}
|
||||
>
|
||||
{/* Widget content */}
|
||||
{children ??
|
||||
(WidgetComponent ? (
|
||||
<WidgetComponent
|
||||
widget={widget}
|
||||
config={widget.config as Record<string, unknown>}
|
||||
isReadOnly={isReadOnly}
|
||||
onConfigUpdate={onConfigUpdate}
|
||||
/>
|
||||
) : (
|
||||
<PluginViewPlaceholder kind="card" viewId={widget.typeId} compact />
|
||||
))}
|
||||
|
||||
{/* Hover controls */}
|
||||
{showControls && (
|
||||
<>
|
||||
{/* Top-right action bar */}
|
||||
<div
|
||||
className="absolute top-1 right-1 flex items-center gap-0.5 z-10"
|
||||
style={{ opacity: hovered ? 1 : 0, transition: "opacity 0.1s" }}
|
||||
>
|
||||
<div className="p-1 text-muted-foreground/40 cursor-grab">
|
||||
<GripVertical size={10} />
|
||||
</div>
|
||||
<button
|
||||
className="p-1 bg-background/90 hover:bg-background border border-border text-muted-foreground hover:text-foreground transition-colors"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit?.(widget.id);
|
||||
}}
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 bg-background/90 hover:bg-destructive border border-border text-muted-foreground hover:text-destructive-foreground transition-colors"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete?.(widget.id);
|
||||
}}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Resize handle — bottom right */}
|
||||
<div
|
||||
className="absolute bottom-0 right-0 w-5 h-5 cursor-se-resize z-10 flex items-end justify-end pb-0.5 pr-0.5"
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
onStartResize?.(e, widget);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
className="text-muted-foreground/50"
|
||||
>
|
||||
<line
|
||||
x1="2"
|
||||
y1="10"
|
||||
x2="10"
|
||||
y2="2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="6"
|
||||
y1="10"
|
||||
x2="10"
|
||||
y2="6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user