plugins panel keyed (414 -> 383)
AvailableDetail was an implicit-return arrow, so it got a body to hold the hook.
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
type PluginItem,
|
||||
type PluginVerb,
|
||||
} from './usePlugins';
|
||||
import { useTranslation } from '../../locales';
|
||||
|
||||
// The right panel: one plugin, and its verbs — install, enable, disable, update, delete.
|
||||
//
|
||||
@@ -80,11 +81,12 @@ const Parts = ({ has }: { has: PluginItem['has'] }) => {
|
||||
* "Requires: nothing" row is noise on every other plugin's page.
|
||||
*/
|
||||
const Dependencies = ({ deps }: { deps: PluginDependencies }) => {
|
||||
const { t, tUi } = useTranslation();
|
||||
if (!deps.items.length) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-4 border-t border-duck-dark/10 pt-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-duck-dark/40">Needs on this machine</p>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-duck-dark/40">{t('plugins.needsOnThisMachine')}</p>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{deps.items.map((item) => (
|
||||
<li key={item.binary} className="flex gap-2 text-sm">
|
||||
@@ -201,56 +203,59 @@ const AvailableDetail = ({
|
||||
steps: ActionEntry[];
|
||||
result: PluginActionResult | null;
|
||||
running: PluginVerb | null;
|
||||
}) => (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<AvailableGlyph plugin={plugin} />
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-lg font-semibold text-duck-dark">{plugin.label}</h2>
|
||||
<p className="mt-0.5 text-sm text-duck-dark/60">{plugin.summary}</p>
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<AvailableGlyph plugin={plugin} />
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-lg font-semibold text-duck-dark">{plugin.label}</h2>
|
||||
<p className="mt-0.5 text-sm text-duck-dark/60">{plugin.summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 border-t border-duck-dark/10 pt-4">
|
||||
<Row label="Publisher">{plugin.publisher}</Row>
|
||||
<Row label="Version">{plugin.version}</Row>
|
||||
<Row label="Needs platform">{plugin.platform}</Row>
|
||||
<Row label="Would mount">
|
||||
<code>/api{plugin.prefix}</code>
|
||||
</Row>
|
||||
<Row label="Ships">
|
||||
<Parts has={plugin.has} />
|
||||
</Row>
|
||||
<Row label="Permissions">
|
||||
{plugin.permissions.length ? plugin.permissions.map((p) => p.key).join(', ') : 'none'}
|
||||
</Row>
|
||||
{/* Shown because installing this runs `git clone` against it. An owner agreeing to put code on
|
||||
<div className="mt-5 border-t border-duck-dark/10 pt-4">
|
||||
<Row label={t('plugins.publisher')}>{plugin.publisher}</Row>
|
||||
<Row label={t('plugins.version')}>{plugin.version}</Row>
|
||||
<Row label={t('plugins.needsPlatform')}>{plugin.platform}</Row>
|
||||
<Row label={t('plugins.wouldMount')}>
|
||||
<code>/api{plugin.prefix}</code>
|
||||
</Row>
|
||||
<Row label={t('plugins.ships')}>
|
||||
<Parts has={plugin.has} />
|
||||
</Row>
|
||||
<Row label={t('plugins.permissions')}>
|
||||
{plugin.permissions.length ? plugin.permissions.map((p) => p.key).join(', ') : 'none'}
|
||||
</Row>
|
||||
{/* Shown because installing this runs `git clone` against it. An owner agreeing to put code on
|
||||
their machine should be able to read where it comes from without opening a config file. */}
|
||||
<Row label="Source">
|
||||
<code className="break-all text-xs">{plugin.source}</code>
|
||||
</Row>
|
||||
<Row label={t('plugins.source')}>
|
||||
<code className="break-all text-xs">{plugin.source}</code>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<Dependencies deps={plugin.dependencies} />
|
||||
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
tone="primary"
|
||||
disabled={busy || (!plugin.dependencies.satisfied && !plugin.dependencies.canInstall)}
|
||||
onClick={onInstall}
|
||||
>
|
||||
{t('plugins.install')}
|
||||
</Button>
|
||||
<p className="mt-2 text-xs text-duck-dark/50">
|
||||
{t('plugins.clonesInto')} <code>plugins/{plugin.appName}</code>, then installs it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ActionLog steps={steps} result={result} running={running} />
|
||||
|
||||
<Readme appName={plugin.appName} />
|
||||
</div>
|
||||
|
||||
<Dependencies deps={plugin.dependencies} />
|
||||
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
tone="primary"
|
||||
disabled={busy || (!plugin.dependencies.satisfied && !plugin.dependencies.canInstall)}
|
||||
onClick={onInstall}
|
||||
>
|
||||
Install
|
||||
</Button>
|
||||
<p className="mt-2 text-xs text-duck-dark/50">
|
||||
Clones the repository into <code>plugins/{plugin.appName}</code>, then installs it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ActionLog steps={steps} result={result} running={running} />
|
||||
|
||||
<Readme appName={plugin.appName} />
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* What the marketplace says about a plugin, in the pane where somebody decides whether to install it.
|
||||
@@ -317,6 +322,7 @@ const DeletePlugin = ({
|
||||
busy: boolean;
|
||||
onDelete: (dropData: boolean) => void;
|
||||
}) => {
|
||||
const { t, tUi } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [dropData, setDropData] = useState(false);
|
||||
|
||||
@@ -334,7 +340,7 @@ const DeletePlugin = ({
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
@@ -360,7 +366,7 @@ const DeletePlugin = ({
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="text-xs text-duck-dark">
|
||||
<span className="font-medium">Also delete all its data</span>
|
||||
<span className="font-medium">{t('plugins.deleteData')}</span>
|
||||
<span className="mt-0.5 block text-duck-dark/60">
|
||||
Drops every table this plugin owns. Without this its rows stay, and reinstalling later finds them
|
||||
where they were.
|
||||
@@ -368,12 +374,12 @@ const DeletePlugin = ({
|
||||
</span>
|
||||
</label>
|
||||
) : (
|
||||
<p className="text-xs text-duck-dark/50">This plugin declares no tables, so there is no data to keep.</p>
|
||||
<p className="text-xs text-duck-dark/50">{t('plugins.noTables')}</p>
|
||||
)}
|
||||
|
||||
<DialogFooter className="mt-2">
|
||||
<Button disabled={busy} onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
{tUi('button.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
tone="danger"
|
||||
@@ -383,7 +389,7 @@ const DeletePlugin = ({
|
||||
onDelete(dropData);
|
||||
}}
|
||||
>
|
||||
{dropData ? 'Delete it and its data' : `Delete ${plugin.label}`}
|
||||
{dropData ? 'Delete it and its data' : t('plugins.deleteNamed', { name: plugin.label })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -393,6 +399,7 @@ const DeletePlugin = ({
|
||||
};
|
||||
|
||||
export const PluginDetail = () => {
|
||||
const { t, tUi } = useTranslation();
|
||||
const { plugins, available, steps, result, running, run } = usePlugins();
|
||||
const [params] = useSearchParams();
|
||||
const selected = params.get('selected');
|
||||
@@ -417,7 +424,7 @@ export const PluginDetail = () => {
|
||||
}
|
||||
|
||||
if (!plugin) {
|
||||
return <div className="p-6 text-sm text-duck-dark/50">Select a plugin.</div>;
|
||||
return <div className="p-6 text-sm text-duck-dark/50">{t('plugins.selectOne')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -431,22 +438,22 @@ export const PluginDetail = () => {
|
||||
</div>
|
||||
|
||||
<div className="mt-5 border-t border-duck-dark/10 pt-4">
|
||||
<Row label="Mounts at">
|
||||
<Row label={t('plugins.mountsAt')}>
|
||||
<code>/api{plugin.prefix}</code>
|
||||
</Row>
|
||||
<Row label="Publisher">{plugin.publisher}</Row>
|
||||
<Row label="Version">
|
||||
<Row label={t('plugins.publisher')}>{plugin.publisher}</Row>
|
||||
<Row label={t('plugins.version')}>
|
||||
{plugin.version}
|
||||
{plugin.outdated ? (
|
||||
<span className="ml-2 text-busy-ink">on disk — installed {plugin.installedVersion}</span>
|
||||
) : null}
|
||||
</Row>
|
||||
<Row label="Needs platform">{plugin.platform}</Row>
|
||||
<Row label="Ships">
|
||||
<Row label={t('plugins.needsPlatform')}>{plugin.platform}</Row>
|
||||
<Row label={t('plugins.ships')}>
|
||||
<Parts has={plugin.has} />
|
||||
</Row>
|
||||
{plugin.has.sidecar ? (
|
||||
<Row label="Sidecar">
|
||||
<Row label={t('plugins.sidecar')}>
|
||||
{/* Enabled but not online is the state worth naming: the plugin is switched on and its
|
||||
process is not running, which is broken rather than off. */}
|
||||
<span
|
||||
@@ -460,7 +467,7 @@ export const PluginDetail = () => {
|
||||
</span>
|
||||
</Row>
|
||||
) : null}
|
||||
<Row label="Permissions">
|
||||
<Row label={t('plugins.permissions')}>
|
||||
{plugin.permissions.length
|
||||
? plugin.permissions.map((p) => `${p.key}${p.ownerOnly ? ' (owner only)' : ''}`).join(', ')
|
||||
: 'none — reachable by anyone who can reach the platform'}
|
||||
@@ -494,7 +501,7 @@ export const PluginDetail = () => {
|
||||
disabled={busy || (!plugin.dependencies.satisfied && !plugin.dependencies.canInstall)}
|
||||
onClick={() => run('install', plugin.appName)}
|
||||
>
|
||||
Install
|
||||
{t('plugins.install')}
|
||||
</Button>
|
||||
) : null}
|
||||
{!plugin.installed ? (
|
||||
@@ -508,11 +515,11 @@ export const PluginDetail = () => {
|
||||
<>
|
||||
{plugin.enabled ? (
|
||||
<Button disabled={busy} onClick={() => run('disable', plugin.appName)}>
|
||||
Disable
|
||||
{t('plugins.disable')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button tone="primary" disabled={busy} onClick={() => run('enable', plugin.appName)}>
|
||||
Enable
|
||||
{t('plugins.enable')}
|
||||
</Button>
|
||||
)}
|
||||
{/* Always offered, not gated on `outdated`. That flag compares the manifest's version to the
|
||||
@@ -521,7 +528,7 @@ export const PluginDetail = () => {
|
||||
which cannot fetch: the directory exists, so the clone is skipped and only the apply
|
||||
steps re-ran. `update` pulls first. */}
|
||||
<Button disabled={busy} onClick={() => run('update', plugin.appName)}>
|
||||
{plugin.outdated ? `Update to ${plugin.version}` : 'Update'}
|
||||
{plugin.outdated ? t('plugins.updateTo', { version: plugin.version }) : 'Update'}
|
||||
</Button>
|
||||
{/* Absent while enabled — disable first. `DeletePlugin` decides that itself rather than being
|
||||
conditioned here, so the rule lives next to the confirmation that explains it. */}
|
||||
|
||||
@@ -2,6 +2,7 @@ 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.
|
||||
//
|
||||
@@ -17,11 +18,12 @@ const Status = ({ plugin }: { plugin: PluginItem }) => {
|
||||
};
|
||||
|
||||
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">Loading…</div>;
|
||||
if (isLoading) return <div className="p-4 text-sm text-duck-dark/50">{t('plugins.loading')}</div>;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
@@ -34,11 +36,13 @@ export const PluginsList = () => {
|
||||
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">
|
||||
{/* "On this machine", not "Installed" — the list is every plugin DIRECTORY, and an uninstalled
|
||||
{/* 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">On this machine</span>
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-duck-dark/40">
|
||||
{t('plugins.onThisMachine')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void updateAll()}
|
||||
@@ -73,7 +77,7 @@ export const PluginsList = () => {
|
||||
|
||||
{plugins.length === 0 && available.length === 0 && broken.length === 0 ? (
|
||||
<div className="p-4 text-sm text-duck-dark/50">
|
||||
No plugins in <code>plugins/</code> yet.
|
||||
{t('plugins.noPluginsIn')} <code>plugins/</code> yet.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -113,7 +117,9 @@ export const PluginsList = () => {
|
||||
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">Available</span>
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-duck-dark/40">
|
||||
{t('plugins.available')}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{available.map((plugin) => (
|
||||
|
||||
@@ -188,5 +188,30 @@
|
||||
"extractedTo": "Extracted to “{{name}}”",
|
||||
"downloadInstead": "Download instead",
|
||||
"archiveFile": "Archive file"
|
||||
},
|
||||
"plugins": {
|
||||
"loading": "Loading…",
|
||||
"onThisMachine": "On this machine",
|
||||
"noPluginsIn": "No plugins in",
|
||||
"available": "Available",
|
||||
"needsOnThisMachine": "Needs on this machine",
|
||||
"install": "Install",
|
||||
"clonesInto": "Clones the repository into",
|
||||
"deleteData": "Also delete all its data",
|
||||
"noTables": "This plugin declares no tables, so there is no data to keep.",
|
||||
"selectOne": "Select a plugin.",
|
||||
"disable": "Disable",
|
||||
"enable": "Enable",
|
||||
"publisher": "Publisher",
|
||||
"version": "Version",
|
||||
"needsPlatform": "Needs platform",
|
||||
"wouldMount": "Would mount",
|
||||
"ships": "Ships",
|
||||
"permissions": "Permissions",
|
||||
"source": "Source",
|
||||
"mountsAt": "Mounts at",
|
||||
"sidecar": "Sidecar",
|
||||
"deleteNamed": "Delete {{name}}",
|
||||
"updateTo": "Update to {{version}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,5 +188,30 @@
|
||||
"extractedTo": "Extraído para “{{name}}”",
|
||||
"downloadInstead": "Descarregar em vez disso",
|
||||
"archiveFile": "Ficheiro de arquivo"
|
||||
},
|
||||
"plugins": {
|
||||
"loading": "A carregar…",
|
||||
"onThisMachine": "Nesta máquina",
|
||||
"noPluginsIn": "Sem plugins em",
|
||||
"available": "Disponíveis",
|
||||
"needsOnThisMachine": "Requisitos nesta máquina",
|
||||
"install": "Instalar",
|
||||
"clonesInto": "Clona o repositório para",
|
||||
"deleteData": "Eliminar também todos os seus dados",
|
||||
"noTables": "Este plugin não declara tabelas, pelo que não há dados a guardar.",
|
||||
"selectOne": "Selecione um plugin.",
|
||||
"disable": "Desativar",
|
||||
"enable": "Ativar",
|
||||
"publisher": "Editor",
|
||||
"version": "Versão",
|
||||
"needsPlatform": "Requer a plataforma",
|
||||
"wouldMount": "Montaria em",
|
||||
"ships": "Inclui",
|
||||
"permissions": "Permissões",
|
||||
"source": "Origem",
|
||||
"mountsAt": "Monta em",
|
||||
"sidecar": "Sidecar",
|
||||
"deleteNamed": "Eliminar {{name}}",
|
||||
"updateTo": "Atualizar para {{version}}"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user