@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
@@ -119,11 +119,11 @@ export default async function ReputationPage({
119
119
  )}
120
120
 
121
121
  {view.rows.length === 0 ? (
122
- <p className="text-sm text-muted-foreground">
122
+ <p className="rounded-lg border border-border bg-card p-4 text-sm text-muted-foreground">
123
123
  {await tr('page.nobody-has-rated-them-yet')}
124
124
  </p>
125
125
  ) : (
126
- <ul className="flex flex-col divide-y divide-border rounded-lg border border-border">
126
+ <ul className="flex flex-col divide-y divide-border rounded-lg border border-border bg-card">
127
127
  {view.rows.map((row) => (
128
128
  <li key={row.id} className="flex flex-col gap-1 px-4 py-3">
129
129
  <div className="flex flex-wrap items-baseline gap-2 text-sm">
@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
2
2
  import { notFound } from 'next/navigation'
3
3
 
4
4
  import { postBodyHtml } from '@meith/markdown'
5
+ import { Card, CardContent, CardFooter } from '@meith/ui'
5
6
 
6
7
  import { PanelPage } from '@/components/shell/panel-page'
7
8
  import { getContainer } from '@/server/container'
@@ -63,27 +64,34 @@ export default async function MessagePage({ params }: { params: Promise<{ id: st
63
64
  }
64
65
  : {})}
65
66
  >
66
- <article
67
- className="prose prose-sm max-w-none break-words"
68
- dangerouslySetInnerHTML={{ __html: view.bodyHtml }}
69
- />
67
+ <Card>
68
+ <CardContent className="p-4 sm:p-5">
69
+ <article
70
+ className="prose prose-sm max-w-none break-words"
71
+ dangerouslySetInnerHTML={{ __html: view.bodyHtml }}
72
+ />
73
+ </CardContent>
70
74
 
71
- <div className="flex flex-wrap items-center gap-4 border-t border-border pt-4">
72
- {view.actions.map((action) => (
73
- <a
74
- key={action.href}
75
- href={action.href}
76
- className="text-sm font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
77
- >
78
- {action.label}
79
- </a>
80
- ))}
81
- {view.reportHref !== null && (
82
- <a href={view.reportHref} className="text-sm text-muted-foreground hover:text-foreground">
83
- {await tr('page.report-this-message')}
84
- </a>
85
- )}
86
- </div>
75
+ <CardFooter className="gap-4">
76
+ {view.actions.map((action) => (
77
+ <a
78
+ key={action.href}
79
+ href={action.href}
80
+ className="text-sm font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
81
+ >
82
+ {action.label}
83
+ </a>
84
+ ))}
85
+ {view.reportHref !== null && (
86
+ <a
87
+ href={view.reportHref}
88
+ className="text-sm text-muted-foreground hover:text-foreground"
89
+ >
90
+ {await tr('page.report-this-message')}
91
+ </a>
92
+ )}
93
+ </CardFooter>
94
+ </Card>
87
95
 
88
96
  {view.tracking.length > 0 && (
89
97
  <section className="flex flex-col gap-2 rounded-lg border border-border bg-card p-4">
@@ -110,7 +110,7 @@ export default async function MessagesPage({
110
110
  ) : null}
111
111
 
112
112
  {view.rows.length === 0 ? (
113
- <p className="text-sm text-muted-foreground">
113
+ <p className="rounded-lg border border-border bg-card p-4 text-sm text-muted-foreground">
114
114
  {folder === 'inbox'
115
115
  ? translator.t('board.messages.emptyInbox')
116
116
  : folder === 'sent'
@@ -125,7 +125,7 @@ export default async function MessagesPage({
125
125
  copy={messageFormsCopy(await getTranslator())}
126
126
  />
127
127
 
128
- <ul className="flex flex-col divide-y divide-border rounded-lg border border-border">
128
+ <ul className="flex flex-col divide-y divide-border rounded-lg border border-border bg-card">
129
129
  {view.rows.map((row) => (
130
130
  <li key={row.copyId} className="flex items-start gap-3 px-4 py-3">
131
131
  <input
@@ -79,7 +79,9 @@ export default async function NotificationsPage({
79
79
  {notice !== null && <BoardNotice kind="info" message={notice} dismissHref="/notifications" />}
80
80
 
81
81
  {view.rows.length === 0 ? (
82
- <p className="text-sm text-muted-foreground">{translator.t('board.notifications.empty')}</p>
82
+ <p className="rounded-lg border border-border bg-card p-4 text-sm text-muted-foreground">
83
+ {translator.t('board.notifications.empty')}
84
+ </p>
83
85
  ) : (
84
86
  <ul className="flex flex-col gap-3">
85
87
  {view.rows.map((row) => (
@@ -65,7 +65,9 @@ export default async function SubscriptionsPage({
65
65
  {notice !== null && <BoardNotice kind="info" message={notice} dismissHref="/subscriptions" />}
66
66
 
67
67
  {view.total === 0 ? (
68
- <p className="text-sm text-muted-foreground">{await tr('page.subscriptions.none')}</p>
68
+ <p className="rounded-lg border border-border bg-card p-4 text-sm text-muted-foreground">
69
+ {await tr('page.subscriptions.none')}
70
+ </p>
69
71
  ) : (
70
72
  <>
71
73
  <Section
@@ -110,7 +112,9 @@ function Section({
110
112
  <h2 className="font-heading text-lg font-semibold">{title}</h2>
111
113
 
112
114
  {rows.length === 0 ? (
113
- <p className="text-sm text-muted-foreground">{empty}</p>
115
+ <p className="rounded-lg border border-border bg-card p-4 text-sm text-muted-foreground">
116
+ {empty}
117
+ </p>
114
118
  ) : (
115
119
  <ul className="flex flex-col gap-3">
116
120
  {rows.map((row) => (
@@ -5,7 +5,7 @@ import { MAX_RELATIONS } from '@meith/relations'
5
5
 
6
6
  import { RemoveRelationForm } from '@/components/account/relation-forms'
7
7
  import { BoardNotice } from '@/components/shell/board-notice'
8
- import { PANEL_LIST } from '@/components/shell/panel-list'
8
+ import { PANEL_LIST, PANEL_NOTE } from '@/components/shell/panel-list'
9
9
  import { PanelPage } from '@/components/shell/panel-page'
10
10
  import { getActor } from '@/server/context'
11
11
  import { getTranslator, tr } from '@/server/i18n'
@@ -68,9 +68,7 @@ export default async function ContactsPage({
68
68
  </h2>
69
69
 
70
70
  {view.buddies.length === 0 ? (
71
- <p className="text-sm text-muted-foreground">
72
- {translator.t('board.contacts.emptyBuddies')}
73
- </p>
71
+ <p className={PANEL_NOTE}>{translator.t('board.contacts.emptyBuddies')}</p>
74
72
  ) : (
75
73
  <ul className={PANEL_LIST}>
76
74
  {view.buddies.map((row) => (
@@ -89,9 +87,7 @@ export default async function ContactsPage({
89
87
  </p>
90
88
 
91
89
  {view.ignored.length === 0 ? (
92
- <p className="text-sm text-muted-foreground">
93
- {translator.t('board.contacts.emptyIgnored')}
94
- </p>
90
+ <p className={PANEL_NOTE}>{translator.t('board.contacts.emptyIgnored')}</p>
95
91
  ) : (
96
92
  <ul className={PANEL_LIST}>
97
93
  {view.ignored.map((row) => (
@@ -1,6 +1,7 @@
1
1
  import type { Metadata } from 'next'
2
2
 
3
3
  import { IssueTokenForm, RevokeTokenForm } from '@/components/admin/api-token-forms'
4
+ import { PANEL_CARD } from '@/components/shell/panel-list'
4
5
  import { PanelPage } from '@/components/shell/panel-page'
5
6
  import { adminPageContext } from '@/server/admin'
6
7
  import { buildApiTokenView } from '@/server/api-tokens-admin'
@@ -47,12 +48,12 @@ export default async function AdminApiTokensPage() {
47
48
  }
48
49
  gap="loose"
49
50
  >
50
- <section className="flex flex-col gap-3">
51
+ <section className={PANEL_CARD}>
51
52
  <h2 className="font-heading text-lg font-semibold">{await tr('page.issue-token')}</h2>
52
53
  <IssueTokenForm scopes={view.scopes} copy={copy} />
53
54
  </section>
54
55
 
55
- <section className="flex flex-col gap-3">
56
+ <section className={PANEL_CARD}>
56
57
  <h2 className="font-heading text-lg font-semibold">{await tr('page.existing-tokens')}</h2>
57
58
 
58
59
  {view.tokens.length === 0 ? (
@@ -47,28 +47,31 @@ export default async function AdminForumPage({ params }: { params: Promise<{ id:
47
47
  lede={t.t('adminForums.optionsLede')}
48
48
  gap="loose"
49
49
  >
50
- <ForumOptionsForm
51
- forum={{
52
- id: forum.id,
53
- type: forum.type,
54
- title: options.title,
55
- slug: options.slug,
56
- description: options.description ?? '',
57
- linkUrl: options.linkUrl ?? '',
58
- displayOrder: options.displayOrder,
59
- flags: {
60
- isOpen: options.isOpen,
61
- allowThreads: options.allowThreads,
62
- allowReplies: options.allowReplies,
63
- allowPolls: options.allowPolls,
64
- allowAttachments: options.allowAttachments,
65
- requiresPrefix: options.requiresPrefix,
66
- moderateNewThreads: options.moderateNewThreads,
67
- moderateNewPosts: options.moderateNewPosts,
68
- },
69
- }}
70
- copy={forumAdminCopy(t)}
71
- />
50
+ <section className={PANEL_CARD}>
51
+ <h2 className="font-heading text-lg font-semibold">{await tr('page.forum-options')}</h2>
52
+ <ForumOptionsForm
53
+ forum={{
54
+ id: forum.id,
55
+ type: forum.type,
56
+ title: options.title,
57
+ slug: options.slug,
58
+ description: options.description ?? '',
59
+ linkUrl: options.linkUrl ?? '',
60
+ displayOrder: options.displayOrder,
61
+ flags: {
62
+ isOpen: options.isOpen,
63
+ allowThreads: options.allowThreads,
64
+ allowReplies: options.allowReplies,
65
+ allowPolls: options.allowPolls,
66
+ allowAttachments: options.allowAttachments,
67
+ requiresPrefix: options.requiresPrefix,
68
+ moderateNewThreads: options.moderateNewThreads,
69
+ moderateNewPosts: options.moderateNewPosts,
70
+ },
71
+ }}
72
+ copy={forumAdminCopy(t)}
73
+ />
74
+ </section>
72
75
 
73
76
  <section className={PANEL_CARD}>
74
77
  <h2 className="font-heading text-lg font-semibold">{t.t('adminForums.move')}</h2>
@@ -44,13 +44,13 @@ export default async function AdminMembershipsPage() {
44
44
  }))}
45
45
  copy={groupAdminCopy(translator)}
46
46
  />
47
- </section>
48
47
 
49
- <p className="text-xs text-muted-foreground">
50
- {translator.t('adminGroups.membershipsBefore')}{' '}
51
- <strong>{translator.t('adminGroups.primary')}</strong>
52
- {translator.t('adminGroups.membershipsAfter')}
53
- </p>
48
+ <p className="border-t border-border pt-4 text-xs text-muted-foreground">
49
+ {translator.t('adminGroups.membershipsBefore')}{' '}
50
+ <strong>{translator.t('adminGroups.primary')}</strong>
51
+ {translator.t('adminGroups.membershipsAfter')}
52
+ </p>
53
+ </section>
54
54
  </PanelPage>
55
55
  )
56
56
  }
@@ -1,13 +1,11 @@
1
1
  import type { Metadata } from 'next'
2
2
 
3
- import { cn } from '@meith/ui'
4
-
5
3
  import {
6
4
  ApplyPromotionsForm,
7
5
  NewPromotionRuleForm,
8
6
  PromotionRuleRowForm,
9
7
  } from '@/components/admin/group-forms'
10
- import { PANEL_CARD, PANEL_LIST, PANEL_NOTE } from '@/components/shell/panel-list'
8
+ import { PANEL_CARD } from '@/components/shell/panel-list'
11
9
  import { PanelPage } from '@/components/shell/panel-page'
12
10
  import { adminPageContext } from '@/server/admin'
13
11
  import {
@@ -93,7 +91,7 @@ export default async function AdminPromotionsPage() {
93
91
  <NewPromotionRuleForm groups={options} copy={copy} />
94
92
  </section>
95
93
 
96
- <section className="flex flex-col gap-3">
94
+ <section className={PANEL_CARD}>
97
95
  <h2 className="font-heading text-lg font-semibold">
98
96
  {await tr('page.what-would-happen-now')}
99
97
  </h2>
@@ -102,24 +100,24 @@ export default async function AdminPromotionsPage() {
102
100
  </p>
103
101
 
104
102
  {result.complete ? null : (
105
- <p className={PANEL_NOTE}>
103
+ <p className="text-sm text-muted-foreground">
106
104
  {translator.t('adminGroups.previewCapped', { count: result.examined })}
107
105
  </p>
108
106
  )}
109
107
 
110
108
  {result.outcomes.length === 0 ? (
111
- <p className={PANEL_NOTE}>
109
+ <p className="text-sm text-muted-foreground">
112
110
  {stored.length === 0
113
111
  ? translator.t('adminGroups.nobodyNoRules')
114
112
  : translator.t('adminGroups.nobodyQualified')}
115
113
  </p>
116
114
  ) : (
117
115
  <>
118
- <ul className={cn(PANEL_LIST, 'text-sm')}>
116
+ <ul className="flex flex-col divide-y divide-border text-sm">
119
117
  {result.outcomes.map((outcome) => (
120
118
  <li
121
119
  key={`${outcome.userId}:${outcome.ruleId}`}
122
- className="flex flex-wrap items-baseline gap-2 px-4 py-2"
120
+ className="flex flex-wrap items-baseline gap-2 py-2 first:pt-0 last:pb-0"
123
121
  >
124
122
  <span className="font-medium">
125
123
  {translator.t('adminGroups.member', { id: outcome.userId })}
@@ -132,18 +130,18 @@ export default async function AdminPromotionsPage() {
132
130
  ))}
133
131
  </ul>
134
132
 
135
- <section className={PANEL_CARD}>
136
- <h3 className="font-heading text-lg font-semibold">{await tr('page.run-it')}</h3>
133
+ <div className="flex flex-col gap-3 border-t border-border pt-4">
134
+ <h3 className="font-heading text-base font-semibold">{await tr('page.run-it')}</h3>
137
135
  <ApplyPromotionsForm count={result.outcomes.length} copy={copy} />
138
- </section>
136
+ </div>
139
137
  </>
140
138
  )}
141
- </section>
142
139
 
143
- <p className="text-xs text-muted-foreground">
144
- {translator.t('adminGroups.promotionNoteBefore')} <code>promotions.apply</code>
145
- {translator.t('adminGroups.promotionNoteEnd')}
146
- </p>
140
+ <p className="border-t border-border pt-4 text-xs text-muted-foreground">
141
+ {translator.t('adminGroups.promotionNoteBefore')} <code>promotions.apply</code>
142
+ {translator.t('adminGroups.promotionNoteEnd')}
143
+ </p>
144
+ </section>
147
145
  </PanelPage>
148
146
  )
149
147
  }
@@ -1,6 +1,8 @@
1
1
  import type { Metadata } from 'next'
2
2
 
3
- import { PANEL_LIST } from '@/components/shell/panel-list'
3
+ import { cn } from '@meith/ui'
4
+
5
+ import { PANEL_CARD, PANEL_LIST, PANEL_NOTE } from '@/components/shell/panel-list'
4
6
  import { PanelPage } from '@/components/shell/panel-page'
5
7
  import { PanelPagination } from '@/components/shell/panel-pagination'
6
8
  import { adminPageContext } from '@/server/admin'
@@ -52,7 +54,7 @@ export default async function AdminLogPage({
52
54
  lede={await tr('page.every-administrative-moderation-action-newest')}
53
55
  width="wide"
54
56
  >
55
- <form method="get" className="flex flex-wrap items-end gap-2">
57
+ <form method="get" className={cn(PANEL_CARD, 'flex-row flex-wrap items-end')}>
56
58
  <label className="flex flex-col gap-1 text-sm">
57
59
  <span className="font-medium">{translator.t('adminLog.action')}</span>
58
60
  <select
@@ -77,7 +79,7 @@ export default async function AdminLogPage({
77
79
  </form>
78
80
 
79
81
  {view.rows.length === 0 ? (
80
- <p className="text-sm text-muted-foreground">{await tr('page.nothing-logged')}</p>
82
+ <p className={PANEL_NOTE}>{await tr('page.nothing-logged')}</p>
81
83
  ) : (
82
84
  <ul className={PANEL_LIST}>
83
85
  {view.rows.map((row) => (
@@ -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 { catalogTabs } from '@/view/marketplace-panel'
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
- <ViewTabs
38
- label={t.t('adminMarketplace.tabsLabel')}
39
- tabs={catalogTabs({
40
- installedHref: '/admin/plugins',
41
- browseHref: '/admin/plugins/browse',
42
- current: 'installed',
43
- t,
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) => (
@@ -1,8 +1,9 @@
1
1
  import type { Metadata } from 'next'
2
2
 
3
3
  import { AUTH_EVENT_KINDS } from '@meith/accounts'
4
+ import { cn } from '@meith/ui'
4
5
 
5
- import { PANEL_LIST, PANEL_ROW } from '@/components/shell/panel-list'
6
+ import { PANEL_CARD, PANEL_LIST, PANEL_NOTE, PANEL_ROW } from '@/components/shell/panel-list'
6
7
  import { PanelPage } from '@/components/shell/panel-page'
7
8
  import { adminPageContext } from '@/server/admin'
8
9
  import { boardSecurityActivity } from '@/server/auth-events'
@@ -51,7 +52,7 @@ export default async function AdminSecurityPage({
51
52
  lede={translator.t('adminSecurity.lede')}
52
53
  width="wide"
53
54
  >
54
- <form method="get" className="flex flex-wrap items-end gap-2">
55
+ <form method="get" className={cn(PANEL_CARD, 'flex-row flex-wrap items-end')}>
55
56
  <label className="flex flex-col gap-1 text-sm">
56
57
  <span className="font-medium">{translator.t('adminSecurity.kind')}</span>
57
58
  <select
@@ -76,7 +77,7 @@ export default async function AdminSecurityPage({
76
77
  </form>
77
78
 
78
79
  {page.length === 0 ? (
79
- <p className="text-sm text-muted-foreground">{await tr('page.nothing-recorded-yet')}</p>
80
+ <p className={PANEL_NOTE}>{await tr('page.nothing-recorded-yet')}</p>
80
81
  ) : (
81
82
  <ul className={PANEL_LIST}>
82
83
  {page.map((event) => (
@@ -11,7 +11,7 @@ import {
11
11
  ReindexSearchForm,
12
12
  RetryJobForm,
13
13
  } from '@/components/admin/system-forms'
14
- import { PANEL_CARD, PANEL_LIST, PANEL_NOTE, PANEL_ROW } from '@/components/shell/panel-list'
14
+ import { PANEL_CARD } from '@/components/shell/panel-list'
15
15
  import { PanelPage } from '@/components/shell/panel-page'
16
16
  import { adminPageContext } from '@/server/admin'
17
17
  import { getTranslator, tr } from '@/server/i18n'
@@ -122,7 +122,7 @@ export default async function AdminSystemPage() {
122
122
  </section>
123
123
  )}
124
124
 
125
- <section className="flex flex-col gap-3">
125
+ <section className={PANEL_CARD}>
126
126
  <h2 className="font-heading text-lg font-semibold">{translator.t('adminSystem.mail')}</h2>
127
127
  <p className="text-sm">
128
128
  {mail.summary}
@@ -152,14 +152,17 @@ export default async function AdminSystemPage() {
152
152
  <p className="text-sm text-muted-foreground">{translator.t('adminSystem.mailSchedule')}</p>
153
153
  </section>
154
154
 
155
- <section className="flex flex-col gap-3">
155
+ <section className={PANEL_CARD}>
156
156
  <h2 className="font-heading text-lg font-semibold">{await tr('page.scheduled-tasks')}</h2>
157
157
  {scheduler.tasks.length === 0 ? (
158
- <p className={PANEL_NOTE}>{translator.t('adminSystem.noTasks')}</p>
158
+ <p className="text-sm text-muted-foreground">{translator.t('adminSystem.noTasks')}</p>
159
159
  ) : (
160
- <ul className={PANEL_LIST}>
160
+ <ul className="flex flex-col divide-y divide-border">
161
161
  {scheduler.tasks.map((task) => (
162
- <li key={task.key} className={PANEL_ROW}>
162
+ <li
163
+ key={task.key}
164
+ className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 py-3 first:pt-0 last:pb-0"
165
+ >
163
166
  <span className="flex min-w-0 flex-col">
164
167
  <span className="truncate text-sm font-medium">
165
168
  {task.titleKey === undefined ? task.key : translator.t(task.titleKey)}
@@ -198,7 +201,7 @@ export default async function AdminSystemPage() {
198
201
  )}
199
202
  </section>
200
203
 
201
- <section className="flex flex-col gap-3">
204
+ <section className={PANEL_CARD}>
202
205
  <h2 className="font-heading text-lg font-semibold">{await tr('page.recent-runs')}</h2>
203
206
  {view.runs.length === 0 ? (
204
207
  <p className="text-sm text-muted-foreground">{await tr('page.nothing-has-run-yet')}</p>
@@ -227,7 +230,7 @@ export default async function AdminSystemPage() {
227
230
  )}
228
231
  </section>
229
232
 
230
- <section className="flex flex-col gap-3">
233
+ <section className={PANEL_CARD}>
231
234
  <h2 className="font-heading text-lg font-semibold">
232
235
  {translator.t('adminSystem.volumes')}
233
236
  </h2>
@@ -243,7 +246,7 @@ export default async function AdminSystemPage() {
243
246
  </ul>
244
247
  </section>
245
248
 
246
- <section className="flex flex-col gap-3">
249
+ <section className={PANEL_CARD}>
247
250
  <h2 className="font-heading text-lg font-semibold">
248
251
  {translator.t('adminSystem.recount')}
249
252
  </h2>
@@ -266,7 +269,7 @@ export default async function AdminSystemPage() {
266
269
  <RecountForm copy={copy} />
267
270
  </section>
268
271
 
269
- <section className="flex flex-col gap-3">
272
+ <section className={PANEL_CARD}>
270
273
  <h2 className="font-heading text-lg font-semibold">{await tr('page.search-index')}</h2>
271
274
  <p className="text-sm text-muted-foreground">
272
275
  {translator.t('adminSystem.searchIndexHint')}
@@ -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
- <ViewTabs
40
- label={translator.t('adminMarketplace.tabsLabel')}
41
- tabs={catalogTabs({
42
- installedHref: '/admin/themes',
43
- browseHref: '/admin/themes/browse',
44
- current: 'installed',
45
- t: translator,
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