@meith/web 0.24.0 → 0.25.1

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.
Files changed (68) hide show
  1. package/app/(board)/member/[id]/reputation/page.tsx +2 -2
  2. package/app/(board)/messages/[id]/page.tsx +28 -20
  3. package/app/(board)/messages/page.tsx +2 -2
  4. package/app/(board)/notifications/page.tsx +3 -1
  5. package/app/(board)/subscriptions/page.tsx +6 -2
  6. package/app/(board)/usercp/contacts/page.tsx +3 -7
  7. package/app/admin/api-tokens/page.tsx +3 -2
  8. package/app/admin/forums/[id]/page.tsx +25 -22
  9. package/app/admin/groups/memberships/page.tsx +6 -6
  10. package/app/admin/groups/promotions/page.tsx +14 -16
  11. package/app/admin/log/page.tsx +5 -3
  12. package/app/admin/plugins/page.tsx +26 -12
  13. package/app/admin/security/page.tsx +4 -3
  14. package/app/admin/system/page.tsx +13 -10
  15. package/app/admin/themes/page.tsx +26 -13
  16. package/package.json +48 -48
  17. package/src/components/account/active-sessions.tsx +9 -6
  18. package/src/components/account/credential-proof-form.tsx +6 -7
  19. package/src/components/account/logout-form.tsx +5 -3
  20. package/src/components/account/notification-forms.tsx +9 -9
  21. package/src/components/account/relation-forms.tsx +5 -5
  22. package/src/components/account/reputation-forms.tsx +10 -10
  23. package/src/components/account/sign-in-methods.tsx +12 -6
  24. package/src/components/account/subscription-forms.tsx +7 -7
  25. package/src/components/account/two-factor-forms.tsx +12 -12
  26. package/src/components/account/usercp-forms.tsx +13 -13
  27. package/src/components/admin/admin-forms.tsx +7 -7
  28. package/src/components/admin/badge-forms.tsx +3 -3
  29. package/src/components/admin/ban-filter-forms.tsx +4 -4
  30. package/src/components/admin/branding-forms.tsx +3 -3
  31. package/src/components/admin/content-forms.tsx +15 -15
  32. package/src/components/admin/forum-forms.tsx +7 -7
  33. package/src/components/admin/group-forms.tsx +5 -5
  34. package/src/components/admin/mail-test-card.tsx +3 -3
  35. package/src/components/admin/marketplace-forms.tsx +4 -4
  36. package/src/components/admin/navigation-forms.tsx +3 -3
  37. package/src/components/admin/plugin-forms.tsx +7 -7
  38. package/src/components/admin/settings-form.tsx +5 -2
  39. package/src/components/admin/theme-forms.tsx +7 -10
  40. package/src/components/auth/form-controls.tsx +23 -1
  41. package/src/components/auth/second-factor-form.tsx +4 -4
  42. package/src/components/content/composer-intents.tsx +7 -6
  43. package/src/components/content/edit-post-form.tsx +7 -7
  44. package/src/components/content/poll-edit-form.tsx +3 -1
  45. package/src/components/content/poll.tsx +4 -2
  46. package/src/components/content/thanks-button.tsx +3 -3
  47. package/src/components/content/thread-rating.tsx +3 -3
  48. package/src/components/messages/message-forms.tsx +15 -15
  49. package/src/components/moderation/inline-moderation-form.tsx +19 -20
  50. package/src/components/moderation/queue-form.tsx +5 -7
  51. package/src/components/moderation/report-forms.tsx +8 -10
  52. package/src/components/moderation/signature-lock-form.tsx +5 -5
  53. package/src/components/moderation/thread-surgery-form.tsx +8 -5
  54. package/src/components/moderation/thread-tools-form.tsx +11 -22
  55. package/src/components/moderation/warning-forms.tsx +6 -6
  56. package/src/components/shell/onboarding-banner.tsx +6 -2
  57. package/src/components/shell/theme-switcher.tsx +6 -6
  58. package/src/components/shell/view-tabs.tsx +12 -7
  59. package/src/server/container.ts +4 -0
  60. package/src/server/marketplace-actions.ts +2 -2
  61. package/src/server/marketplace-admin.ts +24 -116
  62. package/src/server/theme-admin.ts +2 -0
  63. package/src/server/upgrade-notice.ts +1 -1
  64. package/app/admin/api/marketplace/screenshot/route.ts +0 -63
  65. package/app/admin/plugins/browse/page.tsx +0 -70
  66. package/app/admin/themes/browse/page.tsx +0 -70
  67. package/src/components/admin/marketplace-listing.tsx +0 -114
  68. package/src/view/marketplace-panel.ts +0 -51
@@ -1,6 +1,6 @@
1
1
  import 'server-only'
2
2
 
3
- import { env, logger, readPluginEnv } from '@meith/core'
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: null })
56
+ map.set(theme.key, { enabled: theme.enabled, version: theme.version })
120
57
  }
121
58
  return map
122
59
  }
123
60
 
124
- function screenshotHref(listingKey: string, index: number): string {
125
- return `/admin/api/marketplace/screenshot?key=${encodeURIComponent(listingKey)}&index=${index}`
126
- }
127
-
128
- function toRow(
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 marketplaceCatalog(kind: ListingKind): Promise<MarketplaceCatalogView> {
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 listings = (cache.feed?.listings ?? [])
159
- .filter((listing) => listing.kind === kind)
160
- .map((listing) => toRow(listing, installed.get(listing.key) ?? null))
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
- listings,
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 pluginInstalledEntries()
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' ? (pluginEntries.get(listing.key) ?? null) : null,
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/browse',
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.24.0'
9
+ export const CODE_VERSION = '0.25.1'
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
- }