bindery-cli 0.2.1 → 0.3.0

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.
@@ -3,685 +3,232 @@
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>Bindery 封面生成器</title>
6
+ <title>书库</title>
7
7
  <style>
8
8
  * {
9
9
  box-sizing: border-box;
10
10
  }
11
-
12
11
  :root {
13
12
  --paper: #ffffff;
14
- --panel: #ffffff;
15
13
  --ink: #262220;
16
14
  --ink-soft: #7a7266;
17
15
  --rule: #d8d1bd;
18
16
  --seal: #a23b2e;
19
17
  --seal-dark: #7e2c22;
20
- --seal-tint: rgba(162, 59, 46, 0.14);
21
18
  }
22
-
23
- html,
24
- body {
25
- height: 100%;
26
- }
27
-
28
19
  body {
29
20
  font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
30
21
  margin: 0;
31
- display: flex;
32
- flex-direction: column;
33
- overflow: hidden;
34
22
  background: var(--paper);
35
23
  color: var(--ink);
36
24
  }
37
-
38
- ::selection {
39
- background: var(--seal-tint);
40
- }
41
-
42
- :focus-visible {
43
- outline: 2px solid var(--seal);
44
- outline-offset: 2px;
45
- }
46
-
47
- .layout {
48
- flex: 1;
49
- min-height: 0;
50
- display: flex;
51
- gap: 32px;
52
- padding: 24px 28px;
53
- max-width: 1100px;
54
- width: 100%;
55
- margin: 0 auto;
56
- align-items: stretch;
57
- }
58
-
59
- .panel {
60
- background: transparent;
61
- padding: 0;
62
- }
63
-
64
- .controls {
65
- flex: 3 1 0;
66
- min-width: 0;
67
- overflow-y: auto;
68
- padding-right: 28px;
69
- border-right: 1px solid var(--rule);
70
- }
71
-
72
- .preview {
73
- flex: 2 1 0;
74
- min-width: 0;
25
+ header {
75
26
  display: flex;
76
- flex-direction: column;
77
27
  align-items: center;
78
- justify-content: center;
79
- gap: 16px;
80
- }
81
-
82
- .section {
83
- margin-bottom: 16px;
28
+ justify-content: space-between;
29
+ padding: 20px 32px;
30
+ border-bottom: 1px solid var(--rule);
84
31
  }
85
-
86
- .section:last-of-type {
87
- margin-bottom: 0;
32
+ header h1 {
33
+ margin: 0;
34
+ font-size: 20px;
35
+ font-weight: 600;
36
+ font-family: "Songti SC", "STSong", serif;
37
+ letter-spacing: 0.02em;
88
38
  }
89
-
90
- .row {
91
- display: flex;
92
- gap: 16px;
39
+ .nav a {
40
+ display: inline-block;
41
+ padding: 8px 16px;
42
+ border-radius: 3px;
43
+ background: var(--seal);
44
+ color: #fff;
45
+ text-decoration: none;
46
+ font-size: 13.5px;
47
+ font-weight: 600;
93
48
  }
94
-
95
- .row + .row {
96
- margin-top: 16px;
49
+ .nav a:hover {
50
+ background: var(--seal-dark);
97
51
  }
98
-
99
- .row > .field {
100
- flex: 1;
101
- min-width: 0;
102
- }
103
-
104
- .row > .field-options {
105
- flex: 0 0 110px;
106
- min-width: 0;
52
+ main {
53
+ max-width: 760px;
54
+ margin: 0 auto;
55
+ padding: 24px 32px 48px;
107
56
  }
108
-
109
- label {
110
- display: block;
111
- font-size: 12.5px;
57
+ .summary {
112
58
  color: var(--ink-soft);
113
- margin: 16px 0 6px;
114
- }
115
-
116
- label:first-child {
117
- margin-top: 0;
118
- }
119
-
120
- input[type="text"],
121
- select {
122
- width: 100%;
123
- padding: 7px 2px 8px;
124
- border: none;
125
- border-bottom: 1px solid var(--rule);
126
- border-radius: 0;
127
- font-size: 14px;
128
- background: transparent;
129
- color: var(--ink);
130
- font-family: inherit;
131
- transition:
132
- border-color 0.15s,
133
- box-shadow 0.15s;
134
- }
135
-
136
- input[type="text"]::placeholder {
137
- color: #b7ae9c;
138
- }
139
-
140
- select {
141
- appearance: none;
142
- -webkit-appearance: none;
143
- background-image:
144
- linear-gradient(45deg, transparent 50%, var(--ink-soft) 50%),
145
- linear-gradient(135deg, var(--ink-soft) 50%, transparent 50%);
146
- background-position:
147
- calc(100% - 6px) center,
148
- calc(100% - 1px) center;
149
- background-size:
150
- 6px 6px,
151
- 6px 6px;
152
- background-repeat: no-repeat;
153
- padding-right: 18px;
154
- cursor: pointer;
59
+ font-size: 13px;
60
+ margin: 0 0 8px;
155
61
  }
156
-
157
- input[type="text"]:focus,
158
- select:focus {
159
- outline: none;
160
- border-bottom: 1px solid var(--accent, var(--seal));
161
- box-shadow: 0 1px 0 0 var(--accent, var(--seal));
162
- }
163
-
164
- .swatches {
62
+ .book {
165
63
  display: flex;
166
- flex-wrap: wrap;
167
- gap: 14px;
168
- padding: 6px;
169
- }
170
-
171
- .swatch {
172
- width: 30px;
173
- height: 30px;
174
- border-radius: 50%;
175
- cursor: pointer;
176
- border: 2px solid transparent;
177
- position: relative;
178
- transition:
179
- box-shadow 0.15s,
180
- transform 0.1s;
181
- }
182
-
183
- .swatch:hover {
184
- transform: translateY(-1px);
185
- }
186
-
187
- .swatch.active {
188
- box-shadow:
189
- 0 0 0 2px var(--panel),
190
- 0 0 0 4px var(--ink);
191
- }
192
-
193
- .swatch.active::after {
194
- content: none;
64
+ gap: 16px;
65
+ padding: 16px 0;
66
+ border-bottom: 1px solid #f0f0f0;
67
+ align-items: center;
195
68
  }
196
-
197
- .swatch.custom {
198
- border: none;
199
- margin: 0;
69
+ .book:last-child {
70
+ border-bottom: none;
200
71
  }
201
-
202
- .swatch.custom::before {
203
- content: "";
204
- position: absolute;
205
- inset: 0;
206
- border-radius: 50%;
207
- background: conic-gradient(#f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00);
72
+ .cover {
73
+ width: 64px;
74
+ height: 102px;
75
+ object-fit: cover;
76
+ border-radius: 3px;
77
+ background: #f5f5f5;
78
+ border: 1px solid var(--rule);
79
+ flex: none;
208
80
  }
209
-
210
- .swatch.custom.active {
211
- box-shadow:
212
- 0 0 0 2px var(--panel),
213
- 0 0 0 4px var(--ink);
81
+ .info {
82
+ flex: 1;
83
+ min-width: 0;
214
84
  }
215
-
216
- .swatch.custom input[type="color"] {
217
- position: absolute;
218
- inset: 0;
219
- width: 100%;
220
- height: 100%;
221
- opacity: 0;
222
- cursor: pointer;
223
- border: none;
224
- padding: 0;
85
+ .info h2 {
225
86
  margin: 0;
87
+ font-size: 16px;
226
88
  }
227
-
228
- canvas {
229
- max-width: 100%;
230
- max-height: 100%;
231
- width: auto;
232
- height: auto;
233
- display: block;
234
- border-radius: 2px;
235
- border: 1px solid var(--rule);
236
- }
237
-
238
- .actions {
239
- display: flex;
240
- gap: 10px;
89
+ .meta {
90
+ color: var(--ink-soft);
91
+ font-size: 13px;
92
+ margin: 4px 0 10px;
241
93
  }
242
-
243
- button {
244
- padding: 10px 18px;
245
- border: none;
94
+ .dl {
95
+ display: inline-block;
96
+ padding: 6px 14px;
246
97
  border-radius: 3px;
247
- font-size: 13.5px;
248
- cursor: pointer;
249
- font-weight: 600;
250
- font-family: inherit;
251
- transition:
252
- transform 0.08s,
253
- background 0.15s,
254
- opacity 0.15s;
255
- }
256
-
257
- .primary {
258
- background: var(--accent, var(--seal));
98
+ background: var(--seal);
259
99
  color: #fff;
100
+ text-decoration: none;
101
+ font-size: 13px;
102
+ font-weight: 600;
260
103
  }
261
-
262
- .primary:hover {
263
- filter: brightness(0.85);
264
- }
265
-
266
- .primary:active {
267
- transform: translateY(1px);
268
- }
269
-
270
- .secondary {
271
- background: transparent;
272
- color: var(--ink);
273
- border: 1px solid var(--rule);
274
- }
275
-
276
- .secondary:hover {
277
- border-color: var(--ink-soft);
104
+ .dl:hover {
105
+ background: var(--seal-dark);
278
106
  }
279
-
280
- button:disabled {
281
- opacity: 0.45;
282
- cursor: not-allowed;
283
- transform: none;
107
+ .not-built {
108
+ color: #999;
109
+ font-size: 13px;
284
110
  }
285
-
286
- #status {
287
- font-size: 12.5px;
111
+ .empty {
112
+ text-align: center;
288
113
  color: var(--ink-soft);
289
- min-height: 18px;
114
+ padding: 48px 0;
290
115
  }
291
-
292
- @media (max-width: 860px) {
293
- .layout {
294
- flex-direction: column;
295
- overflow-y: auto;
296
- padding: 20px;
297
- }
298
-
299
- .controls {
300
- flex: none;
301
- width: 100%;
302
- border-right: none;
303
- border-bottom: 1px solid var(--rule);
304
- padding-right: 0;
305
- padding-bottom: 24px;
306
- overflow-y: visible;
307
- }
308
-
309
- .preview {
310
- padding: 24px 12px;
311
- }
116
+ .empty p {
117
+ margin: 8px 0 0;
118
+ font-size: 13px;
312
119
  }
313
120
  </style>
314
121
  </head>
315
122
  <body>
316
- <div class="layout">
317
- <div class="panel controls">
318
- <div class="section">
319
- <label for="book">选择书籍</label>
320
- <select id="book">
321
- <option value="">(无书籍)</option>
322
- </select>
323
- </div>
324
-
325
- <div class="section">
326
- <div class="row">
327
- <div class="field">
328
- <label for="title">书名</label>
329
- <input type="text" id="title" placeholder="书名" />
330
- </div>
331
- <div class="field-options">
332
- <label for="titleAlign">书名对齐</label>
333
- <select id="titleAlign">
334
- <option value="center">居中</option>
335
- <option value="left">左对齐</option>
336
- <option value="right">右对齐</option>
337
- </select>
338
- </div>
339
- </div>
340
- <div class="row">
341
- <div class="field">
342
- <label for="subtitle">副标题</label>
343
- <input type="text" id="subtitle" placeholder="副标题" />
344
- </div>
345
- <div class="field-options">
346
- <label for="placement">副标题位置</label>
347
- <select id="placement">
348
- <option value="bottom_right">下方 · 右</option>
349
- <option value="bottom_left">下方 · 左</option>
350
- <option value="top_right">上方 · 右</option>
351
- <option value="top_left">上方 · 左</option>
352
- </select>
353
- </div>
354
- </div>
355
- <label for="topText">顶部文字</label>
356
- <input type="text" id="topText" placeholder="一句话介绍这本书" />
357
- <label for="author">作者</label>
358
- <input type="text" id="author" placeholder="作者" />
359
- </div>
360
-
361
- <div class="section">
362
- <label for="font">字体</label>
363
- <select id="font"></select>
364
- <label for="logo">LOGO</label>
365
- <input type="text" id="logo" value="再读经典" />
366
- <label>配色</label>
367
- <div class="swatches" id="colors"></div>
368
- </div>
123
+ <header>
124
+ <h1 data-i18n="library">书库</h1>
125
+ <div class="nav">
126
+ <a href="/cover" data-i18n="coverGenerator">封面生成器</a>
369
127
  </div>
370
-
371
- <div class="panel preview">
372
- <canvas id="cover" width="1000" height="1600"></canvas>
373
- <div class="actions">
374
- <button class="primary" id="saveBtn" onclick="saveToBook()">
375
- 保存到书籍
376
- </button>
377
- <button class="secondary" onclick="downloadPng()">下载 PNG</button>
378
- </div>
379
- <div id="status"></div>
380
- </div>
381
- </div>
128
+ </header>
129
+ <main>
130
+ <p class="summary" id="summary"></p>
131
+ <div id="books"></div>
132
+ </main>
382
133
 
383
134
  <script>
384
- const COLORS = [
385
- ["Gray", "#6b7280"],
386
- ["Red", "#ff0000"],
387
- ["Orange", "#e66a21"],
388
- ["Yellow", "#f8b824"],
389
- ["Lime", "#8c9d1a"],
390
- ["Green", "#449429"],
391
- ["Teal", "#3a9e9f"],
392
- ["Cyan", "#06b6d4"],
393
- ["Sky", "#0ea5e9"],
394
- ["Blue", "#103fa4"],
395
- ["Indigo", "#6167b3"],
396
- ["Violet", "#480d71"],
397
- ["Fuchsia", "#b8459c"],
398
- ];
399
- const FONTS = [
400
- ["宋体(衬线)", '"Songti SC", "STSong", "SimSun", serif'],
401
- [
402
- "黑体(无衬线)",
403
- '"PingFang SC", "Heiti SC", "Microsoft YaHei", sans-serif',
404
- ],
405
- ["楷体", '"Kaiti SC", "KaiTi", "STKaiti", serif'],
406
- ["仿宋", '"FangSong", "STFangsong", serif'],
407
- ["Georgia(西文)", "Georgia, serif"],
408
- ["Helvetica(西文)", "Helvetica, Arial, sans-serif"],
409
- ["系统默认", "system-ui, -apple-system, sans-serif"],
410
- ];
411
- const canvas = document.getElementById("cover");
412
- const ctx = canvas.getContext("2d");
413
- const S = 2; // 500x800 逻辑尺寸,2 倍缩放输出
414
-
415
- let color = COLORS[5][1]; // 默认 Green
416
- let font = FONTS[0][1]; // 默认宋体
417
-
418
- function initSwatches() {
419
- const box = document.getElementById("colors");
420
- COLORS.forEach(([name, hex], i) => {
421
- const s = document.createElement("div");
422
- s.className = "swatch" + (hex === color ? " active" : "");
423
- s.title = name;
424
- s.style.background = hex;
425
- s.onclick = () => {
426
- color = hex;
427
- box
428
- .querySelectorAll(".swatch")
429
- .forEach((el) => el.classList.remove("active"));
430
- s.classList.add("active");
431
- render();
432
- };
433
- box.appendChild(s);
434
- });
435
-
436
- // 自定义颜色入口(放最后)
437
- const custom = document.createElement("label");
438
- custom.className = "swatch custom";
439
- custom.title = "自定义颜色";
440
- const input = document.createElement("input");
441
- input.type = "color";
442
- input.value = color;
443
- custom.appendChild(input);
444
- custom.addEventListener("click", (e) => {
445
- if (e.target === custom) input.click();
446
- });
447
- input.addEventListener("input", () => {
448
- color = input.value;
449
- box
450
- .querySelectorAll(".swatch")
451
- .forEach((el) => el.classList.remove("active"));
452
- custom.classList.add("active");
453
- render();
454
- });
455
- box.appendChild(custom);
456
- }
457
-
458
- function initFonts() {
459
- const sel = document.getElementById("font");
460
- FONTS.forEach(([name, family]) => {
461
- const opt = document.createElement("option");
462
- opt.value = family;
463
- opt.textContent = name;
464
- sel.appendChild(opt);
135
+ // ---------- 多语言 ----------
136
+ const LANG = (navigator.language || "en").toLowerCase().startsWith("zh")
137
+ ? "zh"
138
+ : "en";
139
+ const I18N = {
140
+ zh: {
141
+ library: "书库",
142
+ coverGenerator: "封面生成器",
143
+ totalBooks: " {n} 本书",
144
+ download: "下载 EPUB",
145
+ notBuilt: "未构建",
146
+ noBooks: "暂无书籍",
147
+ noBooksHint: "运行 bindery new book 创建第一本书",
148
+ loadError: "加载书库失败",
149
+ },
150
+ en: {
151
+ library: "Library",
152
+ coverGenerator: "Cover generator",
153
+ totalBooks: "{n} books",
154
+ download: "Download EPUB",
155
+ notBuilt: "Not built",
156
+ noBooks: "No books yet",
157
+ noBooksHint: "Run bindery new book to create your first book",
158
+ loadError: "Failed to load library",
159
+ },
160
+ };
161
+ const t = (key) => I18N[LANG][key] || I18N.en[key] || key;
162
+
163
+ function esc(s) {
164
+ return String(s)
165
+ .replace(/&/g, "&amp;")
166
+ .replace(/</g, "&lt;")
167
+ .replace(/>/g, "&gt;")
168
+ .replace(/"/g, "&quot;");
169
+ }
170
+
171
+ function applyI18n() {
172
+ document.querySelectorAll("[data-i18n]").forEach((el) => {
173
+ el.textContent = t(el.dataset.i18n);
465
174
  });
466
- sel.value = font;
467
175
  }
468
176
 
469
- function wrapText(text, maxWidth, font) {
470
- ctx.font = font;
471
- if (!text) return [""];
472
- const words = text.split(/\s+/).filter(Boolean);
473
- const lines = [];
474
- if (words.length <= 1) {
475
- let line = "";
476
- for (const ch of text) {
477
- const test = line + ch;
478
- if (line && ctx.measureText(test).width > maxWidth) {
479
- lines.push(line);
480
- line = ch;
481
- } else line = test;
482
- }
483
- if (line) lines.push(line);
484
- } else {
485
- let line = "";
486
- for (const w of words) {
487
- const test = line ? line + " " + w : w;
488
- if (line && ctx.measureText(test).width > maxWidth) {
489
- lines.push(line);
490
- line = w;
491
- } else line = test;
492
- }
493
- if (line) lines.push(line);
494
- }
495
- return lines;
496
- }
497
-
498
- // 书名自动适应字号
499
- function fitTitle(text, maxWidth) {
500
- let size = 76;
501
- while (size > 30) {
502
- const lines = wrapText(text, maxWidth, `400 ${size}px ${font}`);
503
- if (lines.length <= 4) return { lines, size };
504
- size -= 4;
505
- }
506
- const lines = wrapText(text, maxWidth, `400 30px ${font}`);
507
- return { lines, size: 30 };
508
- }
509
-
510
- function render() {
511
- const topText = document.getElementById("topText").value;
512
- const title = document.getElementById("title").value;
513
- const subtitle = document.getElementById("subtitle").value;
514
- const author = document.getElementById("author").value;
515
- const placement = document.getElementById("placement").value;
516
- font = document.getElementById("font").value;
517
-
518
- // 保存按钮颜色随选中配色同步
519
- document.documentElement.style.setProperty("--accent", color);
520
-
521
- ctx.clearRect(0, 0, canvas.width, canvas.height);
522
- ctx.fillStyle = "#fff";
523
- ctx.fillRect(0, 0, canvas.width, canvas.height);
524
-
525
- // 顶部色条
526
- ctx.fillStyle = color;
527
- ctx.fillRect(20 * S, 0, 460 * S, 10 * S);
528
-
529
- // 顶部文字(一句话介绍)
530
- ctx.fillStyle = "#000";
531
- ctx.textAlign = "center";
532
- ctx.textBaseline = "top";
533
- ctx.font = `400 ${20 * S}px ${font}`;
534
- ctx.fillText(topText, 250 * S, 14 * S, 460 * S);
535
-
536
- drawTextLayer();
537
- }
538
-
539
- function drawTextLayer() {
540
- const logo = document.getElementById("logo").value;
541
- const title = document.getElementById("title").value;
542
- const subtitle = document.getElementById("subtitle").value;
543
- const author = document.getElementById("author").value;
544
- const placement = document.getElementById("placement").value;
545
-
546
- // 书名块
547
- const maxW = 420; // 460 - 2*20 边距
548
- const { lines, size } = fitTitle(title, maxW);
549
- const lineHeight = size * 1.1;
550
- const pad = 25;
551
- const blockH = lines.length * lineHeight + pad * 2;
552
- const titleTop = Math.round((800 - blockH) / 2);
553
-
554
- ctx.fillStyle = color;
555
- ctx.fillRect(20 * S, titleTop * S, 460 * S, blockH * S);
556
- ctx.fillStyle = "#fff";
557
- ctx.textBaseline = "top";
558
- ctx.font = `400 ${size * S}px ${font}`;
559
-
560
- const titleAlign = document.getElementById("titleAlign").value;
561
- let titleX;
562
- if (titleAlign === "left") {
563
- ctx.textAlign = "left";
564
- titleX = 40 * S;
565
- } else if (titleAlign === "right") {
566
- ctx.textAlign = "right";
567
- titleX = 460 * S;
568
- } else {
569
- ctx.textAlign = "center";
570
- titleX = 250 * S;
571
- }
572
- lines.forEach((ln, i) => {
573
- ctx.fillText(ln, titleX, (titleTop + pad + i * lineHeight) * S);
574
- });
575
-
576
- // 副标题
577
- const subBelowY = titleTop + blockH + 8;
578
- const subAboveY = titleTop - 34 - 8;
579
- ctx.fillStyle = "#000";
580
- ctx.font = `400 ${34 * S}px ${font}`;
581
- if (placement === "top_left") {
582
- ctx.textAlign = "left";
583
- ctx.fillText(subtitle, 20 * S, subAboveY * S);
584
- } else if (placement === "top_right") {
585
- ctx.textAlign = "right";
586
- ctx.fillText(subtitle, 480 * S, subAboveY * S);
587
- } else if (placement === "bottom_left") {
588
- ctx.textAlign = "left";
589
- ctx.fillText(subtitle, 20 * S, subBelowY * S);
590
- } else {
591
- ctx.textAlign = "right";
592
- ctx.fillText(subtitle, 480 * S, subBelowY * S);
593
- }
594
-
595
- // LOGO(左下角)
596
- ctx.fillStyle = "#000";
597
- ctx.textAlign = "left";
598
- ctx.textBaseline = "top";
599
- ctx.font = `400 ${28 * S}px ${font}`;
600
- ctx.fillText(logo, 20 * S, (800 - 20 - 28) * S);
601
-
602
- // 作者(右下)
603
- ctx.fillStyle = "#000";
604
- ctx.textAlign = "right";
605
- ctx.font = `400 ${24 * S}px ${font}`;
606
- ctx.fillText(author, 480 * S, (800 - 20 - 24) * S);
607
- }
608
-
609
- // 加载书籍列表并自动填充
610
177
  async function loadBooks() {
178
+ const box = document.getElementById("books");
179
+ const summary = document.getElementById("summary");
611
180
  try {
612
181
  const res = await fetch("/books.json");
613
182
  const data = await res.json();
614
- const sel = document.getElementById("book");
615
- (data.books || []).forEach((b) => {
616
- const opt = document.createElement("option");
617
- opt.value = b.id;
618
- opt.textContent = b.title + "" + b.author + ")";
619
- sel.appendChild(opt);
620
- });
621
- sel.onchange = () => {
622
- const b = (data.books || []).find((x) => x.id === sel.value);
623
- if (b) {
624
- document.getElementById("title").value = b.title || "";
625
- document.getElementById("author").value = b.author || "";
626
- document.getElementById("subtitle").value =
627
- b.category || b.dynasty || "";
628
- render();
629
- }
630
- };
631
- } catch (e) {
632
- console.error("加载书籍失败", e);
633
- }
634
- }
635
-
636
- function status(msg) {
637
- document.getElementById("status").textContent = msg;
638
- }
639
-
640
- async function saveToBook() {
641
- const bookId = document.getElementById("book").value;
642
- if (!bookId) {
643
- status("请先选择要保存到的书籍");
644
- return;
645
- }
646
- const btn = document.getElementById("saveBtn");
647
- btn.disabled = true;
648
- try {
649
- const dataUrl = canvas.toDataURL("image/png");
650
- const res = await fetch("/cover/save", {
651
- method: "POST",
652
- headers: { "Content-Type": "application/json" },
653
- body: JSON.stringify({ book: bookId, data: dataUrl }),
654
- });
655
- const result = await res.json();
656
- status(
657
- result.ok
658
- ? "✅ 封面已保存到书籍 " + bookId
659
- : "❌ " + (result.error || "保存失败"),
660
- );
183
+ const books = data.books || [];
184
+ summary.textContent = t("totalBooks").replace("{n}", books.length);
185
+ if (books.length === 0) {
186
+ box.innerHTML =
187
+ '<div class="empty"><p>' +
188
+ t("noBooks") +
189
+ "</p><p>" +
190
+ t("noBooksHint") +
191
+ "</p></div>";
192
+ return;
193
+ }
194
+ box.innerHTML = books
195
+ .map((b) => {
196
+ const id = esc(b.id || "");
197
+ const title = esc(b.title || "");
198
+ const meta = [b.author, b.dynasty, b.category]
199
+ .filter(Boolean)
200
+ .map(esc)
201
+ .join(" · ");
202
+ const action = b.epub
203
+ ? '<a class="dl" href="/epub/' +
204
+ id +
205
+ '.epub" download>' +
206
+ t("download") +
207
+ "</a>"
208
+ : '<span class="not-built">' + t("notBuilt") + "</span>";
209
+ return (
210
+ '<div class="book"><img class="cover" src="/cover/' +
211
+ id +
212
+ '" alt="' +
213
+ title +
214
+ '"><div class="info"><h2>' +
215
+ title +
216
+ '</h2><p class="meta">' +
217
+ meta +
218
+ "</p>" +
219
+ action +
220
+ "</div></div>"
221
+ );
222
+ })
223
+ .join("");
661
224
  } catch (e) {
662
- status("❌ 保存失败:" + e.message);
663
- } finally {
664
- btn.disabled = false;
225
+ box.innerHTML =
226
+ '<div class="empty"><p>' + t("loadError") + "</p></div>";
665
227
  }
666
228
  }
667
229
 
668
- function downloadPng() {
669
- const a = document.createElement("a");
670
- a.download = "cover.png";
671
- a.href = canvas.toDataURL("image/png");
672
- a.click();
673
- }
674
-
675
- document.querySelectorAll("input[type=text], select").forEach((el) => {
676
- if (el.id !== "book") {
677
- el.addEventListener("input", render);
678
- }
679
- });
680
-
681
- initSwatches();
682
- initFonts();
230
+ applyI18n();
683
231
  loadBooks();
684
- render();
685
232
  </script>
686
233
  </body>
687
234
  </html>