@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terrafirmagreg-team/crowdin",
3
- "version": "1.0.1",
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
- "@terrafirmagreg-team/data": "^1.2.5",
23
- "@terrafirmagreg-team/kit": "^1.2.18",
24
- "@terrafirmagreg-team/translate": "^1.2.9",
25
- "@terrafirmagreg-team/ui": "^1.2.12",
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 type { CrowdinMember } from '../lib/members';
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 { Hint } from '@terrafirmagreg-team/ui/components/blocks/tooltip/hint';
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 { Users } from 'lucide-react';
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
- <h2 className="mb-4 flex items-center gap-2 text-sm font-medium text-muted-foreground">
24
- <Users className="size-4" />
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:text-foreground hover:underline"
291
+ className="hover:underline"
30
292
  >
31
293
  {t('Translators', { note: 'crowdin.translators' })}
32
- </a>
33
- </h2>
34
- <ul className="flex flex-wrap gap-1.5">
35
- {members.map((m) => (
36
- <li key={m.username}>
37
- <Hint label={m.fullName ?? m.username}>
38
- <a
39
- href={`https://crowdin.com/profile/${m.username}`}
40
- target="_blank"
41
- rel="noreferrer noopener"
42
- >
43
- <Avatar>
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
- </a>
53
- </Hint>
54
- </li>
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
- </ul>
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 { crowdinMembers, crowdinProjectSlug } from '../lib/members';
5
- import { MembersGrid } from './members-grid';
5
+ import {
6
+ crowdinMembers,
7
+ crowdinProgress,
8
+ crowdinProjectSlug,
9
+ } from '../lib/members';
10
+ import { MembersGrid, MembersSkeleton } from './members-grid';
6
11
 
7
- export async function CrowdinMembers({ ctx }: { ctx: SectionPageContext }) {
8
- const url =
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 ? await crowdinMembers(slug) : undefined;
14
- if (!url || !members?.length) return null;
15
- return <MembersGrid url={url} members={members} />;
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
+ }
@@ -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 API = `https://${CROWDIN_ORG}.api.crowdin.com/api/v2`;
6
- const PAGE_SIZE = 500;
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
- roles?: { name: string }[];
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
- async function api<T>(path: string): Promise<T | undefined> {
16
- const token = process.env.CROWDIN_API_TOKEN;
17
- if (!token) return undefined;
18
- const res = await fetch(`${API}${path}`, {
19
- headers: { Authorization: `Bearer ${token}` },
20
- next: { revalidate: 3600 },
21
- } as RequestInit);
22
- if (!res.ok) return undefined;
23
- return (await res.json()) as T;
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 projectId = createAsyncCache(async (identifier: string) => {
27
- const res = await api<{
28
- data: { data: { id: number; identifier: string } }[];
29
- }>(`/projects?limit=${PAGE_SIZE}`);
30
- return res?.data.find((p) => p.data.identifier === identifier)?.data.id;
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 (identifier: string): Promise<CrowdinMember[] | undefined> => {
35
- const id = await projectId(identifier);
36
- if (id === undefined) return undefined;
37
- const members: CrowdinMember[] = [];
38
- for (let offset = 0; ; offset += PAGE_SIZE) {
39
- const res = await api<{ data: { data: CrowdinMember }[] }>(
40
- `/projects/${id}/members?limit=${PAGE_SIZE}&offset=${offset}`,
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
- const batch = res?.data ?? [];
43
- members.push(...batch.map((m) => m.data));
44
- if (batch.length < PAGE_SIZE) break;
45
- }
46
- return members;
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
- (id) => id,
224
+ (slug, options) =>
225
+ `${slug}|${options?.minWords ?? ''}|${[...(options?.excludeUsers ?? [])]
226
+ .sort()
227
+ .join(',')}`,
49
228
  3_600_000,
50
229
  );
51
230