a Marketplace section, and the git field lives where its result appears
a new row above the tags, and where the store now opens. the tags say what a
plugin is FOR; this says what state the machine is in, which is the question
somebody opening a store asks first — narrowing by kind is what you do after.
THREE TABS, because there are genuinely three states and the old grid had two.
`Installed` is what runs. the other two are both "not installed" and are not the
same thing:
Official the catalogue — somewhere else, a download away.
Available already ON THIS MACHINE, not installed. where a plugin fetched from
a git URL lands, and where one deleted-but-not-purged sits.
collapsing those hid the entire point of fetching from a repository: you add
something and it is indistinguishable from twenty things you have not downloaded.
it opens on Installed rather than the catalogue. a store that opens on what it
can sell you is selling; this is mostly where somebody checks on something they
already have.
THE GIT FIELD MOVED INTO THE AVAILABLE TAB, which is the tab its result appears
in — and only shows there. floating above a filtered grid it was an action whose
outcome landed somewhere off screen, which is how the first version installed a
plugin with no feedback at all. same mistake, one layer up.
the tag grid is untouched and still what a filter shows. two views because they
answer different questions: "what have I got" wants installed-versus-not, "what
is there for my calendar" wants everything of that kind whatever its state.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Check, Download, GitBranch, Store } from 'lucide-react';
|
||||
import { InstallFromUrl } from './InstallFromUrl';
|
||||
import type { AvailablePlugin, PluginItem } from './usePlugins';
|
||||
|
||||
/**
|
||||
* The Marketplace section: what this machine has, and what it could have.
|
||||
*
|
||||
* ── Three tabs, because there are genuinely three states ──
|
||||
*
|
||||
* `Installed` is what is running. The other two are both "not installed" and they are NOT the same
|
||||
* thing, which is the distinction the old single grid lost:
|
||||
*
|
||||
* `Official` the marketplace catalogue — somewhere else, one click and a download away.
|
||||
* `Available` already ON THIS MACHINE and not installed. A plugin fetched from a git URL lands
|
||||
* here, and so does one that was deleted without being purged.
|
||||
*
|
||||
* Collapsing those two hid the whole point of fetching from a repository: you add something, and it is
|
||||
* indistinguishable from twenty things you have not downloaded yet.
|
||||
*
|
||||
* ── Why the git field lives in Available ──
|
||||
*
|
||||
* That is the tab its result appears in. A button that adds something to the list you are looking at is
|
||||
* legible; the same button floating above a filtered grid was not, which is how the first version
|
||||
* managed to install a plugin with no feedback at all.
|
||||
*/
|
||||
|
||||
type Entry = (PluginItem | AvailablePlugin) & { installed?: boolean };
|
||||
type TabKey = 'installed' | 'official' | 'available';
|
||||
|
||||
export const MarketplaceOverview = ({
|
||||
installed,
|
||||
official,
|
||||
available,
|
||||
renderCard,
|
||||
onFetch,
|
||||
}: {
|
||||
installed: Entry[];
|
||||
official: Entry[];
|
||||
available: Entry[];
|
||||
renderCard: (entry: Entry) => ReactNode;
|
||||
onFetch: (url: string) => Promise<{ ok: boolean; appName?: string; error?: string }>;
|
||||
}) => {
|
||||
// Opens on what you HAVE. A store that opens on the catalogue is selling; this is mostly a place
|
||||
// people come to check on something they already installed.
|
||||
const [tab, setTab] = useState<TabKey>('installed');
|
||||
|
||||
const tabs: { key: TabKey; label: string; icon: typeof Check; items: Entry[] }[] = [
|
||||
{ key: 'installed', label: 'Installed', icon: Check, items: installed },
|
||||
{ key: 'official', label: 'Official', icon: Store, items: official },
|
||||
{ key: 'available', label: 'Available', icon: Download, items: available },
|
||||
];
|
||||
const current = tabs.find((t) => t.key === tab) ?? tabs[0]!;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* One ruled row, same border as the nav beside it, so the two panels read as one screen. */}
|
||||
<div className="flex shrink-0 items-center gap-1 border-b border-border px-3">
|
||||
{tabs.map(({ key, label, icon: Icon, items }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setTab(key)}
|
||||
className={`flex items-center gap-1.5 border-b-2 px-3 py-2.5 text-sm transition-colors ${
|
||||
tab === key ? 'border-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0" />
|
||||
{label}
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{items.length}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Only on the tab it affects. Elsewhere it would be an action whose result appears somewhere
|
||||
you cannot see, which is the mistake this whole section is correcting. */}
|
||||
{tab === 'available' && (
|
||||
<div className="ml-auto flex min-w-0 flex-1 justify-end py-1.5 pl-3">
|
||||
<InstallFromUrl onFetch={onFetch} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto p-4">
|
||||
{current.items.length === 0 ? (
|
||||
<Empty tab={current.key} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{current.items.map((entry) => renderCard(entry))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Says what the tab is FOR, not just that it is empty — an empty tab is otherwise a dead end. */
|
||||
const Empty = ({ tab }: { tab: TabKey }) => {
|
||||
const text =
|
||||
tab === 'installed'
|
||||
? 'Nothing installed yet. Official has the catalogue.'
|
||||
: tab === 'official'
|
||||
? 'The marketplace catalogue is empty or unreachable.'
|
||||
: 'Nothing waiting to be installed. Add one from a Git repository above.';
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-8 text-center">
|
||||
<GitBranch className="size-5 opacity-30" />
|
||||
<p className="text-sm text-muted-foreground">{text}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,7 @@ 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';
|
||||
import { InstallFromUrl } from './InstallFromUrl';
|
||||
import { MarketplaceOverview } from './MarketplaceOverview';
|
||||
|
||||
/**
|
||||
* The store: cards in a grid, filtered by the tag on the left.
|
||||
@@ -85,7 +85,28 @@ export const PluginGrid = () => {
|
||||
const [params] = useSearchParams();
|
||||
const tag = params.get('tag');
|
||||
|
||||
if (isLoading) return <div className="p-4 text-sm text-duck-dark/50">Loading…</div>;
|
||||
if (isLoading) return <div className="p-4 text-sm text-muted-foreground">Loading…</div>;
|
||||
|
||||
/*
|
||||
No tag means the Marketplace section — the state of this machine, in three tabs.
|
||||
|
||||
The tag grid below is unchanged and still what a filter shows. Two views rather than one because
|
||||
they answer different questions: "what have I got" wants installed-versus-not, and "what is there
|
||||
for my calendar" wants everything of that kind together regardless of state.
|
||||
*/
|
||||
if (!tag) {
|
||||
const installed = plugins.filter((p) => p.installed);
|
||||
const onDisk = plugins.filter((p) => !p.installed);
|
||||
return (
|
||||
<MarketplaceOverview
|
||||
installed={installed}
|
||||
official={available}
|
||||
available={onDisk}
|
||||
onFetch={fetchFromUrl}
|
||||
renderCard={(entry) => <Card key={entry.appName} entry={entry} search={params.toString()} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -107,16 +128,11 @@ export const PluginGrid = () => {
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-4">
|
||||
{/* The count and the escape hatch share a row: the catalogue is the way in, and this is the
|
||||
exception beside it rather than an equal option above it. */}
|
||||
<div className="mb-3 flex items-center gap-3 text-xs text-duck-dark/40">
|
||||
<span className="shrink-0">
|
||||
{shown.length} plugin{shown.length === 1 ? '' : 's'}
|
||||
{tag && tag !== '__untagged' ? ` in ${tag}` : ''}
|
||||
</span>
|
||||
<div className="ml-auto flex min-w-0 flex-1 justify-end">
|
||||
<InstallFromUrl onFetch={fetchFromUrl} />
|
||||
</div>
|
||||
{/* No git field here any more: it lives in the Marketplace section's Available tab, which is where
|
||||
what it adds actually shows up. */}
|
||||
<div className="mb-3 text-xs text-muted-foreground">
|
||||
{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) => (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { Puzzle, AlertTriangle, Database, Package } from 'lucide-react';
|
||||
import { Puzzle, AlertTriangle, Database, Package, Store } from 'lucide-react';
|
||||
import { usePlugins, type OrphanTable } from './usePlugins';
|
||||
import { resolveIcon } from '../../utils/resolve-icon';
|
||||
|
||||
@@ -72,6 +72,25 @@ export const TagNav = () => {
|
||||
);
|
||||
|
||||
const untagged = all.filter((p) => !p.tags?.length).length;
|
||||
// On disk and not installed — a plugin fetched from a git URL lands here, which is why it is counted
|
||||
// beside the catalogue rather than folded into it.
|
||||
const notInstalled = plugins.filter((p) => !p.installed).length;
|
||||
|
||||
/**
|
||||
* The Marketplace section — a view, not a filter.
|
||||
*
|
||||
* `?tag=` narrows the same grid; this replaces it, so it clears the tag rather than setting one. It is
|
||||
* also the DEFAULT: arriving with neither parameter lands here, because "what do I have, and what can
|
||||
* I get" is the question somebody opening a store is asking, and the tag list is how you narrow it
|
||||
* afterwards.
|
||||
*/
|
||||
const overviewLink = () => {
|
||||
const next = new URLSearchParams(params);
|
||||
next.delete('tag');
|
||||
next.delete('selected');
|
||||
return { search: next.toString() ? `?${next}` : '' };
|
||||
};
|
||||
const onOverview = !active;
|
||||
|
||||
const link = (tag: string | null) => {
|
||||
const next = new URLSearchParams(params);
|
||||
@@ -108,7 +127,19 @@ export const TagNav = () => {
|
||||
return (
|
||||
<nav className="flex h-full flex-col overflow-auto">
|
||||
<div className="flex flex-col">
|
||||
{row('__all', link(null), Package, 'All', all.length, !active)}
|
||||
{/* Above the tags and separated from them: it answers a different question. The tags say what a
|
||||
plugin is FOR; this says what state this machine is in. */}
|
||||
<Link to={overviewLink()} className={`${rowClass(onOverview)} border-b-2`}>
|
||||
<Store className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm">Marketplace</span>
|
||||
<span className="block truncate text-xs text-muted-foreground tabular-nums">
|
||||
{plugins.filter((p) => p.installed).length} installed · {notInstalled + available.length} available
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{row('__all', link(null), Package, 'All', all.length, false)}
|
||||
{ordered.map(([tag, n]) =>
|
||||
row(tag, link(tag), resolveIcon(TAG_ICONS[tag] ?? 'Puzzle'), tag, n, active === tag),
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user