diff --git a/src/apps/officer-web/Screens/Dashboard/Plugins/PluginsScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Plugins/PluginsScreen.tsx index 240bc4c3..d9c8b517 100644 --- a/src/apps/officer-web/Screens/Dashboard/Plugins/PluginsScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Plugins/PluginsScreen.tsx @@ -1,6 +1,6 @@ import type { LayoutNode } from 'officerdev'; import { WorkspaceView } from 'officerdev'; -import { useDashboardState } from 'state/useDashboardState'; +import { useAuthoredLayout } from 'state/useAuthoredLayout'; import { defaultLayout } from './defaultLayout'; // /plugins — what is in the tree, what is installed, and the four verbs that change it. @@ -11,7 +11,11 @@ import { defaultLayout } from './defaultLayout'; // Not the app store. That installs sidecars from a catalogue, provisioning containers and asking // questions; this installs plugins from `platform/plugins/`, and asks nothing. export const PluginsScreen = () => { - const workspace = useDashboardState('screens/plugins', defaultLayout); + // `useAuthoredLayout`, not `useDashboardState`: the STRUCTURE here is ours and comes from source on + // every render, while only the sizes are the user's and persisted. Under the old hook the whole tree + // was stored, so the panels this screen ships today would never have reached anyone who had opened it + // before — they would still be looking at the list-and-preview this replaced. + const workspace = useAuthoredLayout('screens/plugins', defaultLayout); return (
diff --git a/src/apps/officer-web/Screens/Dashboard/Plugins/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Plugins/defaultLayout.ts index 044aab24..e4bd759c 100644 --- a/src/apps/officer-web/Screens/Dashboard/Plugins/defaultLayout.ts +++ b/src/apps/officer-web/Screens/Dashboard/Plugins/defaultLayout.ts @@ -8,7 +8,9 @@ export const defaultLayout: LayoutNode = { id: 'plugins-root', direction: 'horizontal', children: [ - { node: { type: 'panel', id: 'plugins-list', appType: 'plugins-list' }, size: 32 }, - { node: { type: 'panel', id: 'plugin-detail', appType: 'plugin-detail' }, size: 68 }, + // 22/78, not 32/68. The left panel was a list of every plugin and needed the room; it is a list of + // seven tags now, and the grid on the right is what benefits from the width — one more card per row. + { node: { type: 'panel', id: 'plugins-list', appType: 'plugins-list' }, size: 22 }, + { node: { type: 'panel', id: 'plugin-detail', appType: 'plugin-detail' }, size: 78 }, ], }; diff --git a/src/servers/api/plugins/router.ts b/src/servers/api/plugins/router.ts index 6da459f4..0afda51b 100644 --- a/src/servers/api/plugins/router.ts +++ b/src/servers/api/plugins/router.ts @@ -99,6 +99,9 @@ pluginsRouter.get('/', async (ctx) => { prefix: e.publisher === 'officerdev' ? `/${e.appName}` : `/p/${e.publisher}/${e.appName}`, label: e.label, summary: e.summary, + // What it is FOR, which is how the store is browsed. From the catalogue for a plugin that is not + // here yet, from the manifest below for one that is — the same field either way. + tags: e.tags ?? [], image: e.iconUrl, // Same precedence the installed list uses: artwork wins, the glyph name is the fallback. ...(e.iconUrl ? {} : { icon: e.icon }), @@ -131,6 +134,7 @@ pluginsRouter.get('/', async (ctx) => { prefix: mountPrefix(plugin), label: plugin.manifest.label, summary: plugin.manifest.summary, + tags: plugin.manifest.tags ?? [], icon: plugin.manifest.icon, // The plugin's own artwork, once published. Only while INSTALLED: `publishAssets` copies // `assets/` into `public/plugins//` at install and `unpublishAssets` removes it, so a @@ -147,9 +151,9 @@ pluginsRouter.get('/', async (ctx) => { version: plugin.manifest.version, platform: plugin.manifest.platform, permissions: plugin.manifest.permissions, - // What the owner must be able to set. Declared rather than rendered by the plugin, so a backend - // plugin with no frontend of its own still gets a settings form — see `PluginSettings.tsx`. - settings: plugin.manifest.settings ?? [], + // What the owner must be able to set. Declared rather than rendered by the plugin, so a backend + // plugin with no frontend of its own still gets a settings form — see `PluginSettings.tsx`. + settings: plugin.manifest.settings ?? [], // What the tree declared. The UI shows these so "installed but does nothing" is legible. has: { api: !!plugin.api, diff --git a/src/servers/plugins/marketplace.ts b/src/servers/plugins/marketplace.ts index d11d91d6..238a83bc 100644 --- a/src/servers/plugins/marketplace.ts +++ b/src/servers/plugins/marketplace.ts @@ -42,6 +42,13 @@ export type CatalogueEntry = { platform: string; label: string; summary: string; + /** + * What it is for, from the plugin's own manifest by way of the catalogue. + * + * Optional here and not in the marketplace's own type, because THIS type also describes entries from + * an older catalogue that predates the field — a remote store is not redeployed in lockstep with this. + */ + tags?: string[]; color: string; tile?: 'badge' | 'bare'; /** diff --git a/src/workspaces/officerdev/src/apps/Plugins/PluginGrid.tsx b/src/workspaces/officerdev/src/apps/Plugins/PluginGrid.tsx new file mode 100644 index 00000000..fa4dc61b --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Plugins/PluginGrid.tsx @@ -0,0 +1,113 @@ +import { Link, useSearchParams } from 'react-router'; +import { Check, Download } from 'lucide-react'; +import { usePlugins, type AvailablePlugin, type PluginItem } from './usePlugins'; +import { resolveIcon } from '../../utils/resolve-icon'; + +/** + * The store: cards in a grid, filtered by the tag on the left. + * + * ── Why a grid and not the list this replaced ── + * + * A plugin is a thing you look at before you take it — artwork, a name, one line about what it does. + * A dense row list is right for administering twenty-one things you already have and wrong for choosing + * one, which is what this screen is mostly for now that the tags exist. + * + * ── The card is a Link, and everything is in the URL ── + * + * `?tag=` for the filter and `?selected=` for the card. Both, so the browser's own Back lands where you + * were: filtered, scrolled, with nothing to restore from memory. `PluginDetail` reads `?selected=` and + * takes over this panel when it is set, which is why the back link only has to drop that one parameter. + */ + +type Entry = (PluginItem | AvailablePlugin) & { installed?: boolean; enabled?: boolean }; + +const Card = ({ entry, search }: { entry: Entry; search: string }) => { + const Icon = resolveIcon(entry.icon ?? 'Puzzle'); + const next = new URLSearchParams(search); + next.set('selected', entry.appName); + + return ( + +
+ {entry.image ? ( + + ) : ( +
+ +
+ )} +
+
{entry.label}
+ {/* The state a person is deciding from. "Installed" is the only one worth a badge here — the + rest (disabled, outdated, sidecar down) belongs in the detail, where there is room to say + what it means rather than a word that raises a question. */} + {entry.installed ? ( + + Installed + + ) : ( + + Available + + )} +
+
+

{entry.summary}

+ {!!entry.tags?.length && ( +
+ {entry.tags.map((tag) => ( + + {tag} + + ))} +
+ )} + + ); +}; + +export const PluginGrid = () => { + const { plugins, available, isLoading } = usePlugins(); + const [params] = useSearchParams(); + const tag = params.get('tag'); + + if (isLoading) return
Loading…
; + + // Installed first, then the rest. Both are the same thing to a person browsing — a plugin — and the + // ones they already have are the ones they are most likely looking for. + const all: Entry[] = [...plugins, ...available.map((a) => ({ ...a, installed: false }))]; + + const shown = !tag + ? all + : tag === '__untagged' + ? all.filter((p) => !p.tags?.length) + : all.filter((p) => p.tags?.includes(tag)); + + if (!shown.length) { + return ( +
+

Nothing tagged {tag}.

+
+ ); + } + + return ( +
+
+ {shown.length} plugin{shown.length === 1 ? '' : 's'} + {tag && tag !== '__untagged' ? ` in ${tag}` : ''} +
+
+ {shown.map((entry) => ( + + ))} +
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx b/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx deleted file mode 100644 index ecc995b5..00000000 --- a/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import { Link, useSearchParams } from 'react-router'; -import { Puzzle, AlertTriangle, Database } from 'lucide-react'; -import { usePlugins, type OrphanTable, type PluginItem } from './usePlugins'; -import { resolveIcon } from '../../utils/resolve-icon'; -import { useTranslation } from '../../locales'; - -// The left panel: every plugin in the tree, installed or not. -// -// Rows are real ``s carrying `?selected=`, not buttons with the name in a closure — so cmd-click, -// middle-click and "copy link" all work, and the detail panel reads the URL rather than being told. -// See docs/navigation-audit.md on the opaque-click anti-pattern. - -const Status = ({ plugin }: { plugin: PluginItem }) => { - if (!plugin.installed) return not installed; - if (!plugin.enabled) return disabled; - if (plugin.outdated) return update available; - return enabled; -}; - -export const PluginsList = () => { - const { t } = useTranslation(); - const { plugins, available, broken, orphans, isLoading, updateAll, updatingAll, updateAllResult } = usePlugins(); - const [params] = useSearchParams(); - const selected = params.get('selected'); - - if (isLoading) return
{t('plugins.loading')}
; - - return ( -
- {/* Update-all lives on the LIST, not in a plugin's detail: it is the one action here that is about - all of them at once. Hidden when nothing is installed, because "update" of nothing reads as a - broken button rather than a no-op. - - There is no versioning yet, so this cannot know which plugins have anything to pull — it pulls - each and re-applies. Saying "Update all" rather than showing a count is the honest label for - that: a count would imply a check that has not happened. */} - {plugins.length ? ( -
- {/* t('plugins.onThisMachine'), not "Installed" — the list is every plugin DIRECTORY, and an uninstalled - one stays in it. Under the old heading such a row read "Installed · not installed", which is - how a real uninstall got mistaken for a failed one: the plugin never moved, so nothing on - screen said the directory was still there deciding what a reinstall would run. */} - - {t('plugins.onThisMachine')} - - -
- ) : null} - {updateAllResult ? ( -
- - {updateAllResult.updated} updated - {updateAllResult.failed.length ? `, ${updateAllResult.failed.length} failed` : ''} - {/* `cloned` and `adopted` are worth saying out loud: they mean this machine had no - capabilities checkout until now, so its agents had no affordances at all. `pulled` is - the ordinary case and does not need announcing. */} - {updateAllResult.capabilities?.ok && updateAllResult.capabilities.action !== 'pulled' - ? ` · capabilities ${updateAllResult.capabilities.action}` - : ''} - - {/* Named, with the reason. The expected failure is a dirty checkout, and "2 failed" without - saying which is a message that sends someone to the terminal to find out. */} - {updateAllResult.failed.map((f) => ( -
- {f.appName}: {f.error} -
- ))} -
- ) : null} - - {plugins.length === 0 && available.length === 0 && broken.length === 0 ? ( -
- {t('plugins.noPluginsIn')} plugins/ yet. -
- ) : null} - - {plugins.map((plugin) => ( - - {/* The plugin's own artwork when it ships some and is installed; otherwise the generic glyph - this drew for everything before plugins could carry an icon. */} - {/* Artwork, then the plugin's own glyph, then a generic one. The middle step matters: without - it a glyph plugin shows a puzzle piece in the store and its real icon after install, which - reads as the icon having changed rather than as the store not knowing it. */} - {plugin.image ? ( - - ) : ( - (() => { - const Glyph = plugin.icon ? resolveIcon(plugin.icon) : Puzzle; - return ; - })() - )} -
-
{plugin.label}
-
{plugin.prefix}
-
- - - ))} - - {/* Offered by the marketplace, absent from this machine. - Visually separated rather than mixed in: "you have this" and "you could have this" are - different answers, and a single list sorted by name would make the owner read a badge to tell - them apart. Installing one moves it into the list above, because the clone gives it a - directory and the next read discovers it like any other. */} - {available.length ? ( -
- - {t('plugins.available')} - -
- ) : null} - {available.map((plugin) => ( - - {/* The marketplace's own copy, fetched straight from the catalogue URL — the one thing a - local not-yet-installed plugin cannot show, because its assets are published at install. */} - {plugin.image ? ( - - ) : ( - - )} -
-
{plugin.label}
-
{plugin.publisher}
-
- available - - ))} - - {/* A directory that could not be read is shown rather than swallowed — otherwise a malformed - manifest looks exactly like a plugin nobody wrote. */} - {broken.map((b) => ( -
- -
-
{b.appName}
-
{b.error}
-
-
- ))} - - -
- ); -}; - -/** - * Tables in the database that no code on this machine claims. - * - * Deliberately at the BOTTOM and deliberately quiet. It is not an error — a plugin's tables outlive it on - * purpose, so a plugin deleted without its data leaves its tables behind — and most machines will never show - * this at all. - * - * It exists because the last time these were present nothing said so. `bun db:push` saw tables the schema - * did not declare, asked a question no non-TTY caller could answer, applied nothing, exited 0, and the - * install reported success while the plugin was unusable. The owner found the cause by reading `\dt` by - * hand. This is that query, shown before it costs anybody an evening. - * - * No delete button. Dropping a table is not something to offer beside a list of plugins, and the right - * action differs per row: `music_favorites` wants the music plugin installed, `headscale_servers` wants - * dropping. Naming them is the part that was missing. - */ -const Orphans = ({ orphans }: { orphans: OrphanTable[] }) => { - if (orphans.length === 0) return null; - - return ( -
-
- -
-
- {orphans.length} table{orphans.length === 1 ? '' : 's'} with no plugin on this machine -
-
    - {orphans.map((table) => ( -
  • - {table.name} - - ~{table.rows.toLocaleString()} row{table.rows === 1 ? '' : 's'} - {table.likelyOwner ? ` · looks like ${table.likelyOwner}, which is not installed here` : ''} - -
  • - ))} -
-

- Kept, not deleted — a plugin deleted without ticking "also delete all its data" leaves these behind. Install - the plugin to adopt them, or drop them yourself if they are from something gone for good. -

-
-
-
- ); -}; diff --git a/src/workspaces/officerdev/src/apps/Plugins/StorePanel.tsx b/src/workspaces/officerdev/src/apps/Plugins/StorePanel.tsx new file mode 100644 index 00000000..0a087c42 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Plugins/StorePanel.tsx @@ -0,0 +1,49 @@ +import { Link, useSearchParams } from 'react-router'; +import { ArrowLeft } from 'lucide-react'; +import { PluginGrid } from './PluginGrid'; +import { PluginDetail } from './PluginDetail'; + +/** + * The right-hand panel: the grid, or one plugin, never both. + * + * ── Why it replaces rather than sits beside ── + * + * The owner asked for cards with "a back button to where you were", and that is a different screen shape + * from the master-and-preview this used to be. A grid needs the width; a detail beside a grid gets half + * of it and neither reads well. + * + * So this is list-then-page inside one panel. `?selected=` decides which, exactly as before — the + * difference is only that the grid gives up its space instead of sharing it. + * + * ── The back link drops one parameter, not all of them ── + * + * `?tag=` survives. That is the whole point of putting the filter in the URL: Back returns to the + * filtered grid you came from, not to an unfiltered one where you have to find your place again. + * Browser Back works for the same reason and needs no code. + */ +export const StorePanel = () => { + const [params] = useSearchParams(); + const selected = params.get('selected'); + + if (!selected) return ; + + const back = new URLSearchParams(params); + back.delete('selected'); + + return ( +
+
+ + + {params.get('tag') && params.get('tag') !== '__untagged' ? params.get('tag') : 'All plugins'} + +
+
+ +
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Plugins/TagNav.tsx b/src/workspaces/officerdev/src/apps/Plugins/TagNav.tsx new file mode 100644 index 00000000..10551806 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Plugins/TagNav.tsx @@ -0,0 +1,142 @@ +import { Link, useSearchParams } from 'react-router'; +import { Puzzle, AlertTriangle, Database, Package } from 'lucide-react'; +import { usePlugins, type OrphanTable } from './usePlugins'; +import { resolveIcon } from '../../utils/resolve-icon'; + +/** + * The left panel: what a plugin is FOR, as the way into the store. + * + * ── Why tags and not the list of plugins ── + * + * This was every plugin in the tree, one row each, with `?selected=` on it. That is a fine control panel + * and a poor shop: twenty-one rows sorted by nothing anybody cares about, where finding "something for my + * calendar" means reading all of them. The grid on the right is the shop; this is how you narrow it. + * + * ── Rows are Links, and the URL holds the filter ── + * + * `?tag=` alongside `?selected=`. Both in the URL, deliberately: a filter in component state would be + * lost on Back, so a person who filtered, opened a plugin and pressed Back would land in an unfiltered + * grid having lost their place. See docs/navigation-audit.md. + * + * Selecting a tag also CLEARS `?selected=`, because a detail view for a plugin outside the new filter is + * a page you cannot get back to from the grid you are now looking at. + */ + +const TAG_ICONS: Record = { + Personal: 'User', + Homelab: 'Server', + 'Server Management': 'Settings', + Development: 'Code', + Security: 'Shield', + Degoogling: 'ShieldOff', + Fun: 'Gamepad2', +}; + +const ROW = 'flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-colors w-full text-left'; + +export const TagNav = () => { + const { plugins, available, broken, orphans, isLoading, updateAll, updatingAll } = usePlugins(); + const [params] = useSearchParams(); + const active = params.get('tag'); + + if (isLoading) return
Loading…
; + + // Counted over everything the grid can show — installed and available together — so a number here + // always matches what clicking it produces. + const all = [...plugins, ...available]; + const counts = new Map(); + for (const p of all) for (const tag of p.tags ?? []) counts.set(tag, (counts.get(tag) ?? 0) + 1); + + // Order: what the platform declares, not alphabetical and not by count. The vocabulary is ordered on + // purpose — what a newcomer recognises first, down to what only some people want — and re-sorting it + // here would silently discard that. + const ordered = [...counts.entries()].sort( + (a, b) => Object.keys(TAG_ICONS).indexOf(a[0]) - Object.keys(TAG_ICONS).indexOf(b[0]), + ); + + const untagged = all.filter((p) => !p.tags?.length).length; + + const link = (tag: string | null) => { + const next = new URLSearchParams(params); + if (tag) next.set('tag', tag); + else next.delete('tag'); + // Clearing the selection with the filter — see the note above. + next.delete('selected'); + return { search: `?${next}` }; + }; + + return ( + + ); +}; + +export type { OrphanTable }; diff --git a/src/workspaces/officerdev/src/apps/Plugins/index.ts b/src/workspaces/officerdev/src/apps/Plugins/index.ts index 55dac251..7ead8ed7 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/index.ts +++ b/src/workspaces/officerdev/src/apps/Plugins/index.ts @@ -1,30 +1,36 @@ -import { Puzzle } from 'lucide-react'; +import { Puzzle, Tags } from 'lucide-react'; import type { AppRegistryMeta } from '../../AppRegistry'; -import { PluginsList } from './PluginsList'; -import { PluginDetail } from './PluginDetail'; +import { TagNav } from './TagNav'; +import { StorePanel } from './StorePanel'; import { panelName } from '../../locales'; -export { PluginsList } from './PluginsList'; +export { TagNav } from './TagNav'; +export { StorePanel } from './StorePanel'; +export { PluginGrid } from './PluginGrid'; export { PluginDetail } from './PluginDetail'; export { usePlugins } from './usePlugins'; -export type { PluginItem, PluginPermission } from './usePlugins'; +export type { PluginItem, PluginPermission, AvailablePlugin } from './usePlugins'; -// Two panels, read side by side, neither telling the other anything — the selection is `?selected=` and -// both read it. `availableOnPanel: false` keeps them off the generic picker: they only make sense on -// /plugins, together. +// Two panels, read side by side, neither telling the other anything — the state is `?tag=` and +// `?selected=` and both read it from the URL. `availableOnPanel: false` keeps them off the generic +// picker: they only make sense on /marketplace, together. +// +// The KEYS are unchanged (`plugins-list`, `plugin-detail`) even though both components were replaced. +// A user's stored layout names them, and renaming would drop everyone back to the default arrangement to +// no benefit — the keys identify a slot, not an implementation. export const appRegistryMetas: AppRegistryMeta[] = [ { key: 'plugins-list', name: panelName('panelName.plugins'), - icon: Puzzle, - component: PluginsList, + icon: Tags, + component: TagNav, availableOnPanel: false, }, { key: 'plugin-detail', name: panelName('panelName.pluginDetail'), icon: Puzzle, - component: PluginDetail, + component: StorePanel, availableOnPanel: false, }, ]; diff --git a/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts index 1b7c4846..00f8f89d 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts +++ b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts @@ -55,6 +55,8 @@ export type PluginItem = { prefix: string; label: string; summary: string; + /** What it is for — the store's filter. `[]` for a plugin that declares none. */ + tags: string[]; /** A lucide NAME, and only when the plugin ships no artwork of its own. */ icon?: string; /** The plugin's own `assets/icon.png`, served from `/plugins//`. Present only while installed. */ @@ -130,6 +132,8 @@ export type AvailablePlugin = { prefix: string; label: string; summary: string; + /** What it is for. Same field and same meaning as on an installed plugin, so the grid can mix them. */ + tags: string[]; /** An absolute URL to the marketplace's copy — there is no local asset until it is installed. */ image?: string; /** A lucide NAME, for a catalogue entry that ships no artwork. Only when `image` is absent. */