the marketplace is browsed by tag, not scrolled
/marketplace was a list of every plugin on the left and a preview on the right. that is a fine control panel and a poor shop: twenty-one rows sorted by nothing anybody cares about, where "something for my calendar" means reading all of them. now: tags down the left with counts, cards in a grid on the right, and clicking a card takes over the panel with a back link. three things about the shape. BOTH the filter and the selection are in the URL — ?tag= and ?selected=. a filter in component state is lost on Back, so filtering, opening a plugin and pressing Back would drop you in an unfiltered grid having lost your place. browser Back works for free for the same reason. selecting a tag also CLEARS the selection, because a detail for a plugin outside the new filter is a page the grid you are now looking at cannot return to. the detail REPLACES the grid rather than sitting beside it. a grid needs the width and a detail beside one gets half of it; neither reads well. the back link drops ?selected= and keeps ?tag=, which is the entire point of putting the filter there. the panel KEYS are unchanged though both components were replaced. a stored layout names them, and renaming would drop everyone back to the default arrangement for nothing — the key identifies a slot, not an implementation. two things found on the way. PluginItem and AvailablePlugin had no tags, so the api now carries them on both branches — from the manifest for a plugin that is here, from the catalogue for one that is not, same field either way. and the screen used useDashboardState, which persists the whole tree: the panels this ships would never have reached anyone who had opened it before. useAuthoredLayout takes the structure from source and persists only sizes, which is what an authored screen wants. 21/22 -> 22/78 as well: the left panel holds seven tags now, not twenty-one rows, and the width is worth more to the grid. verified: bundle contains the new panels, api returns tags for all 21 with none missing the field. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -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 (
|
||||
<Link
|
||||
to={{ search: `?${next}` }}
|
||||
className="group flex flex-col gap-2 rounded-xl border border-duck-dark/10 bg-white/40 p-3 transition-colors hover:border-duck-teal/40 hover:bg-duck-teal/5"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{entry.image ? (
|
||||
<img src={entry.image} alt="" className="size-11 shrink-0 rounded-xl object-cover" />
|
||||
) : (
|
||||
<div
|
||||
className="flex size-11 shrink-0 items-center justify-center rounded-xl"
|
||||
style={{ backgroundColor: entry.color }}
|
||||
>
|
||||
<Icon className="size-5 text-white" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{entry.label}</div>
|
||||
{/* 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 ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-success">
|
||||
<Check className="size-3" /> Installed
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-duck-dark/40">
|
||||
<Download className="size-3" /> Available
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="line-clamp-2 text-xs leading-relaxed text-duck-dark/60">{entry.summary}</p>
|
||||
{!!entry.tags?.length && (
|
||||
<div className="mt-auto flex flex-wrap gap-1">
|
||||
{entry.tags.map((tag) => (
|
||||
<span key={tag} className="rounded-full bg-duck-dark/5 px-2 py-0.5 text-[10px] text-duck-dark/50">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export const PluginGrid = () => {
|
||||
const { plugins, available, isLoading } = usePlugins();
|
||||
const [params] = useSearchParams();
|
||||
const tag = params.get('tag');
|
||||
|
||||
if (isLoading) return <div className="p-4 text-sm text-duck-dark/50">Loading…</div>;
|
||||
|
||||
// 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 (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-8 text-center">
|
||||
<p className="text-sm text-duck-dark/50">Nothing tagged {tag}.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<div className="mb-3 text-xs text-duck-dark/40">
|
||||
{shown.length} plugin{shown.length === 1 ? '' : 's'}
|
||||
{tag && tag !== '__untagged' ? ` in ${tag}` : ''}
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-3">
|
||||
{shown.map((entry) => (
|
||||
<Card key={entry.appName} entry={entry} search={params.toString()} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 `<Link>`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 <span className="text-xs text-duck-dark/40">not installed</span>;
|
||||
if (!plugin.enabled) return <span className="text-xs text-busy-ink">disabled</span>;
|
||||
if (plugin.outdated) return <span className="text-xs text-busy-ink">update available</span>;
|
||||
return <span className="text-xs text-success">enabled</span>;
|
||||
};
|
||||
|
||||
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 <div className="p-4 text-sm text-duck-dark/50">{t('plugins.loading')}</div>;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
{/* 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 ? (
|
||||
<div className="flex items-center justify-between gap-2 border-b border-duck-dark/5 px-3 py-2">
|
||||
{/* 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. */}
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-duck-dark/40">
|
||||
{t('plugins.onThisMachine')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void updateAll()}
|
||||
disabled={updatingAll}
|
||||
className="rounded-md border border-duck-dark/15 px-2 py-1 text-xs font-medium text-duck-dark transition-colors hover:bg-duck-dark/5 disabled:opacity-50"
|
||||
>
|
||||
{updatingAll ? 'Updating…' : 'Update all'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{updateAllResult ? (
|
||||
<div className="border-b border-duck-dark/5 px-3 py-2 text-xs">
|
||||
<span className="text-duck-dark/60">
|
||||
{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}`
|
||||
: ''}
|
||||
</span>
|
||||
{/* 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) => (
|
||||
<div key={f.appName} className="mt-0.5 text-destructive">
|
||||
{f.appName}: {f.error}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{plugins.length === 0 && available.length === 0 && broken.length === 0 ? (
|
||||
<div className="p-4 text-sm text-duck-dark/50">
|
||||
{t('plugins.noPluginsIn')} <code>plugins/</code> yet.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{plugins.map((plugin) => (
|
||||
<Link
|
||||
key={plugin.appName}
|
||||
to={`/marketplace?selected=${encodeURIComponent(plugin.appName)}`}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 border-b border-duck-dark/5 transition-colors ${
|
||||
selected === plugin.appName ? 'bg-duck-teal/10' : 'hover:bg-duck-dark/5'
|
||||
}`}
|
||||
>
|
||||
{/* 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 ? (
|
||||
<img src={plugin.image} alt="" className="h-5 w-5 shrink-0 object-contain" />
|
||||
) : (
|
||||
(() => {
|
||||
const Glyph = plugin.icon ? resolveIcon(plugin.icon) : Puzzle;
|
||||
return <Glyph className="h-4 w-4 shrink-0" style={{ color: plugin.color }} />;
|
||||
})()
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-duck-dark">{plugin.label}</div>
|
||||
<div className="truncate text-xs text-duck-dark/50">{plugin.prefix}</div>
|
||||
</div>
|
||||
<Status plugin={plugin} />
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* 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 ? (
|
||||
<div className="border-b border-duck-dark/5 bg-duck-dark/[0.02] px-3 py-1.5">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-duck-dark/40">
|
||||
{t('plugins.available')}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{available.map((plugin) => (
|
||||
<Link
|
||||
key={`available-${plugin.appName}`}
|
||||
to={`/marketplace?selected=${encodeURIComponent(plugin.appName)}`}
|
||||
className={`flex items-center gap-3 border-b border-duck-dark/5 px-3 py-2.5 transition-colors ${
|
||||
selected === plugin.appName ? 'bg-duck-teal/10' : 'hover:bg-duck-dark/5'
|
||||
}`}
|
||||
>
|
||||
{/* 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 ? (
|
||||
<img src={plugin.image} alt="" className="h-5 w-5 shrink-0 object-contain" />
|
||||
) : (
|
||||
<Puzzle className="h-4 w-4 shrink-0" style={{ color: plugin.color }} />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-duck-dark">{plugin.label}</div>
|
||||
<div className="truncate text-xs text-duck-dark/50">{plugin.publisher}</div>
|
||||
</div>
|
||||
<span className="text-xs text-duck-dark/40">available</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* 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) => (
|
||||
<div key={b.appName} className="flex items-start gap-3 px-3 py-2.5 border-b border-duck-dark/5">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-duck-dark">{b.appName}</div>
|
||||
<div className="text-xs text-destructive">{b.error}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Orphans orphans={orphans} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="border-t border-duck-dark/10 px-3 py-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Database className="mt-0.5 h-3.5 w-3.5 shrink-0 text-busy-ink" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium text-duck-dark/70">
|
||||
{orphans.length} table{orphans.length === 1 ? '' : 's'} with no plugin on this machine
|
||||
</div>
|
||||
<ul className="mt-1.5 space-y-1">
|
||||
{orphans.map((table) => (
|
||||
<li key={table.name} className="font-mono text-[11px] text-duck-dark/50">
|
||||
{table.name}
|
||||
<span className="ml-1.5 font-sans text-duck-dark/35">
|
||||
~{table.rows.toLocaleString()} row{table.rows === 1 ? '' : 's'}
|
||||
{table.likelyOwner ? ` · looks like ${table.likelyOwner}, which is not installed here` : ''}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-1.5 text-[11px] text-duck-dark/35">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 <PluginGrid />;
|
||||
|
||||
const back = new URLSearchParams(params);
|
||||
back.delete('selected');
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex items-center gap-2 border-b border-duck-dark/5 px-3 py-2">
|
||||
<Link
|
||||
to={{ search: back.toString() ? `?${back}` : '' }}
|
||||
className="flex items-center gap-1.5 rounded-lg px-2 py-1 text-sm text-duck-dark/60 transition-colors hover:bg-duck-dark/5 hover:text-duck-dark"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
{params.get('tag') && params.get('tag') !== '__untagged' ? params.get('tag') : 'All plugins'}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<PluginDetail />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<string, string> = {
|
||||
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 <div className="p-4 text-sm text-duck-dark/50">Loading…</div>;
|
||||
|
||||
// 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<string, number>();
|
||||
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 (
|
||||
<nav className="flex h-full flex-col overflow-auto">
|
||||
<div className="flex flex-col gap-0.5 p-2">
|
||||
<Link
|
||||
to={link(null)}
|
||||
className={`${ROW} ${!active ? 'bg-duck-teal/10 font-medium text-duck-teal' : 'text-duck-dark/70 hover:bg-duck-dark/5'}`}
|
||||
>
|
||||
<Package className="size-4 shrink-0" />
|
||||
<span className="flex-1">All</span>
|
||||
<span className="text-xs tabular-nums opacity-60">{all.length}</span>
|
||||
</Link>
|
||||
|
||||
{ordered.map(([tag, n]) => {
|
||||
const Icon = resolveIcon(TAG_ICONS[tag] ?? 'Puzzle');
|
||||
return (
|
||||
<Link
|
||||
key={tag}
|
||||
to={link(tag)}
|
||||
className={`${ROW} ${active === tag ? 'bg-duck-teal/10 font-medium text-duck-teal' : 'text-duck-dark/70 hover:bg-duck-dark/5'}`}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<span className="flex-1 truncate">{tag}</span>
|
||||
<span className="text-xs tabular-nums opacity-60">{n}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Shown only when there are any. A plugin with no tags is reachable from All but from no filter,
|
||||
and that is worth being able to SEE rather than discovering by its absence. */}
|
||||
{untagged > 0 && (
|
||||
<Link
|
||||
to={link('__untagged')}
|
||||
className={`${ROW} ${active === '__untagged' ? 'bg-duck-teal/10 font-medium text-duck-teal' : 'text-duck-dark/50 hover:bg-duck-dark/5'}`}
|
||||
>
|
||||
<Puzzle className="size-4 shrink-0" />
|
||||
<span className="flex-1 truncate">Untagged</span>
|
||||
<span className="text-xs tabular-nums opacity-60">{untagged}</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-1 border-t border-duck-dark/5 p-2 text-xs">
|
||||
{/* Update-all belongs here rather than in a plugin's detail: it is the one action about all of
|
||||
them at once. Hidden when nothing is installed, where it would read as a broken button. */}
|
||||
{plugins.length > 0 && (
|
||||
<button
|
||||
onClick={() => updateAll()}
|
||||
disabled={updatingAll}
|
||||
className="rounded-lg px-3 py-2 text-left text-duck-dark/60 hover:bg-duck-dark/5 disabled:opacity-50"
|
||||
>
|
||||
{updatingAll ? 'Updating…' : 'Update all'}
|
||||
</button>
|
||||
)}
|
||||
{broken.length > 0 && (
|
||||
<div className="flex items-start gap-2 px-3 py-1.5 text-busy-ink">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>
|
||||
{broken.length} broken plugin{broken.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{orphans.length > 0 && (
|
||||
<div className="flex items-start gap-2 px-3 py-1.5 text-duck-dark/50">
|
||||
<Database className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>
|
||||
{orphans.length} orphaned table{orphans.length === 1 ? '' : 's'} from removed plugins
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export type { OrphanTable };
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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/<appName>/`. 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. */
|
||||
|
||||
Reference in New Issue
Block a user