@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.
- package/LICENSE.md +21 -0
- package/README.md +33 -0
- package/package.json +32 -0
- package/src/awards.ts +99 -0
- package/src/definition.ts +116 -0
- package/src/display-cache.ts +48 -0
- package/src/handlers.ts +125 -0
- package/src/index.ts +2 -0
- package/src/messages/en.json +82 -0
- package/src/messages/index.ts +3 -0
- package/src/rules.ts +77 -0
- package/src/schema.ts +38 -0
- package/src/store.ts +259 -0
- package/src/tasks.ts +65 -0
- package/src/ui/admin.tsx +165 -0
- package/src/ui/page.tsx +217 -0
- package/src/ui/rules.tsx +126 -0
- package/src/ui/shared.tsx +134 -0
package/src/schema.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { PluginMigration } from '@meith/plugin-kit'
|
|
2
|
+
|
|
3
|
+
export const AWARDS_MIGRATIONS: readonly PluginMigration[] = [
|
|
4
|
+
{
|
|
5
|
+
id: '0001_awards',
|
|
6
|
+
statements: [
|
|
7
|
+
`create table plugin_awards_award (
|
|
8
|
+
id bigint generated by default as identity primary key,
|
|
9
|
+
name text not null, description text not null default '',
|
|
10
|
+
icon_kind text not null default 'emoji', icon text not null default '🏆',
|
|
11
|
+
display_order int not null default 0, allow_multiple bool not null default false,
|
|
12
|
+
listed bool not null default true, archived_at timestamptz,
|
|
13
|
+
created_at timestamptz not null default now(), updated_at timestamptz not null default now()
|
|
14
|
+
)`,
|
|
15
|
+
`create table plugin_awards_rule (
|
|
16
|
+
id bigint generated by default as identity primary key,
|
|
17
|
+
award_id bigint not null references plugin_awards_award(id) on delete cascade,
|
|
18
|
+
title text not null, enabled bool not null default true,
|
|
19
|
+
min_post_count int, min_thread_count int, min_reputation int, min_days_registered int,
|
|
20
|
+
created_at timestamptz not null default now(), updated_at timestamptz not null default now()
|
|
21
|
+
)`,
|
|
22
|
+
`create table plugin_awards_grant (
|
|
23
|
+
id bigint generated by default as identity primary key,
|
|
24
|
+
award_id bigint not null references plugin_awards_award(id) on delete cascade,
|
|
25
|
+
user_id int not null, rule_id bigint, granted_by_user_id int,
|
|
26
|
+
reason text not null default '', granted_at timestamptz not null default now()
|
|
27
|
+
)`,
|
|
28
|
+
`create index plugin_awards_grant_user on plugin_awards_grant (user_id, granted_at desc)`,
|
|
29
|
+
`create index plugin_awards_grant_award on plugin_awards_grant (award_id, granted_at desc)`,
|
|
30
|
+
`create unique index plugin_awards_grant_rule_user on plugin_awards_grant (rule_id, user_id) where rule_id is not null`,
|
|
31
|
+
`create table plugin_awards_dirty (user_id int primary key, queued_at timestamptz not null default now())`,
|
|
32
|
+
`create table plugin_awards_scan (
|
|
33
|
+
id smallint primary key default 1 check (id = 1), cursor int not null default 0, completed_at timestamptz
|
|
34
|
+
)`,
|
|
35
|
+
`insert into plugin_awards_scan (id) values (1)`,
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
]
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import type { PluginData, PluginRuntimeContext } from '@meith/plugin-kit'
|
|
2
|
+
|
|
3
|
+
import type { Award, AwardDraft, DisplayAward } from './awards'
|
|
4
|
+
import { clearDisplay } from './display-cache'
|
|
5
|
+
import type { AwardRule, AwardRuleInput } from './rules'
|
|
6
|
+
|
|
7
|
+
type AwardRow = Award & Record<string, unknown>
|
|
8
|
+
export interface GrantRow extends AwardRow {
|
|
9
|
+
readonly grant_id: number
|
|
10
|
+
readonly user_id: number
|
|
11
|
+
readonly rule_id: number | null
|
|
12
|
+
readonly reason: string
|
|
13
|
+
readonly granted_at: string | Date
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function allAwards(
|
|
17
|
+
data: PluginData,
|
|
18
|
+
publicOnly = false,
|
|
19
|
+
): Promise<readonly DisplayAward[]> {
|
|
20
|
+
return data.query<DisplayAward>(
|
|
21
|
+
`select a.*, count(distinct g.user_id)::int as count
|
|
22
|
+
from plugin_awards_award a left join plugin_awards_grant g on g.award_id = a.id
|
|
23
|
+
where ($1 = false or (a.listed and a.archived_at is null))
|
|
24
|
+
group by a.id order by a.display_order, a.id`,
|
|
25
|
+
[publicOnly],
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function awardById(data: PluginData, id: number): Promise<AwardRow | null> {
|
|
30
|
+
return data.one<AwardRow>(`select * from plugin_awards_award where id = $1`, [id])
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function saveAward(
|
|
34
|
+
data: PluginData,
|
|
35
|
+
draft: AwardDraft,
|
|
36
|
+
id: number | null,
|
|
37
|
+
): Promise<'saved' | 'missing' | 'multiple'> {
|
|
38
|
+
const params = [
|
|
39
|
+
draft.name,
|
|
40
|
+
draft.description,
|
|
41
|
+
draft.icon_kind,
|
|
42
|
+
draft.icon,
|
|
43
|
+
draft.display_order,
|
|
44
|
+
draft.allow_multiple,
|
|
45
|
+
draft.listed,
|
|
46
|
+
]
|
|
47
|
+
const result = await data.tx(async (tx) => {
|
|
48
|
+
if (id === null) {
|
|
49
|
+
await tx.query(
|
|
50
|
+
`insert into plugin_awards_award
|
|
51
|
+
(name, description, icon_kind, icon, display_order, allow_multiple, listed)
|
|
52
|
+
values ($1, $2, $3, $4, $5, $6, $7)`,
|
|
53
|
+
params,
|
|
54
|
+
)
|
|
55
|
+
} else {
|
|
56
|
+
if (
|
|
57
|
+
(await tx.one(`select id from plugin_awards_award where id = $1 for update`, [id])) === null
|
|
58
|
+
)
|
|
59
|
+
return 'missing'
|
|
60
|
+
if (
|
|
61
|
+
!draft.allow_multiple &&
|
|
62
|
+
(await tx.one(
|
|
63
|
+
`select user_id from plugin_awards_grant
|
|
64
|
+
where award_id = $1 group by user_id having count(*) > 1 limit 1`,
|
|
65
|
+
[id],
|
|
66
|
+
)) !== null
|
|
67
|
+
)
|
|
68
|
+
return 'multiple'
|
|
69
|
+
await tx.query(
|
|
70
|
+
`update plugin_awards_award set name = $1, description = $2,
|
|
71
|
+
icon_kind = $3, icon = $4, display_order = $5, allow_multiple = $6, listed = $7,
|
|
72
|
+
updated_at = now() where id = $8`,
|
|
73
|
+
[...params, id],
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
return 'saved'
|
|
77
|
+
})
|
|
78
|
+
clearDisplay()
|
|
79
|
+
return result
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function deleteAward(data: PluginData, id: number): Promise<boolean> {
|
|
83
|
+
return data.tx(async (tx) => {
|
|
84
|
+
await tx.one(`select id from plugin_awards_award where id = $1 for update`, [id])
|
|
85
|
+
const row = await tx.one(
|
|
86
|
+
`delete from plugin_awards_award where id = $1
|
|
87
|
+
and not exists (select 1 from plugin_awards_grant where award_id = $1) returning id`,
|
|
88
|
+
[id],
|
|
89
|
+
)
|
|
90
|
+
clearDisplay()
|
|
91
|
+
return row !== null
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function archiveAward(data: PluginData, id: number, archived: boolean): Promise<void> {
|
|
96
|
+
await data.query(
|
|
97
|
+
`update plugin_awards_award set archived_at = case when $2 then now() else null end, updated_at = now() where id = $1`,
|
|
98
|
+
[id, archived],
|
|
99
|
+
)
|
|
100
|
+
clearDisplay()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function grantAward(
|
|
104
|
+
context: PluginRuntimeContext,
|
|
105
|
+
input: {
|
|
106
|
+
awardId: number
|
|
107
|
+
userId: number
|
|
108
|
+
byUserId: number | null
|
|
109
|
+
reason: string
|
|
110
|
+
ruleId?: number
|
|
111
|
+
},
|
|
112
|
+
): Promise<number | null> {
|
|
113
|
+
const row = await context.data.tx(async (tx) => {
|
|
114
|
+
const award = await tx.one<AwardRow>(
|
|
115
|
+
`select * from plugin_awards_award where id = $1 for update`,
|
|
116
|
+
[input.awardId],
|
|
117
|
+
)
|
|
118
|
+
if (award === null || award.archived_at !== null) return null
|
|
119
|
+
return tx.one<{ id: number }>(
|
|
120
|
+
`insert into plugin_awards_grant
|
|
121
|
+
(award_id, user_id, granted_by_user_id, reason, rule_id)
|
|
122
|
+
select $1, $2, $3, $4, $5 where $6 or not exists (
|
|
123
|
+
select 1 from plugin_awards_grant where award_id = $1 and user_id = $2
|
|
124
|
+
) on conflict do nothing returning id`,
|
|
125
|
+
[
|
|
126
|
+
input.awardId,
|
|
127
|
+
input.userId,
|
|
128
|
+
input.byUserId,
|
|
129
|
+
input.reason,
|
|
130
|
+
input.ruleId ?? null,
|
|
131
|
+
award.allow_multiple,
|
|
132
|
+
],
|
|
133
|
+
)
|
|
134
|
+
})
|
|
135
|
+
if (row === null) return null
|
|
136
|
+
clearDisplay(input.userId)
|
|
137
|
+
if (context.settings.notify_on_grant !== false) {
|
|
138
|
+
await context.notify.send({
|
|
139
|
+
userId: input.userId,
|
|
140
|
+
kind: 'award_received',
|
|
141
|
+
subjectKey: 'awards.notification.subject',
|
|
142
|
+
href: `/plugins/awards/member?id=${input.userId}`,
|
|
143
|
+
dedupeKey: `grant:${row.id}`,
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
return Number(row.id)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function revokeGrant(data: PluginData, id: number): Promise<void> {
|
|
150
|
+
const row = await data.one<{ user_id: number }>(
|
|
151
|
+
`delete from plugin_awards_grant where id = $1 returning user_id`,
|
|
152
|
+
[id],
|
|
153
|
+
)
|
|
154
|
+
if (row !== null) clearDisplay(Number(row.user_id))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function memberGrants(data: PluginData, userId: number): Promise<readonly GrantRow[]> {
|
|
158
|
+
return data.query<GrantRow>(
|
|
159
|
+
`select a.*, g.id as grant_id, g.user_id, g.rule_id, g.reason, g.granted_at
|
|
160
|
+
from plugin_awards_grant g join plugin_awards_award a on a.id = g.award_id
|
|
161
|
+
where g.user_id = $1 and a.archived_at is null order by a.display_order, a.id, g.granted_at desc`,
|
|
162
|
+
[userId],
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function recentGrants(
|
|
167
|
+
data: PluginData,
|
|
168
|
+
userId: number | null,
|
|
169
|
+
): Promise<readonly GrantRow[]> {
|
|
170
|
+
return data.query<GrantRow>(
|
|
171
|
+
`select a.*, g.id as grant_id, g.user_id, g.rule_id, g.reason, g.granted_at
|
|
172
|
+
from plugin_awards_grant g join plugin_awards_award a on a.id = g.award_id
|
|
173
|
+
where ($1::int is null or g.user_id = $1) order by g.granted_at desc, g.id desc limit 50`,
|
|
174
|
+
[userId],
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function deleteMember(data: PluginData, userId: number): Promise<void> {
|
|
179
|
+
await data.tx(async (tx) => {
|
|
180
|
+
await tx.query(`delete from plugin_awards_grant where user_id = $1`, [userId])
|
|
181
|
+
await tx.query(`delete from plugin_awards_dirty where user_id = $1`, [userId])
|
|
182
|
+
})
|
|
183
|
+
clearDisplay(userId)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function mergeMember(data: PluginData, kept: number, merged: number): Promise<void> {
|
|
187
|
+
if (kept === merged) return
|
|
188
|
+
await data.tx(async (tx) => {
|
|
189
|
+
await tx.query(`select id from plugin_awards_award order by id for update`)
|
|
190
|
+
await tx.query(
|
|
191
|
+
`delete from plugin_awards_grant g using plugin_awards_grant other
|
|
192
|
+
where g.user_id = $2 and other.user_id = $1 and g.rule_id = other.rule_id`,
|
|
193
|
+
[kept, merged],
|
|
194
|
+
)
|
|
195
|
+
await tx.query(`update plugin_awards_grant set user_id = $1 where user_id = $2`, [kept, merged])
|
|
196
|
+
await tx.query(
|
|
197
|
+
`delete from plugin_awards_grant g using plugin_awards_award a
|
|
198
|
+
where g.award_id = a.id and not a.allow_multiple and g.user_id = $1
|
|
199
|
+
and exists (select 1 from plugin_awards_grant other
|
|
200
|
+
where other.award_id = g.award_id and other.user_id = $1 and other.id < g.id)`,
|
|
201
|
+
[kept],
|
|
202
|
+
)
|
|
203
|
+
await tx.query(`delete from plugin_awards_dirty where user_id = $1`, [merged])
|
|
204
|
+
await tx.query(`insert into plugin_awards_dirty (user_id) values ($1) on conflict do nothing`, [
|
|
205
|
+
kept,
|
|
206
|
+
])
|
|
207
|
+
})
|
|
208
|
+
clearDisplay(kept)
|
|
209
|
+
clearDisplay(merged)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function awardRules(
|
|
213
|
+
data: PluginData,
|
|
214
|
+
enabledOnly = false,
|
|
215
|
+
): Promise<readonly AwardRule[]> {
|
|
216
|
+
return data
|
|
217
|
+
.query<AwardRule & Record<string, unknown>>(
|
|
218
|
+
`select r.id, r.award_id as "awardId", r.title, r.enabled,
|
|
219
|
+
r.min_post_count as "minPostCount", r.min_thread_count as "minThreadCount",
|
|
220
|
+
r.min_reputation as "minReputation", r.min_days_registered as "minDaysRegistered"
|
|
221
|
+
from plugin_awards_rule r join plugin_awards_award a on a.id = r.award_id
|
|
222
|
+
where ($1 = false or (r.enabled and a.archived_at is null)) order by r.id`,
|
|
223
|
+
[enabledOnly],
|
|
224
|
+
)
|
|
225
|
+
.then((rows) =>
|
|
226
|
+
rows.map((row) => ({ ...row, id: Number(row.id), awardId: Number(row.awardId) })),
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function saveRule(
|
|
231
|
+
data: PluginData,
|
|
232
|
+
rule: AwardRuleInput,
|
|
233
|
+
id: number | null,
|
|
234
|
+
): Promise<void> {
|
|
235
|
+
const params = [
|
|
236
|
+
rule.awardId,
|
|
237
|
+
rule.title,
|
|
238
|
+
rule.enabled,
|
|
239
|
+
rule.minPostCount,
|
|
240
|
+
rule.minThreadCount,
|
|
241
|
+
rule.minReputation,
|
|
242
|
+
rule.minDaysRegistered,
|
|
243
|
+
]
|
|
244
|
+
if (id === null) {
|
|
245
|
+
await data.query(
|
|
246
|
+
`insert into plugin_awards_rule
|
|
247
|
+
(award_id, title, enabled, min_post_count, min_thread_count, min_reputation, min_days_registered)
|
|
248
|
+
values ($1, $2, $3, $4, $5, $6, $7)`,
|
|
249
|
+
params,
|
|
250
|
+
)
|
|
251
|
+
} else {
|
|
252
|
+
await data.query(
|
|
253
|
+
`update plugin_awards_rule set award_id = $1, title = $2, enabled = $3,
|
|
254
|
+
min_post_count = $4, min_thread_count = $5, min_reputation = $6, min_days_registered = $7,
|
|
255
|
+
updated_at = now() where id = $8`,
|
|
256
|
+
[...params, id],
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
}
|
package/src/tasks.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { PluginRuntimeContext, PluginUserStanding } from '@meith/plugin-kit'
|
|
2
|
+
|
|
3
|
+
import { evaluateAwardRules } from './rules'
|
|
4
|
+
import { awardRules, grantAward } from './store'
|
|
5
|
+
|
|
6
|
+
export async function queueMember(context: PluginRuntimeContext, userId: number): Promise<void> {
|
|
7
|
+
await context.data.query(
|
|
8
|
+
`insert into plugin_awards_dirty (user_id) values ($1) on conflict do nothing`,
|
|
9
|
+
[userId],
|
|
10
|
+
)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function evaluateAwards(context: PluginRuntimeContext): Promise<void> {
|
|
14
|
+
const rules = await awardRules(context.data, true)
|
|
15
|
+
const now = new Date()
|
|
16
|
+
const evaluate = async (members: readonly PluginUserStanding[]) => {
|
|
17
|
+
for (const outcome of evaluateAwardRules(rules, members, now)) {
|
|
18
|
+
await grantAward(context, { ...outcome, byUserId: null, reason: '' })
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
for (let drained = 0; drained < 500; ) {
|
|
23
|
+
const limit = Math.min(200, 500 - drained)
|
|
24
|
+
const rows = await context.data.query<{ user_id: number }>(
|
|
25
|
+
`delete from plugin_awards_dirty
|
|
26
|
+
where user_id in (select user_id from plugin_awards_dirty order by queued_at, user_id limit $1 for update skip locked)
|
|
27
|
+
returning user_id`,
|
|
28
|
+
[limit],
|
|
29
|
+
)
|
|
30
|
+
const ids = rows.map((row) => Number(row.user_id))
|
|
31
|
+
if (ids.length === 0) break
|
|
32
|
+
try {
|
|
33
|
+
await evaluate(await context.users.standing(ids))
|
|
34
|
+
} catch (error) {
|
|
35
|
+
await context.data.query(
|
|
36
|
+
`insert into plugin_awards_dirty (user_id)
|
|
37
|
+
select unnest($1::int[]) on conflict do nothing`,
|
|
38
|
+
[ids],
|
|
39
|
+
)
|
|
40
|
+
throw error
|
|
41
|
+
}
|
|
42
|
+
drained += ids.length
|
|
43
|
+
if (ids.length < limit) break
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const state = await context.data.one<{ cursor: number }>(
|
|
47
|
+
`select cursor from plugin_awards_scan where id = 1`,
|
|
48
|
+
)
|
|
49
|
+
let cursor = Number(state?.cursor ?? 0)
|
|
50
|
+
for (let scanned = 0; scanned < 1000; ) {
|
|
51
|
+
const members = await context.users.scan({ afterUserId: cursor, limit: 200 })
|
|
52
|
+
await evaluate(members)
|
|
53
|
+
const complete = members.length < 200
|
|
54
|
+
const next = complete ? 0 : members[members.length - 1]!.userId
|
|
55
|
+
const advanced = await context.data.one(
|
|
56
|
+
`update plugin_awards_scan set cursor = $1,
|
|
57
|
+
completed_at = case when $2 then now() else completed_at end
|
|
58
|
+
where id = 1 and cursor = $3 returning cursor`,
|
|
59
|
+
[next, complete, cursor],
|
|
60
|
+
)
|
|
61
|
+
if (advanced === null || complete) break
|
|
62
|
+
cursor = next
|
|
63
|
+
scanned += members.length
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/ui/admin.tsx
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { PluginAdminPageContext } from '@meith/plugin-kit'
|
|
2
|
+
import { controlVariants, surfaceVariants, textLinkVariants } from '@meith/ui'
|
|
3
|
+
|
|
4
|
+
import { asId, ICON_PATHS } from '../awards'
|
|
5
|
+
import { allAwards, awardById, recentGrants } from '../store'
|
|
6
|
+
import { action, button, checkbox, date, field, icon, notice, translated } from './shared'
|
|
7
|
+
|
|
8
|
+
export async function AwardsAdmin(context: PluginAdminPageContext) {
|
|
9
|
+
const awards = await allAwards(context.data)
|
|
10
|
+
const editId = asId(context.query.edit)
|
|
11
|
+
const edit = editId === null ? null : await awardById(context.data, editId)
|
|
12
|
+
return (
|
|
13
|
+
<div className="flex flex-col gap-4">
|
|
14
|
+
{notice(context, context.query.notice)}
|
|
15
|
+
<section className={surfaceVariants({ padded: true })}>
|
|
16
|
+
<h2 className="mb-4 font-semibold">
|
|
17
|
+
{translated(context, edit === null ? 'awards.create' : 'awards.edit')}
|
|
18
|
+
</h2>
|
|
19
|
+
<form
|
|
20
|
+
method="post"
|
|
21
|
+
action="/admin/api/plugins/awards/awards"
|
|
22
|
+
className="grid gap-3 sm:grid-cols-2"
|
|
23
|
+
>
|
|
24
|
+
{edit !== null && <input type="hidden" name="id" value={edit.id} />}
|
|
25
|
+
{field(context, 'name', 'awards.name', edit?.name ?? '', 'text', true)}
|
|
26
|
+
{field(context, 'icon', 'awards.icon', edit?.icon ?? '🏆', 'text', true)}
|
|
27
|
+
<p className="text-sm text-muted-foreground sm:col-span-2">
|
|
28
|
+
{translated(context, 'awards.icon.help')} {Object.keys(ICON_PATHS).join(', ')}
|
|
29
|
+
</p>
|
|
30
|
+
<label className="flex flex-col gap-1 sm:col-span-2">
|
|
31
|
+
{translated(context, 'awards.description')}
|
|
32
|
+
<textarea
|
|
33
|
+
name="description"
|
|
34
|
+
maxLength={2000}
|
|
35
|
+
defaultValue={edit?.description ?? ''}
|
|
36
|
+
className={controlVariants()}
|
|
37
|
+
/>
|
|
38
|
+
</label>
|
|
39
|
+
{field(context, 'display_order', 'awards.order', edit?.display_order ?? 0, 'number')}
|
|
40
|
+
{checkbox(context, 'allow_multiple', 'awards.multiple', edit?.allow_multiple ?? false)}
|
|
41
|
+
{checkbox(context, 'listed', 'awards.listed', edit?.listed ?? true)}
|
|
42
|
+
<div>{button(context, 'awards.save')}</div>
|
|
43
|
+
</form>
|
|
44
|
+
</section>
|
|
45
|
+
<ul className="flex flex-col gap-3">
|
|
46
|
+
{awards.map((award) => (
|
|
47
|
+
<li
|
|
48
|
+
key={award.id}
|
|
49
|
+
className={surfaceVariants({
|
|
50
|
+
padded: true,
|
|
51
|
+
className: 'flex flex-wrap items-center gap-3',
|
|
52
|
+
})}
|
|
53
|
+
>
|
|
54
|
+
{icon(award)}
|
|
55
|
+
<a
|
|
56
|
+
href={`/admin/plugins/awards/awards?edit=${award.id}`}
|
|
57
|
+
className={textLinkVariants()}
|
|
58
|
+
>
|
|
59
|
+
{award.name}
|
|
60
|
+
</a>
|
|
61
|
+
<span>
|
|
62
|
+
{translated(context, 'awards.order')}: {award.display_order}
|
|
63
|
+
</span>
|
|
64
|
+
<span>
|
|
65
|
+
{translated(context, 'awards.holders')}: {award.count}
|
|
66
|
+
</span>
|
|
67
|
+
{action(
|
|
68
|
+
context,
|
|
69
|
+
'awards',
|
|
70
|
+
award.id,
|
|
71
|
+
award.archived_at === null ? 'archive' : 'restore',
|
|
72
|
+
award.archived_at === null ? 'awards.archive' : 'awards.restore',
|
|
73
|
+
)}
|
|
74
|
+
{action(context, 'awards', award.id, 'delete', 'awards.delete')}
|
|
75
|
+
</li>
|
|
76
|
+
))}
|
|
77
|
+
</ul>
|
|
78
|
+
</div>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function GrantAdmin(context: PluginAdminPageContext) {
|
|
83
|
+
const awards = (await allAwards(context.data)).filter((award) => award.archived_at === null)
|
|
84
|
+
const filter = context.query.user?.trim() ?? ''
|
|
85
|
+
const member = filter === '' ? null : await context.users.byUsername(filter)
|
|
86
|
+
const grants =
|
|
87
|
+
filter !== '' && member === null ? [] : await recentGrants(context.data, member?.userId ?? null)
|
|
88
|
+
const users = await context.users.standing([
|
|
89
|
+
...new Set(grants.map((grant) => Number(grant.user_id))),
|
|
90
|
+
])
|
|
91
|
+
const names = new Map(users.map((user) => [user.userId, user.username]))
|
|
92
|
+
return (
|
|
93
|
+
<div className="flex flex-col gap-4">
|
|
94
|
+
{notice(context, context.query.notice)}
|
|
95
|
+
<form
|
|
96
|
+
method="post"
|
|
97
|
+
action="/admin/api/plugins/awards/grant"
|
|
98
|
+
className={surfaceVariants({ padded: true, className: 'grid gap-3 sm:grid-cols-2' })}
|
|
99
|
+
>
|
|
100
|
+
<label className="flex flex-col gap-1">
|
|
101
|
+
{translated(context, 'awards.award')}
|
|
102
|
+
<select name="award_id" required className={controlVariants()}>
|
|
103
|
+
{awards.map((award) => (
|
|
104
|
+
<option key={award.id} value={award.id}>
|
|
105
|
+
{award.name}
|
|
106
|
+
</option>
|
|
107
|
+
))}
|
|
108
|
+
</select>
|
|
109
|
+
</label>
|
|
110
|
+
{field(context, 'usernames', 'awards.usernames', '', 'text', true)}
|
|
111
|
+
<label className="flex flex-col gap-1">
|
|
112
|
+
{translated(context, 'awards.reason')}
|
|
113
|
+
<textarea name="reason" maxLength={2000} className={controlVariants()} />
|
|
114
|
+
</label>
|
|
115
|
+
<div>{button(context, 'awards.grant')}</div>
|
|
116
|
+
</form>
|
|
117
|
+
<form method="get" className="flex flex-wrap items-end gap-3">
|
|
118
|
+
{field(context, 'user', 'awards.filter', filter)}
|
|
119
|
+
{button(context, 'awards.filter.apply')}
|
|
120
|
+
</form>
|
|
121
|
+
<div className="overflow-x-auto">
|
|
122
|
+
<table className="w-full text-left text-sm">
|
|
123
|
+
<caption className="text-left font-semibold">
|
|
124
|
+
{translated(context, 'awards.recent')}
|
|
125
|
+
</caption>
|
|
126
|
+
<thead>
|
|
127
|
+
<tr>
|
|
128
|
+
{(
|
|
129
|
+
[
|
|
130
|
+
'awards.member',
|
|
131
|
+
'awards.award',
|
|
132
|
+
'awards.reason',
|
|
133
|
+
'awards.date',
|
|
134
|
+
'awards.actions',
|
|
135
|
+
] as const
|
|
136
|
+
).map((key) => (
|
|
137
|
+
<th key={key} className="p-2">
|
|
138
|
+
{translated(context, key)}
|
|
139
|
+
</th>
|
|
140
|
+
))}
|
|
141
|
+
</tr>
|
|
142
|
+
</thead>
|
|
143
|
+
<tbody>
|
|
144
|
+
{grants.map((grant) => (
|
|
145
|
+
<tr key={grant.grant_id} className="border-t">
|
|
146
|
+
<td className="p-2">
|
|
147
|
+
<a href={`/plugins/awards/member?id=${grant.user_id}`}>
|
|
148
|
+
{names.get(Number(grant.user_id)) ??
|
|
149
|
+
translated(context, 'awards.deletedMember')}
|
|
150
|
+
</a>
|
|
151
|
+
</td>
|
|
152
|
+
<td className="p-2">{grant.name}</td>
|
|
153
|
+
<td className="p-2">{grant.reason}</td>
|
|
154
|
+
<td className="p-2">{date(context, grant.granted_at)}</td>
|
|
155
|
+
<td className="p-2">
|
|
156
|
+
{action(context, 'grant', grant.grant_id, 'revoke', 'awards.revoke')}
|
|
157
|
+
</td>
|
|
158
|
+
</tr>
|
|
159
|
+
))}
|
|
160
|
+
</tbody>
|
|
161
|
+
</table>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
)
|
|
165
|
+
}
|