refactor(web): share the card head and page top on the Plugins page

The cards and the pages of the Plugins page repeated their head (icon, opening title, tags, one-liner) and their top (crumb, icon, actions); CardHead and DetailTop carry them once, which is what the duplication gate asked for. DOM and behavior are unchanged.
This commit is contained in:
Yichen Jiang committed 2026-09-16 22:01:40 +08:00
1 parent 62c66dad02
commit a63dd809a2
1 file changed
+85 -60
@@ -235,6 +235,52 @@ function packageStatus(pkg: PackageView): 'running' | 'disabled' | 'problem' {
return pkg.enabled ? 'running' : 'disabled'
}
/** The head every card shares: the pinwheel icon, the name that opens the page beside its tags, its one-liner, and what sits at the end. */
function CardHead({ title, t, onOpen, tags, description, end }: {
readonly title: string
readonly t: Translate
readonly onOpen: () => void
readonly tags?: ReactNode
readonly description: ReactNode
readonly end?: ReactNode
}): ReactNode {
return (
<div className={css.cardHead}>
<span className={css.cardIcon} aria-hidden="true"><IconPluginPinwheelOutline16 size={20} /></span>
<div className={css.cardMain}>
<div className={css.titleRow}>
<button type="button" className={`${css.cardTitle} ${css.cardOpen}`} aria-label={t('openDetail', { name: title })} onClick={onOpen}>{title}</button>
{tags}
</div>
{description === undefined ? null : <span className={css.cardDesc}>{description}</span>}
</div>
{end === undefined ? null : <div className={css.cardEnd}>{end}</div>}
</div>
)
}
/** The top every page shares: the crumb that leads back, then the icon with the page's actions at its right. */
function DetailTop({ crumbLabel, crumbText, onBack, icon, actions }: {
readonly crumbLabel: string
readonly crumbText: string
readonly onBack: () => void
readonly icon?: ReactNode
readonly actions?: ReactNode
}): ReactNode {
return (
<>
<button type="button" className={css.crumb} aria-label={crumbLabel} onClick={onBack}>
<IconChevronDownOutline14 className={css.crumbIcon} aria-hidden="true" />
<span>{crumbText}</span>
</button>
<div className={css.detailHead}>
<span className={css.cardIcon} aria-hidden="true">{icon ?? <IconPluginPinwheelOutline16 size={20} />}</span>
{actions}
</div>
</>
)
}
/** One package as a card that opens its page: its name, its one-liner, its tags, and its bundle switch. */
function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled }: {
readonly pkg: PackageView
@@ -253,20 +299,19 @@ function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled }: {
data-plugin-status={status}
{...highlighted ? { 'data-plugin-highlight': '' } : {}}
>
<div className={css.cardHead}>
<span className={css.cardIcon} aria-hidden="true"><IconPluginPinwheelOutline16 size={20} /></span>
<div className={css.cardMain}>
<div className={css.titleRow}>
<button type="button" className={`${css.cardTitle} ${css.cardOpen}`} aria-label={t('openDetail', { name: title })} onClick={onOpen}>{title}</button>
<CardHead
title={title}
t={t}
onOpen={onOpen}
tags={(
<>
{beta ? <Tag className={css.statusTag} tone="info">{t('statusBeta')}</Tag> : null}
{status === 'problem' ? <Tag className={css.statusTag} tone="danger">{t('statusProblem')}</Tag> : null}
</div>
{description === undefined ? null : <span className={css.cardDesc}>{description}</span>}
</div>
<div className={css.cardEnd}>
<EnableSwitch pkg={pkg} title={title} t={t} busy={busy} onSetEnabled={onSetEnabled} />
</div>
</div>
</>
)}
description={description}
end={<EnableSwitch pkg={pkg} title={title} t={t} busy={busy} onSetEnabled={onSetEnabled} />}
/>
</li>
)
}
@@ -283,15 +328,7 @@ function ItemCard({ item, t, onOpen, renderSlot }: {
}): ReactNode {
return (
<li className={`${css.card} ${css.cardLink}`} data-plugin-item={item.id}>
<div className={css.cardHead}>
<span className={css.cardIcon} aria-hidden="true"><IconPluginPinwheelOutline16 size={20} /></span>
<div className={css.cardMain}>
<div className={css.titleRow}>
<button type="button" className={`${css.cardTitle} ${css.cardOpen}`} aria-label={t('openDetail', { name: item.label })} onClick={onOpen}>{item.label}</button>
</div>
<span className={css.cardDesc}>{renderSlot('plugins.item', { view: 'summary' }, { only: item.id })}</span>
</div>
</div>
<CardHead title={item.label} t={t} onOpen={onOpen} description={renderSlot('plugins.item', { view: 'summary' }, { only: item.id })} />
</li>
)
}
@@ -305,13 +342,7 @@ function ItemDetail({ item, t, onBack, renderSlot }: {
}): ReactNode {
return (
<div className={css.detail} data-plugin-item-detail={item.id}>
<button type="button" className={css.crumb} aria-label={t('backToList')} onClick={onBack}>
<IconChevronDownOutline14 className={css.crumbIcon} aria-hidden="true" />
<span>{t('crumbRoot')}</span>
</button>
<div className={css.detailHead}>
<span className={css.cardIcon} aria-hidden="true"><IconPluginPinwheelOutline16 size={20} /></span>
</div>
<DetailTop crumbLabel={t('backToList')} crumbText={t('crumbRoot')} onBack={onBack} />
<div className={css.detailMain}>
<div className={css.titleRow}>
<h3 className={css.detailTitle}>{item.label}</h3>
@@ -340,13 +371,7 @@ function RowDetail({ pkg, row, t, onBack, renderSlot }: {
const key = rowConfigKey(pkg.name, row.rowId)
return (
<div className={css.detail} data-plugin-row-detail={key}>
<button type="button" className={css.crumb} aria-label={t('backToPackage', { name: title })} onClick={onBack}>
<IconChevronDownOutline14 className={css.crumbIcon} aria-hidden="true" />
<span>{title}</span>
</button>
<div className={css.detailHead}>
<span className={css.cardIcon} aria-hidden="true"><IconCordisPluginOutline14 size={20} /></span>
</div>
<DetailTop crumbLabel={t('backToPackage', { name: title })} crumbText={title} onBack={onBack} icon={<IconCordisPluginOutline14 size={20} />} />
<div className={css.detailMain}>
<div className={css.titleRow}>
<h3 className={css.detailTitle}>{row.rowId}</h3>
@@ -391,31 +416,31 @@ function PackageDetail({
const status = packageStatus(pkg)
return (
<div className={css.detail} data-plugin-detail={pkg.name}>
<button type="button" className={css.crumb} aria-label={t('backToList')} onClick={onBack}>
<IconChevronDownOutline14 className={css.crumbIcon} aria-hidden="true" />
<span>{t('crumbRoot')}</span>
</button>
<div className={css.detailHead}>
<span className={css.cardIcon} aria-hidden="true"><IconPluginPinwheelOutline16 size={20} /></span>
<div className={css.detailActions}>
{pkg.installed
? (
<Button
variant="outline"
size="sm"
className={css.danger}
icon={<IconTrashOutline16 size={13} />}
aria-label={t('uninstallLabel', { name: title })}
disabled={busy || pkg.readOnlyReason !== undefined}
onClick={onUninstall}
>
{t('uninstall')}
</Button>
)
: null}
<EnableSwitch pkg={pkg} title={title} t={t} busy={busy} onSetEnabled={onSetEnabled} />
</div>
</div>
<DetailTop
crumbLabel={t('backToList')}
crumbText={t('crumbRoot')}
onBack={onBack}
actions={(
<div className={css.detailActions}>
{pkg.installed
? (
<Button
variant="outline"
size="sm"
className={css.danger}
icon={<IconTrashOutline16 size={13} />}
aria-label={t('uninstallLabel', { name: title })}
disabled={busy || pkg.readOnlyReason !== undefined}
onClick={onUninstall}
>
{t('uninstall')}
</Button>
)
: null}
<EnableSwitch pkg={pkg} title={title} t={t} busy={busy} onSetEnabled={onSetEnabled} />
</div>
)}
/>
<div className={css.detailMain}>
<div className={css.titleRow}>
<h3 className={css.detailTitle}>{title}</h3>