-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathscript.js
More file actions
708 lines (644 loc) · 27.5 KB
/
Copy pathscript.js
File metadata and controls
708 lines (644 loc) · 27.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
const repositoryUrl = "https://github.com/YaKun9/codex-pets";
const petPreviewAnimation = {
row: 0,
frameCount: 7,
frameWidth: 192,
frameHeight: 208,
frameDuration: 140,
loopPause: 800
};
const pets = [
{
id: "chinese-paladin-liu-yifei-zhao-linger",
series: "chinesePaladin",
name: { zh: "刘亦菲·赵灵儿", en: "Liu Yifei · Zhao Ling'er" },
description: {
zh: "刘亦菲在 2005 版《仙剑奇侠传》中赵灵儿造型的真人写实宠物,保留清雅眉眼、半束长发与白浅玉色纱裙,不采用 Q 版比例。",
en: "A photorealistic, non-chibi Liu Yifei as Zhao Ling'er from the 2005 live-action Chinese Paladin, with her period hairstyle and white-and-pale-jade gauze costume."
},
licenseType: "fan-noncommercial",
contributor: "YaKun9",
color: "151, 205, 188"
},
{
id: "genshin-impact-keqing",
series: "genshin",
name: { zh: "刻晴", en: "Keqing" },
description: {
zh: "《原神》刻晴的官方设定风 Q 版 Codex 宠物,干练优雅,带有鲜明的雷元素气质。",
en: "A poised chibi Codex pet inspired by Keqing from Genshin Impact, with her unmistakable Electro style."
},
licenseType: "fan-noncommercial",
contributor: "YaKun9",
color: "181, 142, 255"
},
{
id: "genshin-impact-kamisato-ayaka",
series: "genshin",
name: { zh: "神里绫华", en: "Kamisato Ayaka" },
description: {
zh: "《原神》神里绫华的官方设定风 Q 版宠物,优雅端庄,保留霜华气质与标志性的白蓝和服甲胄造型。",
en: "An elegant chibi Kamisato Ayaka with her silver ponytail, blue-white armored kimono, and graceful Cryo poise."
},
licenseType: "fan-noncommercial",
contributor: "YaKun9",
color: "132, 192, 255"
},
{
id: "naruto-six-paths",
series: "naruto",
name: { zh: "六道鸣人", en: "Six Paths Naruto" },
description: {
zh: "鸣人六道仙人形态的 Q 版动画宠物,金色查克拉外衣与六道纹样醒目,勇敢而温暖。",
en: "A warmhearted chibi pet of Naruto in Six Paths Sage Mode, wrapped in a striking golden chakra cloak."
},
licenseType: "fan-noncommercial",
contributor: "GitXMING",
color: "255, 184, 64"
},
{
id: "honkai-star-rail-yaoguang-yaoyao",
series: "starrail",
name: { zh: "爻光·摇摇", en: "Yaoguang · Yaoyao" },
description: {
zh: "《崩坏:星穹铁道》爻光的联名摇一摇灵感 Q 版宠物,保留银白长发与青蓝孔雀羽饰。",
en: "A chibi Codex pet inspired by Yaoguang's Yaoyao interaction, featuring silver hair and peacock ornaments."
},
licenseType: "fan-noncommercial",
contributor: "YaKun9",
color: "84, 220, 211"
},
{
id: "honkai-star-rail-acheron",
series: "starrail",
name: { zh: "黄泉", en: "Acheron" },
description: {
zh: "《崩坏:星穹铁道》黄泉的精美 Q 版宠物,冷艳沉静,携长刀并带有红白雷光拔刀演出。",
en: "A polished chibi Acheron with a cool composure, long blade, and red-and-white lightning draw animation."
},
licenseType: "fan-noncommercial",
contributor: "YaKun9",
color: "244, 86, 132"
},
{
id: "wuthering-waves-iuno",
series: "wuthering",
name: { zh: "尤诺", en: "Iuno" },
description: {
zh: "《鸣潮》月相主题共鸣者尤诺的灵动优雅 Q 版宠物,保留深蓝渐变长发与月环意象。",
en: "An elegant chibi Iuno from Wuthering Waves, preserving her deep-blue hair and signature moon-ring motif."
},
licenseType: "fan-noncommercial",
contributor: "yanhuuo",
color: "91, 178, 255"
},
{
id: "wuthering-waves-suisui",
series: "wuthering",
name: { zh: "穗穗", en: "Suisui" },
description: {
zh: "《鸣潮》穗穗的温婉灵动 Q 版宠物,保留金白长发、蓝金水袖、水扇与红色饰件。",
en: "A gentle and lively chibi Suisui, featuring gold-white hair, flowing blue sleeves, and her water fan."
},
licenseType: "fan-noncommercial",
contributor: "mizunagare",
color: "104, 211, 235"
},
{
id: "wuthering-waves-aemeath",
series: "wuthering",
name: { zh: "爱弥斯", en: "Aemeath" },
description: {
zh: "《鸣潮》爱弥斯的星海歌姬风 Q 版宠物,保留粉色长马尾、金色眼眸与星空驾驶员礼服。",
en: "A polished chibi Aemeath with a pink ponytail, golden eyes, and her white-blue starry pilot outfit."
},
licenseType: "fan-noncommercial",
contributor: "mizunagare",
color: "255, 129, 197"
},
{
id: "original-taotao",
series: "original",
name: { zh: "涛涛", en: "Taotao" },
description: {
zh: "涛涛是一个面向开发者社区的原创 IT 吉祥物,以阳光开朗的形象与活力十足的背带舞陪伴每一次编码。",
en: "Taotao is an original IT mascot for developers, bringing upbeat suspender-dance energy to every coding session."
},
licenseType: "cc-by-nc-sa",
contributor: "GitXMING",
color: "232, 162, 86"
},
{
id: "original-zongzhu-basketball-chicken",
series: "original",
name: { zh: "篮球哥哥", en: "Basketball Bro" },
description: {
zh: "篮球哥哥是一个以宗主经典篮球舞台形象为原型的原创宠物,保留蓬松中分发型、黑色高领、白色背带与篮球舞步等标志性元素。",
en: "Basketball Bro is an original pet inspired by Zongzhu's iconic basketball-stage look, with center-parted hair, a black turtleneck, white suspenders, and signature ball-handling dance moves."
},
licenseType: "cc-by-nc-sa",
contributor: "YaKun9",
color: "255, 190, 45"
},
{
id: "original-xiaoman",
series: "original",
name: { zh: "小满", en: "Xiaoman" },
description: {
zh: "小满是一只以中华田园犬为底子的原创 Q 版宠物,圆脸、麦金短毛、奶油胸口和红色织项圈亲切又机灵。",
en: "Xiaoman is an original chibi Chinese village dog with a rounded puppy face, wheat-gold coat, cream chest, and red woven collar."
},
licenseType: "cc-by-nc-sa",
contributor: "YaKun9",
color: "217, 154, 72"
},
{
id: "original-scout-corgi",
series: "original",
name: { zh: "Scout", en: "Scout" },
description: {
zh: "Scout 是一只原创水彩柯基探险家,背着青绿色背包、挂着相机,陪你记录每一次编码旅程。",
en: "Scout is an original watercolor corgi explorer with a teal backpack and camera, ready for every coding journey."
},
licenseType: "cc-by-nc-sa",
contributor: "fanslead",
color: "46, 188, 181"
},
{
id: "ultraman-tiga-multi-type",
series: "ultraman",
name: { zh: "迪迦奥特曼", en: "Ultraman Tiga" },
description: {
zh: "《迪迦奥特曼》经典三形态的精致动画赛璐璐风 Q 版宠物,以复合型为主体,并在移动与跳跃动画中展现空中型和强力型。",
en: "A polished cel-animated chibi Ultraman Tiga featuring all three classic forms, led by Multi Type with Sky Type movement and a Power Type jump."
},
licenseType: "fan-noncommercial",
contributor: "YaKun9",
color: "136, 91, 200"
}
];
const translations = {
zh: {
skipToCatalog: "跳到宠物图鉴",
navPets: "宠物图鉴",
navInstall: "安装指南",
navContribute: "参与贡献",
heroEyebrow: "由社区共同维护的 Codex 宠物收藏库",
heroTitle: "让每一行代码,<br><span>都有伙伴相伴。</span>",
heroDescription: "发现、安装和分享各具特色的动态宠物,为你的 Codex 工作空间增添个性、陪伴与一点小惊喜。",
browsePets: "浏览宠物",
howToInstall: "如何安装",
statPets: "位伙伴",
statWorlds: "个作品世界",
statCreators: "位贡献者",
catalogKicker: "PET CATALOG",
catalogTitle: "选择你的编码伙伴",
catalogDescription: "每一只宠物都由社区成员精心制作,并遵循 Codex Pets v2 资源规范。",
searchLabel: "搜索宠物",
searchPlaceholder: "搜索名称或 ID",
emptyState: "没有找到匹配的宠物,换个关键词试试吧。",
installKicker: "QUICK START",
installTitle: "优先用 AI,三步安装宠物",
stepOneTitle: "选择宠物并复制提示词",
stepOneDescription: "在宠物图鉴中选择喜欢的宠物,点击“复制 AI 安装提示词”按钮。",
stepTwoTitle: "交给 AI 安装",
stepTwoDescription: "将提示词发送给 Codex 或能够访问本机文件的 AI,让它自动完成安装。",
stepThreeTitle: "唤醒伙伴",
stepThreeDescription: "刷新 Pets 设置并选中宠物,然后输入 /pet 与它见面。",
manualInstallKicker: "手动安装(可选)",
manualInstallTitle: "也可以复制命令手动安装",
manualInstallDescription: "下面以刻晴为例。仅在不使用 AI 安装时,将 petId 替换成图鉴中的任意宠物 ID。",
copyCode: "复制",
contributeKicker: "BUILD THE COLLECTION",
contributeTitle: "让你的创意,成为下一位伙伴。",
contributeDescription: "无论是游戏角色、原创形象还是品牌吉祥物,只要符合资源规范,都欢迎通过 Pull Request 加入图鉴。",
readGuide: "查看贡献指南",
viewRepository: "浏览 GitHub 仓库",
footerText: "由社区创造,陪每一位开发者写下有温度的代码。",
allSeries: "全部",
copyPrompt: "复制 AI 安装提示词",
viewFiles: "查看文件",
assetLicense: "素材许可",
viewFullLicense: "查看完整许可",
contributedBy: "贡献者",
copied: "已复制到剪贴板",
promptCopied: "安装提示词已复制"
},
en: {
skipToCatalog: "Skip to pet catalog",
navPets: "Pets",
navInstall: "Install",
navContribute: "Contribute",
heroEyebrow: "A community-powered collection of Codex pets",
heroTitle: "Every line of code<br><span>deserves company.</span>",
heroDescription: "Discover, install, and share animated companions that bring personality, warmth, and a little delight to your Codex workspace.",
browsePets: "Browse pets",
howToInstall: "How to install",
statPets: "companions",
statWorlds: "worlds",
statCreators: "creators",
catalogKicker: "PET CATALOG",
catalogTitle: "Choose your coding companion",
catalogDescription: "Every pet is carefully crafted by the community and follows the Codex Pets v2 asset format.",
searchLabel: "Search pets",
searchPlaceholder: "Search by name or ID",
emptyState: "No matching pets found. Try another search.",
installKicker: "QUICK START",
installTitle: "Install a pet with AI in three steps",
stepOneTitle: "Choose a pet and copy the prompt",
stepOneDescription: "Pick a pet from the catalog and click “Copy AI install prompt.”",
stepTwoTitle: "Ask AI to install it",
stepTwoDescription: "Paste the prompt into Codex or another AI with local file access and let it complete the installation.",
stepThreeTitle: "Wake your companion",
stepThreeDescription: "Refresh Pets, choose your companion, then enter /pet to say hello.",
manualInstallKicker: "OPTIONAL · MANUAL INSTALL",
manualInstallTitle: "Prefer the command line?",
manualInstallDescription: "This Keqing example is an optional alternative to AI installation. Replace petId with any ID from the catalog.",
copyCode: "Copy",
contributeKicker: "BUILD THE COLLECTION",
contributeTitle: "Turn your idea into the next companion.",
contributeDescription: "Game characters, original creations, and brand mascots are all welcome when they follow the asset guidelines. Add yours with a Pull Request.",
readGuide: "Read the contribution guide",
viewRepository: "Explore the GitHub repository",
footerText: "Created by the community, for developers who like a little company.",
allSeries: "All",
copyPrompt: "Copy AI install prompt",
viewFiles: "View files",
assetLicense: "Asset license",
viewFullLicense: "View full license",
contributedBy: "By",
copied: "Copied to clipboard",
promptCopied: "Install prompt copied"
}
};
const seriesLabels = {
chinesePaladin: { zh: "仙剑奇侠传", en: "Chinese Paladin" },
genshin: { zh: "原神", en: "Genshin Impact" },
naruto: { zh: "火影忍者", en: "Naruto" },
ultraman: { zh: "奥特曼", en: "Ultraman" },
starrail: { zh: "崩坏:星穹铁道", en: "Honkai: Star Rail" },
wuthering: { zh: "鸣潮", en: "Wuthering Waves" },
original: { zh: "原创", en: "Original" }
};
const licenseTypes = {
"fan-noncommercial": {
tone: "fan",
icon: "shield",
label: { zh: "同人 · 非商用", en: "Fan · Non-commercial" },
title: { zh: "同人素材非商业许可", en: "Fan asset non-commercial permission" },
description: {
zh: "允许非商业下载、使用、修改和分享;禁止商用、出售、付费分发和转授权。第三方角色权利归原权利人。",
en: "Non-commercial download, use, modification, and sharing are permitted. Commercial use, sale, paid distribution, and sublicensing are prohibited. Third-party character rights remain with their owners."
}
},
"cc-by-nc-sa": {
tone: "original",
icon: "cc",
label: { zh: "CC BY-NC-SA", en: "CC BY-NC-SA" },
title: { zh: "CC BY-NC-SA 4.0", en: "CC BY-NC-SA 4.0" },
description: {
zh: "允许非商业使用、修改和分享;需要署名,修改版本须采用相同许可。",
en: "Non-commercial use, modification, and sharing are permitted with attribution. Adaptations must use the same license."
}
}
};
const installCommands = {
windows: `$petId = "genshin-impact-keqing"
git clone https://github.com/YaKun9/codex-pets.git
$codexHome = if ($env:CODEX_HOME) { $env:CODEX_HOME } else { Join-Path $HOME ".codex" }
$petsDir = Join-Path $codexHome "pets"
New-Item -ItemType Directory -Force -Path $petsDir | Out-Null
Copy-Item -Recurse -LiteralPath (Join-Path $PWD "codex-pets\\$petId") -Destination $petsDir`,
unix: `PET_ID="genshin-impact-keqing"
git clone https://github.com/YaKun9/codex-pets.git
CODEX_HOME="\${CODEX_HOME:-$HOME/.codex}"
mkdir -p "$CODEX_HOME/pets"
cp -R "./codex-pets/$PET_ID" "$CODEX_HOME/pets/"`
};
const state = {
language: localStorage.getItem("codex-pets-language") || (navigator.language.toLowerCase().startsWith("zh") ? "zh" : "en"),
series: "all",
query: "",
platform: "windows"
};
const petGrid = document.querySelector("#petGrid");
const filters = document.querySelector("#seriesFilters");
const searchInput = document.querySelector("#petSearch");
const emptyState = document.querySelector("#emptyState");
const languageToggle = document.querySelector("#languageToggle");
const languageLabel = document.querySelector("#languageLabel");
const installCode = document.querySelector("#installCode");
const codeLabel = document.querySelector("#codeLabel");
const toast = document.querySelector("#toast");
const heroPetSlots = [...document.querySelectorAll("[data-hero-pet]")];
const reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
const spriteSheetCache = new Map();
const activePetPreviews = new Set();
let toastTimer;
function getText(key) {
return translations[state.language][key];
}
function renderLicenseIcon(license) {
if (license.icon === "cc") {
return '<span class="license-mark" aria-hidden="true">CC</span>';
}
return '<svg class="license-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3 19 6v5c0 4.6-2.8 8.5-7 10-4.2-1.5-7-5.4-7-10V6l7-3Z"/><path d="m9 12 2 2 4-4"/></svg>';
}
function renderRandomHeroPets() {
const randomizedPets = [...pets];
for (let index = randomizedPets.length - 1; index > 0; index -= 1) {
const randomIndex = Math.floor(Math.random() * (index + 1));
[randomizedPets[index], randomizedPets[randomIndex]] = [randomizedPets[randomIndex], randomizedPets[index]];
}
heroPetSlots.forEach((slot, index) => {
const pet = randomizedPets[index];
const image = slot.querySelector("img");
slot.dataset.heroPet = pet.id;
slot.style.setProperty("--pet-color", pet.color);
image.src = `./${pet.id}/preview.webp`;
});
}
function showToast(message) {
window.clearTimeout(toastTimer);
toast.textContent = message;
toast.classList.add("visible");
toastTimer = window.setTimeout(() => toast.classList.remove("visible"), 1800);
}
function createInstallPrompt(pet) {
if (state.language === "zh") {
return `请帮我安装 Codex 宠物 \`${pet.id}\`:\n\n- 从 ${repositoryUrl} 获取该宠物目录。\n- 确定当前用户的 CODEX_HOME;如果未设置,则使用 ~/.codex。\n- 将整个宠物目录复制到 <CODEX_HOME>/pets/,最终目录名必须与 pet.json 的 id 一致。\n- 确认 pet.json 和 spritesheetPath 指向的精灵图都存在,不要修改宠物资源内容。\n- 完成后告诉我实际安装路径,并说明如何在桌面端或 Codex CLI 中选择它。`;
}
return `Install the Codex pet \`${pet.id}\` for me:\n\n- Get the pet directory from ${repositoryUrl}.\n- Determine the current user's CODEX_HOME; if it is not set, use ~/.codex.\n- Copy the entire pet directory into <CODEX_HOME>/pets/. The final directory name must match the id in pet.json.\n- Confirm that pet.json and the spritesheet referenced by spritesheetPath both exist. Do not modify the pet assets.\n- When finished, report the installed path and explain how to select the pet in the desktop app or Codex CLI.`;
}
function loadSpriteSheet(petId) {
if (!spriteSheetCache.has(petId)) {
const spriteSheet = new Image();
spriteSheet.decoding = "async";
const request = new Promise((resolve, reject) => {
spriteSheet.addEventListener("load", () => resolve(spriteSheet), { once: true });
spriteSheet.addEventListener("error", reject, { once: true });
});
spriteSheet.src = `./${petId}/spritesheet.webp`;
spriteSheetCache.set(petId, request);
}
return spriteSheetCache.get(petId);
}
function drawPetPreviewFrame(preview) {
const { frameHeight, frameWidth, row } = petPreviewAnimation;
preview.context.clearRect(0, 0, frameWidth, frameHeight);
preview.context.drawImage(
preview.spriteSheet,
preview.frame * frameWidth,
row * frameHeight,
frameWidth,
frameHeight,
0,
0,
frameWidth,
frameHeight
);
}
function schedulePetPreview(preview) {
if (!preview.active) {
return;
}
drawPetPreviewFrame(preview);
preview.visual.classList.add("is-animating");
if (preview.frame === petPreviewAnimation.frameCount - 1) {
preview.timer = window.setTimeout(() => {
if (!preview.active) {
return;
}
preview.frame = 0;
drawPetPreviewFrame(preview);
preview.timer = window.setTimeout(() => {
preview.frame = 1;
schedulePetPreview(preview);
}, petPreviewAnimation.loopPause);
}, petPreviewAnimation.frameDuration);
return;
}
preview.frame += 1;
preview.timer = window.setTimeout(
() => schedulePetPreview(preview),
petPreviewAnimation.frameDuration
);
}
async function startPetPreview(preview) {
if (preview.active || reducedMotionQuery.matches) {
return;
}
preview.active = true;
activePetPreviews.add(preview);
try {
preview.spriteSheet = await loadSpriteSheet(preview.petId);
} catch {
stopPetPreview(preview);
return;
}
if (!preview.active) {
return;
}
preview.frame = 0;
schedulePetPreview(preview);
}
function stopPetPreview(preview) {
preview.active = false;
window.clearTimeout(preview.timer);
preview.visual.classList.remove("is-animating");
preview.context.clearRect(
0,
0,
petPreviewAnimation.frameWidth,
petPreviewAnimation.frameHeight
);
activePetPreviews.delete(preview);
}
function stopAllPetPreviews() {
[...activePetPreviews].forEach(stopPetPreview);
}
function bindPetPreviews() {
petGrid.querySelectorAll("[data-pet-preview]").forEach((visual) => {
const card = visual.closest(".pet-card");
const canvas = visual.querySelector("canvas");
const preview = {
active: false,
context: canvas.getContext("2d"),
frame: 0,
petId: visual.dataset.petPreview,
spriteSheet: null,
timer: null,
visual
};
let hovered = false;
let focused = false;
function updatePreview() {
if (hovered || focused) {
startPetPreview(preview);
} else {
stopPetPreview(preview);
}
}
card.addEventListener("pointerenter", () => {
hovered = true;
updatePreview();
});
card.addEventListener("pointerleave", () => {
hovered = false;
updatePreview();
});
card.addEventListener("focusin", () => {
focused = true;
updatePreview();
});
card.addEventListener("focusout", (event) => {
if (!card.contains(event.relatedTarget)) {
focused = false;
updatePreview();
}
});
});
}
function renderFilters() {
const options = ["all", ...Object.keys(seriesLabels)];
filters.innerHTML = options.map((key) => {
const label = key === "all" ? getText("allSeries") : seriesLabels[key][state.language];
const active = state.series === key;
return `<button class="filter-chip${active ? " active" : ""}" type="button" data-series="${key}" aria-pressed="${active}">${label}</button>`;
}).join("");
filters.querySelectorAll(".filter-chip").forEach((button) => {
button.addEventListener("click", () => {
state.series = button.dataset.series;
renderFilters();
renderPets();
});
});
}
function renderPets() {
stopAllPetPreviews();
const query = state.query.trim().toLowerCase();
const visiblePets = pets.filter((pet) => {
const matchesSeries = state.series === "all" || pet.series === state.series;
const haystack = `${pet.id} ${pet.name.zh} ${pet.name.en} ${seriesLabels[pet.series].zh} ${seriesLabels[pet.series].en}`.toLowerCase();
return matchesSeries && (!query || haystack.includes(query));
});
petGrid.innerHTML = visiblePets.map((pet) => {
const license = licenseTypes[pet.licenseType];
const tooltipId = `license-tooltip-${pet.id}`;
return `
<article class="pet-card" style="--pet-rgb: ${pet.color}">
<div class="pet-visual" data-pet-preview="${pet.id}">
<span class="series-pill">${seriesLabels[pet.series][state.language]}</span>
<span class="version-pill">v2</span>
<img src="./${pet.id}/preview.webp" alt="${pet.name[state.language]}" loading="lazy" width="192" height="208">
<canvas width="192" height="208" aria-hidden="true"></canvas>
</div>
<div class="pet-content">
<div class="pet-title-row">
<div>
<h3>${pet.name[state.language]}</h3>
<span class="pet-id">${pet.id}</span>
</div>
</div>
<p class="pet-description">${pet.description[state.language]}</p>
<div class="pet-meta">
<div class="pet-meta-row">
<span>${getText("assetLicense")}</span>
<span class="license-wrap license-${license.tone}">
<a class="license-badge" href="${repositoryUrl}/blob/main/${pet.id}/LICENSE.md" target="_blank" rel="noreferrer" aria-describedby="${tooltipId}">
${renderLicenseIcon(license)}
<span>${license.label[state.language]}</span>
</a>
<span class="license-tooltip" id="${tooltipId}" role="tooltip">
<strong>${license.title[state.language]}</strong>
<span>${license.description[state.language]}</span>
<span class="license-tooltip-link">${getText("viewFullLicense")} ↗</span>
</span>
</span>
</div>
<div class="pet-meta-row">
<span>${getText("contributedBy")}</span>
<a class="creator" href="https://github.com/${pet.contributor}" target="_blank" rel="noreferrer">
<span class="creator-avatar">${pet.contributor.slice(0, 1).toUpperCase()}</span>
<span>${pet.contributor}</span>
</a>
</div>
</div>
<div class="pet-actions">
<button type="button" data-copy-prompt="${pet.id}">${getText("copyPrompt")}</button>
<a href="${repositoryUrl}/tree/main/${pet.id}" target="_blank" rel="noreferrer" title="${getText("viewFiles")}" aria-label="${getText("viewFiles")}">↗</a>
</div>
</div>
</article>
`;
}).join("");
emptyState.hidden = visiblePets.length !== 0;
bindPetPreviews();
petGrid.querySelectorAll("[data-copy-prompt]").forEach((button) => {
button.addEventListener("click", async () => {
const pet = pets.find((item) => item.id === button.dataset.copyPrompt);
await navigator.clipboard.writeText(createInstallPrompt(pet));
showToast(getText("promptCopied"));
});
});
}
function renderTranslations() {
document.documentElement.lang = state.language === "zh" ? "zh-CN" : "en";
document.title = state.language === "zh" ? "codex-pets · 给代码找个伙伴" : "codex-pets · Every line deserves company";
document.querySelector('meta[name="description"]').content = state.language === "zh"
? "发现、安装和分享 Codex 动态宠物,让每一次编码都有个性伙伴相伴。"
: "Discover, install, and share animated Codex pets that bring personality to every coding session.";
document.querySelectorAll("[data-i18n]").forEach((element) => {
const key = element.dataset.i18n;
element.innerHTML = getText(key);
});
document.querySelectorAll("[data-i18n-placeholder]").forEach((element) => {
element.placeholder = getText(element.dataset.i18nPlaceholder);
});
languageLabel.textContent = state.language === "zh" ? "EN" : "中文";
languageToggle.setAttribute("aria-label", state.language === "zh" ? "Switch to English" : "切换到中文");
document.querySelector("#contributeGuide").href = state.language === "zh"
? `${repositoryUrl}#通过-pull-request-添加宠物`
: `${repositoryUrl}/blob/main/README_EN.md#add-a-pet-via-pull-request`;
renderFilters();
renderPets();
}
function renderInstallCode() {
installCode.textContent = installCommands[state.platform];
codeLabel.textContent = state.platform === "windows" ? "PowerShell" : "Terminal";
}
languageToggle.addEventListener("click", () => {
state.language = state.language === "zh" ? "en" : "zh";
localStorage.setItem("codex-pets-language", state.language);
renderTranslations();
});
searchInput.addEventListener("input", (event) => {
state.query = event.target.value;
renderPets();
});
document.querySelectorAll(".platform-tab").forEach((button) => {
button.addEventListener("click", () => {
state.platform = button.dataset.platform;
document.querySelectorAll(".platform-tab").forEach((tab) => {
const active = tab === button;
tab.classList.toggle("active", active);
tab.setAttribute("aria-pressed", String(active));
});
renderInstallCode();
});
});
document.querySelector("#copyInstallCode").addEventListener("click", async () => {
await navigator.clipboard.writeText(installCommands[state.platform]);
showToast(getText("copied"));
});
document.querySelector("#petCount").textContent = String(pets.length);
document.querySelector("#worldCount").textContent = String(new Set(pets.map((pet) => pet.series)).size);
document.querySelector("#creatorCount").textContent = String(new Set(pets.map((pet) => pet.contributor)).size);
reducedMotionQuery.addEventListener("change", (event) => {
if (event.matches) {
stopAllPetPreviews();
}
});
renderRandomHeroPets();
renderInstallCode();
renderTranslations();