@meith/plugin-awards 0.37.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.
@@ -0,0 +1,217 @@
1
+ import type {
2
+ PluginPageContext,
3
+ PluginRegionContext,
4
+ PluginRuntimeContext,
5
+ } from '@meith/plugin-kit'
6
+ import { surfaceVariants, textLinkVariants } from '@meith/ui'
7
+
8
+ import { asId, postbitLimit } from '../awards'
9
+ import { cachedAwards } from '../display-cache'
10
+ import { allAwards, awardById, awardRules, type GrantRow, memberGrants } from '../store'
11
+ import { ruleSummary } from './rules'
12
+ import { date, icon, type TextContext, translated } from './shared'
13
+
14
+ export async function AwardsPage(context: PluginPageContext) {
15
+ const awards = await allAwards(context.data, true)
16
+ const rules = await awardRules(context.data, true)
17
+ return (
18
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
19
+ {awards.length === 0 && <p>{translated(context, 'awards.empty')}</p>}
20
+ {awards.map((award) => (
21
+ <article
22
+ key={award.id}
23
+ className={surfaceVariants({ padded: true, className: 'flex flex-col gap-3' })}
24
+ >
25
+ <div className="flex items-center gap-3">
26
+ {icon(award)}
27
+ <h2 className="font-semibold">
28
+ <a href={`/plugins/awards/award?id=${award.id}`} className={textLinkVariants()}>
29
+ {award.name}
30
+ </a>
31
+ </h2>
32
+ </div>
33
+ <p className="whitespace-pre-wrap text-sm">{award.description}</p>
34
+ <p className="text-sm text-muted-foreground">
35
+ {translated(context, 'awards.holders')}: {award.count}
36
+ </p>
37
+ <div className="text-sm">
38
+ {rules.some((rule) => rule.awardId === Number(award.id)) ? (
39
+ rules
40
+ .filter((rule) => rule.awardId === Number(award.id))
41
+ .map((rule) => <p key={rule.id}>{ruleSummary(context, rule)}</p>)
42
+ ) : (
43
+ <p>{translated(context, 'awards.staff')}</p>
44
+ )}
45
+ </div>
46
+ </article>
47
+ ))}
48
+ </div>
49
+ )
50
+ }
51
+
52
+ export async function AwardPage(context: PluginPageContext) {
53
+ const id = asId(context.query.id)
54
+ const award = id === null ? null : await awardById(context.data, id)
55
+ if (award === null || award.archived_at !== null)
56
+ return <p>{translated(context, 'awards.notice.missing')}</p>
57
+ const page = Math.min(asId(context.query.page) ?? 1, 1_000_000)
58
+ const holders = await context.data.query<{
59
+ user_id: number
60
+ count: number
61
+ granted_at: string | Date
62
+ }>(
63
+ `select user_id, count(*)::int as count, max(granted_at) as granted_at
64
+ from plugin_awards_grant where award_id = $1 group by user_id
65
+ order by max(granted_at) desc, user_id limit 51 offset $2`,
66
+ [id, (page - 1) * 50],
67
+ )
68
+ const visible = holders.slice(0, 50)
69
+ const members = await context.users.standing(visible.map((holder) => Number(holder.user_id)))
70
+ const names = new Map(members.map((member) => [member.userId, member.username]))
71
+ return (
72
+ <section className={surfaceVariants({ padded: true, className: 'flex flex-col gap-4' })}>
73
+ <div className="flex items-center gap-3">
74
+ {icon(award)}
75
+ <h2 className="text-lg font-semibold">{award.name}</h2>
76
+ </div>
77
+ <p className="whitespace-pre-wrap">{award.description}</p>
78
+ <h3 className="font-semibold">{translated(context, 'awards.holders')}</h3>
79
+ <ul className="flex flex-col gap-2">
80
+ {visible.map((holder) => (
81
+ <li key={holder.user_id} className="flex flex-wrap gap-3">
82
+ <a className={textLinkVariants()} href={`/plugins/awards/member?id=${holder.user_id}`}>
83
+ {names.get(Number(holder.user_id)) ?? translated(context, 'awards.deletedMember')}
84
+ </a>
85
+ <span>×{holder.count}</span>
86
+ <span>{date(context, holder.granted_at)}</span>
87
+ </li>
88
+ ))}
89
+ </ul>
90
+ <nav className="flex gap-4" aria-label={translated(context, 'awards.pagination')}>
91
+ {page > 1 && (
92
+ <a
93
+ href={`/plugins/awards/award?id=${id}&page=${page - 1}`}
94
+ className={textLinkVariants()}
95
+ >
96
+ {translated(context, 'awards.previous')}
97
+ </a>
98
+ )}
99
+ {holders.length > 50 && (
100
+ <a
101
+ href={`/plugins/awards/award?id=${id}&page=${page + 1}`}
102
+ className={textLinkVariants()}
103
+ >
104
+ {translated(context, 'awards.next')}
105
+ </a>
106
+ )}
107
+ </nav>
108
+ </section>
109
+ )
110
+ }
111
+
112
+ export async function memberList(context: TextContext & PluginRuntimeContext, userId: number) {
113
+ const grants = await memberGrants(context.data, userId)
114
+ const grouped = new Map<number, GrantRow[]>()
115
+ for (const grant of grants) {
116
+ const items = grouped.get(Number(grant.id)) ?? []
117
+ items.push(grant)
118
+ grouped.set(Number(grant.id), items)
119
+ }
120
+ return (
121
+ <ul className="flex flex-col gap-4">
122
+ {[...grouped].map(([id, items]) => {
123
+ const award = items[0]!
124
+ return (
125
+ <li key={id} className="flex gap-3">
126
+ {icon(award)}
127
+ <div>
128
+ <a className={textLinkVariants()} href={`/plugins/awards/award?id=${id}`}>
129
+ {award.name}
130
+ </a>
131
+ <span> ×{items.length}</span>
132
+ <ul className="text-sm text-muted-foreground">
133
+ {items.map((grant) => (
134
+ <li key={grant.grant_id}>
135
+ <time dateTime={new Date(grant.granted_at).toISOString()}>
136
+ {date(context, grant.granted_at)}
137
+ </time>
138
+ {context.settings.show_reasons !== false &&
139
+ grant.rule_id === null &&
140
+ grant.reason !== '' && <span> · {grant.reason}</span>}
141
+ </li>
142
+ ))}
143
+ </ul>
144
+ </div>
145
+ </li>
146
+ )
147
+ })}
148
+ {grants.length === 0 && <li>{translated(context, 'awards.member.empty')}</li>}
149
+ </ul>
150
+ )
151
+ }
152
+
153
+ export async function MemberPage(context: PluginPageContext) {
154
+ const id = asId(context.query.id)
155
+ const member = id === null ? undefined : (await context.users.standing([id]))[0]
156
+ if (member === undefined) return <p>{translated(context, 'awards.notice.unknown')}</p>
157
+ return (
158
+ <section className={surfaceVariants({ padded: true, className: 'flex flex-col gap-4' })}>
159
+ <h2 className="font-semibold">
160
+ <a href={`/member/${member.userId}`} className={textLinkVariants()}>
161
+ {member.username}
162
+ </a>
163
+ </h2>
164
+ {await memberList(context, member.userId)}
165
+ </section>
166
+ )
167
+ }
168
+
169
+ export async function PostbitBadges(context: PluginRegionContext) {
170
+ if (context.authorId === null) return null
171
+ const runtime = await context.runtime()
172
+ const limit = postbitLimit(runtime.settings)
173
+ if (limit === 0) return null
174
+ const awards = await cachedAwards(runtime.data, context.authorId, limit)
175
+ const more = Number(awards[0]?.total ?? 0) - limit
176
+ const href = `/plugins/awards/member?id=${context.authorId}`
177
+ return (
178
+ <span className="flex flex-wrap items-center gap-1">
179
+ {awards.slice(0, limit).map((award) => (
180
+ <a key={award.id} href={href} title={award.name} aria-label={award.name}>
181
+ {icon(award)}
182
+ </a>
183
+ ))}
184
+ {more > 0 && (
185
+ <a href={href} title={translated(context, 'awards.more')}>
186
+ +{more}
187
+ </a>
188
+ )}
189
+ </span>
190
+ )
191
+ }
192
+
193
+ export async function ProfilePanel(context: PluginRegionContext) {
194
+ if (context.subjectId === null) return null
195
+ const runtime = await context.runtime()
196
+ return (
197
+ <section className="flex flex-col gap-3">
198
+ <h2 className="font-semibold">{translated(context, 'awards.title')}</h2>
199
+ {await memberList({ ...runtime, t: context.t, locale: context.locale }, context.subjectId)}
200
+ </section>
201
+ )
202
+ }
203
+
204
+ export async function Dashboard(context: PluginRegionContext) {
205
+ const { data } = await context.runtime()
206
+ const row = await data.one<{ count: number }>(
207
+ `select count(*)::int as count from plugin_awards_grant where granted_at >= now() - interval '7 days'`,
208
+ )
209
+ return (
210
+ <p>
211
+ <a href="/admin/plugins/awards/grant" className={textLinkVariants()}>
212
+ {translated(context, 'awards.dashboard')}
213
+ </a>
214
+ : {row?.count ?? 0}
215
+ </p>
216
+ )
217
+ }
@@ -0,0 +1,126 @@
1
+ import type { PluginAdminPageContext } from '@meith/plugin-kit'
2
+ import { controlVariants, surfaceVariants, textLinkVariants } from '@meith/ui'
3
+
4
+ import { asId } from '../awards'
5
+ import { type AwardRule, CRITERIA } from '../rules'
6
+ import { allAwards, awardRules } from '../store'
7
+ import {
8
+ action,
9
+ button,
10
+ checkbox,
11
+ date,
12
+ field,
13
+ notice,
14
+ type TextContext,
15
+ translated,
16
+ } from './shared'
17
+
18
+ const LABELS = {
19
+ minPostCount: 'awards.rule.posts',
20
+ minThreadCount: 'awards.rule.threads',
21
+ minReputation: 'awards.rule.reputation',
22
+ minDaysRegistered: 'awards.rule.days',
23
+ } as const
24
+ const FIELDS = {
25
+ minPostCount: 'min_post_count',
26
+ minThreadCount: 'min_thread_count',
27
+ minReputation: 'min_reputation',
28
+ minDaysRegistered: 'min_days_registered',
29
+ } as const
30
+
31
+ export function ruleSummary(context: TextContext, rule: AwardRule): string {
32
+ return `${translated(context, 'awards.rule.all')} ${CRITERIA.filter((key) => rule[key] !== null)
33
+ .map((key) => `${translated(context, LABELS[key])}: ${rule[key]}`)
34
+ .join(' · ')}`
35
+ }
36
+
37
+ export async function RulesAdmin(context: PluginAdminPageContext) {
38
+ const rules = await awardRules(context.data)
39
+ const awards = await allAwards(context.data)
40
+ const names = new Map(awards.map((award) => [Number(award.id), award.name]))
41
+ const edit = rules.find((rule) => rule.id === asId(context.query.edit))
42
+ const state = await context.data.one<{ cursor: number; completed_at: string | Date | null }>(
43
+ `select cursor, completed_at from plugin_awards_scan where id = 1`,
44
+ )
45
+ return (
46
+ <div className="flex flex-col gap-4">
47
+ {notice(context, context.query.notice)}
48
+ <section className={surfaceVariants({ padded: true, className: 'flex flex-col gap-3' })}>
49
+ <p>
50
+ {translated(context, 'awards.rule.cursor')}: {state?.cursor ?? 0}
51
+ </p>
52
+ <p>
53
+ {translated(context, 'awards.rule.completed')}:{' '}
54
+ {state?.completed_at == null
55
+ ? translated(context, 'awards.rule.never')
56
+ : date(context, state.completed_at)}
57
+ </p>
58
+ <div className="flex flex-wrap gap-3">
59
+ {action(context, 'rules', 1, 'run', 'awards.rule.run')}
60
+ {action(context, 'rules', 1, 'reset', 'awards.rule.reset')}
61
+ </div>
62
+ <p className="text-sm text-muted-foreground">{translated(context, 'awards.rule.budget')}</p>
63
+ </section>
64
+ <form
65
+ method="post"
66
+ action="/admin/api/plugins/awards/rules"
67
+ className={surfaceVariants({ padded: true, className: 'grid gap-3 sm:grid-cols-2' })}
68
+ >
69
+ <h2 className="font-semibold sm:col-span-2">
70
+ {translated(context, edit === undefined ? 'awards.rule.create' : 'awards.rule.edit')}
71
+ </h2>
72
+ {edit !== undefined && <input type="hidden" name="id" value={edit.id} />}
73
+ {field(context, 'title', 'awards.rule.title', edit?.title ?? '', 'text', true)}
74
+ <label className="flex flex-col gap-1">
75
+ {translated(context, 'awards.award')}
76
+ <select
77
+ name="award_id"
78
+ required
79
+ defaultValue={edit?.awardId}
80
+ className={controlVariants()}
81
+ >
82
+ {awards
83
+ .filter((award) => award.archived_at === null)
84
+ .map((award) => (
85
+ <option key={award.id} value={award.id}>
86
+ {award.name}
87
+ </option>
88
+ ))}
89
+ </select>
90
+ </label>
91
+ {CRITERIA.map((key) => (
92
+ <div key={key}>
93
+ {field(context, FIELDS[key], LABELS[key], edit?.[key] ?? '', 'number')}
94
+ </div>
95
+ ))}
96
+ {checkbox(context, 'enabled', 'awards.rule.enabled', edit?.enabled ?? true)}
97
+ <p className="text-sm text-muted-foreground">{translated(context, 'awards.rule.help')}</p>
98
+ <div>{button(context, 'awards.rule.save')}</div>
99
+ </form>
100
+ <ul className="flex flex-col gap-3">
101
+ {rules.map((rule) => (
102
+ <li
103
+ key={rule.id}
104
+ className={surfaceVariants({ padded: true, className: 'flex flex-col gap-2' })}
105
+ >
106
+ <a href={`/admin/plugins/awards/rules?edit=${rule.id}`} className={textLinkVariants()}>
107
+ {rule.title}
108
+ </a>
109
+ <p>{names.get(rule.awardId)}</p>
110
+ <p className="text-sm">{ruleSummary(context, rule)}</p>
111
+ <div className="flex gap-3">
112
+ {action(
113
+ context,
114
+ 'rules',
115
+ rule.id,
116
+ rule.enabled ? 'disable' : 'enable',
117
+ rule.enabled ? 'awards.rule.disable' : 'awards.rule.enable',
118
+ )}
119
+ {action(context, 'rules', rule.id, 'delete', 'awards.delete')}
120
+ </div>
121
+ </li>
122
+ ))}
123
+ </ul>
124
+ </div>
125
+ )
126
+ }
@@ -0,0 +1,134 @@
1
+ import type { PluginPageContext } from '@meith/plugin-kit'
2
+ import { buttonVariants, controlVariants } from '@meith/ui'
3
+
4
+ import { type AwardDraft, ICON_PATHS } from '../awards'
5
+ import en from '../messages/en.json'
6
+
7
+ export type TextContext = Pick<PluginPageContext, 't' | 'locale'>
8
+ export function translated(context: TextContext, key: keyof typeof en): string {
9
+ return context.t.has(key) ? context.t.t(key) : en[key]
10
+ }
11
+ export function date(context: TextContext, value: Date | string): string {
12
+ return new Intl.DateTimeFormat(context.locale, { dateStyle: 'medium', timeZone: 'UTC' }).format(
13
+ new Date(value),
14
+ )
15
+ }
16
+ export function icon(award: Pick<AwardDraft, 'icon' | 'icon_kind'>) {
17
+ if (award.icon_kind === 'image')
18
+ return (
19
+ <img
20
+ src={award.icon}
21
+ alt=""
22
+ width={24}
23
+ height={24}
24
+ loading="lazy"
25
+ className="inline-block size-6 object-contain"
26
+ />
27
+ )
28
+ if (award.icon_kind === 'svg')
29
+ return (
30
+ <svg
31
+ viewBox="0 0 24 24"
32
+ width="24"
33
+ height="24"
34
+ fill="none"
35
+ stroke="currentColor"
36
+ strokeWidth="1.6"
37
+ strokeLinecap="round"
38
+ strokeLinejoin="round"
39
+ aria-hidden="true"
40
+ >
41
+ <path d={ICON_PATHS[award.icon as keyof typeof ICON_PATHS]} />
42
+ </svg>
43
+ )
44
+ return (
45
+ <span aria-hidden="true" className="text-xl">
46
+ {award.icon}
47
+ </span>
48
+ )
49
+ }
50
+ export function field(
51
+ context: TextContext,
52
+ name: string,
53
+ key: keyof typeof en,
54
+ value: string | number = '',
55
+ type = 'text',
56
+ required = false,
57
+ ) {
58
+ return (
59
+ <label className="flex flex-col gap-1">
60
+ {translated(context, key)}
61
+ <input
62
+ className={controlVariants()}
63
+ name={name}
64
+ defaultValue={value}
65
+ type={type}
66
+ required={required}
67
+ {...(type === 'number' ? { min: 0, max: 2147483647, step: 1 } : {})}
68
+ />
69
+ </label>
70
+ )
71
+ }
72
+ export function checkbox(
73
+ context: TextContext,
74
+ name: string,
75
+ key: keyof typeof en,
76
+ checked: boolean,
77
+ ) {
78
+ return (
79
+ <label className="flex items-center gap-2">
80
+ <input type="checkbox" name={name} defaultChecked={checked} />
81
+ {translated(context, key)}
82
+ </label>
83
+ )
84
+ }
85
+ export function button(context: TextContext, key: keyof typeof en) {
86
+ return (
87
+ <button type="submit" className={buttonVariants({ variant: 'primary', size: 'sm' })}>
88
+ {translated(context, key)}
89
+ </button>
90
+ )
91
+ }
92
+ export function action(
93
+ context: TextContext,
94
+ route: string,
95
+ id: number,
96
+ value: string,
97
+ key: keyof typeof en,
98
+ ) {
99
+ return (
100
+ <form method="post" action={`/admin/api/plugins/awards/${route}`}>
101
+ <input type="hidden" name="id" value={id} />
102
+ <input type="hidden" name="action" value={value} />
103
+ {button(context, key)}
104
+ </form>
105
+ )
106
+ }
107
+ export function notice(context: TextContext, value: string | undefined) {
108
+ const notices = {
109
+ evaluated: 'awards.notice.evaluated',
110
+ reset: 'awards.notice.reset',
111
+ 'rule-invalid': 'awards.notice.rule-invalid',
112
+ 'rule-empty': 'awards.notice.rule-empty',
113
+ 'rule-missing': 'awards.notice.rule-missing',
114
+ 'rule-saved': 'awards.notice.rule-saved',
115
+ already: 'awards.notice.already',
116
+ deleted: 'awards.notice.deleted',
117
+ granted: 'awards.notice.granted',
118
+ held: 'awards.notice.held',
119
+ icon: 'awards.notice.icon',
120
+ invalid: 'awards.notice.invalid',
121
+ missing: 'awards.notice.missing',
122
+ multiple: 'awards.notice.multiple',
123
+ partial: 'awards.notice.partial',
124
+ revoked: 'awards.notice.revoked',
125
+ saved: 'awards.notice.saved',
126
+ unknown: 'awards.notice.unknown',
127
+ } as const
128
+ const key = notices[value as keyof typeof notices]
129
+ return key !== undefined ? (
130
+ <p role="status" className="rounded border p-3">
131
+ {translated(context, key as keyof typeof en)}
132
+ </p>
133
+ ) : null
134
+ }