@meith/web 0.24.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/admin/plugins/page.tsx +26 -12
- package/app/admin/themes/page.tsx +26 -13
- package/package.json +48 -48
- package/src/server/container.ts +4 -0
- package/src/server/marketplace-actions.ts +2 -2
- package/src/server/marketplace-admin.ts +24 -116
- package/src/server/theme-admin.ts +2 -0
- package/src/server/upgrade-notice.ts +1 -1
- package/app/admin/api/marketplace/screenshot/route.ts +0 -63
- package/app/admin/plugins/browse/page.tsx +0 -70
- package/app/admin/themes/browse/page.tsx +0 -70
- package/src/components/admin/marketplace-listing.tsx +0 -114
- package/src/view/marketplace-panel.ts +0 -51
|
@@ -2,15 +2,16 @@ import type { Metadata } from 'next'
|
|
|
2
2
|
|
|
3
3
|
import { cn } from '@meith/ui'
|
|
4
4
|
|
|
5
|
+
import { MarketplaceRefreshForm } from '@/components/admin/marketplace-forms'
|
|
5
6
|
import { PluginEnableForm, PluginHealthResetForm } from '@/components/admin/plugin-forms'
|
|
6
7
|
import { PANEL_CARD, PANEL_LIST, PANEL_NOTE, PANEL_ROW } from '@/components/shell/panel-list'
|
|
7
8
|
import { PanelPage } from '@/components/shell/panel-page'
|
|
8
|
-
import { ViewTabs } from '@/components/shell/view-tabs'
|
|
9
9
|
import { adminPageContext } from '@/server/admin'
|
|
10
10
|
import { getTranslator, tr } from '@/server/i18n'
|
|
11
|
+
import { marketplaceUpdates } from '@/server/marketplace-admin'
|
|
11
12
|
import { hookListeners, pluginInventory } from '@/server/plugin-admin'
|
|
12
|
-
import { pluginFormsCopy } from '@/view/admin-panel-copy'
|
|
13
|
-
import {
|
|
13
|
+
import { marketplaceFormsCopy, pluginFormsCopy } from '@/view/admin-panel-copy'
|
|
14
|
+
import { formatTime } from '@/view/time'
|
|
14
15
|
|
|
15
16
|
export async function generateMetadata(): Promise<Metadata> {
|
|
16
17
|
return { title: await tr('page.plugins') }
|
|
@@ -23,6 +24,9 @@ export default async function AdminPluginsPage() {
|
|
|
23
24
|
const { plugins, migrationsKnown } = await pluginInventory(t)
|
|
24
25
|
const listeners = hookListeners()
|
|
25
26
|
const copy = pluginFormsCopy(t)
|
|
27
|
+
const marketplaceCopy = marketplaceFormsCopy(t)
|
|
28
|
+
const updates = await marketplaceUpdates('plugin')
|
|
29
|
+
const now = new Date()
|
|
26
30
|
|
|
27
31
|
return (
|
|
28
32
|
<PanelPage
|
|
@@ -34,15 +38,18 @@ export default async function AdminPluginsPage() {
|
|
|
34
38
|
</>
|
|
35
39
|
}
|
|
36
40
|
>
|
|
37
|
-
<
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
<section className="flex flex-wrap items-center justify-between gap-3">
|
|
42
|
+
<p className={PANEL_NOTE}>
|
|
43
|
+
{updates.unreachable
|
|
44
|
+
? t.t('adminMarketplace.unreachable')
|
|
45
|
+
: updates.hasEverFetched && updates.fetchedAt !== null
|
|
46
|
+
? t.t('adminMarketplace.lastChecked', {
|
|
47
|
+
time: formatTime(updates.fetchedAt, now, t).label,
|
|
48
|
+
})
|
|
49
|
+
: t.t('adminMarketplace.neverChecked')}
|
|
50
|
+
</p>
|
|
51
|
+
<MarketplaceRefreshForm copy={marketplaceCopy} />
|
|
52
|
+
</section>
|
|
46
53
|
|
|
47
54
|
{plugins.length === 0 ? (
|
|
48
55
|
<p className={PANEL_NOTE}>{t.t('page.no-plugins-configured-this-board')}</p>
|
|
@@ -50,6 +57,7 @@ export default async function AdminPluginsPage() {
|
|
|
50
57
|
<ul className={PANEL_LIST}>
|
|
51
58
|
{plugins.map((plugin) => {
|
|
52
59
|
const pending = plugin.migrations.filter((migration) => !migration.applied).length
|
|
60
|
+
const latestVersion = updates.latestByKey.get(plugin.key) ?? null
|
|
53
61
|
|
|
54
62
|
return (
|
|
55
63
|
<li key={plugin.key} className={PANEL_ROW}>
|
|
@@ -98,6 +106,12 @@ export default async function AdminPluginsPage() {
|
|
|
98
106
|
</span>
|
|
99
107
|
)}
|
|
100
108
|
|
|
109
|
+
{latestVersion !== null && (
|
|
110
|
+
<span className="w-fit rounded-full border border-border bg-accent px-2 py-0.5 text-xs font-medium text-foreground">
|
|
111
|
+
{t.t('adminMarketplace.updateAvailable', { version: latestVersion })}
|
|
112
|
+
</span>
|
|
113
|
+
)}
|
|
114
|
+
|
|
101
115
|
{plugin.pages.length > 0 && (
|
|
102
116
|
<span className="flex flex-wrap items-center gap-x-2 gap-y-1 pt-1">
|
|
103
117
|
{plugin.pages.map((page) => (
|
|
@@ -3,18 +3,18 @@ import type { Metadata } from 'next'
|
|
|
3
3
|
import { cn } from '@meith/ui'
|
|
4
4
|
|
|
5
5
|
import { LogoUploadForm } from '@/components/admin/branding-forms'
|
|
6
|
+
import { MarketplaceRefreshForm } from '@/components/admin/marketplace-forms'
|
|
6
7
|
import { ThemeStateForms } from '@/components/admin/theme-forms'
|
|
7
|
-
import { PANEL_CARD, PANEL_LIST, PANEL_ROW } from '@/components/shell/panel-list'
|
|
8
|
+
import { PANEL_CARD, PANEL_LIST, PANEL_NOTE, PANEL_ROW } from '@/components/shell/panel-list'
|
|
8
9
|
import { PanelPage } from '@/components/shell/panel-page'
|
|
9
|
-
import { ViewTabs } from '@/components/shell/view-tabs'
|
|
10
10
|
import { adminPageContext } from '@/server/admin'
|
|
11
11
|
import { logoKey, logoSrc } from '@/server/branding'
|
|
12
12
|
import { getTranslator, tr } from '@/server/i18n'
|
|
13
13
|
import { MAX_IMAGE_BYTES } from '@/server/image-upload'
|
|
14
|
+
import { marketplaceUpdates } from '@/server/marketplace-admin'
|
|
14
15
|
import { themeListing } from '@/server/theme-admin'
|
|
15
|
-
import { brandingFormsCopy } from '@/view/admin-panel-copy'
|
|
16
|
+
import { brandingFormsCopy, marketplaceFormsCopy } from '@/view/admin-panel-copy'
|
|
16
17
|
import { themeStateCopy } from '@/view/admin-theme-copy'
|
|
17
|
-
import { catalogTabs } from '@/view/marketplace-panel'
|
|
18
18
|
import { formatTime } from '@/view/time'
|
|
19
19
|
|
|
20
20
|
export async function generateMetadata(): Promise<Metadata> {
|
|
@@ -33,18 +33,23 @@ export default async function AdminThemesPage() {
|
|
|
33
33
|
const now = new Date()
|
|
34
34
|
const brandingCopy = brandingFormsCopy(translator)
|
|
35
35
|
const stateCopy = themeStateCopy(translator)
|
|
36
|
+
const marketplaceCopy = marketplaceFormsCopy(translator)
|
|
37
|
+
const updates = await marketplaceUpdates('theme')
|
|
36
38
|
|
|
37
39
|
return (
|
|
38
40
|
<PanelPage title={await tr('page.themes')} lede={translator.t('adminThemes.lede')}>
|
|
39
|
-
<
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
41
|
+
<section className="flex flex-wrap items-center justify-between gap-3">
|
|
42
|
+
<p className={PANEL_NOTE}>
|
|
43
|
+
{updates.unreachable
|
|
44
|
+
? translator.t('adminMarketplace.unreachable')
|
|
45
|
+
: updates.hasEverFetched && updates.fetchedAt !== null
|
|
46
|
+
? translator.t('adminMarketplace.lastChecked', {
|
|
47
|
+
time: formatTime(updates.fetchedAt, now, translator).label,
|
|
48
|
+
})
|
|
49
|
+
: translator.t('adminMarketplace.neverChecked')}
|
|
50
|
+
</p>
|
|
51
|
+
<MarketplaceRefreshForm copy={marketplaceCopy} />
|
|
52
|
+
</section>
|
|
48
53
|
|
|
49
54
|
<section className="flex flex-col gap-3">
|
|
50
55
|
<div className="flex flex-col gap-1">
|
|
@@ -124,6 +129,14 @@ export default async function AdminThemesPage() {
|
|
|
124
129
|
</>
|
|
125
130
|
)}
|
|
126
131
|
</span>
|
|
132
|
+
|
|
133
|
+
{updates.latestByKey.has(theme.key) && (
|
|
134
|
+
<span className="w-fit rounded-full border border-border bg-accent px-2 py-0.5 text-xs font-medium text-foreground">
|
|
135
|
+
{translator.t('adminMarketplace.updateAvailable', {
|
|
136
|
+
version: updates.latestByKey.get(theme.key) ?? '',
|
|
137
|
+
})}
|
|
138
|
+
</span>
|
|
139
|
+
)}
|
|
127
140
|
</span>
|
|
128
141
|
|
|
129
142
|
<ThemeStateForms
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "The board itself: the Next.js app, and the forum-web bin that materializes it into an external board workspace.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -43,53 +43,53 @@
|
|
|
43
43
|
"react-dom": "19.2.8",
|
|
44
44
|
"tailwindcss": "^4.3.3",
|
|
45
45
|
"typescript": "7.0.2",
|
|
46
|
-
"@meith/accounts": "0.
|
|
47
|
-
"@meith/admin": "0.
|
|
48
|
-
"@meith/antispam": "0.
|
|
49
|
-
"@meith/api": "0.
|
|
50
|
-
"@meith/attachments": "0.
|
|
51
|
-
"@meith/authorization": "0.
|
|
52
|
-
"@meith/avatars": "0.
|
|
53
|
-
"@meith/core": "0.
|
|
54
|
-
"@meith/db": "0.
|
|
55
|
-
"@meith/demo": "0.
|
|
56
|
-
"@meith/drafts": "0.
|
|
57
|
-
"@meith/drivers": "0.
|
|
58
|
-
"@meith/events": "0.
|
|
59
|
-
"@meith/forums": "0.
|
|
60
|
-
"@meith/groups": "0.
|
|
61
|
-
"@meith/i18n": "0.
|
|
62
|
-
"@meith/import": "0.
|
|
63
|
-
"@meith/install": "0.
|
|
64
|
-
"@meith/mail": "0.
|
|
65
|
-
"@meith/markdown": "0.
|
|
66
|
-
"@meith/marketplace": "0.
|
|
67
|
-
"@meith/messages": "0.
|
|
68
|
-
"@meith/moderation": "0.
|
|
69
|
-
"@meith/notifications": "0.
|
|
70
|
-
"@meith/plugin-calendar": "0.
|
|
71
|
-
"@meith/plugin-dues": "0.
|
|
72
|
-
"@meith/plugin-kit": "0.
|
|
73
|
-
"@meith/polls": "0.
|
|
74
|
-
"@meith/posts": "0.
|
|
75
|
-
"@meith/profile-fields": "0.
|
|
76
|
-
"@meith/relations": "0.
|
|
77
|
-
"@meith/reputation": "0.
|
|
78
|
-
"@meith/runtime": "0.
|
|
79
|
-
"@meith/search": "0.
|
|
80
|
-
"@meith/settings": "0.
|
|
81
|
-
"@meith/signatures": "0.
|
|
82
|
-
"@meith/subscriptions": "0.
|
|
83
|
-
"@meith/tasks": "0.
|
|
84
|
-
"@meith/theme-clubhouse": "0.
|
|
85
|
-
"@meith/theme-default": "0.
|
|
86
|
-
"@meith/theme-kit": "0.
|
|
87
|
-
"@meith/theme-midnight": "0.
|
|
88
|
-
"@meith/theme-phasebook": "0.
|
|
89
|
-
"@meith/theme-raidframe": "0.
|
|
90
|
-
"@meith/threads": "0.
|
|
91
|
-
"@meith/ui": "0.
|
|
92
|
-
"@meith/upgrade": "0.
|
|
46
|
+
"@meith/accounts": "0.25.0",
|
|
47
|
+
"@meith/admin": "0.25.0",
|
|
48
|
+
"@meith/antispam": "0.25.0",
|
|
49
|
+
"@meith/api": "0.25.0",
|
|
50
|
+
"@meith/attachments": "0.25.0",
|
|
51
|
+
"@meith/authorization": "0.25.0",
|
|
52
|
+
"@meith/avatars": "0.25.0",
|
|
53
|
+
"@meith/core": "0.25.0",
|
|
54
|
+
"@meith/db": "0.25.0",
|
|
55
|
+
"@meith/demo": "0.25.0",
|
|
56
|
+
"@meith/drafts": "0.25.0",
|
|
57
|
+
"@meith/drivers": "0.25.0",
|
|
58
|
+
"@meith/events": "0.25.0",
|
|
59
|
+
"@meith/forums": "0.25.0",
|
|
60
|
+
"@meith/groups": "0.25.0",
|
|
61
|
+
"@meith/i18n": "0.25.0",
|
|
62
|
+
"@meith/import": "0.25.0",
|
|
63
|
+
"@meith/install": "0.25.0",
|
|
64
|
+
"@meith/mail": "0.25.0",
|
|
65
|
+
"@meith/markdown": "0.25.0",
|
|
66
|
+
"@meith/marketplace": "0.25.0",
|
|
67
|
+
"@meith/messages": "0.25.0",
|
|
68
|
+
"@meith/moderation": "0.25.0",
|
|
69
|
+
"@meith/notifications": "0.25.0",
|
|
70
|
+
"@meith/plugin-calendar": "0.25.0",
|
|
71
|
+
"@meith/plugin-dues": "0.25.0",
|
|
72
|
+
"@meith/plugin-kit": "0.25.0",
|
|
73
|
+
"@meith/polls": "0.25.0",
|
|
74
|
+
"@meith/posts": "0.25.0",
|
|
75
|
+
"@meith/profile-fields": "0.25.0",
|
|
76
|
+
"@meith/relations": "0.25.0",
|
|
77
|
+
"@meith/reputation": "0.25.0",
|
|
78
|
+
"@meith/runtime": "0.25.0",
|
|
79
|
+
"@meith/search": "0.25.0",
|
|
80
|
+
"@meith/settings": "0.25.0",
|
|
81
|
+
"@meith/signatures": "0.25.0",
|
|
82
|
+
"@meith/subscriptions": "0.25.0",
|
|
83
|
+
"@meith/tasks": "0.25.0",
|
|
84
|
+
"@meith/theme-clubhouse": "0.25.0",
|
|
85
|
+
"@meith/theme-default": "0.25.0",
|
|
86
|
+
"@meith/theme-kit": "0.25.0",
|
|
87
|
+
"@meith/theme-midnight": "0.25.0",
|
|
88
|
+
"@meith/theme-phasebook": "0.25.0",
|
|
89
|
+
"@meith/theme-raidframe": "0.25.0",
|
|
90
|
+
"@meith/threads": "0.25.0",
|
|
91
|
+
"@meith/ui": "0.25.0",
|
|
92
|
+
"@meith/upgrade": "0.25.0"
|
|
93
93
|
},
|
|
94
94
|
"scripts": {
|
|
95
95
|
"dev": "next dev",
|
package/src/server/container.ts
CHANGED
|
@@ -305,6 +305,10 @@ function buildPostgres(onBypass: (e: BypassEvent) => void): Container {
|
|
|
305
305
|
themeTokens: Object.fromEntries(
|
|
306
306
|
Object.values(forumConfig.themes).map((theme) => [theme.key, theme.tokens]),
|
|
307
307
|
),
|
|
308
|
+
themeVersions: Object.values(forumConfig.themes).map((theme) => ({
|
|
309
|
+
key: theme.key,
|
|
310
|
+
version: theme.theme?.version ?? null,
|
|
311
|
+
})),
|
|
308
312
|
files: drivers().files,
|
|
309
313
|
images: imageProcessor,
|
|
310
314
|
plugins: activeDefinitions(),
|
|
@@ -18,8 +18,8 @@ export async function refreshMarketplaceAction(
|
|
|
18
18
|
|
|
19
19
|
const result = await refreshMarketplaceNow()
|
|
20
20
|
|
|
21
|
-
revalidatePath('/admin/plugins
|
|
22
|
-
revalidatePath('/admin/themes
|
|
21
|
+
revalidatePath('/admin/plugins')
|
|
22
|
+
revalidatePath('/admin/themes')
|
|
23
23
|
|
|
24
24
|
await recordAdminAction({
|
|
25
25
|
action: 'marketplace.refresh',
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import 'server-only'
|
|
2
2
|
|
|
3
|
-
import { env, logger
|
|
3
|
+
import { env, logger } from '@meith/core'
|
|
4
4
|
import { getDb, PostgresMarketplaceCacheRepository } from '@meith/db'
|
|
5
5
|
import {
|
|
6
6
|
type CachedMarketplace,
|
|
@@ -8,8 +8,6 @@ import {
|
|
|
8
8
|
EMPTY_CACHE,
|
|
9
9
|
type InstalledEntry,
|
|
10
10
|
type ListingKind,
|
|
11
|
-
type ListingStatus,
|
|
12
|
-
type MarketplaceListing,
|
|
13
11
|
MEITH_VERSION,
|
|
14
12
|
PLUGIN_API_MAJOR,
|
|
15
13
|
refreshCatalog,
|
|
@@ -27,28 +25,6 @@ const BUILD_INFO = {
|
|
|
27
25
|
themeApiMajor: THEME_API_MAJOR,
|
|
28
26
|
}
|
|
29
27
|
|
|
30
|
-
function onStockImage(): boolean {
|
|
31
|
-
return readPluginEnv('BOARD_PLUGINS_MANIFEST') !== undefined
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const MAX_NAME_LENGTH = 120
|
|
35
|
-
const MAX_DESCRIPTION_LENGTH = 500
|
|
36
|
-
const MAX_LICENCE_LENGTH = 100
|
|
37
|
-
const MAX_REASON_LENGTH = 300
|
|
38
|
-
|
|
39
|
-
function truncate(value: string, max: number): string {
|
|
40
|
-
return value.length > max ? `${value.slice(0, max - 1)}…` : value
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function safeHttpsUrl(value: string): string | null {
|
|
44
|
-
try {
|
|
45
|
-
const url = new URL(value)
|
|
46
|
-
return url.protocol === 'https:' ? url.toString() : null
|
|
47
|
-
} catch {
|
|
48
|
-
return null
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
28
|
export function marketplaceCacheRepository(): PostgresMarketplaceCacheRepository | null {
|
|
53
29
|
return env.DATA_SOURCE === 'postgres' ? new PostgresMarketplaceCacheRepository(getDb()) : null
|
|
54
30
|
}
|
|
@@ -65,45 +41,6 @@ async function readCache(): Promise<CachedMarketplace> {
|
|
|
65
41
|
}
|
|
66
42
|
}
|
|
67
43
|
|
|
68
|
-
export interface MarketplaceListingRow {
|
|
69
|
-
readonly key: string
|
|
70
|
-
readonly kind: ListingKind
|
|
71
|
-
readonly name: string
|
|
72
|
-
readonly description: string
|
|
73
|
-
readonly version: string
|
|
74
|
-
readonly package: string
|
|
75
|
-
readonly licence: string
|
|
76
|
-
readonly repositoryUrl: string | null
|
|
77
|
-
readonly screenshotHrefs: readonly string[]
|
|
78
|
-
readonly status: ListingStatus
|
|
79
|
-
readonly incompatibleReason: string | null
|
|
80
|
-
readonly installedVersion: string | null
|
|
81
|
-
readonly installSteps: readonly string[] | null
|
|
82
|
-
readonly onStockImage: boolean
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export interface MarketplaceCatalogView {
|
|
86
|
-
readonly listings: readonly MarketplaceListingRow[]
|
|
87
|
-
readonly fetchedAt: Date | null
|
|
88
|
-
readonly hasEverFetched: boolean
|
|
89
|
-
readonly unreachable: boolean
|
|
90
|
-
readonly errorMessage: string | null
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function installSteps(kind: ListingKind, packageName: string): readonly string[] {
|
|
94
|
-
return kind === 'plugin'
|
|
95
|
-
? [
|
|
96
|
-
`npm install ${packageName}`,
|
|
97
|
-
`community plugin:add ${packageName}`,
|
|
98
|
-
'Rebuild and redeploy for it to take effect.',
|
|
99
|
-
]
|
|
100
|
-
: [
|
|
101
|
-
`npm install ${packageName}`,
|
|
102
|
-
'Register it in community.config.ts (and set it as defaultTheme if it should be the board default).',
|
|
103
|
-
'Rebuild and redeploy for it to take effect.',
|
|
104
|
-
]
|
|
105
|
-
}
|
|
106
|
-
|
|
107
44
|
async function pluginInstalledEntries(): Promise<ReadonlyMap<string, InstalledEntry>> {
|
|
108
45
|
const map = new Map<string, InstalledEntry>()
|
|
109
46
|
for (const plugin of configuredPlugins()) {
|
|
@@ -116,70 +53,38 @@ async function pluginInstalledEntries(): Promise<ReadonlyMap<string, InstalledEn
|
|
|
116
53
|
async function themeInstalledEntries(): Promise<ReadonlyMap<string, InstalledEntry>> {
|
|
117
54
|
const map = new Map<string, InstalledEntry>()
|
|
118
55
|
for (const theme of await themeListing()) {
|
|
119
|
-
map.set(theme.key, { enabled: theme.enabled, version:
|
|
56
|
+
map.set(theme.key, { enabled: theme.enabled, version: theme.version })
|
|
120
57
|
}
|
|
121
58
|
return map
|
|
122
59
|
}
|
|
123
60
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
listing: MarketplaceListing,
|
|
130
|
-
installed: InstalledEntry | null,
|
|
131
|
-
): MarketplaceListingRow {
|
|
132
|
-
const { status, incompatibleReason } = computeListingStatus({ ...listing, installed }, BUILD_INFO)
|
|
133
|
-
|
|
134
|
-
return {
|
|
135
|
-
key: listing.key,
|
|
136
|
-
kind: listing.kind,
|
|
137
|
-
name: truncate(listing.name, MAX_NAME_LENGTH),
|
|
138
|
-
description: truncate(listing.description, MAX_DESCRIPTION_LENGTH),
|
|
139
|
-
version: listing.version,
|
|
140
|
-
package: listing.package,
|
|
141
|
-
licence: truncate(listing.licence, MAX_LICENCE_LENGTH),
|
|
142
|
-
repositoryUrl: safeHttpsUrl(listing.repository),
|
|
143
|
-
screenshotHrefs: listing.screenshots.map((_, index) => screenshotHref(listing.key, index)),
|
|
144
|
-
status,
|
|
145
|
-
incompatibleReason:
|
|
146
|
-
incompatibleReason === null ? null : truncate(incompatibleReason, MAX_REASON_LENGTH),
|
|
147
|
-
installedVersion: installed?.version ?? null,
|
|
148
|
-
installSteps: status === 'not-installed' ? installSteps(listing.kind, listing.package) : null,
|
|
149
|
-
onStockImage: status === 'not-installed' && onStockImage(),
|
|
150
|
-
}
|
|
61
|
+
export interface MarketplaceUpdatesView {
|
|
62
|
+
readonly latestByKey: ReadonlyMap<string, string>
|
|
63
|
+
readonly fetchedAt: Date | null
|
|
64
|
+
readonly hasEverFetched: boolean
|
|
65
|
+
readonly unreachable: boolean
|
|
151
66
|
}
|
|
152
67
|
|
|
153
|
-
export async function
|
|
68
|
+
export async function marketplaceUpdates(kind: ListingKind): Promise<MarketplaceUpdatesView> {
|
|
154
69
|
const cache = await readCache()
|
|
155
70
|
const installed =
|
|
156
71
|
kind === 'plugin' ? await pluginInstalledEntries() : await themeInstalledEntries()
|
|
157
72
|
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
73
|
+
const latestByKey = new Map<string, string>()
|
|
74
|
+
for (const listing of cache.feed?.listings ?? []) {
|
|
75
|
+
if (listing.kind !== kind) continue
|
|
76
|
+
const entry = installed.get(listing.key)
|
|
77
|
+
if (entry === undefined) continue
|
|
78
|
+
|
|
79
|
+
const { status } = computeListingStatus({ ...listing, installed: entry }, BUILD_INFO)
|
|
80
|
+
if (status === 'update-available') latestByKey.set(listing.key, listing.version)
|
|
81
|
+
}
|
|
161
82
|
|
|
162
83
|
return {
|
|
163
|
-
|
|
84
|
+
latestByKey,
|
|
164
85
|
fetchedAt: cache.fetchedAt,
|
|
165
86
|
hasEverFetched: cache.fetchedAt !== null,
|
|
166
87
|
unreachable: cache.error !== null,
|
|
167
|
-
errorMessage: cache.error,
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
export async function marketplaceScreenshotUrl(key: string, index: number): Promise<string | null> {
|
|
172
|
-
const cache = await readCache()
|
|
173
|
-
if (cache.feed === null || cache.sourceUrl === null) return null
|
|
174
|
-
|
|
175
|
-
const listing = cache.feed.listings.find((entry) => entry.key === key)
|
|
176
|
-
const path = listing?.screenshots[index]
|
|
177
|
-
if (path === undefined) return null
|
|
178
|
-
|
|
179
|
-
try {
|
|
180
|
-
return new URL(path, cache.sourceUrl).toString()
|
|
181
|
-
} catch {
|
|
182
|
-
return null
|
|
183
88
|
}
|
|
184
89
|
}
|
|
185
90
|
|
|
@@ -194,7 +99,10 @@ export async function refreshMarketplaceNow(): Promise<{
|
|
|
194
99
|
}
|
|
195
100
|
|
|
196
101
|
const url = (await getSettingsUncached()).get('marketplace.feed_url')
|
|
197
|
-
const pluginEntries = await
|
|
102
|
+
const [pluginEntries, themeEntries] = await Promise.all([
|
|
103
|
+
pluginInstalledEntries(),
|
|
104
|
+
themeInstalledEntries(),
|
|
105
|
+
])
|
|
198
106
|
const notifications = notificationService()
|
|
199
107
|
|
|
200
108
|
const result = await refreshCatalog({
|
|
@@ -202,7 +110,7 @@ export async function refreshMarketplaceNow(): Promise<{
|
|
|
202
110
|
repository,
|
|
203
111
|
build: BUILD_INFO,
|
|
204
112
|
resolveInstalled: (listing) =>
|
|
205
|
-
listing.kind === 'plugin' ?
|
|
113
|
+
(listing.kind === 'plugin' ? pluginEntries : themeEntries).get(listing.key) ?? null,
|
|
206
114
|
notifyUpdate: async (listing) => {
|
|
207
115
|
if (notifications === null) return
|
|
208
116
|
await notifications.raiseForAdministrators({
|
|
@@ -213,7 +121,7 @@ export async function refreshMarketplaceNow(): Promise<{
|
|
|
213
121
|
package: listing.package,
|
|
214
122
|
version: listing.version,
|
|
215
123
|
},
|
|
216
|
-
href: '/admin/plugins/
|
|
124
|
+
href: listing.kind === 'plugin' ? '/admin/plugins' : '/admin/themes',
|
|
217
125
|
dedupeKey: `marketplace.update_available:${listing.key}:${listing.version}`,
|
|
218
126
|
})
|
|
219
127
|
},
|
|
@@ -42,6 +42,7 @@ export interface ThemeAdminView {
|
|
|
42
42
|
export interface ThemeListing {
|
|
43
43
|
readonly key: string
|
|
44
44
|
readonly title: string
|
|
45
|
+
readonly version: string | null
|
|
45
46
|
readonly isBuildTheme: boolean
|
|
46
47
|
readonly isDefault: boolean
|
|
47
48
|
readonly enabled: boolean
|
|
@@ -82,6 +83,7 @@ export async function themeListing(): Promise<readonly ThemeListing[]> {
|
|
|
82
83
|
return {
|
|
83
84
|
key: theme.key,
|
|
84
85
|
title: theme.title,
|
|
86
|
+
version: theme.theme?.version ?? null,
|
|
85
87
|
isBuildTheme: theme.key === buildThemeKey,
|
|
86
88
|
isDefault:
|
|
87
89
|
claimed === undefined || byKey.get(claimed.key)?.enabled === false
|
|
@@ -6,7 +6,7 @@ import { planUpgrade, type UpgradeState, upgradeNotice } from '@meith/upgrade'
|
|
|
6
6
|
|
|
7
7
|
import { activeDefinitions } from './plugin-host'
|
|
8
8
|
|
|
9
|
-
export const CODE_VERSION = '0.
|
|
9
|
+
export const CODE_VERSION = '0.25.0'
|
|
10
10
|
|
|
11
11
|
export async function pendingUpgradeNotice(): Promise<string | null> {
|
|
12
12
|
if (env.DATA_SOURCE !== 'postgres') return null
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import type { NextRequest } from 'next/server'
|
|
2
|
-
|
|
3
|
-
import { readCappedBody } from '@meith/marketplace'
|
|
4
|
-
|
|
5
|
-
import { requireAdmin } from '@/server/admin'
|
|
6
|
-
import { marketplaceScreenshotUrl } from '@/server/marketplace-admin'
|
|
7
|
-
|
|
8
|
-
export const dynamic = 'force-dynamic'
|
|
9
|
-
|
|
10
|
-
const TIMEOUT_MS = 10_000
|
|
11
|
-
const MAX_BYTES = 5_000_000
|
|
12
|
-
|
|
13
|
-
function fail(status: number, message: string): Response {
|
|
14
|
-
return new Response(message, {
|
|
15
|
-
status,
|
|
16
|
-
headers: { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' },
|
|
17
|
-
})
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export async function GET(request: NextRequest): Promise<Response> {
|
|
21
|
-
try {
|
|
22
|
-
await requireAdmin()
|
|
23
|
-
} catch {
|
|
24
|
-
return fail(403, 'Enter the control panel and try again.')
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const key = request.nextUrl.searchParams.get('key') ?? ''
|
|
28
|
-
const index = Number(request.nextUrl.searchParams.get('index') ?? '')
|
|
29
|
-
if (key === '' || !Number.isInteger(index) || index < 0) {
|
|
30
|
-
return fail(400, 'A screenshot needs a listing key and an index.')
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const url = await marketplaceScreenshotUrl(key, index)
|
|
34
|
-
if (url === null) return fail(404, 'No such screenshot.')
|
|
35
|
-
|
|
36
|
-
const controller = new AbortController()
|
|
37
|
-
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
|
38
|
-
|
|
39
|
-
try {
|
|
40
|
-
const upstream = await fetch(url, {
|
|
41
|
-
signal: controller.signal,
|
|
42
|
-
headers: { accept: 'image/png' },
|
|
43
|
-
redirect: 'manual',
|
|
44
|
-
})
|
|
45
|
-
if (!upstream.ok) return fail(502, 'The catalog host did not answer with the screenshot.')
|
|
46
|
-
|
|
47
|
-
const bytes = await readCappedBody(upstream, MAX_BYTES)
|
|
48
|
-
if (bytes === null) return fail(502, 'The screenshot was larger than expected.')
|
|
49
|
-
|
|
50
|
-
return new Response(bytes.buffer as ArrayBuffer, {
|
|
51
|
-
headers: {
|
|
52
|
-
'Content-Type': 'image/png',
|
|
53
|
-
'X-Content-Type-Options': 'nosniff',
|
|
54
|
-
'Content-Security-Policy': "default-src 'none'; sandbox",
|
|
55
|
-
'Cache-Control': 'private, max-age=3600',
|
|
56
|
-
},
|
|
57
|
-
})
|
|
58
|
-
} catch {
|
|
59
|
-
return fail(502, 'Could not reach the catalog host.')
|
|
60
|
-
} finally {
|
|
61
|
-
clearTimeout(timeout)
|
|
62
|
-
}
|
|
63
|
-
}
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import type { Metadata } from 'next'
|
|
2
|
-
|
|
3
|
-
import { MarketplaceRefreshForm } from '@/components/admin/marketplace-forms'
|
|
4
|
-
import { MarketplaceListingCard } from '@/components/admin/marketplace-listing'
|
|
5
|
-
import { PANEL_NOTE } from '@/components/shell/panel-list'
|
|
6
|
-
import { PanelPage } from '@/components/shell/panel-page'
|
|
7
|
-
import { ViewTabs } from '@/components/shell/view-tabs'
|
|
8
|
-
import { adminPageContext } from '@/server/admin'
|
|
9
|
-
import { getTranslator, tr } from '@/server/i18n'
|
|
10
|
-
import { marketplaceCatalog } from '@/server/marketplace-admin'
|
|
11
|
-
import { marketplaceFormsCopy } from '@/view/admin-panel-copy'
|
|
12
|
-
import { catalogTabs } from '@/view/marketplace-panel'
|
|
13
|
-
import { formatTime } from '@/view/time'
|
|
14
|
-
|
|
15
|
-
export async function generateMetadata(): Promise<Metadata> {
|
|
16
|
-
return { title: await tr('adminMarketplace.browsePluginsTitle') }
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export default async function AdminPluginsBrowsePage() {
|
|
20
|
-
if ((await adminPageContext()) === null) return null
|
|
21
|
-
|
|
22
|
-
const t = await getTranslator()
|
|
23
|
-
const catalog = await marketplaceCatalog('plugin')
|
|
24
|
-
const copy = marketplaceFormsCopy(t)
|
|
25
|
-
const now = new Date()
|
|
26
|
-
|
|
27
|
-
return (
|
|
28
|
-
<PanelPage
|
|
29
|
-
title={t.t('adminMarketplace.browsePluginsTitle')}
|
|
30
|
-
lede={t.t('adminMarketplace.browseLede')}
|
|
31
|
-
>
|
|
32
|
-
<ViewTabs
|
|
33
|
-
label={t.t('adminMarketplace.tabsLabel')}
|
|
34
|
-
tabs={catalogTabs({
|
|
35
|
-
installedHref: '/admin/plugins',
|
|
36
|
-
browseHref: '/admin/plugins/browse',
|
|
37
|
-
current: 'browse',
|
|
38
|
-
t,
|
|
39
|
-
})}
|
|
40
|
-
aside={
|
|
41
|
-
catalog.fetchedAt !== null
|
|
42
|
-
? t.t('adminMarketplace.lastFetched', {
|
|
43
|
-
time: formatTime(catalog.fetchedAt, now, t).label,
|
|
44
|
-
})
|
|
45
|
-
: undefined
|
|
46
|
-
}
|
|
47
|
-
/>
|
|
48
|
-
|
|
49
|
-
<MarketplaceRefreshForm copy={copy} />
|
|
50
|
-
|
|
51
|
-
{catalog.unreachable && <p className={PANEL_NOTE}>{t.t('adminMarketplace.unreachable')}</p>}
|
|
52
|
-
|
|
53
|
-
{!catalog.unreachable && !catalog.hasEverFetched && (
|
|
54
|
-
<p className={PANEL_NOTE}>{t.t('adminMarketplace.neverFetched')}</p>
|
|
55
|
-
)}
|
|
56
|
-
|
|
57
|
-
{catalog.hasEverFetched && catalog.listings.length === 0 && (
|
|
58
|
-
<p className={PANEL_NOTE}>{t.t('adminMarketplace.emptyPlugins')}</p>
|
|
59
|
-
)}
|
|
60
|
-
|
|
61
|
-
{catalog.listings.length > 0 && (
|
|
62
|
-
<ul className="flex flex-col gap-4">
|
|
63
|
-
{catalog.listings.map((listing) => (
|
|
64
|
-
<MarketplaceListingCard key={listing.key} listing={listing} t={t} />
|
|
65
|
-
))}
|
|
66
|
-
</ul>
|
|
67
|
-
)}
|
|
68
|
-
</PanelPage>
|
|
69
|
-
)
|
|
70
|
-
}
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import type { Metadata } from 'next'
|
|
2
|
-
|
|
3
|
-
import { MarketplaceRefreshForm } from '@/components/admin/marketplace-forms'
|
|
4
|
-
import { MarketplaceListingCard } from '@/components/admin/marketplace-listing'
|
|
5
|
-
import { PANEL_NOTE } from '@/components/shell/panel-list'
|
|
6
|
-
import { PanelPage } from '@/components/shell/panel-page'
|
|
7
|
-
import { ViewTabs } from '@/components/shell/view-tabs'
|
|
8
|
-
import { adminPageContext } from '@/server/admin'
|
|
9
|
-
import { getTranslator, tr } from '@/server/i18n'
|
|
10
|
-
import { marketplaceCatalog } from '@/server/marketplace-admin'
|
|
11
|
-
import { marketplaceFormsCopy } from '@/view/admin-panel-copy'
|
|
12
|
-
import { catalogTabs } from '@/view/marketplace-panel'
|
|
13
|
-
import { formatTime } from '@/view/time'
|
|
14
|
-
|
|
15
|
-
export async function generateMetadata(): Promise<Metadata> {
|
|
16
|
-
return { title: await tr('adminMarketplace.browseThemesTitle') }
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export default async function AdminThemesBrowsePage() {
|
|
20
|
-
if ((await adminPageContext()) === null) return null
|
|
21
|
-
|
|
22
|
-
const t = await getTranslator()
|
|
23
|
-
const catalog = await marketplaceCatalog('theme')
|
|
24
|
-
const copy = marketplaceFormsCopy(t)
|
|
25
|
-
const now = new Date()
|
|
26
|
-
|
|
27
|
-
return (
|
|
28
|
-
<PanelPage
|
|
29
|
-
title={t.t('adminMarketplace.browseThemesTitle')}
|
|
30
|
-
lede={t.t('adminMarketplace.browseLede')}
|
|
31
|
-
>
|
|
32
|
-
<ViewTabs
|
|
33
|
-
label={t.t('adminMarketplace.tabsLabel')}
|
|
34
|
-
tabs={catalogTabs({
|
|
35
|
-
installedHref: '/admin/themes',
|
|
36
|
-
browseHref: '/admin/themes/browse',
|
|
37
|
-
current: 'browse',
|
|
38
|
-
t,
|
|
39
|
-
})}
|
|
40
|
-
aside={
|
|
41
|
-
catalog.fetchedAt !== null
|
|
42
|
-
? t.t('adminMarketplace.lastFetched', {
|
|
43
|
-
time: formatTime(catalog.fetchedAt, now, t).label,
|
|
44
|
-
})
|
|
45
|
-
: undefined
|
|
46
|
-
}
|
|
47
|
-
/>
|
|
48
|
-
|
|
49
|
-
<MarketplaceRefreshForm copy={copy} />
|
|
50
|
-
|
|
51
|
-
{catalog.unreachable && <p className={PANEL_NOTE}>{t.t('adminMarketplace.unreachable')}</p>}
|
|
52
|
-
|
|
53
|
-
{!catalog.unreachable && !catalog.hasEverFetched && (
|
|
54
|
-
<p className={PANEL_NOTE}>{t.t('adminMarketplace.neverFetched')}</p>
|
|
55
|
-
)}
|
|
56
|
-
|
|
57
|
-
{catalog.hasEverFetched && catalog.listings.length === 0 && (
|
|
58
|
-
<p className={PANEL_NOTE}>{t.t('adminMarketplace.emptyThemes')}</p>
|
|
59
|
-
)}
|
|
60
|
-
|
|
61
|
-
{catalog.listings.length > 0 && (
|
|
62
|
-
<ul className="flex flex-col gap-4">
|
|
63
|
-
{catalog.listings.map((listing) => (
|
|
64
|
-
<MarketplaceListingCard key={listing.key} listing={listing} t={t} />
|
|
65
|
-
))}
|
|
66
|
-
</ul>
|
|
67
|
-
)}
|
|
68
|
-
</PanelPage>
|
|
69
|
-
)
|
|
70
|
-
}
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
import type { Translator } from '@meith/i18n'
|
|
2
|
-
import { cn } from '@meith/ui'
|
|
3
|
-
|
|
4
|
-
import { PANEL_CARD } from '@/components/shell/panel-list'
|
|
5
|
-
import type { MarketplaceListingRow } from '@/server/marketplace-admin'
|
|
6
|
-
import { type StatusTone, statusBadge } from '@/view/marketplace-panel'
|
|
7
|
-
|
|
8
|
-
const TONE_CLASS: Readonly<Record<StatusTone, string>> = {
|
|
9
|
-
active: 'bg-primary text-primary-foreground',
|
|
10
|
-
muted: 'border border-border text-muted-foreground',
|
|
11
|
-
warning: 'border border-border bg-accent text-foreground font-medium',
|
|
12
|
-
destructive: 'bg-destructive/10 text-destructive',
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function MarketplaceListingCard({
|
|
16
|
-
listing,
|
|
17
|
-
t,
|
|
18
|
-
}: {
|
|
19
|
-
listing: MarketplaceListingRow
|
|
20
|
-
t: Translator
|
|
21
|
-
}) {
|
|
22
|
-
const badge = statusBadge(listing.status, t)
|
|
23
|
-
|
|
24
|
-
return (
|
|
25
|
-
<li className={cn(PANEL_CARD, 'gap-3')}>
|
|
26
|
-
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
27
|
-
<div className="flex min-w-0 flex-col gap-0.5">
|
|
28
|
-
<span className="flex flex-wrap items-center gap-2">
|
|
29
|
-
<span className="text-sm font-medium">{listing.name}</span>
|
|
30
|
-
<span className="text-xs text-muted-foreground">{listing.version}</span>
|
|
31
|
-
</span>
|
|
32
|
-
<span className="truncate font-mono text-xs text-muted-foreground">
|
|
33
|
-
{listing.key} · {listing.package}
|
|
34
|
-
</span>
|
|
35
|
-
</div>
|
|
36
|
-
<span className={cn('shrink-0 rounded-full px-2 py-0.5 text-xs', TONE_CLASS[badge.tone])}>
|
|
37
|
-
{badge.label}
|
|
38
|
-
</span>
|
|
39
|
-
</div>
|
|
40
|
-
|
|
41
|
-
{listing.screenshotHrefs.length > 0 && (
|
|
42
|
-
<div className="flex flex-wrap gap-2">
|
|
43
|
-
{listing.screenshotHrefs.map((href) => (
|
|
44
|
-
<img
|
|
45
|
-
key={href}
|
|
46
|
-
src={href}
|
|
47
|
-
alt=""
|
|
48
|
-
loading="lazy"
|
|
49
|
-
className="h-28 w-auto rounded-md border border-border object-cover"
|
|
50
|
-
/>
|
|
51
|
-
))}
|
|
52
|
-
</div>
|
|
53
|
-
)}
|
|
54
|
-
|
|
55
|
-
<p className="text-sm text-muted-foreground">{listing.description}</p>
|
|
56
|
-
|
|
57
|
-
{listing.status === 'incompatible' && listing.incompatibleReason !== null && (
|
|
58
|
-
<p className="text-sm text-destructive">{listing.incompatibleReason}</p>
|
|
59
|
-
)}
|
|
60
|
-
|
|
61
|
-
{listing.status === 'update-available' && listing.installedVersion !== null && (
|
|
62
|
-
<p className="text-xs text-muted-foreground">
|
|
63
|
-
{t.t('adminMarketplace.installedVersion', { version: listing.installedVersion })}
|
|
64
|
-
</p>
|
|
65
|
-
)}
|
|
66
|
-
|
|
67
|
-
{listing.installSteps !== null && (
|
|
68
|
-
<div className="flex flex-col gap-1.5 rounded-md border border-border bg-surface p-3 text-sm">
|
|
69
|
-
<p className="font-medium">{t.t('adminMarketplace.installSteps')}</p>
|
|
70
|
-
<ol className="flex flex-col gap-1">
|
|
71
|
-
{listing.installSteps.map((step) => (
|
|
72
|
-
<li key={step} className="text-xs">
|
|
73
|
-
{step.startsWith('npm ') || step.startsWith('community ') ? (
|
|
74
|
-
<code className="rounded bg-muted px-1.5 py-0.5">{step}</code>
|
|
75
|
-
) : (
|
|
76
|
-
<span className="text-muted-foreground">{step}</span>
|
|
77
|
-
)}
|
|
78
|
-
</li>
|
|
79
|
-
))}
|
|
80
|
-
</ol>
|
|
81
|
-
<a
|
|
82
|
-
href="https://www.meith.dev/docs/marketplace"
|
|
83
|
-
rel="noopener noreferrer"
|
|
84
|
-
className="w-fit text-xs font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
|
|
85
|
-
>
|
|
86
|
-
{t.t('adminMarketplace.installDocsLink')}
|
|
87
|
-
</a>
|
|
88
|
-
{listing.onStockImage && (
|
|
89
|
-
<a
|
|
90
|
-
href="https://www.meith.dev/docs/marketplace#moving-to-a-custom-board"
|
|
91
|
-
rel="noopener noreferrer"
|
|
92
|
-
className="w-fit text-xs font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
|
|
93
|
-
>
|
|
94
|
-
{t.t('adminMarketplace.graduationLink')}
|
|
95
|
-
</a>
|
|
96
|
-
)}
|
|
97
|
-
</div>
|
|
98
|
-
)}
|
|
99
|
-
|
|
100
|
-
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
|
101
|
-
<span>{listing.licence}</span>
|
|
102
|
-
{listing.repositoryUrl !== null && (
|
|
103
|
-
<a
|
|
104
|
-
href={listing.repositoryUrl}
|
|
105
|
-
rel="noopener noreferrer"
|
|
106
|
-
className="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
|
|
107
|
-
>
|
|
108
|
-
{t.t('adminMarketplace.repository')}
|
|
109
|
-
</a>
|
|
110
|
-
)}
|
|
111
|
-
</div>
|
|
112
|
-
</li>
|
|
113
|
-
)
|
|
114
|
-
}
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import type { Translator } from '@meith/i18n'
|
|
2
|
-
import type { ListingStatus } from '@meith/marketplace'
|
|
3
|
-
|
|
4
|
-
import type { ViewTab } from '@/components/shell/view-tabs'
|
|
5
|
-
|
|
6
|
-
export type StatusTone = 'active' | 'muted' | 'warning' | 'destructive'
|
|
7
|
-
|
|
8
|
-
export interface StatusBadge {
|
|
9
|
-
readonly label: string
|
|
10
|
-
readonly tone: StatusTone
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const STATUS_KEYS: Readonly<Record<ListingStatus, string>> = {
|
|
14
|
-
active: 'adminMarketplace.status.active',
|
|
15
|
-
'installed-disabled': 'adminMarketplace.status.installedDisabled',
|
|
16
|
-
'not-installed': 'adminMarketplace.status.notInstalled',
|
|
17
|
-
'update-available': 'adminMarketplace.status.updateAvailable',
|
|
18
|
-
incompatible: 'adminMarketplace.status.incompatible',
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const STATUS_TONES: Readonly<Record<ListingStatus, StatusTone>> = {
|
|
22
|
-
active: 'active',
|
|
23
|
-
'installed-disabled': 'muted',
|
|
24
|
-
'not-installed': 'muted',
|
|
25
|
-
'update-available': 'warning',
|
|
26
|
-
incompatible: 'destructive',
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function statusBadge(status: ListingStatus, t: Translator): StatusBadge {
|
|
30
|
-
return { label: t.t(STATUS_KEYS[status]), tone: STATUS_TONES[status] }
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function catalogTabs(input: {
|
|
34
|
-
readonly installedHref: string
|
|
35
|
-
readonly browseHref: string
|
|
36
|
-
readonly current: 'installed' | 'browse'
|
|
37
|
-
readonly t: Translator
|
|
38
|
-
}): readonly ViewTab[] {
|
|
39
|
-
return [
|
|
40
|
-
{
|
|
41
|
-
href: input.installedHref,
|
|
42
|
-
label: input.t.t('adminMarketplace.tab.installed'),
|
|
43
|
-
isCurrent: input.current === 'installed',
|
|
44
|
-
},
|
|
45
|
-
{
|
|
46
|
-
href: input.browseHref,
|
|
47
|
-
label: input.t.t('adminMarketplace.tab.browse'),
|
|
48
|
-
isCurrent: input.current === 'browse',
|
|
49
|
-
},
|
|
50
|
-
]
|
|
51
|
-
}
|