@terrafirmagreg-team/crowdin 1.0.1 → 1.0.3
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/package.json +6 -5
- package/src/components/members-grid.tsx +452 -22
- package/src/components/members.tsx +36 -8
- package/src/lib/languages.ts +31 -0
- package/src/lib/members.ts +209 -30
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@terrafirmagreg-team/crowdin",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Crowdin project members (translator avatars)",
|
|
6
6
|
"license": "LGPL-3.0-only",
|
|
@@ -19,10 +19,11 @@
|
|
|
19
19
|
"src"
|
|
20
20
|
],
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@
|
|
23
|
-
"@terrafirmagreg-team/
|
|
24
|
-
"@terrafirmagreg-team/
|
|
25
|
-
"@terrafirmagreg-team/
|
|
22
|
+
"@crowdin/crowdin-api-client": "1.59.2",
|
|
23
|
+
"@terrafirmagreg-team/data": "^1.2.7",
|
|
24
|
+
"@terrafirmagreg-team/kit": "^1.2.20",
|
|
25
|
+
"@terrafirmagreg-team/translate": "^1.2.11",
|
|
26
|
+
"@terrafirmagreg-team/ui": "^1.2.14",
|
|
26
27
|
"lucide-react": "^1.70.0",
|
|
27
28
|
"server-only": "^0.0.1"
|
|
28
29
|
},
|
|
@@ -1,46 +1,326 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import { useMemo, useState } from 'react';
|
|
4
|
+
import type { CrowdinLanguageProgress, CrowdinMember } from '../lib/members';
|
|
5
|
+
import { crowdinFlagLocale } from '../lib/languages';
|
|
4
6
|
import {
|
|
5
7
|
Avatar,
|
|
6
8
|
AvatarFallback,
|
|
7
9
|
AvatarImage,
|
|
8
10
|
} from '@terrafirmagreg-team/ui/components/ui/avatar';
|
|
9
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
HoverCard,
|
|
13
|
+
HoverCardContent,
|
|
14
|
+
HoverCardTrigger,
|
|
15
|
+
} from '@terrafirmagreg-team/ui/components/ui/hover-card';
|
|
16
|
+
import { LangFlag } from '@terrafirmagreg-team/ui/components/blocks/lang/flag';
|
|
17
|
+
import { Heading } from '@terrafirmagreg-team/ui/components/blocks/page/heading';
|
|
10
18
|
import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
|
|
11
|
-
import {
|
|
19
|
+
import { useResolvedLang } from '@terrafirmagreg-team/translate/hooks/use-resolved-lang';
|
|
20
|
+
import {
|
|
21
|
+
BadgeCheck,
|
|
22
|
+
Crown,
|
|
23
|
+
Globe,
|
|
24
|
+
KeyRound,
|
|
25
|
+
Languages,
|
|
26
|
+
ShieldCheck,
|
|
27
|
+
Users,
|
|
28
|
+
Wrench,
|
|
29
|
+
} from 'lucide-react';
|
|
30
|
+
import type { LucideIcon } from 'lucide-react';
|
|
31
|
+
|
|
32
|
+
const MAX_AVATARS = 20;
|
|
33
|
+
const PODIUM_COLORS = [
|
|
34
|
+
'bg-amber-400 text-amber-950',
|
|
35
|
+
'bg-slate-300 text-slate-700',
|
|
36
|
+
'bg-amber-700 text-amber-50',
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const ROLE_BADGE: Record<string, { icon: LucideIcon; className: string }> = {
|
|
40
|
+
owner: { icon: KeyRound, className: 'fill-amber-500' },
|
|
41
|
+
manager: { icon: ShieldCheck, className: 'fill-emerald-500' },
|
|
42
|
+
developer: { icon: Wrench, className: 'fill-violet-500' },
|
|
43
|
+
language_coordinator: { icon: Languages, className: 'fill-teal-500' },
|
|
44
|
+
proofreader: { icon: BadgeCheck, className: 'fill-sky-500' },
|
|
45
|
+
};
|
|
46
|
+
const BASE_ROLES = new Set([
|
|
47
|
+
'translator',
|
|
48
|
+
'proofreader',
|
|
49
|
+
'member',
|
|
50
|
+
'blocked',
|
|
51
|
+
'guest',
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const memberBadge = (roles: string[]) =>
|
|
55
|
+
Object.keys(ROLE_BADGE).find((r) => roles.includes(r));
|
|
56
|
+
|
|
57
|
+
type LangLabel = (id: string) => string;
|
|
58
|
+
|
|
59
|
+
function MemberCard({
|
|
60
|
+
member,
|
|
61
|
+
label,
|
|
62
|
+
sinceLabel,
|
|
63
|
+
isNew,
|
|
64
|
+
roleLabels,
|
|
65
|
+
langName,
|
|
66
|
+
}: {
|
|
67
|
+
member: CrowdinMember;
|
|
68
|
+
label: string;
|
|
69
|
+
sinceLabel: string | null;
|
|
70
|
+
isNew: boolean;
|
|
71
|
+
roleLabels: string[];
|
|
72
|
+
langName: LangLabel;
|
|
73
|
+
}) {
|
|
74
|
+
const t = useTranslations();
|
|
75
|
+
return (
|
|
76
|
+
<div className="flex gap-3">
|
|
77
|
+
<Avatar className="size-11 shrink-0">
|
|
78
|
+
<AvatarImage src={member.avatarUrl} alt="" />
|
|
79
|
+
<AvatarFallback>
|
|
80
|
+
{member.username.slice(0, 1).toUpperCase()}
|
|
81
|
+
</AvatarFallback>
|
|
82
|
+
</Avatar>
|
|
83
|
+
<div className="min-w-0">
|
|
84
|
+
<p className="truncate text-sm font-medium">
|
|
85
|
+
{member.fullName ?? member.username}
|
|
86
|
+
{isNew && (
|
|
87
|
+
<span className="ml-1.5 rounded-full bg-emerald-500/15 px-1.5 py-0.5 align-middle text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
|
|
88
|
+
{t('new', { note: 'crowdin.new' })}
|
|
89
|
+
</span>
|
|
90
|
+
)}
|
|
91
|
+
</p>
|
|
92
|
+
<p className="truncate text-xs text-muted-foreground">
|
|
93
|
+
@{member.username}
|
|
94
|
+
</p>
|
|
95
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
96
|
+
{label}
|
|
97
|
+
{sinceLabel && ` · ${sinceLabel}`}
|
|
98
|
+
</p>
|
|
99
|
+
{roleLabels.length > 0 && (
|
|
100
|
+
<p className="mt-0.5 truncate text-xs font-medium text-muted-foreground">
|
|
101
|
+
{roleLabels.join(' · ')}
|
|
102
|
+
</p>
|
|
103
|
+
)}
|
|
104
|
+
{member.languages.length > 0 && (
|
|
105
|
+
<p className="mt-0.5 truncate text-xs text-muted-foreground/80">
|
|
106
|
+
{member.languages.map((l) => langName(l.id)).join(', ')}
|
|
107
|
+
</p>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function MemberAvatar({
|
|
115
|
+
member,
|
|
116
|
+
label,
|
|
117
|
+
sinceLabel,
|
|
118
|
+
isNew,
|
|
119
|
+
roleLabels,
|
|
120
|
+
langName,
|
|
121
|
+
top,
|
|
122
|
+
}: {
|
|
123
|
+
member: CrowdinMember;
|
|
124
|
+
label: string;
|
|
125
|
+
sinceLabel: string | null;
|
|
126
|
+
isNew: boolean;
|
|
127
|
+
roleLabels: string[];
|
|
128
|
+
langName: LangLabel;
|
|
129
|
+
top?: boolean;
|
|
130
|
+
}) {
|
|
131
|
+
const badge = memberBadge(member.roles);
|
|
132
|
+
const BadgeIcon = badge ? ROLE_BADGE[badge].icon : undefined;
|
|
133
|
+
return (
|
|
134
|
+
<HoverCard>
|
|
135
|
+
<HoverCardTrigger
|
|
136
|
+
render={
|
|
137
|
+
<a
|
|
138
|
+
href={`https://crowdin.com/profile/${member.username}`}
|
|
139
|
+
target="_blank"
|
|
140
|
+
rel="noreferrer noopener"
|
|
141
|
+
className="relative block"
|
|
142
|
+
/>
|
|
143
|
+
}
|
|
144
|
+
>
|
|
145
|
+
<Avatar>
|
|
146
|
+
<AvatarImage
|
|
147
|
+
src={member.avatarUrl}
|
|
148
|
+
alt={member.fullName ?? member.username}
|
|
149
|
+
/>
|
|
150
|
+
<AvatarFallback>
|
|
151
|
+
{member.username.slice(0, 1).toUpperCase()}
|
|
152
|
+
</AvatarFallback>
|
|
153
|
+
</Avatar>
|
|
154
|
+
{top && (
|
|
155
|
+
<Crown className="absolute -top-1.5 -right-1 size-3.5 fill-amber-500 text-amber-500 drop-shadow" />
|
|
156
|
+
)}
|
|
157
|
+
{BadgeIcon && badge && (
|
|
158
|
+
<BadgeIcon
|
|
159
|
+
className={`absolute -bottom-1 -right-1 size-3.5 text-background ${ROLE_BADGE[badge].className}`}
|
|
160
|
+
/>
|
|
161
|
+
)}
|
|
162
|
+
</HoverCardTrigger>
|
|
163
|
+
<HoverCardContent collisionPadding={8}>
|
|
164
|
+
<MemberCard
|
|
165
|
+
member={member}
|
|
166
|
+
label={label}
|
|
167
|
+
sinceLabel={sinceLabel}
|
|
168
|
+
isNew={isNew}
|
|
169
|
+
roleLabels={roleLabels}
|
|
170
|
+
langName={langName}
|
|
171
|
+
/>
|
|
172
|
+
</HoverCardContent>
|
|
173
|
+
</HoverCard>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
12
176
|
|
|
13
177
|
export function MembersGrid({
|
|
14
178
|
url,
|
|
15
179
|
members,
|
|
180
|
+
progress,
|
|
181
|
+
languages,
|
|
16
182
|
}: {
|
|
17
183
|
url: string;
|
|
18
184
|
members: CrowdinMember[];
|
|
185
|
+
progress?: Record<string, CrowdinLanguageProgress>;
|
|
186
|
+
languages?: readonly string[];
|
|
19
187
|
}) {
|
|
20
188
|
const t = useTranslations();
|
|
189
|
+
const lang = useResolvedLang();
|
|
190
|
+
const [expanded, setExpanded] = useState<ReadonlySet<string>>(new Set());
|
|
191
|
+
|
|
192
|
+
const langName = useMemo<LangLabel>(() => {
|
|
193
|
+
const names = new Map<string, string>();
|
|
194
|
+
for (const m of members)
|
|
195
|
+
for (const l of m.languages) if (l.name) names.set(l.id, l.name);
|
|
196
|
+
return (id) => names.get(id) ?? id;
|
|
197
|
+
}, [members]);
|
|
198
|
+
|
|
199
|
+
const groups = useMemo(() => {
|
|
200
|
+
const byLang = new Map<
|
|
201
|
+
string,
|
|
202
|
+
{ id: string; name: string; members: CrowdinMember[] }
|
|
203
|
+
>();
|
|
204
|
+
for (const m of members)
|
|
205
|
+
for (const l of m.languages) {
|
|
206
|
+
const group = byLang.get(l.id) ?? {
|
|
207
|
+
id: l.id,
|
|
208
|
+
name: langName(l.id) || l.name,
|
|
209
|
+
members: [],
|
|
210
|
+
};
|
|
211
|
+
group.members.push(m);
|
|
212
|
+
byLang.set(l.id, group);
|
|
213
|
+
}
|
|
214
|
+
const allowed = languages?.length
|
|
215
|
+
? new Set(languages.map((code) => code.toLowerCase()))
|
|
216
|
+
: undefined;
|
|
217
|
+
return [...byLang.values()]
|
|
218
|
+
.filter(
|
|
219
|
+
(g) => !allowed || allowed.has(crowdinFlagLocale(g.id).toLowerCase()),
|
|
220
|
+
)
|
|
221
|
+
.sort((a, b) => {
|
|
222
|
+
const pa = progress?.[a.id];
|
|
223
|
+
const pb = progress?.[b.id];
|
|
224
|
+
const diff =
|
|
225
|
+
(pb?.translation ?? -1) - (pa?.translation ?? -1) ||
|
|
226
|
+
(pb?.approval ?? -1) - (pa?.approval ?? -1) ||
|
|
227
|
+
b.members.length - a.members.length;
|
|
228
|
+
return diff || a.name.localeCompare(b.name);
|
|
229
|
+
});
|
|
230
|
+
}, [members, progress, langName, languages]);
|
|
231
|
+
|
|
232
|
+
const totalWords = useMemo(
|
|
233
|
+
() =>
|
|
234
|
+
Object.values(progress ?? {}).reduce(
|
|
235
|
+
(sum, p) => sum + p.words,
|
|
236
|
+
members.reduce((sum, m) => sum + m.translated + m.approved, 0),
|
|
237
|
+
),
|
|
238
|
+
[progress, members],
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
const compact = new Intl.NumberFormat(lang.replace(/_/g, '-'), {
|
|
242
|
+
notation: 'compact',
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const memberLabel = (m: CrowdinMember) =>
|
|
246
|
+
t('{words} words', {
|
|
247
|
+
note: 'crowdin.words',
|
|
248
|
+
variables: { words: (m.translated + m.approved).toLocaleString() },
|
|
249
|
+
});
|
|
250
|
+
const memberSince = (m: CrowdinMember) =>
|
|
251
|
+
m.joinedAt
|
|
252
|
+
? t('since {year}', {
|
|
253
|
+
note: 'crowdin.since',
|
|
254
|
+
variables: { year: String(new Date(m.joinedAt).getFullYear()) },
|
|
255
|
+
})
|
|
256
|
+
: null;
|
|
257
|
+
const memberIsNew = (m: CrowdinMember) => m.isNew;
|
|
258
|
+
const roleLabels = (m: CrowdinMember) =>
|
|
259
|
+
m.roles
|
|
260
|
+
.filter((r) => !BASE_ROLES.has(r))
|
|
261
|
+
.map((r) => {
|
|
262
|
+
switch (r) {
|
|
263
|
+
case 'owner':
|
|
264
|
+
return t('Owner', { note: 'crowdin.role.owner' });
|
|
265
|
+
case 'manager':
|
|
266
|
+
return t('Manager', { note: 'crowdin.role.manager' });
|
|
267
|
+
case 'developer':
|
|
268
|
+
return t('Developer', { note: 'crowdin.role.developer' });
|
|
269
|
+
case 'language_coordinator':
|
|
270
|
+
return t('Coordinator', { note: 'crowdin.role.coordinator' });
|
|
271
|
+
case 'proofreader':
|
|
272
|
+
return t('Proofreader', { note: 'crowdin.role.proofreader' });
|
|
273
|
+
default:
|
|
274
|
+
return r;
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const podium = members.slice(0, 3);
|
|
279
|
+
|
|
21
280
|
return (
|
|
22
281
|
<section className="not-prose mt-8 border-t pt-6">
|
|
23
|
-
<
|
|
24
|
-
|
|
282
|
+
<Heading
|
|
283
|
+
as="h2"
|
|
284
|
+
id="translators"
|
|
285
|
+
className="mb-3 text-2xl font-[650] tracking-tight"
|
|
286
|
+
>
|
|
25
287
|
<a
|
|
26
288
|
href={url}
|
|
27
289
|
target="_blank"
|
|
28
290
|
rel="noreferrer noopener"
|
|
29
|
-
className="hover:
|
|
291
|
+
className="hover:underline"
|
|
30
292
|
>
|
|
31
293
|
{t('Translators', { note: 'crowdin.translators' })}
|
|
32
|
-
</a>
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
294
|
+
</a>{' '}
|
|
295
|
+
<span className="inline-flex items-center gap-1.5 align-middle text-base font-normal text-muted-foreground">
|
|
296
|
+
{members.length}
|
|
297
|
+
<Users className="size-4" />
|
|
298
|
+
{groups.length > 0 &&
|
|
299
|
+
`· ${t('{count} languages', {
|
|
300
|
+
note: 'crowdin.languages',
|
|
301
|
+
variables: { count: String(groups.length) },
|
|
302
|
+
})}`}
|
|
303
|
+
{totalWords > 0 &&
|
|
304
|
+
` · ${t('{words} words', {
|
|
305
|
+
note: 'crowdin.words',
|
|
306
|
+
variables: { words: compact.format(totalWords) },
|
|
307
|
+
})}`}
|
|
308
|
+
</span>
|
|
309
|
+
</Heading>
|
|
310
|
+
{podium.length >= 3 && (
|
|
311
|
+
<div className="mb-6 flex items-end justify-center gap-5">
|
|
312
|
+
{podium.map((m, i) => (
|
|
313
|
+
<a
|
|
314
|
+
key={m.username}
|
|
315
|
+
href={`https://crowdin.com/profile/${m.username}`}
|
|
316
|
+
target="_blank"
|
|
317
|
+
rel="noreferrer noopener"
|
|
318
|
+
className="group flex flex-col items-center gap-1.5"
|
|
319
|
+
>
|
|
320
|
+
<span className="relative">
|
|
321
|
+
<Avatar
|
|
322
|
+
className={i === 0 ? 'size-14' : 'size-11'}
|
|
323
|
+
>
|
|
44
324
|
<AvatarImage
|
|
45
325
|
src={m.avatarUrl}
|
|
46
326
|
alt={m.fullName ?? m.username}
|
|
@@ -49,11 +329,161 @@ export function MembersGrid({
|
|
|
49
329
|
{m.username.slice(0, 1).toUpperCase()}
|
|
50
330
|
</AvatarFallback>
|
|
51
331
|
</Avatar>
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
332
|
+
<span
|
|
333
|
+
className={`absolute -right-1 -bottom-1 flex size-5 items-center justify-center rounded-full text-[10px] font-bold ${PODIUM_COLORS[i]}`}
|
|
334
|
+
>
|
|
335
|
+
{i + 1}
|
|
336
|
+
</span>
|
|
337
|
+
</span>
|
|
338
|
+
<span className="max-w-24 truncate text-xs font-medium group-hover:underline">
|
|
339
|
+
{m.fullName ?? m.username}
|
|
340
|
+
</span>
|
|
341
|
+
<span className="-mt-1 text-[10px] text-muted-foreground">
|
|
342
|
+
{compact.format(m.translated + m.approved)}
|
|
343
|
+
</span>
|
|
344
|
+
</a>
|
|
345
|
+
))}
|
|
346
|
+
</div>
|
|
347
|
+
)}
|
|
348
|
+
{groups.length === 0 ? (
|
|
349
|
+
<ul className="flex flex-wrap gap-1.5">
|
|
350
|
+
{members.map((m) => (
|
|
351
|
+
<li key={m.username}>
|
|
352
|
+
<MemberAvatar
|
|
353
|
+
member={m}
|
|
354
|
+
label={memberLabel(m)}
|
|
355
|
+
sinceLabel={memberSince(m)}
|
|
356
|
+
isNew={memberIsNew(m)}
|
|
357
|
+
roleLabels={roleLabels(m)}
|
|
358
|
+
langName={langName}
|
|
359
|
+
/>
|
|
360
|
+
</li>
|
|
361
|
+
))}
|
|
362
|
+
</ul>
|
|
363
|
+
) : (
|
|
364
|
+
<div className="space-y-5">
|
|
365
|
+
{groups.map((group) => {
|
|
366
|
+
const p = progress?.[group.id];
|
|
367
|
+
const hidden =
|
|
368
|
+
group.members.length > MAX_AVATARS && !expanded.has(group.id)
|
|
369
|
+
? group.members.length - MAX_AVATARS
|
|
370
|
+
: 0;
|
|
371
|
+
const shown = hidden
|
|
372
|
+
? group.members.slice(0, MAX_AVATARS)
|
|
373
|
+
: group.members;
|
|
374
|
+
return (
|
|
375
|
+
<div key={group.id}>
|
|
376
|
+
<h3 className="mb-2 flex flex-wrap items-center gap-2 text-xs font-medium text-muted-foreground">
|
|
377
|
+
<LangFlag locale={crowdinFlagLocale(group.id)} />
|
|
378
|
+
<a
|
|
379
|
+
href={`${url}/${group.id}`}
|
|
380
|
+
target="_blank"
|
|
381
|
+
rel="noreferrer noopener"
|
|
382
|
+
className="min-w-0 text-foreground hover:underline"
|
|
383
|
+
>
|
|
384
|
+
{group.name}
|
|
385
|
+
</a>
|
|
386
|
+
<span>{group.members.length}</span>
|
|
387
|
+
{p && (
|
|
388
|
+
<span className="ml-auto flex items-center gap-2">
|
|
389
|
+
<span
|
|
390
|
+
className="flex h-1.5 w-24 overflow-hidden rounded-full bg-foreground/15"
|
|
391
|
+
title={`${p.translation}% · ${t('{pct}% approved', {
|
|
392
|
+
note: 'crowdin.approved',
|
|
393
|
+
variables: { pct: String(p.approval) },
|
|
394
|
+
})}`}
|
|
395
|
+
>
|
|
396
|
+
<span
|
|
397
|
+
className="h-full bg-primary"
|
|
398
|
+
style={{ width: `${p.approval}%` }}
|
|
399
|
+
/>
|
|
400
|
+
<span
|
|
401
|
+
className="h-full bg-primary/40"
|
|
402
|
+
style={{
|
|
403
|
+
width: `${Math.max(p.translation - p.approval, 0)}%`,
|
|
404
|
+
}}
|
|
405
|
+
/>
|
|
406
|
+
</span>
|
|
407
|
+
<span className="w-9 text-right tabular-nums">
|
|
408
|
+
{p.translation}%
|
|
409
|
+
</span>
|
|
410
|
+
</span>
|
|
411
|
+
)}
|
|
412
|
+
</h3>
|
|
413
|
+
<ul className="flex flex-wrap gap-1.5">
|
|
414
|
+
{shown.map((m, i) => (
|
|
415
|
+
<li key={m.username}>
|
|
416
|
+
<MemberAvatar
|
|
417
|
+
member={m}
|
|
418
|
+
label={memberLabel(m)}
|
|
419
|
+
sinceLabel={memberSince(m)}
|
|
420
|
+
isNew={memberIsNew(m)}
|
|
421
|
+
roleLabels={roleLabels(m)}
|
|
422
|
+
langName={langName}
|
|
423
|
+
top={i === 0}
|
|
424
|
+
/>
|
|
425
|
+
</li>
|
|
426
|
+
))}
|
|
427
|
+
{hidden > 0 && (
|
|
428
|
+
<li>
|
|
429
|
+
<button
|
|
430
|
+
type="button"
|
|
431
|
+
onClick={() =>
|
|
432
|
+
setExpanded((prev) => new Set(prev).add(group.id))
|
|
433
|
+
}
|
|
434
|
+
className="flex size-8 cursor-pointer items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
435
|
+
>
|
|
436
|
+
+{hidden}
|
|
437
|
+
</button>
|
|
438
|
+
</li>
|
|
439
|
+
)}
|
|
440
|
+
</ul>
|
|
441
|
+
</div>
|
|
442
|
+
);
|
|
443
|
+
})}
|
|
444
|
+
</div>
|
|
445
|
+
)}
|
|
446
|
+
<a
|
|
447
|
+
href={url}
|
|
448
|
+
target="_blank"
|
|
449
|
+
rel="noreferrer noopener"
|
|
450
|
+
className="mt-6 inline-flex items-center gap-2 rounded-full border border-border px-3.5 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
451
|
+
>
|
|
452
|
+
<Globe className="size-3.5" />
|
|
453
|
+
{t('Help translate', { note: 'crowdin.join' })}
|
|
454
|
+
</a>
|
|
455
|
+
</section>
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function MembersSkeleton() {
|
|
460
|
+
return (
|
|
461
|
+
<section className="not-prose mt-8 animate-pulse border-t pt-6">
|
|
462
|
+
<div className="mb-4 h-4 w-48 rounded bg-muted" />
|
|
463
|
+
<div className="mb-6 flex items-end gap-5">
|
|
464
|
+
{[14, 11, 11].map((s, i) => (
|
|
465
|
+
<div key={i} className="flex flex-col items-center gap-1.5">
|
|
466
|
+
<div
|
|
467
|
+
className="rounded-full bg-muted"
|
|
468
|
+
style={{ width: s * 4, height: s * 4 }}
|
|
469
|
+
/>
|
|
470
|
+
<div className="h-3 w-16 rounded bg-muted" />
|
|
471
|
+
</div>
|
|
55
472
|
))}
|
|
56
|
-
</
|
|
473
|
+
</div>
|
|
474
|
+
{[0, 1, 2, 3].map((row) => (
|
|
475
|
+
<div key={row} className="mb-5">
|
|
476
|
+
<div className="mb-2 flex items-center gap-2">
|
|
477
|
+
<div className="size-4 rounded bg-muted" />
|
|
478
|
+
<div className="h-3 w-24 rounded bg-muted" />
|
|
479
|
+
</div>
|
|
480
|
+
<div className="flex flex-wrap gap-1.5">
|
|
481
|
+
{Array.from({ length: 8 - row }).map((_, i) => (
|
|
482
|
+
<div key={i} className="size-8 rounded-full bg-muted" />
|
|
483
|
+
))}
|
|
484
|
+
</div>
|
|
485
|
+
</div>
|
|
486
|
+
))}
|
|
57
487
|
</section>
|
|
58
488
|
);
|
|
59
489
|
}
|
|
@@ -1,16 +1,44 @@
|
|
|
1
1
|
import 'server-only';
|
|
2
|
+
import { Suspense } from 'react';
|
|
2
3
|
import { sectionConfig } from '@terrafirmagreg-team/kit/lib/config';
|
|
3
4
|
import type { SectionPageContext } from '@terrafirmagreg-team/kit/types/features';
|
|
4
|
-
import {
|
|
5
|
-
|
|
5
|
+
import {
|
|
6
|
+
crowdinMembers,
|
|
7
|
+
crowdinProgress,
|
|
8
|
+
crowdinProjectSlug,
|
|
9
|
+
} from '../lib/members';
|
|
10
|
+
import { MembersGrid, MembersSkeleton } from './members-grid';
|
|
6
11
|
|
|
7
|
-
|
|
8
|
-
const
|
|
12
|
+
async function CrowdinMembersList({ ctx }: { ctx: SectionPageContext }) {
|
|
13
|
+
const crowdin =
|
|
9
14
|
ctx.datasetId && ctx.page.slugs.length === 2
|
|
10
15
|
? sectionConfig().datasetConfig[ctx.datasetId]?.crowdin
|
|
11
16
|
: undefined;
|
|
12
|
-
const slug = crowdinProjectSlug(url);
|
|
13
|
-
const members = slug
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
const slug = crowdinProjectSlug(crowdin?.url);
|
|
18
|
+
const [members, progress] = slug
|
|
19
|
+
? await Promise.all([
|
|
20
|
+
crowdinMembers(slug, {
|
|
21
|
+
excludeUsers: crowdin?.excludeUsers,
|
|
22
|
+
minWords: crowdin?.minWords,
|
|
23
|
+
}),
|
|
24
|
+
crowdinProgress(slug),
|
|
25
|
+
])
|
|
26
|
+
: [undefined, undefined];
|
|
27
|
+
if (!crowdin?.url || !members?.length) return null;
|
|
28
|
+
return (
|
|
29
|
+
<MembersGrid
|
|
30
|
+
url={crowdin.url}
|
|
31
|
+
members={members}
|
|
32
|
+
progress={progress}
|
|
33
|
+
languages={sectionConfig().languages}
|
|
34
|
+
/>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function CrowdinMembers({ ctx }: { ctx: SectionPageContext }) {
|
|
39
|
+
return (
|
|
40
|
+
<Suspense fallback={<MembersSkeleton />}>
|
|
41
|
+
<CrowdinMembersList ctx={ctx} />
|
|
42
|
+
</Suspense>
|
|
43
|
+
);
|
|
16
44
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const LANG_REGION: Record<string, string> = {
|
|
2
|
+
af: 'za',
|
|
3
|
+
ar: 'sa',
|
|
4
|
+
ca: 'es',
|
|
5
|
+
cs: 'cz',
|
|
6
|
+
da: 'dk',
|
|
7
|
+
el: 'gr',
|
|
8
|
+
et: 'ee',
|
|
9
|
+
eu: 'es',
|
|
10
|
+
fa: 'ir',
|
|
11
|
+
he: 'il',
|
|
12
|
+
hi: 'in',
|
|
13
|
+
hy: 'am',
|
|
14
|
+
ja: 'jp',
|
|
15
|
+
ka: 'ge',
|
|
16
|
+
ko: 'kr',
|
|
17
|
+
ms: 'my',
|
|
18
|
+
sl: 'si',
|
|
19
|
+
sq: 'al',
|
|
20
|
+
sr: 'rs',
|
|
21
|
+
sv: 'se',
|
|
22
|
+
uk: 'ua',
|
|
23
|
+
vi: 'vn',
|
|
24
|
+
zh: 'cn',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function crowdinFlagLocale(id: string): string {
|
|
28
|
+
const [primary, region] = id.split('-');
|
|
29
|
+
const flag = region ?? LANG_REGION[primary!] ?? primary;
|
|
30
|
+
return `${primary}_${flag}`;
|
|
31
|
+
}
|
package/src/lib/members.ts
CHANGED
|
@@ -1,51 +1,230 @@
|
|
|
1
1
|
import 'server-only';
|
|
2
|
+
import { Client } from '@crowdin/crowdin-api-client';
|
|
2
3
|
import { createAsyncCache } from '@terrafirmagreg-team/data/lib/async-cache';
|
|
4
|
+
import { env, featureMode } from '@terrafirmagreg-team/data/lib/env';
|
|
5
|
+
import { persistentCache } from '@terrafirmagreg-team/data/lib/persistent-cache';
|
|
3
6
|
|
|
4
7
|
const CROWDIN_ORG = 'terrafirmagreg';
|
|
5
|
-
const
|
|
6
|
-
const
|
|
8
|
+
const REPORT_POLL_MS = 1500;
|
|
9
|
+
const REPORT_ATTEMPTS = 40;
|
|
10
|
+
const PERSIST_TTL = 24 * 3_600_000;
|
|
11
|
+
|
|
12
|
+
export type CrowdinLanguage = { id: string; name: string };
|
|
7
13
|
|
|
8
14
|
export type CrowdinMember = {
|
|
9
15
|
username: string;
|
|
10
16
|
fullName: string | null;
|
|
11
17
|
avatarUrl: string;
|
|
12
|
-
|
|
18
|
+
translated: number;
|
|
19
|
+
approved: number;
|
|
20
|
+
languages: CrowdinLanguage[];
|
|
21
|
+
roles: string[];
|
|
22
|
+
joinedAt: number | null;
|
|
23
|
+
isNew: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const NEW_MEMBER_MS = 90 * 86_400_000;
|
|
27
|
+
|
|
28
|
+
const joined = (raw: string | null | undefined) => {
|
|
29
|
+
const at = raw ? Date.parse(`${raw.replace(' ', 'T')}Z`) : NaN;
|
|
30
|
+
return Number.isNaN(at) ? { joinedAt: null, isNew: false } : {
|
|
31
|
+
joinedAt: at,
|
|
32
|
+
isNew: Date.now() - at < NEW_MEMBER_MS,
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type ReportRow = {
|
|
37
|
+
user: {
|
|
38
|
+
id: number;
|
|
39
|
+
username: string;
|
|
40
|
+
fullName?: string | null;
|
|
41
|
+
firstName?: string | null;
|
|
42
|
+
lastName?: string | null;
|
|
43
|
+
avatarUrl?: string | null;
|
|
44
|
+
joined?: string | null;
|
|
45
|
+
};
|
|
46
|
+
translated: number | string;
|
|
47
|
+
approved: number | string;
|
|
48
|
+
languages?: CrowdinLanguage[];
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type CrowdinMembersOptions = {
|
|
52
|
+
excludeUsers?: readonly string[];
|
|
53
|
+
minWords?: number;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type CrowdinLanguageProgress = {
|
|
57
|
+
languageId: string;
|
|
58
|
+
translation: number;
|
|
59
|
+
approval: number;
|
|
60
|
+
words: number;
|
|
61
|
+
total: number;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const client = () => {
|
|
65
|
+
const token = env('CROWDIN_API_TOKEN');
|
|
66
|
+
return token ? new Client({ token, organization: CROWDIN_ORG }) : undefined;
|
|
13
67
|
};
|
|
14
68
|
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
69
|
+
const projectId = createAsyncCache(async (slug: string) => {
|
|
70
|
+
const api = client()?.projectsGroupsApi.withFetchAll();
|
|
71
|
+
const res = await api?.listProjects().catch(() => undefined);
|
|
72
|
+
const name = slug.toLowerCase();
|
|
73
|
+
return res?.data.find(
|
|
74
|
+
(p) => p.data.identifier === slug || p.data.name.toLowerCase() === name,
|
|
75
|
+
)?.data.id;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
async function topMembers(api: Client, id: number) {
|
|
79
|
+
const report = await api.reportsApi.generateReport(id, {
|
|
80
|
+
name: 'top-members',
|
|
81
|
+
schema: { unit: 'words', format: 'json' },
|
|
82
|
+
});
|
|
83
|
+
const reportId = report.data.identifier;
|
|
84
|
+
for (let i = 0; i < REPORT_ATTEMPTS; i++) {
|
|
85
|
+
const status = await api.reportsApi.checkReportStatus(id, reportId);
|
|
86
|
+
if (status.data.status === 'finished') {
|
|
87
|
+
const link = await api.reportsApi.downloadReport(id, reportId);
|
|
88
|
+
const res = await fetch(link.data.url);
|
|
89
|
+
if (!res.ok) return undefined;
|
|
90
|
+
return ((await res.json()) as { data?: ReportRow[] }).data;
|
|
91
|
+
}
|
|
92
|
+
if (!['created', 'in_progress', 'in-progress'].includes(status.data.status))
|
|
93
|
+
return undefined;
|
|
94
|
+
await new Promise((resolve) => setTimeout(resolve, REPORT_POLL_MS));
|
|
95
|
+
}
|
|
96
|
+
return undefined;
|
|
24
97
|
}
|
|
25
98
|
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
99
|
+
const projectRoles = createAsyncCache(async (slug: string) => {
|
|
100
|
+
const api = client()?.usersApi.withFetchAll();
|
|
101
|
+
const id = api ? await projectId(slug) : undefined;
|
|
102
|
+
const map = new Map<string, string[]>();
|
|
103
|
+
if (!api || id === undefined) return map;
|
|
104
|
+
const res = await api.listProjectMembers(id).catch(() => undefined);
|
|
105
|
+
for (const { data: m } of res?.data ?? [])
|
|
106
|
+
map.set(
|
|
107
|
+
m.username,
|
|
108
|
+
(m.roles ?? []).map((r) => r.name),
|
|
109
|
+
);
|
|
110
|
+
return map;
|
|
31
111
|
});
|
|
32
112
|
|
|
113
|
+
const STUB_LANGS: CrowdinLanguage[] = [
|
|
114
|
+
{ id: 'ru', name: 'Russian' },
|
|
115
|
+
{ id: 'pl', name: 'Polish' },
|
|
116
|
+
{ id: 'de', name: 'German' },
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
export const crowdinProgress = createAsyncCache(
|
|
120
|
+
async (
|
|
121
|
+
slug: string,
|
|
122
|
+
): Promise<Record<string, CrowdinLanguageProgress> | undefined> => {
|
|
123
|
+
const mode = featureMode('CROWDIN');
|
|
124
|
+
if (mode === 'off') return undefined;
|
|
125
|
+
const api = client();
|
|
126
|
+
const id = api ? await projectId(slug) : undefined;
|
|
127
|
+
if (!api || id === undefined) {
|
|
128
|
+
if (mode !== 'dev') return undefined;
|
|
129
|
+
const out: Record<string, CrowdinLanguageProgress> = {};
|
|
130
|
+
STUB_LANGS.forEach((l, i) => {
|
|
131
|
+
out[l.id] = {
|
|
132
|
+
languageId: l.id,
|
|
133
|
+
translation: 70 - i * 20,
|
|
134
|
+
approval: 40 - i * 15,
|
|
135
|
+
words: 5000 - i * 1500,
|
|
136
|
+
total: 10000,
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
return persistentCache(`crowdin-progress:${slug}`, PERSIST_TTL, async () => {
|
|
142
|
+
const res = await api.translationStatusApi
|
|
143
|
+
.withFetchAll()
|
|
144
|
+
.getProjectProgress(id)
|
|
145
|
+
.catch(() => undefined);
|
|
146
|
+
const out: Record<string, CrowdinLanguageProgress> = {};
|
|
147
|
+
for (const { data: p } of res?.data ?? [])
|
|
148
|
+
out[p.languageId] = {
|
|
149
|
+
languageId: p.languageId,
|
|
150
|
+
translation: p.translationProgress,
|
|
151
|
+
approval: p.approvalProgress,
|
|
152
|
+
words: p.words.translated,
|
|
153
|
+
total: p.words.total,
|
|
154
|
+
};
|
|
155
|
+
return out;
|
|
156
|
+
}, { staleWhileRevalidate: true });
|
|
157
|
+
},
|
|
158
|
+
(slug) => slug,
|
|
159
|
+
3_600_000,
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const stubMembers = (count = 14): CrowdinMember[] =>
|
|
163
|
+
Array.from({ length: count }, (_, i) => ({
|
|
164
|
+
username: `translator-${i + 1}`,
|
|
165
|
+
fullName: `Translator ${i + 1}`,
|
|
166
|
+
avatarUrl: '',
|
|
167
|
+
translated: 0,
|
|
168
|
+
approved: 0,
|
|
169
|
+
languages: STUB_LANGS.slice(0, (i % STUB_LANGS.length) + 1),
|
|
170
|
+
roles: i === 0 ? ['manager'] : i === 1 ? ['developer'] : ['translator'],
|
|
171
|
+
...joined(
|
|
172
|
+
new Date(Date.now() - (i * 30 + 10) * 86_400_000)
|
|
173
|
+
.toISOString()
|
|
174
|
+
.replace('T', ' ')
|
|
175
|
+
.slice(0, 19),
|
|
176
|
+
),
|
|
177
|
+
}));
|
|
178
|
+
|
|
33
179
|
export const crowdinMembers = createAsyncCache(
|
|
34
|
-
async (
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
180
|
+
async (
|
|
181
|
+
slug: string,
|
|
182
|
+
options?: CrowdinMembersOptions,
|
|
183
|
+
): Promise<CrowdinMember[] | undefined> => {
|
|
184
|
+
const mode = featureMode('CROWDIN');
|
|
185
|
+
if (mode === 'off') return undefined;
|
|
186
|
+
const api = client();
|
|
187
|
+
const id = api ? await projectId(slug) : undefined;
|
|
188
|
+
if (!api || id === undefined)
|
|
189
|
+
return mode === 'dev' ? stubMembers() : undefined;
|
|
190
|
+
const persistKey = `crowdin-members:${slug}:${options?.minWords ?? ''}:${[
|
|
191
|
+
...(options?.excludeUsers ?? []),
|
|
192
|
+
]
|
|
193
|
+
.sort()
|
|
194
|
+
.join(',')}`;
|
|
195
|
+
return persistentCache(persistKey, PERSIST_TTL, async () => {
|
|
196
|
+
const [rows, roles] = await Promise.all([
|
|
197
|
+
topMembers(api, id).catch(() => undefined),
|
|
198
|
+
projectRoles(slug),
|
|
199
|
+
]);
|
|
200
|
+
if (!rows) return undefined;
|
|
201
|
+
const min = options?.minWords ?? 100;
|
|
202
|
+
const excluded = new Set(
|
|
203
|
+
options?.excludeUsers?.map((u) => u.toLowerCase()) ?? [],
|
|
41
204
|
);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
205
|
+
return rows
|
|
206
|
+
.filter((r) => r.user.username !== 'REMOVED_USER')
|
|
207
|
+
.filter((r) => !excluded.has(r.user.username.toLowerCase()))
|
|
208
|
+
.filter((r) => Number(r.translated) + Number(r.approved) >= min)
|
|
209
|
+
.map((r) => ({
|
|
210
|
+
username: r.user.username,
|
|
211
|
+
fullName:
|
|
212
|
+
r.user.fullName ??
|
|
213
|
+
([r.user.firstName, r.user.lastName].filter(Boolean).join(' ') ||
|
|
214
|
+
null),
|
|
215
|
+
avatarUrl: r.user.avatarUrl ?? '',
|
|
216
|
+
translated: Number(r.translated),
|
|
217
|
+
approved: Number(r.approved),
|
|
218
|
+
languages: r.languages ?? [],
|
|
219
|
+
roles: roles.get(r.user.username) ?? [],
|
|
220
|
+
...joined(r.user.joined),
|
|
221
|
+
}));
|
|
222
|
+
}, { staleWhileRevalidate: true });
|
|
47
223
|
},
|
|
48
|
-
(
|
|
224
|
+
(slug, options) =>
|
|
225
|
+
`${slug}|${options?.minWords ?? ''}|${[...(options?.excludeUsers ?? [])]
|
|
226
|
+
.sort()
|
|
227
|
+
.join(',')}`,
|
|
49
228
|
3_600_000,
|
|
50
229
|
);
|
|
51
230
|
|