@terrafirmagreg-team/crowdin 1.0.1 → 1.0.2

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.2",
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.6",
24
+ "@terrafirmagreg-team/kit": "^1.2.19",
25
+ "@terrafirmagreg-team/translate": "^1.2.10",
26
+ "@terrafirmagreg-team/ui": "^1.2.13",
26
27
  "lucide-react": "^1.70.0",
27
28
  "server-only": "^0.0.1"
28
29
  },
@@ -1,46 +1,332 @@
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 displayNames = new Intl.DisplayNames([lang.replace(/_/g, '-')], {
194
+ type: 'language',
195
+ });
196
+ return (id) => {
197
+ try {
198
+ return displayNames.of(id) ?? id;
199
+ } catch {
200
+ return id;
201
+ }
202
+ };
203
+ }, [lang]);
204
+
205
+ const groups = useMemo(() => {
206
+ const byLang = new Map<
207
+ string,
208
+ { id: string; name: string; members: CrowdinMember[] }
209
+ >();
210
+ for (const m of members)
211
+ for (const l of m.languages) {
212
+ const group = byLang.get(l.id) ?? {
213
+ id: l.id,
214
+ name: langName(l.id) || l.name,
215
+ members: [],
216
+ };
217
+ group.members.push(m);
218
+ byLang.set(l.id, group);
219
+ }
220
+ const allowed = languages?.length
221
+ ? new Set(languages.map((code) => code.toLowerCase()))
222
+ : undefined;
223
+ return [...byLang.values()]
224
+ .filter(
225
+ (g) => !allowed || allowed.has(crowdinFlagLocale(g.id).toLowerCase()),
226
+ )
227
+ .sort((a, b) => {
228
+ const pa = progress?.[a.id];
229
+ const pb = progress?.[b.id];
230
+ const diff =
231
+ (pb?.translation ?? -1) - (pa?.translation ?? -1) ||
232
+ (pb?.approval ?? -1) - (pa?.approval ?? -1) ||
233
+ b.members.length - a.members.length;
234
+ return diff || a.name.localeCompare(b.name);
235
+ });
236
+ }, [members, progress, langName, languages]);
237
+
238
+ const totalWords = useMemo(
239
+ () =>
240
+ Object.values(progress ?? {}).reduce(
241
+ (sum, p) => sum + p.words,
242
+ members.reduce((sum, m) => sum + m.translated + m.approved, 0),
243
+ ),
244
+ [progress, members],
245
+ );
246
+
247
+ const compact = new Intl.NumberFormat(lang.replace(/_/g, '-'), {
248
+ notation: 'compact',
249
+ });
250
+
251
+ const memberLabel = (m: CrowdinMember) =>
252
+ t('{words} words', {
253
+ note: 'crowdin.words',
254
+ variables: { words: (m.translated + m.approved).toLocaleString() },
255
+ });
256
+ const memberSince = (m: CrowdinMember) =>
257
+ m.joinedAt
258
+ ? t('since {year}', {
259
+ note: 'crowdin.since',
260
+ variables: { year: String(new Date(m.joinedAt).getFullYear()) },
261
+ })
262
+ : null;
263
+ const memberIsNew = (m: CrowdinMember) => m.isNew;
264
+ const roleLabels = (m: CrowdinMember) =>
265
+ m.roles
266
+ .filter((r) => !BASE_ROLES.has(r))
267
+ .map((r) => {
268
+ switch (r) {
269
+ case 'owner':
270
+ return t('Owner', { note: 'crowdin.role.owner' });
271
+ case 'manager':
272
+ return t('Manager', { note: 'crowdin.role.manager' });
273
+ case 'developer':
274
+ return t('Developer', { note: 'crowdin.role.developer' });
275
+ case 'language_coordinator':
276
+ return t('Coordinator', { note: 'crowdin.role.coordinator' });
277
+ case 'proofreader':
278
+ return t('Proofreader', { note: 'crowdin.role.proofreader' });
279
+ default:
280
+ return r;
281
+ }
282
+ });
283
+
284
+ const podium = members.slice(0, 3);
285
+
21
286
  return (
22
287
  <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" />
288
+ <Heading
289
+ as="h2"
290
+ id="translators"
291
+ className="mb-3 text-2xl font-[650] tracking-tight"
292
+ >
25
293
  <a
26
294
  href={url}
27
295
  target="_blank"
28
296
  rel="noreferrer noopener"
29
- className="hover:text-foreground hover:underline"
297
+ className="hover:underline"
30
298
  >
31
299
  {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>
300
+ </a>{' '}
301
+ <span className="inline-flex items-center gap-1.5 align-middle text-base font-normal text-muted-foreground">
302
+ {members.length}
303
+ <Users className="size-4" />
304
+ {groups.length > 0 &&
305
+ `· ${t('{count} languages', {
306
+ note: 'crowdin.languages',
307
+ variables: { count: String(groups.length) },
308
+ })}`}
309
+ {totalWords > 0 &&
310
+ ` · ${t('{words} words', {
311
+ note: 'crowdin.words',
312
+ variables: { words: compact.format(totalWords) },
313
+ })}`}
314
+ </span>
315
+ </Heading>
316
+ {podium.length >= 3 && (
317
+ <div className="mb-6 flex items-end justify-center gap-5">
318
+ {podium.map((m, i) => (
319
+ <a
320
+ key={m.username}
321
+ href={`https://crowdin.com/profile/${m.username}`}
322
+ target="_blank"
323
+ rel="noreferrer noopener"
324
+ className="group flex flex-col items-center gap-1.5"
325
+ >
326
+ <span className="relative">
327
+ <Avatar
328
+ className={i === 0 ? 'size-14' : 'size-11'}
329
+ >
44
330
  <AvatarImage
45
331
  src={m.avatarUrl}
46
332
  alt={m.fullName ?? m.username}
@@ -49,11 +335,161 @@ export function MembersGrid({
49
335
  {m.username.slice(0, 1).toUpperCase()}
50
336
  </AvatarFallback>
51
337
  </Avatar>
52
- </a>
53
- </Hint>
54
- </li>
338
+ <span
339
+ className={`absolute -right-1 -bottom-1 flex size-5 items-center justify-center rounded-full text-[10px] font-bold ${PODIUM_COLORS[i]}`}
340
+ >
341
+ {i + 1}
342
+ </span>
343
+ </span>
344
+ <span className="max-w-24 truncate text-xs font-medium group-hover:underline">
345
+ {m.fullName ?? m.username}
346
+ </span>
347
+ <span className="-mt-1 text-[10px] text-muted-foreground">
348
+ {compact.format(m.translated + m.approved)}
349
+ </span>
350
+ </a>
351
+ ))}
352
+ </div>
353
+ )}
354
+ {groups.length === 0 ? (
355
+ <ul className="flex flex-wrap gap-1.5">
356
+ {members.map((m) => (
357
+ <li key={m.username}>
358
+ <MemberAvatar
359
+ member={m}
360
+ label={memberLabel(m)}
361
+ sinceLabel={memberSince(m)}
362
+ isNew={memberIsNew(m)}
363
+ roleLabels={roleLabels(m)}
364
+ langName={langName}
365
+ />
366
+ </li>
367
+ ))}
368
+ </ul>
369
+ ) : (
370
+ <div className="space-y-5">
371
+ {groups.map((group) => {
372
+ const p = progress?.[group.id];
373
+ const hidden =
374
+ group.members.length > MAX_AVATARS && !expanded.has(group.id)
375
+ ? group.members.length - MAX_AVATARS
376
+ : 0;
377
+ const shown = hidden
378
+ ? group.members.slice(0, MAX_AVATARS)
379
+ : group.members;
380
+ return (
381
+ <div key={group.id}>
382
+ <h3 className="mb-2 flex flex-wrap items-center gap-2 text-xs font-medium text-muted-foreground">
383
+ <LangFlag locale={crowdinFlagLocale(group.id)} />
384
+ <a
385
+ href={`${url}/${group.id}`}
386
+ target="_blank"
387
+ rel="noreferrer noopener"
388
+ className="min-w-0 text-foreground hover:underline"
389
+ >
390
+ {group.name}
391
+ </a>
392
+ <span>{group.members.length}</span>
393
+ {p && (
394
+ <span className="ml-auto flex items-center gap-2">
395
+ <span
396
+ className="flex h-1.5 w-24 overflow-hidden rounded-full bg-foreground/15"
397
+ title={`${p.translation}% · ${t('{pct}% approved', {
398
+ note: 'crowdin.approved',
399
+ variables: { pct: String(p.approval) },
400
+ })}`}
401
+ >
402
+ <span
403
+ className="h-full bg-primary"
404
+ style={{ width: `${p.approval}%` }}
405
+ />
406
+ <span
407
+ className="h-full bg-primary/40"
408
+ style={{
409
+ width: `${Math.max(p.translation - p.approval, 0)}%`,
410
+ }}
411
+ />
412
+ </span>
413
+ <span className="w-9 text-right tabular-nums">
414
+ {p.translation}%
415
+ </span>
416
+ </span>
417
+ )}
418
+ </h3>
419
+ <ul className="flex flex-wrap gap-1.5">
420
+ {shown.map((m, i) => (
421
+ <li key={m.username}>
422
+ <MemberAvatar
423
+ member={m}
424
+ label={memberLabel(m)}
425
+ sinceLabel={memberSince(m)}
426
+ isNew={memberIsNew(m)}
427
+ roleLabels={roleLabels(m)}
428
+ langName={langName}
429
+ top={i === 0}
430
+ />
431
+ </li>
432
+ ))}
433
+ {hidden > 0 && (
434
+ <li>
435
+ <button
436
+ type="button"
437
+ onClick={() =>
438
+ setExpanded((prev) => new Set(prev).add(group.id))
439
+ }
440
+ 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"
441
+ >
442
+ +{hidden}
443
+ </button>
444
+ </li>
445
+ )}
446
+ </ul>
447
+ </div>
448
+ );
449
+ })}
450
+ </div>
451
+ )}
452
+ <a
453
+ href={url}
454
+ target="_blank"
455
+ rel="noreferrer noopener"
456
+ 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"
457
+ >
458
+ <Globe className="size-3.5" />
459
+ {t('Help translate', { note: 'crowdin.join' })}
460
+ </a>
461
+ </section>
462
+ );
463
+ }
464
+
465
+ export function MembersSkeleton() {
466
+ return (
467
+ <section className="not-prose mt-8 animate-pulse border-t pt-6">
468
+ <div className="mb-4 h-4 w-48 rounded bg-muted" />
469
+ <div className="mb-6 flex items-end gap-5">
470
+ {[14, 11, 11].map((s, i) => (
471
+ <div key={i} className="flex flex-col items-center gap-1.5">
472
+ <div
473
+ className="rounded-full bg-muted"
474
+ style={{ width: s * 4, height: s * 4 }}
475
+ />
476
+ <div className="h-3 w-16 rounded bg-muted" />
477
+ </div>
55
478
  ))}
56
- </ul>
479
+ </div>
480
+ {[0, 1, 2, 3].map((row) => (
481
+ <div key={row} className="mb-5">
482
+ <div className="mb-2 flex items-center gap-2">
483
+ <div className="size-4 rounded bg-muted" />
484
+ <div className="h-3 w-24 rounded bg-muted" />
485
+ </div>
486
+ <div className="flex flex-wrap gap-1.5">
487
+ {Array.from({ length: 8 - row }).map((_, i) => (
488
+ <div key={i} className="size-8 rounded-full bg-muted" />
489
+ ))}
490
+ </div>
491
+ </div>
492
+ ))}
57
493
  </section>
58
494
  );
59
495
  }
@@ -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
+ });
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
+ });
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