@terrafirmagreg-team/crowdin 0.0.0-stage → 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,47 @@
1
1
  {
2
2
  "name": "@terrafirmagreg-team/crowdin",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.0.2",
4
+ "type": "module",
5
+ "description": "Crowdin project members (translator avatars)",
6
+ "license": "LGPL-3.0-only",
7
+ "exports": {
8
+ "./lib/*": [
9
+ "./src/lib/*.ts",
10
+ "./src/lib/*/index.ts"
11
+ ],
12
+ "./components/*": [
13
+ "./src/components/*.tsx",
14
+ "./src/components/*/index.tsx",
15
+ "./src/components/*/index.ts"
16
+ ]
17
+ },
18
+ "files": [
19
+ "src"
20
+ ],
21
+ "dependencies": {
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",
27
+ "lucide-react": "^1.70.0",
28
+ "server-only": "^0.0.1"
29
+ },
30
+ "peerDependencies": {
31
+ "react": ">=19",
32
+ "react-dom": ">=19"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^26.6.6",
36
+ "@types/react": "^19.3.0",
37
+ "@types/react-dom": "^19.3.0"
38
+ },
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "https://github.com/TerraFirmaGreg-Team/Web-Foundation.git",
42
+ "directory": "packages/crowdin"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ }
6
47
  }
@@ -0,0 +1,495 @@
1
+ 'use client';
2
+
3
+ import { useMemo, useState } from 'react';
4
+ import type { CrowdinLanguageProgress, CrowdinMember } from '../lib/members';
5
+ import { crowdinFlagLocale } from '../lib/languages';
6
+ import {
7
+ Avatar,
8
+ AvatarFallback,
9
+ AvatarImage,
10
+ } from '@terrafirmagreg-team/ui/components/ui/avatar';
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';
18
+ import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
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
+ }
176
+
177
+ export function MembersGrid({
178
+ url,
179
+ members,
180
+ progress,
181
+ languages,
182
+ }: {
183
+ url: string;
184
+ members: CrowdinMember[];
185
+ progress?: Record<string, CrowdinLanguageProgress>;
186
+ languages?: readonly string[];
187
+ }) {
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
+
286
+ return (
287
+ <section className="not-prose mt-8 border-t pt-6">
288
+ <Heading
289
+ as="h2"
290
+ id="translators"
291
+ className="mb-3 text-2xl font-[650] tracking-tight"
292
+ >
293
+ <a
294
+ href={url}
295
+ target="_blank"
296
+ rel="noreferrer noopener"
297
+ className="hover:underline"
298
+ >
299
+ {t('Translators', { note: 'crowdin.translators' })}
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
+ >
330
+ <AvatarImage
331
+ src={m.avatarUrl}
332
+ alt={m.fullName ?? m.username}
333
+ />
334
+ <AvatarFallback>
335
+ {m.username.slice(0, 1).toUpperCase()}
336
+ </AvatarFallback>
337
+ </Avatar>
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>
478
+ ))}
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
+ ))}
493
+ </section>
494
+ );
495
+ }
@@ -0,0 +1,44 @@
1
+ import 'server-only';
2
+ import { Suspense } from 'react';
3
+ import { sectionConfig } from '@terrafirmagreg-team/kit/lib/config';
4
+ import type { SectionPageContext } from '@terrafirmagreg-team/kit/types/features';
5
+ import {
6
+ crowdinMembers,
7
+ crowdinProgress,
8
+ crowdinProjectSlug,
9
+ } from '../lib/members';
10
+ import { MembersGrid, MembersSkeleton } from './members-grid';
11
+
12
+ async function CrowdinMembersList({ ctx }: { ctx: SectionPageContext }) {
13
+ const crowdin =
14
+ ctx.datasetId && ctx.page.slugs.length === 2
15
+ ? sectionConfig().datasetConfig[ctx.datasetId]?.crowdin
16
+ : undefined;
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
+ );
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
+ }
@@ -0,0 +1,238 @@
1
+ import 'server-only';
2
+ import { Client } from '@crowdin/crowdin-api-client';
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';
6
+
7
+ const CROWDIN_ORG = 'terrafirmagreg';
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 };
13
+
14
+ export type CrowdinMember = {
15
+ username: string;
16
+ fullName: string | null;
17
+ avatarUrl: 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;
67
+ };
68
+
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;
97
+ }
98
+
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;
111
+ });
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
+
179
+ export const crowdinMembers = createAsyncCache(
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()) ?? [],
204
+ );
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
+ });
223
+ },
224
+ (slug, options) =>
225
+ `${slug}|${options?.minWords ?? ''}|${[...(options?.excludeUsers ?? [])]
226
+ .sort()
227
+ .join(',')}`,
228
+ 3_600_000,
229
+ );
230
+
231
+ export function crowdinProjectSlug(url: string | undefined) {
232
+ if (!url) return undefined;
233
+ try {
234
+ return new URL(url).pathname.split('/').filter(Boolean).pop();
235
+ } catch {
236
+ return undefined;
237
+ }
238
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.