dsh-reference-frames 0.0.0-stage → 1.6.1

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/client.js ADDED
@@ -0,0 +1,3685 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-reference-frames',
3
+ factory(require) {
4
+ const React = require('react')
5
+ const h = React.createElement
6
+ const { useState, useEffect, useMemo, useRef, useCallback } = React
7
+
8
+ const NS = 'anistyle'
9
+ const TOKEN_KEY = 'anistyle:token'
10
+ const BOARDS_KEY = 'anistyle:boards:v1'
11
+ const VIEW_KEY = 'anistyle:view:v1'
12
+ const AI_KEY = 'anistyle:ai'
13
+ const AI_COUNT_KEY = 'anistyle:ai-count'
14
+ const KW_KEY = 'anistyle:keywords:v1'
15
+
16
+ const KW_DEFAULT = { hidden: [], hiddenGroups: [], labels: {}, extra: {}, groups: [] }
17
+
18
+ function loadKeywords() {
19
+ try {
20
+ const raw = window.localStorage.getItem(KW_KEY)
21
+ const parsed = raw ? JSON.parse(raw) : null
22
+ if (!parsed || typeof parsed !== 'object') return KW_DEFAULT
23
+ const groups = Array.isArray(parsed.groups)
24
+ ? parsed.groups
25
+ .filter((group) => group && typeof group.id === 'string')
26
+ .map((group) => ({
27
+ id: group.id,
28
+ name: typeof group.name === 'string' && group.name ? group.name : '自定义',
29
+ terms: Array.isArray(group.terms) ? group.terms.filter((term) => typeof term === 'string' && term) : [],
30
+ }))
31
+ : []
32
+ // v1 shape: { hidden, custom } — fold the loose custom terms into one user category
33
+ if (!groups.length && Array.isArray(parsed.custom) && parsed.custom.length) {
34
+ groups.push({ id: 'custom', name: '自定义', terms: parsed.custom.filter((term) => typeof term === 'string') })
35
+ }
36
+ const extra = {}
37
+ if (parsed.extra && typeof parsed.extra === 'object') {
38
+ for (const key of Object.keys(parsed.extra)) {
39
+ const list = parsed.extra[key]
40
+ if (!Array.isArray(list)) continue
41
+ extra[key] = list.filter((term) => typeof term === 'string' && term)
42
+ }
43
+ }
44
+ return {
45
+ hidden: Array.isArray(parsed.hidden) ? parsed.hidden.filter((item) => typeof item === 'string') : [],
46
+ hiddenGroups: Array.isArray(parsed.hiddenGroups)
47
+ ? parsed.hiddenGroups.filter((item) => typeof item === 'string')
48
+ : [],
49
+ labels: parsed.labels && typeof parsed.labels === 'object' ? parsed.labels : {},
50
+ extra,
51
+ groups,
52
+ }
53
+ } catch { return KW_DEFAULT }
54
+ }
55
+
56
+ function buildKeywordGroups(groups, store) {
57
+ const hidden = new Set(store.hidden || [])
58
+ const hiddenGroups = new Set(store.hiddenGroups || [])
59
+ const labels = store.labels || {}
60
+ const out = []
61
+ for (const group of groups || []) {
62
+ if (hiddenGroups.has(group.id)) continue
63
+ const values = Array.from(new Set(
64
+ (group.values || []).concat((store.extra && store.extra[group.id]) || []),
65
+ )).filter((value) => !hidden.has(value))
66
+ if (!values.length) continue
67
+ const renamed = labels[group.id]
68
+ out.push({
69
+ id: group.id,
70
+ builtin: true,
71
+ label: { zh: renamed || group.label.zh, en: renamed || group.label.en },
72
+ values,
73
+ })
74
+ }
75
+ for (const group of store.groups || []) {
76
+ out.push({ id: group.id, builtin: false, label: { zh: group.name, en: group.name }, values: group.terms })
77
+ }
78
+ return out
79
+ }
80
+
81
+ function hasTerm(input, term) {
82
+ const body = String(input || '').toLowerCase()
83
+ const needle = String(term || '').toLowerCase().trim()
84
+ return needle ? body.includes(needle) : false
85
+ }
86
+
87
+ function joinTerm(input, term) {
88
+ const body = String(input || '').trim()
89
+ const value = String(term || '').trim()
90
+ if (!value) return body
91
+ if (hasTerm(body, value)) return body
92
+ if (!body) return value
93
+ return body.replace(/[,,、;;\s]+$/, '') + ',' + value
94
+ }
95
+
96
+ function dropTerm(input, term) {
97
+ const value = String(term || '').trim()
98
+ const body = String(input || '')
99
+ if (!value) return body
100
+ const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
101
+ return body
102
+ .replace(new RegExp('[,,、;;]\\s*' + escaped, 'gi'), '')
103
+ .replace(new RegExp(escaped + '\\s*[,,、;;]?', 'gi'), '')
104
+ .replace(/^[,,、;;\s]+/, '')
105
+ .replace(/[,,、;;\s]+$/, '')
106
+ }
107
+ const PAGE_SIZE = 24
108
+ const NON_LATIN_RE = /[\u0370-\u03ff\u0400-\u052f\u0530-\u058f\u0590-\u05ff\u0600-\u06ff\u0900-\u097f\u0e00-\u0e7f\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af]/
109
+ const ACCENTED_LATIN_RE = /[\u00c0-\u00d6\u00d8-\u00f6\u00f8-\u024f\u1e00-\u1eff]/
110
+ // FrameThrower only indexes English descriptions. Catch scripts that are
111
+ // definitely not English before sending them; accented Latin covers French,
112
+ // Spanish, Portuguese and friends. Plain-ASCII Latin languages are left to
113
+ // the model, which translates them when AI search is on.
114
+ const looksNonEnglish = (value) => {
115
+ const body = String(value || '')
116
+ return NON_LATIN_RE.test(body) || ACCENTED_LATIN_RE.test(body)
117
+ }
118
+
119
+ const I18n = React.createContext((key) => key)
120
+ const useT = () => React.useContext(I18n)
121
+
122
+ const DICT = {
123
+ zh: {
124
+ nav: "参考帧",
125
+ discover: "发现",
126
+ boards: "情绪板",
127
+ placeholder: "描述你想象的画面感觉",
128
+ lensToggle: "关键词",
129
+ untitled: "未命名情绪板",
130
+ settings: "设置",
131
+ language: "语言",
132
+ settingsNote: "密钥只保存在本机浏览器,经本机路由转发,不会进入对话。",
133
+ getToken: "获取 token",
134
+ connected: "已连接",
135
+ notConfigured: "未配置",
136
+ sourceToken: "API Token",
137
+ save: "保存",
138
+ clear: "清除",
139
+ close: "关闭",
140
+ search: "检索参考帧",
141
+ searchFail: "检索失败",
142
+ results: "参考帧",
143
+ more: "换一批",
144
+ clearAll: "清空",
145
+ idleTitle: "描述一个画面,或直接贴一张图",
146
+ idleBody: "按 Enter 开始检索,参考帧会出现在这里。",
147
+ resultsEmpty: "没有检索到帧",
148
+ resultsEmptyBody: "换个说法试试:补上画面里有什么、时间、天气,或者再多给一点情绪。",
149
+ queryTerms: "检索词",
150
+ retry: "重试",
151
+ similar: "找相似的帧",
152
+ noSimilar: "没有更多相似的帧了",
153
+ openOriginal: "打开原图",
154
+ previous: "上一张",
155
+ next: "下一张",
156
+ pickImage: "图片",
157
+ pickImageTip: "选择图片,也可以直接粘贴或拖进来",
158
+ imageAttached: "以这张图检索",
159
+ viewImage: "放大查看",
160
+ copyImage: "复制图片",
161
+ imageCopyUnsupported: "当前浏览器不支持复制图片",
162
+ imageCopiedToast: "图片已复制到剪贴板",
163
+ imageCopyFailedToast: "图片复制失败,请重试",
164
+ removeImage: "移除",
165
+ imageFailed: "这张图读不出来,换一张试试",
166
+ noToken: "还没配置 token,检索会失败",
167
+ sources: "检索源",
168
+ sourcesHint: "选择参与这次检索的来源",
169
+ noSources: "没有可用的检索源,先在“检索源”里开启一个",
170
+ noTokenHint: "先给 FrameThrower 填一个 token,或在“检索源”里开启网页图片",
171
+ srcSkipped: "未配置 token,这次跳过",
172
+ noSetup: "无需配置",
173
+ webBadge: "网页",
174
+ failWebNetwork: "连不上网页图片源(网络或服务故障)",
175
+ failWebEmpty: "没有返回图片",
176
+ failWebPartial: "部分检索词没有返回图片",
177
+ srcOn: "已开启",
178
+ srcOff: "已关闭",
179
+ srcFramethrower: "FrameThrower",
180
+ srcAnimation: "动画",
181
+ srcFilm: "电影剧照",
182
+ srcCommercial: "广告镜头",
183
+ srcWeb: "网页图片",
184
+ familyToggle: "同时开关动画、电影、广告",
185
+ filmBadge: "电影",
186
+ adBadge: "广告",
187
+ imageNeedsFrames: "以图检索需要网页图片源,或给 FrameThrower 填一个 token",
188
+ imageFilmUnsupported: "暂不支持以图检索:接口只收公开图片链接",
189
+ imageSimilar: "相似帧",
190
+ imageWebNeedsAi: "需要 AI 看图",
191
+ imageNoVision: "当前默认模型不支持读图,换一个支持图片的模型",
192
+ imageNoAttachments: "宿主没有挂载图片附件服务",
193
+ imageBad: "这张图无法读取",
194
+ imageRejected: "宿主拒绝了这张图片",
195
+ imageUnparsed: "模型没给出可用的网页检索词",
196
+ imageTimeout: "模型 20 秒内没回应",
197
+ imageAiOff: "AI 已关闭,无法从图片生成网页检索词;打开 AI 后再试",
198
+ imageAiOffText: "AI 已关闭,网页图片按你输入的文字检索",
199
+ boardsEmptyBody: "情绪板是你选中的帧的集合。先去发现页找几张,或者现在就建一个空板。",
200
+ newBoard: "新建情绪板",
201
+ boardName: "情绪板名称",
202
+ added: "已加入",
203
+ duplicate: "这张已经在情绪板里了",
204
+ pickBoard: "选择情绪板",
205
+ yourBoards: "你的情绪板",
206
+ pickerEmpty: "还没有情绪板,先建一个",
207
+ deleteBoard: "删除情绪板",
208
+ pin: "置顶",
209
+ unpin: "取消置顶",
210
+ confirmDelete: "确定删除?",
211
+ removeFrame: "移除",
212
+ exportMd: "导出 Markdown",
213
+ boardEmpty: "这个情绪板还是空的",
214
+ boardEmptyBody: "去发现页找几张参考帧,点 + 就能加进来。",
215
+ goDiscover: "去发现",
216
+ suggestTitle: "相似推荐",
217
+ sampleRef: "相似推荐",
218
+ resample: "换一组",
219
+ suggestHint: "点 + 加入这个情绪板",
220
+ addToBoard: "加入这个情绪板",
221
+ addBoard: "加入情绪板",
222
+ syncPreset: "同步至 GitHub",
223
+ presetsInstall: "社区情绪板",
224
+ presetsNote: "作者分享的情绪板,挑喜欢的加进你自己的情绪板。",
225
+ presetsAdded: "已安装情绪板",
226
+ presetsNone: "暂时没有可加入的社区情绪板",
227
+ add: "加入",
228
+ presetSaved: "已写入预设文件,情绪板数",
229
+ presetPushed: "已写入并成功推送",
230
+ presetPushFailed: "已写入,但推送失败",
231
+ backToBoards: "全部情绪板",
232
+ items: "张",
233
+ lang: "zh",
234
+ updated: "更新于",
235
+ palette: "色板",
236
+ kwHint: "点关键词加进需求,可多选",
237
+ kwManage: "管理",
238
+ kwDone: "完成",
239
+ kwAdd: "加词",
240
+ kwDelete: "删除",
241
+ kwAddGroup: "新类别",
242
+ kwDeleteGroup: "删除类别",
243
+ kwRestore: "恢复默认类别",
244
+ kwEmpty: "还没有关键词",
245
+ failAuth: "Token 无效或已过期,去设置里重新填一个",
246
+ failCredits: "FrameThrower 额度不足",
247
+ failTooMany: "请求太频繁了,等一下再试",
248
+ failNetwork: "连不上 FrameThrower(网络或服务故障)",
249
+ failRejected: "请求被上游拒绝",
250
+ failStatus: "上游返回",
251
+ aiNoLlm: "宿主没有挂载 AI 服务",
252
+ aiNoModel: "没有选中默认模型",
253
+ aiUnparsed: "模型没给出可用的帧描述",
254
+ aiTimeout: "模型 15 秒内没回应",
255
+ aiCallFailed: "模型调用失败",
256
+ aiUnreachable: "连不上本机路由(宿主可能没重启)",
257
+ aiFellBack: "已按原文检索",
258
+ aiOff: "AI 未生效",
259
+ aiSearch: "AI检索",
260
+ aiDirections: "AI 方向数",
261
+ retryLoose: "去掉作品名/画风,只按画面再搜",
262
+ loadMore: "浏览更多",
263
+ noMore: "没有更多了",
264
+ aiNeeded: "需要 AI检索",
265
+ aiNeededBody: "FrameThrower 按英文的单帧画面描述匹配,任何非英文原文(中文、日文、法文……)只会让它退回默认结果。打开右下角的 AI检索,让模型把它写成英文再搜。",
266
+ aiNeededCta: "打开 AI检索并重试",
267
+ version: "版本",
268
+ },
269
+ en: {
270
+ nav: "Reference Frames",
271
+ discover: "Discover",
272
+ boards: "Moodboards",
273
+ placeholder: "Describe the feeling of the image in your mind",
274
+ lensToggle: "Keywords",
275
+ untitled: "Untitled board",
276
+ settings: "Settings",
277
+ language: "Language",
278
+ settingsNote: "Keys stay in this browser and are proxied locally; they never enter the chat.",
279
+ getToken: "Get a token",
280
+ connected: "Connected",
281
+ notConfigured: "Not set",
282
+ sourceToken: "API token",
283
+ save: "Save",
284
+ clear: "Clear",
285
+ close: "Close",
286
+ search: "Search frames",
287
+ searchFail: "Search failed",
288
+ results: "Reference frames",
289
+ more: "More frames",
290
+ clearAll: "Clear",
291
+ idleTitle: "Describe a frame, or drop an image",
292
+ idleBody: "Press Enter to search — the frames will show up here.",
293
+ resultsEmpty: "No frames found",
294
+ resultsEmptyBody: "Try describing what is in frame — subject, time of day, weather, mood.",
295
+ queryTerms: "Terms",
296
+ retry: "Try again",
297
+ similar: "More like this",
298
+ noSimilar: "No more similar frames",
299
+ openOriginal: "Open original",
300
+ previous: "Previous",
301
+ next: "Next",
302
+ pickImage: "Image",
303
+ pickImageTip: "Pick an image, or just paste / drop one",
304
+ imageAttached: "Searching by this image",
305
+ viewImage: "View larger",
306
+ copyImage: "Copy image",
307
+ imageCopyUnsupported: "This browser cannot copy images",
308
+ imageCopiedToast: "Image copied to clipboard",
309
+ imageCopyFailedToast: "Could not copy the image — try again",
310
+ removeImage: "Remove",
311
+ imageFailed: "That image could not be read",
312
+ noToken: "No token yet — search will fail",
313
+ sources: "Sources",
314
+ sourcesHint: "Pick which sources join this search",
315
+ noSources: "No source is available — enable one under Sources",
316
+ noTokenHint: "Add a FrameThrower token first, or enable web images under Sources",
317
+ srcSkipped: "no token — skipped this search",
318
+ noSetup: "No setup",
319
+ webBadge: "Web",
320
+ failWebNetwork: "Can't reach the web image source",
321
+ failWebEmpty: "returned no images",
322
+ failWebPartial: "some search phrases returned no images",
323
+ srcOn: "On",
324
+ srcOff: "Off",
325
+ srcFramethrower: "FrameThrower",
326
+ srcAnimation: "Animation",
327
+ srcFilm: "Film stills",
328
+ srcCommercial: "Commercial shots",
329
+ srcWeb: "Web images",
330
+ familyToggle: "Toggles animation, film and commercial together",
331
+ filmBadge: "Film",
332
+ adBadge: "Ad",
333
+ imageNeedsFrames: "Image search needs the web source, or a FrameThrower token",
334
+ imageFilmUnsupported: "can't search by image yet — the API needs a public image URL",
335
+ imageSimilar: "Similar frames",
336
+ imageWebNeedsAi: "needs AI vision",
337
+ imageNoVision: "The default model can't read images — pick a vision model",
338
+ imageNoAttachments: "The host has no image attachment service",
339
+ imageBad: "This image could not be read",
340
+ imageRejected: "The host rejected this image",
341
+ imageUnparsed: "The model returned no usable web search phrases",
342
+ imageTimeout: "The model did not answer within 20s",
343
+ imageAiOff: "AI is off, so the image cannot become web search phrases — turn AI on and retry",
344
+ imageAiOffText: "AI is off; web images use your text",
345
+ boardsEmptyBody: "A moodboard is a set of frames you chose. Find a few first, or create an empty one now.",
346
+ newBoard: "New moodboard",
347
+ boardName: "Board name",
348
+ added: "Added",
349
+ duplicate: "Already in this moodboard",
350
+ pickBoard: "Choose a moodboard",
351
+ yourBoards: "Your moodboards",
352
+ pickerEmpty: "No moodboards yet — create one",
353
+ deleteBoard: "Delete moodboard",
354
+ pin: "Pin to top",
355
+ unpin: "Unpin",
356
+ confirmDelete: "Delete this board?",
357
+ removeFrame: "Remove",
358
+ exportMd: "Export Markdown",
359
+ boardEmpty: "This board is empty",
360
+ boardEmptyBody: "Find frames on Discover, then hit + to add them.",
361
+ goDiscover: "Go to Discover",
362
+ suggestTitle: "Similar picks",
363
+ sampleRef: "Similar picks",
364
+ resample: "Reshuffle",
365
+ suggestHint: "Tap + to add it to this board",
366
+ addToBoard: "Add to this board",
367
+ addBoard: "Add to a board",
368
+ syncPreset: "Sync to GitHub",
369
+ presetsInstall: "Community boards",
370
+ presetsNote: "Moodboards shared by the author — add any you like to your own.",
371
+ presetsAdded: "Boards installed",
372
+ presetsNone: "No community boards to add right now",
373
+ add: "Add",
374
+ presetSaved: "Preset file written, boards:",
375
+ presetPushed: "Saved and pushed",
376
+ presetPushFailed: "Written, push failed",
377
+ backToBoards: "All moodboards",
378
+ items: "frames",
379
+ lang: "en",
380
+ updated: "Updated",
381
+ palette: "Palette",
382
+ kwHint: "Click a keyword to add it — pick as many as you like",
383
+ kwManage: "Manage",
384
+ kwDone: "Done",
385
+ kwAdd: "Add",
386
+ kwDelete: "Delete",
387
+ kwAddGroup: "New category",
388
+ kwDeleteGroup: "Delete category",
389
+ kwRestore: "Restore defaults",
390
+ kwEmpty: "No keywords yet",
391
+ failAuth: "That token was rejected — update it in Settings",
392
+ failCredits: "Out of FrameThrower credits",
393
+ failTooMany: "Rate limited — try again in a moment",
394
+ failNetwork: "Can't reach FrameThrower (network or outage)",
395
+ failRejected: "The upstream rejected the request",
396
+ failStatus: "Upstream returned",
397
+ aiNoLlm: "The host has no AI service mounted",
398
+ aiNoModel: "No default model is selected",
399
+ aiUnparsed: "The model returned no usable frame description",
400
+ aiTimeout: "The model did not answer within 15s",
401
+ aiCallFailed: "The model call failed",
402
+ aiUnreachable: "Can't reach the local route (host may need a restart)",
403
+ aiFellBack: "searched your text as-is",
404
+ aiOff: "AI did not run",
405
+ aiSearch: "AI search",
406
+ aiDirections: "AI directions",
407
+ retryLoose: "Search the frame description only",
408
+ loadMore: "Browse more",
409
+ noMore: "No more frames",
410
+ aiNeeded: "AI search needed",
411
+ aiNeededBody: "FrameThrower matches English frame descriptions; a brief in any other language — Chinese, Japanese, French, and so on — only gets its default page back. Turn on AI search in the composer to have it written in English first.",
412
+ aiNeededCta: "Turn on AI search and retry",
413
+ version: "Version",
414
+ },
415
+ }
416
+
417
+ // ---------------------------------------------------------------- vocabulary
418
+
419
+ const LENS_ORDER = ['era', 'rendering', 'light', 'colour', 'mood']
420
+ const GROUP_LABELS = {
421
+ era: { zh: '年代', en: 'Era' },
422
+ rendering: { zh: '渲染', en: 'Rendering' },
423
+ light: { zh: '光线', en: 'Light' },
424
+ colour: { zh: '色彩', en: 'Colour' },
425
+ mood: { zh: '氛围', en: 'Mood' },
426
+ genre: { zh: '类型', en: 'Genre' },
427
+ }
428
+
429
+ const stamp = (timestamp) => {
430
+ const date = new Date(Number(timestamp) || Date.now())
431
+ const pad = (value) => String(value).padStart(2, '0')
432
+ return date.getFullYear() + '.' + pad(date.getMonth() + 1) + '.' + pad(date.getDate())
433
+ }
434
+
435
+ function parseVocabulary(payload) {
436
+ const data = payload && typeof payload === 'object' && payload.data && typeof payload.data === 'object'
437
+ ? payload.data
438
+ : payload
439
+ if (!data || typeof data !== 'object') return []
440
+ const asList = (value) => {
441
+ if (!Array.isArray(value)) return []
442
+ return value
443
+ .map((entry) => (typeof entry === 'string' ? entry : entry && (entry.value ?? entry.name ?? entry.id)))
444
+ .filter((entry) => typeof entry === 'string' && entry)
445
+ }
446
+ const groups = []
447
+ const raw = data.style_groups
448
+ if (raw && typeof raw === 'object') {
449
+ for (const [key, value] of Object.entries(raw)) {
450
+ const values = asList(value)
451
+ if (values.length) groups.push({ id: key, label: GROUP_LABELS[key] || { zh: key, en: key }, values })
452
+ }
453
+ }
454
+ if (!groups.length) {
455
+ const styles = asList(data.styles)
456
+ if (styles.length) groups.push({ id: 'rendering', label: GROUP_LABELS.rendering, values: styles })
457
+ }
458
+ groups.sort((a, b) => {
459
+ const ai = LENS_ORDER.indexOf(a.id)
460
+ const bi = LENS_ORDER.indexOf(b.id)
461
+ return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi)
462
+ })
463
+ return groups.slice(0, 3)
464
+ }
465
+
466
+ // Retrieval backends. Each source is independent: the user picks which ones
467
+ // take part in a search, and one failing never blocks the others. The three
468
+ // FrameThrower libraries use one token but are toggled separately.
469
+ const SOURCES = [
470
+ {
471
+ id: 'framethrower',
472
+ label: { zh: '动画帧', en: 'Animation frames' },
473
+ kind: 'token',
474
+ group: 'framethrower',
475
+ key: TOKEN_KEY,
476
+ docs: 'https://framethrower.ai/settings?tab=api',
477
+ },
478
+ {
479
+ id: 'film',
480
+ label: { zh: '电影剧照', en: 'Film stills' },
481
+ kind: 'token',
482
+ group: 'framethrower',
483
+ key: TOKEN_KEY,
484
+ },
485
+ {
486
+ id: 'commercial',
487
+ label: { zh: '广告镜头', en: 'Commercial shots' },
488
+ kind: 'token',
489
+ group: 'framethrower',
490
+ key: TOKEN_KEY,
491
+ },
492
+ {
493
+ id: 'chrome',
494
+ label: { zh: 'Chrome 网页图片', en: 'Chrome web images' },
495
+ kind: 'web',
496
+ group: 'web',
497
+ },
498
+ ]
499
+ const SOURCE_IDS = SOURCES.map((source) => source.id)
500
+ const FRAME_SOURCE_IDS = ['framethrower', 'film', 'commercial']
501
+ const FRAME_SOURCE_KIND = { framethrower: 'animation', film: 'film', commercial: 'commercial' }
502
+ const FRAME_SOURCE_LABEL = { framethrower: 'srcAnimation', film: 'srcFilm', commercial: 'srcCommercial' }
503
+ const frameSourceLabel = (id, T) => T(FRAME_SOURCE_LABEL[id] || 'srcFramethrower')
504
+ // Film and commercials are opt-in: leaving them off keeps the old one-call
505
+ // search (and credit cost) intact until the user turns them on.
506
+ const DEFAULT_SOURCES = ['framethrower', 'chrome']
507
+ const SRC_KEY = 'anistyle:sources:v1'
508
+ const sourceLabel = (source, lang) => (source.label && (source.label[lang] || source.label.zh)) || source.id
509
+
510
+ function readKey(key) {
511
+ try { return window.localStorage.getItem(key) || '' } catch { return '' }
512
+ }
513
+ function writeKey(key, value) {
514
+ try {
515
+ if (value) window.localStorage.setItem(key, value)
516
+ else window.localStorage.removeItem(key)
517
+ } catch {}
518
+ }
519
+ function loadSources() {
520
+ try {
521
+ const raw = window.localStorage.getItem(SRC_KEY)
522
+ if (!raw) return DEFAULT_SOURCES.slice()
523
+ const parsed = JSON.parse(raw)
524
+ const list = parsed && Array.isArray(parsed.enabled) ? parsed.enabled : null
525
+ if (!list) return DEFAULT_SOURCES.slice()
526
+ return list.filter((id) => SOURCE_IDS.includes(id))
527
+ } catch { return DEFAULT_SOURCES.slice() }
528
+ }
529
+ function saveSources(list) {
530
+ try { window.localStorage.setItem(SRC_KEY, JSON.stringify({ enabled: list })) } catch {}
531
+ }
532
+ function readToken() {
533
+ return readKey(TOKEN_KEY)
534
+ }
535
+
536
+ function loadView() {
537
+ try {
538
+ const raw = window.localStorage.getItem(VIEW_KEY)
539
+ const parsed = raw ? JSON.parse(raw) : null
540
+ return parsed && typeof parsed === 'object' ? parsed : {}
541
+ } catch { return {} }
542
+ }
543
+
544
+ function loadBoards() {
545
+ try {
546
+ const raw = window.localStorage.getItem(BOARDS_KEY)
547
+ const parsed = raw ? JSON.parse(raw) : []
548
+ return Array.isArray(parsed)
549
+ ? parsed.filter((board) => board && typeof board.id === 'string' && Array.isArray(board.items))
550
+ : []
551
+ } catch { return [] }
552
+ }
553
+
554
+ const untitledName = (boards, label) => {
555
+ const base = label || 'Untitled'
556
+ const used = new Set(boards.map((board) => board.name))
557
+ let index = 1
558
+ while (used.has(base + ' ' + index)) index += 1
559
+ return base + ' ' + index
560
+ }
561
+
562
+ const newId = () => 'b_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
563
+ const frameKey = (frame) => frame.id || frame.link || frame.thumb
564
+ // older web results stored Bing highlight markers; strip them at render time too
565
+ const stripGlyphs = (value) => String(value == null ? '' : value)
566
+ .replace(/[\uE000-\uF8FF\u200B\uFEFF\u00AD]/g, '')
567
+ .replace(/\s+/g, ' ')
568
+ .trim()
569
+
570
+ // Interleave several searches round by round so one interpretation cannot
571
+ // take over the whole grid, skipping frames that repeat across the sets.
572
+ function mergeGroups(groups) {
573
+ const seen = new Set()
574
+ const out = []
575
+ const lists = (groups || []).filter((list) => Array.isArray(list))
576
+ const length = lists.reduce((max, list) => Math.max(max, list.length), 0)
577
+ for (let index = 0; index < length; index += 1) {
578
+ for (const list of lists) {
579
+ const frame = list[index]
580
+ if (!frame) continue
581
+ const key = frameKey(frame)
582
+ if (seen.has(key)) continue
583
+ seen.add(key)
584
+ out.push(frame)
585
+ }
586
+ }
587
+ return out
588
+ }
589
+
590
+ function boardPalette(items) {
591
+ const counts = new Map()
592
+ for (const item of items) {
593
+ for (const color of item.palette || []) {
594
+ const key = String(color).toUpperCase()
595
+ counts.set(key, (counts.get(key) || 0) + 1)
596
+ }
597
+ }
598
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8).map(([color]) => color.toLowerCase())
599
+ }
600
+
601
+ async function api(path, body) {
602
+ const token = readToken()
603
+ const response = await fetch(path, {
604
+ method: 'POST',
605
+ headers: { 'Content-Type': 'application/json', ...(token ? { 'x-ft-token': token } : {}) },
606
+ body: JSON.stringify(body || {}),
607
+ })
608
+ if (!response.ok) {
609
+ let detail = ''
610
+ try { detail = (await response.json()).error || '' } catch {}
611
+ throw new Error(detail || `HTTP ${response.status}`)
612
+ }
613
+ return response.json()
614
+ }
615
+
616
+
617
+ function download(name, content, type) {
618
+ const blob = new Blob([content], { type })
619
+ const url = URL.createObjectURL(blob)
620
+ const anchor = document.createElement('a')
621
+ anchor.href = url
622
+ anchor.download = name
623
+ document.body.appendChild(anchor)
624
+ anchor.click()
625
+ anchor.remove()
626
+ window.setTimeout(() => URL.revokeObjectURL(url), 2000)
627
+ }
628
+
629
+ // ---------------------------------------------------------------- components
630
+
631
+ function PaletteDots({ colors, size }) {
632
+ if (!colors || !colors.length) return null
633
+ return h('span', { className: 'ase-dots' + (size ? ' ase-dots--' + size : '') },
634
+ colors.map((color, index) => h('i', { key: color + index, style: { background: color } })))
635
+ }
636
+
637
+ const warmed = new Set()
638
+ function warmImage(url) {
639
+ if (!url || warmed.has(url)) return
640
+ if (typeof window === 'undefined' || typeof window.Image !== 'function') return
641
+ warmed.add(url)
642
+ const img = new window.Image()
643
+ img.decoding = 'async'
644
+ img.src = url
645
+ }
646
+
647
+ function readImageFile(file) {
648
+ return new Promise((resolve, reject) => {
649
+ if (!file || !/^image\//.test(file.type || '')) { reject(new Error('not an image')); return }
650
+ const reader = new FileReader()
651
+ reader.onerror = () => reject(new Error('read failed'))
652
+ reader.onload = () => {
653
+ const raw = String(reader.result || '')
654
+ if (typeof window === 'undefined' || typeof window.Image !== 'function') {
655
+ resolve({ dataUrl: raw, preview: raw })
656
+ return
657
+ }
658
+ const image = new window.Image()
659
+ image.onerror = () => reject(new Error('decode failed'))
660
+ image.onload = () => {
661
+ const max = 1024
662
+ const width = image.width || max
663
+ const height = image.height || max
664
+ const scale = Math.min(1, max / Math.max(width, height))
665
+ const canvas = document.createElement('canvas')
666
+ canvas.width = Math.max(1, Math.round(width * scale))
667
+ canvas.height = Math.max(1, Math.round(height * scale))
668
+ const context = canvas.getContext('2d')
669
+ if (!context) { resolve({ dataUrl: raw, preview: raw }); return }
670
+ context.drawImage(image, 0, 0, canvas.width, canvas.height)
671
+ resolve({ dataUrl: canvas.toDataURL('image/jpeg', 0.82), preview: canvas.toDataURL('image/jpeg', 0.5) })
672
+ }
673
+ image.src = raw
674
+ }
675
+ reader.readAsDataURL(file)
676
+ })
677
+ }
678
+
679
+ const COPY_IMAGE_MAX = 2400
680
+
681
+ function imageBlobToPng(blob) {
682
+ return new Promise((resolve, reject) => {
683
+ if (typeof window === 'undefined' || typeof window.Image !== 'function') {
684
+ reject(new Error('unsupported'))
685
+ return
686
+ }
687
+ const url = URL.createObjectURL(blob)
688
+ const image = new window.Image()
689
+ let settled = false
690
+ const finish = (fn, value) => {
691
+ if (settled) return
692
+ settled = true
693
+ URL.revokeObjectURL(url)
694
+ fn(value)
695
+ }
696
+ image.onerror = () => finish(reject, new Error('decode failed'))
697
+ image.onload = () => {
698
+ try {
699
+ const width = image.naturalWidth || image.width
700
+ const height = image.naturalHeight || image.height
701
+ if (!width || !height) throw new Error('empty image')
702
+ const scale = Math.min(1, COPY_IMAGE_MAX / Math.max(width, height))
703
+ const canvas = document.createElement('canvas')
704
+ canvas.width = Math.max(1, Math.round(width * scale))
705
+ canvas.height = Math.max(1, Math.round(height * scale))
706
+ const context = canvas.getContext('2d')
707
+ if (!context) throw new Error('canvas unavailable')
708
+ context.imageSmoothingEnabled = true
709
+ context.imageSmoothingQuality = 'high'
710
+ context.drawImage(image, 0, 0, canvas.width, canvas.height)
711
+ canvas.toBlob((png) => {
712
+ if (png) finish(resolve, png)
713
+ else finish(reject, new Error('encode failed'))
714
+ }, 'image/png')
715
+ } catch (error) {
716
+ finish(reject, error)
717
+ }
718
+ }
719
+ image.src = url
720
+ })
721
+ }
722
+
723
+ async function imagePngBlob(src) {
724
+ let response
725
+ try {
726
+ response = await fetch(src)
727
+ } catch {
728
+ throw new Error('fetch failed')
729
+ }
730
+ if (!response.ok) throw new Error('fetch failed')
731
+ const blob = await response.blob()
732
+ if (!blob || !/^image\//.test(String(blob.type || ''))) throw new Error('not an image')
733
+ return imageBlobToPng(blob)
734
+ }
735
+
736
+ async function copyPngBlob(blob) {
737
+ const clipboard = typeof navigator === 'undefined' ? null : navigator.clipboard
738
+ if (!clipboard || typeof clipboard.write !== 'function' || typeof ClipboardItem === 'undefined') {
739
+ throw new Error('unsupported')
740
+ }
741
+ await clipboard.write([new ClipboardItem({ 'image/png': blob })])
742
+ }
743
+
744
+ function FrameTile({ frame, onAdd, onSimilar, onRemove, onZoom, busy }) {
745
+ const T = useT()
746
+ const [broken, setBroken] = useState(false)
747
+ const meta = [stripGlyphs(frame.title), frame.year].filter(Boolean).join(' · ')
748
+ return h('figure', {
749
+ className: 'ase-tile' + (busy ? ' ase-tile--busy' : ''),
750
+ onMouseEnter: () => warmImage(frame.image),
751
+ },
752
+ h('div', { className: 'ase-thumb' },
753
+ broken
754
+ ? h('div', { className: 'ase-thumb-fallback' })
755
+ : h('img', {
756
+ src: frame.thumb || frame.image,
757
+ alt: frame.caption || frame.title || 'reference frame',
758
+ loading: 'lazy',
759
+ className: onZoom ? 'ase-zoomable' : undefined,
760
+ onClick: onZoom ? () => onZoom(frame) : undefined,
761
+ onError: () => setBroken(true),
762
+ }),
763
+ h('div', { className: 'ase-thumb-actions' },
764
+ onAdd ? h('button', { type: 'button', className: 'ase-mini ase-mini--solid ase-tip', 'data-tip': T('pickBoard'), 'aria-label': T('pickBoard'), onClick: () => onAdd(frame) }, '+') : null,
765
+ onSimilar ? h('button', { type: 'button', className: 'ase-mini ase-tip', 'data-tip': T('similar'), 'aria-label': T('similar'), onClick: () => onSimilar(frame) }, '≈') : null,
766
+ frame.link ? h('a', { className: 'ase-mini ase-tip', 'data-tip': T('openOriginal'), 'aria-label': T('openOriginal'), href: frame.link, target: '_blank', rel: 'noreferrer' }, '↗') : null,
767
+ onRemove ? h('button', { type: 'button', className: 'ase-mini ase-mini--danger ase-tip', 'data-tip': T('removeFrame'), 'aria-label': T('removeFrame'), onClick: () => onRemove(frame) }, '×') : null)),
768
+ h('figcaption', null,
769
+ h('div', { className: 'ase-tile-head' },
770
+ meta ? h('strong', null, meta) : null,
771
+ frame.src === 'chrome'
772
+ ? h('span', { className: 'ase-badge' }, T('webBadge'))
773
+ : frame.kind === 'film'
774
+ ? h('span', { className: 'ase-badge' }, T('filmBadge'))
775
+ : frame.kind === 'commercial'
776
+ ? h('span', { className: 'ase-badge' }, T('adBadge'))
777
+ : null),
778
+ stripGlyphs(frame.caption) ? h('p', null, stripGlyphs(frame.caption)) : null,
779
+ (frame.tags && frame.tags.length)
780
+ ? h('div', { className: 'ase-tags' }, frame.tags.map((tag) => h('span', { key: tag }, tag)))
781
+ : null,
782
+ (frame.palette && frame.palette.length) ? h(PaletteDots, { colors: frame.palette, size: 'bar' }) : null))
783
+ }
784
+
785
+ function BoardModal({ frame, boards, onClose, onAdd, onCreate }) {
786
+ const T = useT()
787
+ const [creating, setCreating] = useState(false)
788
+ const [name, setName] = useState('')
789
+ const [added, setAdded] = useState(() => [])
790
+ const key = frame ? frameKey(frame) : ''
791
+ const inside = (board) => added.includes(board.id) || board.items.some((item) => frameKey(item) === key)
792
+ const add = (boardId) => {
793
+ setAdded((list) => (list.includes(boardId) ? list : list.concat(boardId)))
794
+ onAdd(boardId)
795
+ }
796
+ const create = () => {
797
+ const value = name.trim()
798
+ if (!value) { setCreating(false); return }
799
+ onCreate(value)
800
+ setName('')
801
+ setCreating(false)
802
+ }
803
+ return h('div', { className: 'ase-scrim', onClick: onClose },
804
+ h('div', { className: 'ase-modal', onClick: (event) => event.stopPropagation(), role: 'dialog', 'aria-modal': true },
805
+ h('button', {
806
+ type: 'button',
807
+ className: 'ase-modal-x',
808
+ 'aria-label': T('close'),
809
+ 'data-tip': T('close'),
810
+ onClick: onClose,
811
+ }, '×'),
812
+ h('div', { className: 'ase-modal-head' },
813
+ frame && (frame.thumb || frame.image) ? h('img', { className: 'ase-modal-thumb', src: frame.thumb || frame.image, alt: '' }) : null,
814
+ h('div', null,
815
+ h('h3', { className: 'ase-modal-title' }, T('pickBoard')),
816
+ h('p', { className: 'ase-modal-sub' }, frame && frame.title ? frame.title + (frame.year ? ' · ' + frame.year : '') : T('yourBoards')))),
817
+ h('div', { className: 'ase-chips' },
818
+ boards.map((board) => h('button', {
819
+ key: board.id,
820
+ type: 'button',
821
+ className: 'ase-chip' + (inside(board) ? ' is-in' : ''),
822
+ onClick: () => add(board.id),
823
+ },
824
+ h('span', null, board.name),
825
+ h('small', null, inside(board) ? '✓' : board.items.length + ' ' + T('items')))),
826
+ creating
827
+ ? h('input', {
828
+ className: 'ase-chip-input',
829
+ placeholder: T('boardName'),
830
+ value: name,
831
+ autoFocus: true,
832
+ onChange: (event) => setName(event.target.value),
833
+ onKeyDown: (event) => {
834
+ if (event.key === 'Enter') { event.preventDefault(); create() }
835
+ else if (event.key === 'Escape') { event.preventDefault(); setName(''); setCreating(false) }
836
+ },
837
+ onBlur: () => { if (!name.trim()) setCreating(false) },
838
+ })
839
+ : h('button', {
840
+ type: 'button',
841
+ className: 'ase-chip ase-chip--new',
842
+ 'aria-label': T('newBoard'),
843
+ onClick: () => setCreating(true),
844
+ }, '+'))))
845
+ }
846
+
847
+ // Hover menu for the lightbox: pick a board without leaving the frame.
848
+ function BoardPicker({ frame, boards, onPick, onCreate, onEnter }) {
849
+ const T = useT()
850
+ const [creating, setCreating] = useState(false)
851
+ const [name, setName] = useState('')
852
+ const key = frameKey(frame)
853
+ const contains = (board) => board.items.some((item) => frameKey(item) === key)
854
+ const commit = () => {
855
+ const value = name.trim()
856
+ if (!value) { setCreating(false); return }
857
+ onCreate(value)
858
+ setName('')
859
+ setCreating(false)
860
+ }
861
+ return h('div', {
862
+ className: 'ase-board-pop',
863
+ role: 'dialog',
864
+ 'aria-label': T('pickBoard'),
865
+ onMouseEnter: onEnter,
866
+ onClick: (event) => event.stopPropagation(),
867
+ onKeyDown: (event) => {
868
+ // arrows belong to the name field / list while the menu is open
869
+ if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') event.stopPropagation()
870
+ },
871
+ },
872
+ h('div', { className: 'ase-board-pop-head' }, T('yourBoards')),
873
+ boards.length
874
+ ? h('div', { className: 'ase-board-pop-list' },
875
+ boards.map((board) => {
876
+ const inside = contains(board)
877
+ return h('button', {
878
+ key: board.id,
879
+ type: 'button',
880
+ className: 'ase-board-pop-item' + (inside ? ' is-in' : ''),
881
+ onClick: () => onPick(board.id),
882
+ },
883
+ h('span', { className: 'ase-board-pop-name' }, board.name),
884
+ inside ? h('span', { className: 'ase-board-pop-check' }, '✓') : null,
885
+ h('small', { className: 'ase-board-pop-count' }, inside ? T('added') : board.items.length + ' ' + T('items')))
886
+ }))
887
+ : h('p', { className: 'ase-board-pop-empty' }, T('pickerEmpty')),
888
+ creating
889
+ ? h('input', {
890
+ className: 'ase-board-pop-input',
891
+ placeholder: T('boardName'),
892
+ value: name,
893
+ autoFocus: true,
894
+ onChange: (event) => setName(event.target.value),
895
+ onKeyDown: (event) => {
896
+ if (event.key === 'Enter') { event.preventDefault(); commit() }
897
+ else if (event.key === 'Escape') {
898
+ event.preventDefault()
899
+ event.stopPropagation()
900
+ setName('')
901
+ setCreating(false)
902
+ }
903
+ },
904
+ onBlur: () => { if (!name.trim()) setCreating(false) },
905
+ })
906
+ : h('button', {
907
+ type: 'button',
908
+ className: 'ase-board-pop-new',
909
+ onClick: () => setCreating(true),
910
+ }, '+ ' + T('newBoard')))
911
+ }
912
+
913
+ function BoardsHome({ boards, onCreate, onOpen, onDelete, onPin, presets, onOpenPresets, onSavePreset, savingPreset }) {
914
+ const T = useT()
915
+ return h('section', null,
916
+ h('div', { className: 'ase-section-head' },
917
+ h('h1', { className: 'ase-h1' }, T('boards')),
918
+ h('div', { className: 'ase-section-actions' },
919
+ onSavePreset
920
+ ? h('button', {
921
+ type: 'button',
922
+ className: 'ase-iconbtn ase-tip ase-tip--top',
923
+ 'data-tip': T('syncPreset'),
924
+ 'aria-label': T('syncPreset'),
925
+ disabled: Boolean(savingPreset),
926
+ onClick: onSavePreset,
927
+ }, savingPreset
928
+ ? h('svg', { className: 'ase-spin', width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true },
929
+ h('circle', { cx: 8, cy: 8, r: 5.6, stroke: 'currentColor', strokeWidth: 1.6, strokeDasharray: '26 9', strokeLinecap: 'round' }))
930
+ : h('svg', { width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true },
931
+ h('path', { d: 'M8 10.6V2.7', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round' }),
932
+ h('path', { d: 'M5.2 5.4 8 2.6l2.8 2.8', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round' }),
933
+ h('path', { d: 'M2.8 10.1v2.2c0 .6.5 1.1 1.1 1.1h8.2c.6 0 1.1-.5 1.1-1.1v-2.2', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round' })))
934
+ : null,
935
+ presets && presets.length
936
+ ? h('button', {
937
+ type: 'button',
938
+ className: 'ase-btn',
939
+ onClick: onOpenPresets,
940
+ }, T('presetsInstall'))
941
+ : null,
942
+ h('button', { type: 'button', className: 'ase-btn ase-btn--primary', onClick: () => onCreate('') }, '+ ' + T('newBoard')))),
943
+ boards.length
944
+ ? h('div', { className: 'ase-board-grid' },
945
+ boards.map((board) => h('article', { key: board.id, className: 'ase-board-card' },
946
+ h('button', { type: 'button', className: 'ase-board-open', onClick: () => onOpen(board.id) },
947
+ board.items[0]
948
+ ? h('img', { className: 'ase-board-cover', src: board.items[0].thumb || board.items[0].image, alt: '', loading: 'lazy' })
949
+ : h('div', { className: 'ase-board-cover ase-board-cover--empty' }, '+'),
950
+ h('div', { className: 'ase-board-meta' },
951
+ h('strong', null, board.name),
952
+ h('div', { className: 'ase-board-sub' },
953
+ h('small', null, board.items.length + ' ' + T('items')),
954
+ h('small', { className: 'ase-board-date' }, T('updated') + ' ' + stamp(board.updatedAt))),
955
+ h(PaletteDots, { colors: boardPalette(board.items), size: 'bar' }))),
956
+ h('div', { className: 'ase-board-actions' },
957
+ h('button', {
958
+ type: 'button',
959
+ className: 'ase-board-act ase-board-pin ase-tip' + (board.pinned ? ' is-on' : ''),
960
+ 'data-tip': board.pinned ? T('unpin') : T('pin'),
961
+ 'aria-label': board.pinned ? T('unpin') : T('pin'),
962
+ 'aria-pressed': board.pinned ? 'true' : 'false',
963
+ onClick: () => onPin(board.id),
964
+ }, h('svg', { width: 13, height: 13, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true },
965
+ h('path', { d: 'M8 13.2V4.2', stroke: 'currentColor', strokeWidth: 1.7, strokeLinecap: 'round' }),
966
+ h('path', { d: 'M4.7 7.4 8 4l3.3 3.4', stroke: 'currentColor', strokeWidth: 1.7, strokeLinecap: 'round', strokeLinejoin: 'round' }))),
967
+ h('button', {
968
+ type: 'button',
969
+ className: 'ase-board-act ase-board-del ase-tip',
970
+ 'data-tip': T('deleteBoard'),
971
+ 'aria-label': T('deleteBoard'),
972
+ onClick: () => onDelete(board.id),
973
+ }, '×')))))
974
+ : h('p', { className: 'ase-hint' }, T('boardsEmptyBody')))
975
+ }
976
+
977
+ function CommunityBoards({ presets, existingNames, onAdd, onClose }) {
978
+ const T = useT()
979
+ const [openKey, setOpenKey] = useState(null)
980
+ const open = openKey ? presets.find((board) => (board.id || board.name) === openKey) : null
981
+ const openItems = open && Array.isArray(open.items) ? open.items : []
982
+ const openAdded = open ? existingNames.has(open.name) : false
983
+
984
+ const detail = open
985
+ ? h('div', { className: 'ase-community-detail' },
986
+ h('div', { className: 'ase-community-actions' },
987
+ h('button', {
988
+ type: 'button',
989
+ className: 'ase-btn' + (openAdded ? '' : ' ase-btn--primary'),
990
+ disabled: openAdded,
991
+ onClick: () => onAdd(open),
992
+ }, openAdded ? T('added') : T('add'))),
993
+ openItems.length
994
+ ? h('div', { className: 'ase-community-frames' },
995
+ openItems.map((frame) => h('div', {
996
+ key: frameKey(frame),
997
+ className: 'ase-community-frame',
998
+ }, h('img', { src: frame.thumb || frame.image, alt: frame.caption || '', loading: 'lazy' }))))
999
+ : h('p', { className: 'ase-modal-empty' }, T('boardEmpty')))
1000
+ : null
1001
+
1002
+ const cards = presets.map((board) => {
1003
+ const items = Array.isArray(board.items) ? board.items : []
1004
+ const key = board.id || board.name
1005
+ const added = existingNames.has(board.name)
1006
+ return h('article', { key, className: 'ase-community-card' },
1007
+ h('button', {
1008
+ type: 'button',
1009
+ className: 'ase-community-shot',
1010
+ 'aria-label': board.name,
1011
+ onClick: () => setOpenKey(key),
1012
+ },
1013
+ items[0] ? h('img', { src: items[0].thumb || items[0].image, alt: '', loading: 'lazy' }) : null,
1014
+ h('span', { className: 'ase-community-badge' }, items.length + ' ' + T('items'))),
1015
+ h('div', { className: 'ase-community-foot' },
1016
+ h('button', {
1017
+ type: 'button',
1018
+ className: 'ase-community-name',
1019
+ onClick: () => setOpenKey(key),
1020
+ }, board.name),
1021
+ h('button', {
1022
+ type: 'button',
1023
+ className: 'ase-btn' + (added ? '' : ' ase-btn--primary'),
1024
+ disabled: added,
1025
+ onClick: () => onAdd(board),
1026
+ }, added ? T('added') : T('add'))))
1027
+ })
1028
+
1029
+ const grid = presets.length
1030
+ ? h('div', { className: 'ase-community-grid' }, cards)
1031
+ : h('p', { className: 'ase-modal-empty' }, T('presetsNone'))
1032
+
1033
+ return h('div', { className: 'ase-scrim', onClick: onClose },
1034
+ h('div', {
1035
+ className: 'ase-modal ase-community',
1036
+ onClick: (event) => event.stopPropagation(),
1037
+ role: 'dialog',
1038
+ 'aria-modal': true,
1039
+ },
1040
+ h('div', { className: 'ase-modal-head' },
1041
+ h('div', null,
1042
+ open
1043
+ ? h('button', {
1044
+ type: 'button',
1045
+ className: 'ase-ghost-link ase-community-back',
1046
+ onClick: () => setOpenKey(null),
1047
+ }, '← ' + T('presetsInstall'))
1048
+ : null,
1049
+ h('h2', { className: 'ase-modal-title' }, open ? open.name : T('presetsInstall')),
1050
+ h('p', { className: 'ase-modal-sub' }, open ? (openItems.length + ' ' + T('items')) : T('presetsNote'))),
1051
+ h('button', { type: 'button', className: 'ase-modal-x', onClick: onClose, 'aria-label': T('close') }, '×')),
1052
+ open ? detail : grid))
1053
+ }
1054
+
1055
+ function BoardView({
1056
+ board, onBack, onRename, onRemoveFrame, onExport, onDiscover, onZoomItem,
1057
+ onSimilar, onSample, suggestBusy, suggests, onAddSuggest, onCloseSuggest, onZoomSuggest,
1058
+ }) {
1059
+ const T = useT()
1060
+ const palette = boardPalette(board.items)
1061
+ const inBoard = new Set(board.items.map(frameKey))
1062
+ const picks = suggests ? suggests.frames.filter((frame) => !inBoard.has(frameKey(frame))) : []
1063
+ return h('section', null,
1064
+ h('button', { type: 'button', className: 'ase-back', onClick: onBack }, '← ' + T('backToBoards')),
1065
+ h('div', { className: 'ase-board-head' },
1066
+ h('div', { className: 'ase-board-title-wrap' },
1067
+ h('input', {
1068
+ className: 'ase-board-title',
1069
+ value: board.name,
1070
+ onChange: (event) => onRename(event.target.value),
1071
+ 'aria-label': T('boardName'),
1072
+ }),
1073
+ palette.length
1074
+ ? h('div', { className: 'ase-board-palette' },
1075
+ h('span', { className: 'ase-lens-label' }, T('palette')),
1076
+ h(PaletteDots, { colors: palette, size: 'bar' }),
1077
+ h('code', null, palette.join(' ')))
1078
+ : null),
1079
+ h('div', { className: 'ase-board-head-meta' },
1080
+ h('span', null, board.items.length + ' ' + T('items') + ' · ' + T('updated') + ' ' + stamp(board.updatedAt)),
1081
+ onSample
1082
+ ? h('button', { type: 'button', className: 'ase-btn', onClick: onSample }, T('sampleRef'))
1083
+ : null,
1084
+ h('button', { type: 'button', className: 'ase-btn', onClick: onExport }, T('exportMd')),
1085
+ h('button', { type: 'button', className: 'ase-btn', onClick: onDiscover }, T('goDiscover')))),
1086
+ suggests
1087
+ ? h('section', { className: 'ase-suggest' },
1088
+ h('div', { className: 'ase-suggest-head' },
1089
+ suggests.anchors && suggests.anchors.length
1090
+ ? h('div', { className: 'ase-sug-anchors' },
1091
+ suggests.anchors.map((frame) => h('img', { key: frameKey(frame), src: frame.thumb || frame.image, alt: '' })))
1092
+ : null,
1093
+ h('h3', null, T('suggestTitle')),
1094
+ h('span', { className: 'ase-suggest-hint' }, T('suggestHint')),
1095
+ onSample
1096
+ ? h('button', { type: 'button', className: 'ase-ghost-link', onClick: onSample }, T('resample'))
1097
+ : null,
1098
+ h('button', { type: 'button', className: 'ase-ghost-link', onClick: onCloseSuggest }, T('close'))),
1099
+ suggests.loading
1100
+ ? h('p', { className: 'ase-hint' }, '…')
1101
+ : picks.length
1102
+ ? h('div', { className: 'ase-suggest-grid' },
1103
+ picks.map((frame) => h('figure', { key: frameKey(frame), className: 'ase-sug' },
1104
+ h('img', {
1105
+ src: frame.thumb || frame.image,
1106
+ alt: '',
1107
+ loading: 'lazy',
1108
+ onClick: () => onZoomSuggest(frame),
1109
+ }),
1110
+ h('button', {
1111
+ type: 'button',
1112
+ className: 'ase-sug-add ase-tip',
1113
+ 'data-tip': T('addToBoard'),
1114
+ 'aria-label': T('addToBoard'),
1115
+ onClick: () => onAddSuggest(frame),
1116
+ }, '+'),
1117
+ frame.title || frame.year
1118
+ ? h('figcaption', { className: 'ase-sug-meta' }, [frame.title, frame.year].filter(Boolean).join(' · '))
1119
+ : null)))
1120
+ : h('p', { className: 'ase-hint' }, T('noSimilar')))
1121
+ : null,
1122
+ board.items.length
1123
+ ? h('div', { className: 'ase-grid' },
1124
+ board.items.map((frame) => h(FrameTile, {
1125
+ key: frameKey(frame),
1126
+ frame,
1127
+ busy: suggestBusy === frameKey(frame),
1128
+ onSimilar: onSimilar && frame.src !== 'chrome' ? () => onSimilar(frame) : null,
1129
+ onRemove: (item) => onRemoveFrame(item),
1130
+ onZoom: onZoomItem,
1131
+ })))
1132
+ : h('div', { className: 'ase-empty' },
1133
+ h('h3', null, T('boardEmpty')),
1134
+ h('p', null, T('boardEmptyBody')),
1135
+ h('button', { type: 'button', className: 'ase-btn ase-btn--primary', onClick: onDiscover }, T('goDiscover'))))
1136
+ }
1137
+
1138
+ function GearIcon({ size }) {
1139
+ const edge = size || 17
1140
+ return h('svg', {
1141
+ width: edge, height: edge, viewBox: '0 0 24 24', fill: 'none',
1142
+ 'aria-hidden': true, style: { display: 'block' },
1143
+ },
1144
+ h('circle', { cx: 12, cy: 12, r: 3.1, stroke: 'currentColor', strokeWidth: 1.6 }),
1145
+ h('path', {
1146
+ d: 'M19.3 14.9a1.6 1.6 0 0 0 .32 1.77l.06.06a1.94 1.94 0 1 1-2.74 2.74l-.06-.06a1.6 1.6 0 0 0-1.77-.32 1.6 1.6 0 0 0-.97 1.47v.17a1.94 1.94 0 1 1-3.88 0v-.09a1.6 1.6 0 0 0-1.05-1.47 1.6 1.6 0 0 0-1.77.32l-.06.06a1.94 1.94 0 1 1-2.74-2.74l.06-.06a1.6 1.6 0 0 0 .32-1.77 1.6 1.6 0 0 0-1.47-.97h-.17a1.94 1.94 0 1 1 0-3.88h.09a1.6 1.6 0 0 0 1.47-1.05 1.6 1.6 0 0 0-.32-1.77l-.06-.06a1.94 1.94 0 1 1 2.74-2.74l.06.06a1.6 1.6 0 0 0 1.77.32h.08A1.6 1.6 0 0 0 9.7 3.7v-.17a1.94 1.94 0 1 1 3.88 0v.09a1.6 1.6 0 0 0 .97 1.47 1.6 1.6 0 0 0 1.77-.32l.06-.06a1.94 1.94 0 1 1 2.74 2.74l-.06.06a1.6 1.6 0 0 0-.32 1.77v.08a1.6 1.6 0 0 0 1.47.97h.17a1.94 1.94 0 1 1 0 3.88h-.09a1.6 1.6 0 0 0-1.47.97z',
1147
+ stroke: 'currentColor', strokeWidth: 1.35, strokeLinejoin: 'round',
1148
+ }))
1149
+ }
1150
+
1151
+ function Settings({ drafts, configured, enabled, onToggle, onToggleGroup, onDraft, onSave, onClear, onClose, lang, onChooseLang, version }) {
1152
+ const T = useT()
1153
+ const language = h('section', { className: 'ase-source' },
1154
+ h('div', { className: 'ase-source-head' }, h('strong', null, T('language'))),
1155
+ h('div', { className: 'ase-lang-opts' },
1156
+ h('button', {
1157
+ type: 'button',
1158
+ className: 'ase-btn' + (lang === 'zh' ? ' ase-btn--primary' : ''),
1159
+ onClick: () => onChooseLang('zh'),
1160
+ }, '简体中文'),
1161
+ h('button', {
1162
+ type: 'button',
1163
+ className: 'ase-btn' + (lang === 'en' ? ' ase-btn--primary' : ''),
1164
+ onClick: () => onChooseLang('en'),
1165
+ }, 'English')))
1166
+ // one token card per credential, not per library: animation, film and
1167
+ // commercials all authenticate with the same FrameThrower token
1168
+ const tokenCards = []
1169
+ const seenKeys = new Set()
1170
+ for (const source of SOURCES) {
1171
+ if (source.kind !== 'token' || seenKeys.has(source.key)) continue
1172
+ seenKeys.add(source.key)
1173
+ tokenCards.push({
1174
+ source,
1175
+ ids: SOURCES.filter((item) => item.kind === 'token' && item.key === source.key).map((item) => item.id),
1176
+ })
1177
+ }
1178
+ const sources = tokenCards.map(({ source, ids }) => {
1179
+ const on = ids.some((id) => (enabled || []).includes(id))
1180
+ const ready = ids.some((id) => configured[id])
1181
+ const groupSwitch = ids.length > 1
1182
+ return h('section', { key: source.key, className: 'ase-source' },
1183
+ h('div', { className: 'ase-source-head' },
1184
+ h('div', { className: 'ase-source-title' },
1185
+ h('strong', null, source.group === 'framethrower' ? 'FrameThrower' : sourceLabel(source, lang)),
1186
+ h('span', { className: 'ase-pill' + (ready ? ' ase-pill--on' : '') },
1187
+ ready ? T('connected') : T('notConfigured'))),
1188
+ h('button', {
1189
+ type: 'button',
1190
+ role: 'switch',
1191
+ 'aria-checked': on ? 'true' : 'false',
1192
+ 'aria-label': groupSwitch ? T('familyToggle') : sourceLabel(source, lang),
1193
+ 'data-tip': groupSwitch
1194
+ ? T('familyToggle') + ' · ' + (on ? T('srcOn') : T('srcOff'))
1195
+ : (on ? T('srcOn') : T('srcOff')),
1196
+ className: 'ase-switch' + (on ? ' ase-switch--on' : ''),
1197
+ onClick: () => (onToggleGroup ? onToggleGroup(ids, !on) : onToggle(source.id)),
1198
+ }, h('span', { className: 'ase-switch-knob' }))),
1199
+ h('div', { className: 'ase-source-form' },
1200
+ h('input', {
1201
+ className: 'ase-input',
1202
+ type: 'password',
1203
+ placeholder: T('sourceToken'),
1204
+ value: drafts[source.id] === undefined ? '' : drafts[source.id],
1205
+ onChange: (event) => onDraft(source.id, event.target.value),
1206
+ spellCheck: false,
1207
+ }),
1208
+ h('button', { type: 'button', className: 'ase-btn ase-btn--primary', onClick: () => onSave(source) }, T('save')),
1209
+ h('button', { type: 'button', className: 'ase-btn', onClick: () => onClear(source) }, T('clear'))),
1210
+ source.docs
1211
+ ? h('a', { className: 'ase-source-docs', href: source.docs, target: '_blank', rel: 'noreferrer' }, T('getToken') + ' ↗')
1212
+ : null)
1213
+ })
1214
+ // the version is the one npm put here; Harness's Plugins page owns updates
1215
+ const about = h('section', { className: 'ase-about' },
1216
+ h('span', { className: 'ase-about-label' }, T('version')),
1217
+ h('span', { className: 'ase-about-ver' }, version ? 'v' + version : '—'))
1218
+ return h('div', { className: 'ase-scrim', onClick: onClose },
1219
+ h('div', { className: 'ase-modal ase-settings', onClick: (event) => event.stopPropagation(), role: 'dialog', 'aria-modal': true },
1220
+ h('div', { className: 'ase-settings-head' },
1221
+ h('h3', { className: 'ase-modal-title' }, T('settings')),
1222
+ h('p', { className: 'ase-modal-sub' }, T('settingsNote')),
1223
+ h('button', {
1224
+ type: 'button',
1225
+ className: 'ase-modal-x',
1226
+ 'aria-label': T('close'),
1227
+ 'data-tip': T('close'),
1228
+ onClick: onClose,
1229
+ }, '×')),
1230
+ language,
1231
+ sources,
1232
+ about))
1233
+ }
1234
+
1235
+ function ClipboardIcon() {
1236
+ return h('svg', { width: 13, height: 13, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true },
1237
+ h('rect', { x: 5.3, y: 5.3, width: 8.2, height: 8.2, rx: 1.5, stroke: 'currentColor', strokeWidth: 1.3 }),
1238
+ h('path', {
1239
+ d: 'M10.7 5.3V4A1.5 1.5 0 0 0 9.2 2.5H4A1.5 1.5 0 0 0 2.5 4v5.2A1.5 1.5 0 0 0 4 10.7h1.3',
1240
+ stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round',
1241
+ }))
1242
+ }
1243
+
1244
+ const CONTEXT_MENU_W = 164
1245
+ const CONTEXT_MENU_H = 42
1246
+
1247
+ function contextMenuPosition(x, y) {
1248
+ const pad = 8
1249
+ const width = Math.min(CONTEXT_MENU_W, Math.max(0, window.innerWidth - pad * 2))
1250
+ const left = x + width + pad > window.innerWidth ? x - width : x
1251
+ const top = y + CONTEXT_MENU_H + pad > window.innerHeight ? y - CONTEXT_MENU_H : y
1252
+ return {
1253
+ left: Math.max(pad, Math.min(left, window.innerWidth - width - pad)),
1254
+ top: Math.max(pad, Math.min(top, window.innerHeight - CONTEXT_MENU_H - pad)),
1255
+ }
1256
+ }
1257
+
1258
+ function useImageCopier(src, onToast) {
1259
+ const T = useT()
1260
+ const blobRef = useRef(null)
1261
+ useEffect(() => {
1262
+ let alive = true
1263
+ blobRef.current = null
1264
+ if (!src) return () => { alive = false }
1265
+ imagePngBlob(src).then((blob) => {
1266
+ if (alive) blobRef.current = blob
1267
+ }).catch(() => {})
1268
+ return () => { alive = false }
1269
+ }, [src])
1270
+ return useCallback(async () => {
1271
+ try {
1272
+ const blob = blobRef.current || await imagePngBlob(src)
1273
+ blobRef.current = blob
1274
+ await copyPngBlob(blob)
1275
+ if (onToast) onToast(T('imageCopiedToast'))
1276
+ } catch (error) {
1277
+ if (onToast) onToast(T(error && error.message === 'unsupported' ? 'imageCopyUnsupported' : 'imageCopyFailedToast'))
1278
+ }
1279
+ }, [src, onToast, T])
1280
+ }
1281
+
1282
+ function ImageContextMenu({ x, y, onCopy, onClose }) {
1283
+ const T = useT()
1284
+ const position = contextMenuPosition(x, y)
1285
+ useEffect(() => {
1286
+ const onResize = () => onClose()
1287
+ window.addEventListener('resize', onResize)
1288
+ return () => window.removeEventListener('resize', onResize)
1289
+ }, [onClose])
1290
+ return h(React.Fragment, null,
1291
+ h('div', {
1292
+ className: 'ase-imgmenu-backdrop',
1293
+ onClick: (event) => { event.stopPropagation(); onClose() },
1294
+ onContextMenu: (event) => { event.preventDefault(); event.stopPropagation(); onClose() },
1295
+ }),
1296
+ h('div', {
1297
+ className: 'ase-imgmenu',
1298
+ role: 'menu',
1299
+ 'aria-label': T('copyImage'),
1300
+ style: { left: position.left, top: position.top },
1301
+ onClick: (event) => event.stopPropagation(),
1302
+ onContextMenu: (event) => { event.preventDefault(); event.stopPropagation() },
1303
+ },
1304
+ h('button', {
1305
+ type: 'button',
1306
+ className: 'ase-imgmenu-item',
1307
+ role: 'menuitem',
1308
+ onClick: (event) => { event.stopPropagation(); onCopy() },
1309
+ }, h(ClipboardIcon), h('span', null, T('copyImage')))))
1310
+ }
1311
+
1312
+ function AttachmentPreview({ src, alt, onClose, onToast }) {
1313
+ const T = useT()
1314
+ const [menu, setMenu] = useState(null)
1315
+ const copyImage = useImageCopier(src, onToast)
1316
+ useEffect(() => {
1317
+ const onKey = (event) => {
1318
+ if (event.key !== 'Escape') return
1319
+ if (menu) setMenu(null)
1320
+ else onClose()
1321
+ }
1322
+ window.addEventListener('keydown', onKey)
1323
+ return () => window.removeEventListener('keydown', onKey)
1324
+ }, [menu, onClose])
1325
+ if (!src) return null
1326
+ return h('div', {
1327
+ className: 'ase-zoom-view ase-attach-view',
1328
+ role: 'dialog',
1329
+ 'aria-modal': true,
1330
+ 'aria-label': alt || T('viewImage'),
1331
+ onClick: () => { if (menu) setMenu(null); else onClose() },
1332
+ },
1333
+ h('button', {
1334
+ type: 'button', className: 'ase-zoom-btn ase-zoom-btn--close ase-tip',
1335
+ 'data-tip': T('close'), 'aria-label': T('close'),
1336
+ onClick: (event) => { event.stopPropagation(); onClose() },
1337
+ }, '×'),
1338
+ h('img', {
1339
+ className: 'ase-attach-view-img',
1340
+ src,
1341
+ alt: alt || '',
1342
+ onClick: (event) => event.stopPropagation(),
1343
+ onContextMenu: (event) => {
1344
+ event.preventDefault()
1345
+ event.stopPropagation()
1346
+ setMenu({ x: event.clientX, y: event.clientY })
1347
+ },
1348
+ }),
1349
+ menu
1350
+ ? h(ImageContextMenu, {
1351
+ x: menu.x,
1352
+ y: menu.y,
1353
+ onCopy: () => { setMenu(null); copyImage() },
1354
+ onClose: () => setMenu(null),
1355
+ })
1356
+ : null)
1357
+ }
1358
+
1359
+ function Lightbox({ frames, index, onClose, onIndex, onAdd, addLabel, boards, onPickBoard, onCreateBoard, isAdded, onToast }) {
1360
+ const T = useT()
1361
+ const frame = frames[index]
1362
+ const [menu, setMenu] = useState(null)
1363
+ const [pickerOpen, setPickerOpen] = useState(false)
1364
+ const pickerRef = useRef(null)
1365
+ const pickerTimer = useRef(null)
1366
+ const copyImage = useImageCopier(frame && (frame.image || frame.thumb), onToast)
1367
+ const openPicker = useCallback(() => {
1368
+ if (pickerTimer.current) { clearTimeout(pickerTimer.current); pickerTimer.current = null }
1369
+ setPickerOpen(true)
1370
+ }, [])
1371
+ const closePickerSoon = useCallback(() => {
1372
+ if (pickerTimer.current) clearTimeout(pickerTimer.current)
1373
+ pickerTimer.current = setTimeout(() => {
1374
+ pickerTimer.current = null
1375
+ const active = typeof document === 'undefined' ? null : document.activeElement
1376
+ // keep the menu while a name is being typed or a board item has focus
1377
+ if (pickerRef.current && active && pickerRef.current.contains(active)) return
1378
+ setPickerOpen(false)
1379
+ }, 160)
1380
+ }, [])
1381
+ useEffect(() => () => {
1382
+ if (pickerTimer.current) clearTimeout(pickerTimer.current)
1383
+ }, [])
1384
+ useEffect(() => {
1385
+ if (!pickerOpen) return undefined
1386
+ const onPointerDown = (event) => {
1387
+ if (pickerRef.current && pickerRef.current.contains(event.target)) return
1388
+ setPickerOpen(false)
1389
+ }
1390
+ window.addEventListener('pointerdown', onPointerDown, true)
1391
+ return () => window.removeEventListener('pointerdown', onPointerDown, true)
1392
+ }, [pickerOpen])
1393
+ useEffect(() => {
1394
+ const onKey = (event) => {
1395
+ if (event.key === 'Escape') {
1396
+ if (menu) setMenu(null)
1397
+ else if (pickerOpen) setPickerOpen(false)
1398
+ else onClose()
1399
+ } else if (event.key === 'ArrowRight') {
1400
+ setMenu(null)
1401
+ setPickerOpen(false)
1402
+ onIndex(index + 1)
1403
+ } else if (event.key === 'ArrowLeft') {
1404
+ setMenu(null)
1405
+ setPickerOpen(false)
1406
+ onIndex(index - 1)
1407
+ }
1408
+ }
1409
+ window.addEventListener('keydown', onKey)
1410
+ return () => window.removeEventListener('keydown', onKey)
1411
+ }, [index, menu, pickerOpen, onClose, onIndex])
1412
+ useEffect(() => { setMenu(null); setPickerOpen(false) }, [index])
1413
+ const imgRef = useRef(null)
1414
+ const [ready, setReady] = useState(false)
1415
+ useEffect(() => {
1416
+ setReady(false)
1417
+ const node = imgRef.current
1418
+ if (node && node.complete && node.naturalWidth) setReady(true)
1419
+ }, [index])
1420
+ useEffect(() => {
1421
+ warmImage(frame && (frame.image || frame.thumb))
1422
+ const previous = frames[index - 1]
1423
+ const next = frames[index + 1]
1424
+ if (previous) warmImage(previous.image || previous.thumb)
1425
+ if (next) warmImage(next.image || next.thumb)
1426
+ }, [frames, index])
1427
+ if (!frame) return null
1428
+ const title = [stripGlyphs(frame.title), frame.year].filter(Boolean).join(' · ')
1429
+ const picking = Boolean(boards && onPickBoard)
1430
+ const added = Boolean(isAdded && isAdded(frame))
1431
+ const addControl = picking
1432
+ ? h('div', {
1433
+ ref: pickerRef,
1434
+ className: 'ase-board-pop-wrap' + (pickerOpen ? ' is-open' : ''),
1435
+ onMouseEnter: openPicker,
1436
+ onMouseLeave: closePickerSoon,
1437
+ onBlur: (event) => {
1438
+ if (event.currentTarget.contains(event.relatedTarget)) return
1439
+ if (pickerOpen) setPickerOpen(false)
1440
+ },
1441
+ },
1442
+ h('button', {
1443
+ type: 'button',
1444
+ className: 'ase-zoom-add' + (pickerOpen ? ' is-open' : ''),
1445
+ 'aria-haspopup': 'dialog',
1446
+ 'aria-expanded': pickerOpen ? 'true' : 'false',
1447
+ onClick: (event) => { event.stopPropagation(); openPicker() },
1448
+ }, '+ ' + (addLabel || T('addBoard'))),
1449
+ pickerOpen
1450
+ ? h(BoardPicker, {
1451
+ frame,
1452
+ boards,
1453
+ onPick: (boardId) => { setPickerOpen(false); onPickBoard(boardId, frame) },
1454
+ onCreate: (value) => { setPickerOpen(false); onCreateBoard(value, frame) },
1455
+ onEnter: openPicker,
1456
+ })
1457
+ : null)
1458
+ : onAdd
1459
+ ? (added
1460
+ ? h('span', { className: 'ase-zoom-added' }, '✓ ' + T('added'))
1461
+ : h('button', {
1462
+ type: 'button',
1463
+ className: 'ase-zoom-add',
1464
+ onClick: (event) => { event.stopPropagation(); onAdd(frame) },
1465
+ }, '+ ' + (addLabel || T('addBoard'))))
1466
+ : null
1467
+ return h('div', { className: 'ase-zoom-view', onClick: onClose, role: 'dialog', 'aria-modal': true },
1468
+ h('button', {
1469
+ type: 'button', className: 'ase-zoom-btn ase-zoom-btn--close ase-tip',
1470
+ 'data-tip': T('close'), 'aria-label': T('close'), onClick: onClose,
1471
+ }, '×'),
1472
+ index > 0
1473
+ ? h('button', {
1474
+ type: 'button', className: 'ase-zoom-btn ase-zoom-btn--prev ase-tip',
1475
+ 'data-tip': T('previous'), 'aria-label': T('previous'),
1476
+ onClick: (event) => { event.stopPropagation(); onIndex(index - 1) },
1477
+ }, '‹')
1478
+ : null,
1479
+ index < frames.length - 1
1480
+ ? h('button', {
1481
+ type: 'button', className: 'ase-zoom-btn ase-zoom-btn--next ase-tip',
1482
+ 'data-tip': T('next'), 'aria-label': T('next'),
1483
+ onClick: (event) => { event.stopPropagation(); onIndex(index + 1) },
1484
+ }, '›')
1485
+ : null,
1486
+ h('figure', { className: 'ase-zoom-figure', onClick: (event) => event.stopPropagation() },
1487
+ h('div', { className: 'ase-zoom-stage' + (ready ? ' is-ready' : '') },
1488
+ frame.thumb ? h('img', { className: 'ase-zoom-blur', src: frame.thumb, alt: '', 'aria-hidden': true }) : null,
1489
+ h('img', {
1490
+ ref: imgRef,
1491
+ className: 'ase-zoom-full',
1492
+ src: frame.image || frame.thumb,
1493
+ alt: frame.caption || title || '',
1494
+ decoding: 'async',
1495
+ fetchPriority: 'high',
1496
+ onLoad: () => setReady(true),
1497
+ onContextMenu: (event) => {
1498
+ event.preventDefault()
1499
+ event.stopPropagation()
1500
+ setMenu({ x: event.clientX, y: event.clientY })
1501
+ },
1502
+ })),
1503
+ h('figcaption', null,
1504
+ h('div', { className: 'ase-zoom-meta' },
1505
+ h('strong', null, title),
1506
+ h('span', null, (index + 1) + ' / ' + frames.length)),
1507
+ stripGlyphs(frame.caption) ? h('p', null, stripGlyphs(frame.caption)) : null,
1508
+ (frame.palette && frame.palette.length) ? h(PaletteDots, { colors: frame.palette, size: 'bar' }) : null,
1509
+ h('div', { className: 'ase-zoom-actions' },
1510
+ frame.link
1511
+ ? h('a', { className: 'ase-zoom-link', href: frame.link, target: '_blank', rel: 'noreferrer' }, T('openOriginal') + ' ↗')
1512
+ : null,
1513
+ addControl))),
1514
+ menu
1515
+ ? h(ImageContextMenu, {
1516
+ x: menu.x,
1517
+ y: menu.y,
1518
+ onCopy: () => { setMenu(null); copyImage() },
1519
+ onClose: () => setMenu(null),
1520
+ })
1521
+ : null)
1522
+ }
1523
+
1524
+ // -------------------------------------------------------------------- page
1525
+
1526
+ function Discover({ state }) {
1527
+ const T = useT()
1528
+ const lang = T('lang') === 'en' ? 'en' : 'zh'
1529
+ const {
1530
+ query, setQuery, results, status, hint, warnings,
1531
+ submit, more, clearAll, similar, hasToken, openToken, canMore, openBoardModal, openZoom, busyId,
1532
+ attachment, onAttachment, onClearAttachment, onImageError, onToast, understood, aiNote, failure,
1533
+ aiOn, onToggleAi, onAiRetry, canLoose, onLooseRetry,
1534
+ aiCount, onAiCount,
1535
+ loadMore, loadingMore, canLoadMore, noMore,
1536
+ keywordGroups, addGroup, renameGroup, deleteGroup, addTerm, removeTerm, renameTerm,
1537
+ restoreKeywords, canRestore,
1538
+ enabledSources, onToggleSource,
1539
+ } = state
1540
+ const [kwOpen, setKwOpen] = useState(false)
1541
+ const [srcOpen, setSrcOpen] = useState(false)
1542
+ const [managing, setManaging] = useState(false)
1543
+ const [termDraft, setTermDraft] = useState({})
1544
+ const [addingGroup, setAddingGroup] = useState(null)
1545
+ const [labelEdit, setLabelEdit] = useState(null)
1546
+ const [termEdit, setTermEdit] = useState(null)
1547
+ const [dropping, setDropping] = useState(false)
1548
+ const [countOpen, setCountOpen] = useState(false)
1549
+ const [attachPreview, setAttachPreview] = useState(false)
1550
+ const fileRef = useRef(null)
1551
+ const panelRef = useRef(null)
1552
+ const triggerRef = useRef(null)
1553
+ const srcRef = useRef(null)
1554
+ const srcTriggerRef = useRef(null)
1555
+ const scrollRef = useRef(null)
1556
+ const skipCommitRef = useRef(false)
1557
+ const countRef = useRef(null)
1558
+ const loading = status === 'loading'
1559
+
1560
+ useEffect(() => {
1561
+ if (!countOpen) return undefined
1562
+ const onPointerDown = (event) => {
1563
+ if (countRef.current && countRef.current.contains(event.target)) return
1564
+ setCountOpen(false)
1565
+ }
1566
+ const onKeyDown = (event) => {
1567
+ if (event.key === 'Escape') setCountOpen(false)
1568
+ }
1569
+ document.addEventListener('pointerdown', onPointerDown, true)
1570
+ document.addEventListener('keydown', onKeyDown)
1571
+ return () => {
1572
+ document.removeEventListener('pointerdown', onPointerDown, true)
1573
+ document.removeEventListener('keydown', onKeyDown)
1574
+ }
1575
+ }, [countOpen])
1576
+
1577
+ useEffect(() => {
1578
+ if (!kwOpen) return undefined
1579
+ const onPointerDown = (event) => {
1580
+ const inPanel = panelRef.current && panelRef.current.contains(event.target)
1581
+ const inTrigger = triggerRef.current && triggerRef.current.contains(event.target)
1582
+ if (inPanel || inTrigger) return
1583
+ setKwOpen(false)
1584
+ }
1585
+ const onKeyDown = (event) => {
1586
+ if (event.key === 'Escape') setKwOpen(false)
1587
+ }
1588
+ document.addEventListener('pointerdown', onPointerDown, true)
1589
+ document.addEventListener('keydown', onKeyDown)
1590
+ return () => {
1591
+ document.removeEventListener('pointerdown', onPointerDown, true)
1592
+ document.removeEventListener('keydown', onKeyDown)
1593
+ }
1594
+ }, [kwOpen])
1595
+
1596
+ useEffect(() => {
1597
+ if (!srcOpen) return undefined
1598
+ const onPointerDown = (event) => {
1599
+ const inPanel = srcRef.current && srcRef.current.contains(event.target)
1600
+ const inTrigger = srcTriggerRef.current && srcTriggerRef.current.contains(event.target)
1601
+ if (inPanel || inTrigger) return
1602
+ setSrcOpen(false)
1603
+ }
1604
+ const onKeyDown = (event) => {
1605
+ if (event.key === 'Escape') setSrcOpen(false)
1606
+ }
1607
+ document.addEventListener('pointerdown', onPointerDown, true)
1608
+ document.addEventListener('keydown', onKeyDown)
1609
+ return () => {
1610
+ document.removeEventListener('pointerdown', onPointerDown, true)
1611
+ document.removeEventListener('keydown', onKeyDown)
1612
+ }
1613
+ }, [srcOpen])
1614
+
1615
+ useEffect(() => {
1616
+ if (status === 'loading' && scrollRef.current) scrollRef.current.scrollTop = 0
1617
+ }, [status])
1618
+
1619
+ const pickFile = (file) => {
1620
+ if (!file) return
1621
+ readImageFile(file).then((image) => onAttachment(image), () => onImageError())
1622
+ }
1623
+ const toggleTerm = (term) => setQuery(hasTerm(query, term) ? dropTerm(query, term) : joinTerm(query, term))
1624
+ const runSubmit = () => { setKwOpen(false); submit() }
1625
+ const commitTerm = (group) => {
1626
+ const value = String(termDraft[group.id] || '').trim()
1627
+ if (value && !(group.values || []).includes(value)) addTerm(group.id, group.builtin, value)
1628
+ setTermDraft({ ...termDraft, [group.id]: '' })
1629
+ setAddingGroup(null)
1630
+ }
1631
+
1632
+ const panel = h('div', { className: 'ase-kw-panel', ref: panelRef },
1633
+ h('div', { className: 'ase-kw-head' },
1634
+ h('span', { className: 'ase-kw-hint' }, T('kwHint')),
1635
+ h('button', {
1636
+ type: 'button',
1637
+ className: 'ase-ghost-link',
1638
+ onClick: () => { setManaging(!managing); setLabelEdit(null); setTermEdit(null); setAddingGroup(null) },
1639
+ }, managing ? T('kwDone') : T('kwManage'))),
1640
+ keywordGroups.length
1641
+ ? keywordGroups.map((group) => h('div', { key: group.id, className: 'ase-kw-group' },
1642
+ h('div', { className: 'ase-kw-labelcell' },
1643
+ labelEdit && labelEdit.id === group.id
1644
+ ? h('input', {
1645
+ className: 'ase-kw-label-input',
1646
+ value: labelEdit.value,
1647
+ autoFocus: true,
1648
+ onChange: (event) => setLabelEdit({ id: group.id, value: event.target.value }),
1649
+ onKeyDown: (event) => {
1650
+ if (event.key === 'Enter') { renameGroup(group.id, group.builtin, labelEdit.value); setLabelEdit(null) }
1651
+ if (event.key === 'Escape') setLabelEdit(null)
1652
+ },
1653
+ onBlur: () => setLabelEdit(null),
1654
+ })
1655
+ : h('button', {
1656
+ type: 'button',
1657
+ className: 'ase-kw-label' + (managing ? ' ase-kw-label--edit' : ''),
1658
+ onClick: () => { if (managing) setLabelEdit({ id: group.id, value: group.label[lang] || group.label.zh }) },
1659
+ }, group.label[lang] || group.label.zh),
1660
+ managing
1661
+ ? h('button', {
1662
+ type: 'button',
1663
+ className: 'ase-kw-groupdel ase-tip ase-tip--right',
1664
+ 'data-tip': T('kwDeleteGroup'),
1665
+ 'aria-label': T('kwDeleteGroup'),
1666
+ onClick: () => deleteGroup(group.id, group.builtin),
1667
+ }, '×')
1668
+ : null),
1669
+ h('div', { className: 'ase-kw-chips' },
1670
+ group.values.map((term) => (termEdit && termEdit.id === group.id && termEdit.term === term
1671
+ ? h('input', {
1672
+ key: term,
1673
+ className: 'ase-kw-input',
1674
+ value: termEdit.value,
1675
+ autoFocus: true,
1676
+ onChange: (event) => setTermEdit({ ...termEdit, value: event.target.value }),
1677
+ onKeyDown: (event) => {
1678
+ if (event.key === 'Enter') { renameTerm(group.id, group.builtin, term, termEdit.value); setTermEdit(null) }
1679
+ if (event.key === 'Escape') setTermEdit(null)
1680
+ },
1681
+ onBlur: () => setTermEdit(null),
1682
+ })
1683
+ : h('span', { key: term, className: 'ase-kw' + (hasTerm(query, term) ? ' ase-kw--on' : '') },
1684
+ h('button', {
1685
+ type: 'button',
1686
+ className: 'ase-kw-main',
1687
+ onClick: () => {
1688
+ if (managing) setTermEdit({ id: group.id, term, value: term })
1689
+ else toggleTerm(term)
1690
+ },
1691
+ }, term),
1692
+ managing
1693
+ ? h('button', {
1694
+ type: 'button',
1695
+ className: 'ase-kw-x',
1696
+ 'data-tip': T('kwDelete'),
1697
+ 'aria-label': T('kwDelete'),
1698
+ onClick: () => removeTerm(group.id, group.builtin, term),
1699
+ }, '×')
1700
+ : null))),
1701
+ managing
1702
+ ? (addingGroup === group.id
1703
+ ? h('input', {
1704
+ className: 'ase-kw-input',
1705
+ placeholder: T('kwAdd'),
1706
+ autoFocus: true,
1707
+ value: termDraft[group.id] || '',
1708
+ onChange: (event) => setTermDraft({ ...termDraft, [group.id]: event.target.value }),
1709
+ onKeyDown: (event) => {
1710
+ if (event.key === 'Enter') { event.preventDefault(); commitTerm(group) }
1711
+ else if (event.key === 'Escape') {
1712
+ skipCommitRef.current = true
1713
+ setAddingGroup(null)
1714
+ setTermDraft({ ...termDraft, [group.id]: '' })
1715
+ }
1716
+ },
1717
+ onBlur: () => {
1718
+ if (skipCommitRef.current) { skipCommitRef.current = false; return }
1719
+ commitTerm(group)
1720
+ },
1721
+ })
1722
+ : h('button', {
1723
+ type: 'button',
1724
+ className: 'ase-kw-add',
1725
+ 'aria-label': T('kwAdd'),
1726
+ onClick: () => { skipCommitRef.current = false; setAddingGroup(group.id) },
1727
+ }, h('svg', { width: 12, height: 12, viewBox: '0 0 12 12', fill: 'none', 'aria-hidden': true },
1728
+ h('path', { d: 'M6 1.7v8.6M1.7 6h8.6', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round' }))))
1729
+ : null)))
1730
+ : h('p', { className: 'ase-kw-empty' }, T('kwEmpty')),
1731
+ managing
1732
+ ? h('button', {
1733
+ type: 'button',
1734
+ className: 'ase-ghost-link ase-kw-newgroup',
1735
+ onClick: () => {
1736
+ const created = addGroup()
1737
+ if (created) setLabelEdit({ id: created, value: T('kwAddGroup') })
1738
+ },
1739
+ }, '+ ' + T('kwAddGroup'))
1740
+ : null,
1741
+ canRestore
1742
+ ? h('button', {
1743
+ type: 'button',
1744
+ className: 'ase-ghost-link ase-kw-restore',
1745
+ onClick: restoreKeywords,
1746
+ }, T('kwRestore'))
1747
+ : null)
1748
+
1749
+ const anyFrameSource = FRAME_SOURCE_IDS.some((id) => enabledSources.includes(id))
1750
+ const aiAvailable = enabledSources.includes('chrome')
1751
+ || (anyFrameSource && Boolean(readToken()))
1752
+ const aiToggle = aiAvailable
1753
+ ? h('div', { className: 'ase-ai-toggle' },
1754
+ h('span', { className: 'ase-ai-label' }, T('aiSearch')),
1755
+ h('button', {
1756
+ type: 'button',
1757
+ role: 'switch',
1758
+ 'aria-checked': aiOn ? 'true' : 'false',
1759
+ 'aria-label': T('aiSearch'),
1760
+ 'data-tip': T('aiSearch'),
1761
+ className: 'ase-switch' + (aiOn ? ' ase-switch--on' : ''),
1762
+ onClick: () => { setCountOpen(false); onToggleAi(!aiOn) },
1763
+ }, h('span', { className: 'ase-switch-knob' })),
1764
+ aiOn
1765
+ ? h('div', { className: 'ase-ai-count', ref: countRef },
1766
+ h('button', {
1767
+ type: 'button',
1768
+ className: 'ase-ai-count-btn',
1769
+ 'aria-haspopup': 'listbox',
1770
+ 'aria-expanded': countOpen ? 'true' : 'false',
1771
+ 'aria-label': T('aiDirections'),
1772
+ onClick: () => setCountOpen(!countOpen),
1773
+ }, 'x' + aiCount,
1774
+ h('svg', { className: 'ase-ai-count-chevron', width: 8, height: 8, viewBox: '0 0 10 10', fill: 'none', 'aria-hidden': true },
1775
+ h('path', { d: 'M1.7 3.4 5 6.7l3.3-3.3', stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round', strokeLinejoin: 'round' }))),
1776
+ countOpen
1777
+ ? h('div', { className: 'ase-ai-count-menu', role: 'listbox', 'aria-label': T('aiDirections') },
1778
+ [1, 2, 3].map((option) => h('button', {
1779
+ key: option,
1780
+ type: 'button',
1781
+ role: 'option',
1782
+ 'aria-selected': option === aiCount ? 'true' : 'false',
1783
+ className: 'ase-ai-count-option' + (option === aiCount ? ' is-on' : ''),
1784
+ onClick: () => { onAiCount(option); setCountOpen(false) },
1785
+ }, 'x' + option)))
1786
+ : null)
1787
+ : null)
1788
+ : null
1789
+
1790
+ const compose = h('div', {
1791
+ className: 'ase-ask' + (kwOpen ? ' ase-ask--open' : '') + (dropping ? ' ase-ask--drop' : ''),
1792
+ onDragOver: (event) => { event.preventDefault(); setDropping(true) },
1793
+ onDragLeave: () => setDropping(false),
1794
+ onDrop: (event) => {
1795
+ event.preventDefault()
1796
+ setDropping(false)
1797
+ pickFile((event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) || null)
1798
+ },
1799
+ onPaste: (event) => {
1800
+ const files = event.clipboardData && event.clipboardData.files
1801
+ const image = files && Array.prototype.slice.call(files).filter((file) => /^image\//.test(file.type))[0]
1802
+ if (!image) return
1803
+ event.preventDefault()
1804
+ pickFile(image)
1805
+ },
1806
+ },
1807
+ attachment
1808
+ ? h('div', { className: 'ase-attach' },
1809
+ h('button', {
1810
+ type: 'button',
1811
+ className: 'ase-attach-open',
1812
+ 'aria-label': T('viewImage'),
1813
+ onMouseDown: (event) => event.preventDefault(),
1814
+ onClick: () => setAttachPreview(true),
1815
+ }, h('img', {
1816
+ src: attachment.preview || attachment.dataUrl,
1817
+ alt: '',
1818
+ 'aria-label': T('imageAttached'),
1819
+ })),
1820
+ h('button', {
1821
+ type: 'button',
1822
+ className: 'ase-attach-x',
1823
+ 'aria-label': T('removeImage'),
1824
+ onMouseDown: (event) => event.preventDefault(),
1825
+ onClick: onClearAttachment,
1826
+ }, h('svg', { width: 10, height: 10, viewBox: '0 0 10 10', fill: 'none', 'aria-hidden': true },
1827
+ h('path', { d: 'M1.6 1.6 8.4 8.4M8.4 1.6 1.6 8.4', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round' }))))
1828
+ : null,
1829
+ h('textarea', {
1830
+ className: 'ase-textarea',
1831
+ rows: 3,
1832
+ placeholder: T('placeholder'),
1833
+ value: query,
1834
+ onChange: (event) => setQuery(event.target.value),
1835
+ onKeyDown: (event) => {
1836
+ // Enter always submits, even when the text has not changed; Shift+Enter makes a new line
1837
+ if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent?.isComposing) return
1838
+ event.preventDefault()
1839
+ runSubmit()
1840
+ },
1841
+ }),
1842
+ h('div', { className: 'ase-ask-tools' + (aiToggle ? '' : ' ase-ask-tools--no-ai') },
1843
+ kwOpen ? panel : null,
1844
+ h('input', {
1845
+ type: 'file',
1846
+ accept: 'image/*',
1847
+ className: 'ase-file',
1848
+ ref: fileRef,
1849
+ onChange: (event) => {
1850
+ pickFile(event.target.files && event.target.files[0])
1851
+ event.target.value = ''
1852
+ },
1853
+ }),
1854
+ h('button', {
1855
+ type: 'button',
1856
+ className: 'ase-iconbtn ase-addimg ase-tip ase-tip--right',
1857
+ 'data-tip': T('pickImageTip'),
1858
+ 'aria-label': T('pickImage'),
1859
+ onClick: () => { if (fileRef.current) fileRef.current.click() },
1860
+ }, h('svg', { width: 15, height: 15, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true },
1861
+ h('path', { d: 'M8 3v10M3 8h10', stroke: 'currentColor', strokeWidth: 1.7, strokeLinecap: 'round' }))),
1862
+ h('button', {
1863
+ type: 'button',
1864
+ ref: triggerRef,
1865
+ className: 'ase-kw-trigger' + (kwOpen ? ' is-open' : ''),
1866
+ 'aria-expanded': kwOpen ? 'true' : 'false',
1867
+ onClick: () => { setSrcOpen(false); setKwOpen(!kwOpen) },
1868
+ },
1869
+ h('span', { className: 'ase-kw-trigger-label' }, T('lensToggle')),
1870
+ h('svg', { className: 'ase-kw-chevron', width: 10, height: 10, viewBox: '0 0 10 10', fill: 'none', 'aria-hidden': true },
1871
+ h('path', { d: 'M1.7 3.4 5 6.7l3.3-3.3', stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round', strokeLinejoin: 'round' }))),
1872
+ h('div', { className: 'ase-src-wrap' },
1873
+ h('button', {
1874
+ type: 'button',
1875
+ ref: srcTriggerRef,
1876
+ className: 'ase-kw-trigger ase-src-trigger' + (srcOpen ? ' is-open' : ''),
1877
+ 'aria-expanded': srcOpen ? 'true' : 'false',
1878
+ 'aria-haspopup': 'true',
1879
+ onClick: () => { setKwOpen(false); setSrcOpen(!srcOpen) },
1880
+ },
1881
+ h('svg', { className: 'ase-src-icon', width: 12, height: 12, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true },
1882
+ h('path', { d: 'M8 2.3 13.9 5.4 8 8.5 2.1 5.4 8 2.3Z', stroke: 'currentColor', strokeWidth: 1.2, strokeLinejoin: 'round' }),
1883
+ h('path', { d: 'M2.6 8.6 8 11.3 13.4 8.6', stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round', strokeLinejoin: 'round' })),
1884
+ h('span', { className: 'ase-kw-trigger-label' }, T('sources')),
1885
+ h('svg', { className: 'ase-kw-chevron', width: 10, height: 10, viewBox: '0 0 10 10', fill: 'none', 'aria-hidden': true },
1886
+ h('path', { d: 'M1.7 3.4 5 6.7l3.3-3.3', stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round', strokeLinejoin: 'round' }))),
1887
+ srcOpen
1888
+ ? h('div', { className: 'ase-src-panel', ref: srcRef, role: 'group', 'aria-label': T('sources') },
1889
+ h('div', { className: 'ase-src-head' },
1890
+ h('span', { className: 'ase-src-title' }, T('sources')),
1891
+ h('span', { className: 'ase-src-hint' }, T('sourcesHint'))),
1892
+ SOURCES.map((source, index) => {
1893
+ const on = enabledSources.includes(source.id)
1894
+ const ready = source.kind === 'token' ? Boolean(readToken()) : true
1895
+ const first = index === 0 || SOURCES[index - 1].group !== source.group
1896
+ const rows = []
1897
+ if (first) {
1898
+ rows.push(h('div', { key: 'g-' + source.group, className: 'ase-src-group' },
1899
+ h('span', null, source.group === 'framethrower' ? 'FrameThrower' : T('srcWeb'))))
1900
+ }
1901
+ rows.push(h('button', {
1902
+ key: source.id,
1903
+ type: 'button',
1904
+ role: 'switch',
1905
+ 'aria-checked': on ? 'true' : 'false',
1906
+ className: 'ase-src-row' + (on ? ' is-on' : ''),
1907
+ onClick: () => onToggleSource(source.id),
1908
+ },
1909
+ h('span', { className: 'ase-src-check' }, on ? '✓' : ''),
1910
+ h('span', { className: 'ase-src-name' }, sourceLabel(source, lang)),
1911
+ h('span', { className: 'ase-src-state' }, ready ? (source.kind === 'token' ? T('connected') : T('noSetup')) : T('notConfigured'))))
1912
+ return rows
1913
+ }))
1914
+ : null),
1915
+ aiToggle,
1916
+ h('button', {
1917
+ type: 'button',
1918
+ className: 'ase-send ase-tip',
1919
+ 'data-tip': T('search'),
1920
+ 'aria-label': T('search'),
1921
+ disabled: loading || (!String(query || '').trim() && !attachment),
1922
+ onClick: runSubmit,
1923
+ }, h('svg', { width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true },
1924
+ h('circle', { cx: 7, cy: 7, r: 4.6, stroke: 'currentColor', strokeWidth: 1.7 }),
1925
+ h('path', { d: 'M10.4 10.4 14 14', stroke: 'currentColor', strokeWidth: 1.7, strokeLinecap: 'round' })))))
1926
+ return h('section', { className: 'ase-discover' },
1927
+ h('div', { className: 'ase-scroll', ref: scrollRef },
1928
+ !hasToken && anyFrameSource && !enabledSources.includes('chrome')
1929
+ ? h('p', { className: 'ase-note' },
1930
+ h('span', null, T('noToken')),
1931
+ h('button', { type: 'button', className: 'ase-ghost-link', onClick: openToken }, T('settings')))
1932
+ : null,
1933
+ h('section', { className: 'ase-results' + (status === 'idle' && !results.length ? ' ase-results--idle' : '') },
1934
+ h('div', { className: 'ase-results-head' },
1935
+ h('div', { className: 'ase-results-title' },
1936
+ h('h2', { className: 'ase-h2' }, T('results')),
1937
+ results.length ? h('span', { className: 'ase-results-count' }, String(results.length)) : null),
1938
+ h('div', { className: 'ase-results-actions' },
1939
+ status !== 'idle'
1940
+ ? h('button', {
1941
+ type: 'button',
1942
+ className: 'ase-btn',
1943
+ 'aria-label': T('clearAll'),
1944
+ onClick: clearAll,
1945
+ }, T('clearAll'))
1946
+ : null,
1947
+ status === 'ok' && canMore
1948
+ ? h('button', { type: 'button', className: 'ase-btn', onClick: more }, T('more'))
1949
+ : null)),
1950
+ understood && status !== 'needs-ai'
1951
+ ? h('div', { className: 'ase-understood' },
1952
+ h('span', { className: 'ase-understood-key' }, T('queryTerms')),
1953
+ h('div', { className: 'ase-understood-lines' },
1954
+ String(understood).split('\n').filter(Boolean).map((line, index) => h('p', {
1955
+ key: index,
1956
+ className: 'ase-understood-line',
1957
+ }, line))))
1958
+ : null,
1959
+ aiNote ? h('p', { className: 'ase-warn' }, aiNoteMessage(aiNote.reason, T, aiNote.detail)) : null,
1960
+ (warnings || []).map((warning) => h('p', { key: warning, className: 'ase-warn' }, warning)),
1961
+ loading
1962
+ ? h('div', { className: 'ase-grid' },
1963
+ Array.from({ length: 8 }, (_, index) => h('div', { key: index, className: 'ase-tile ase-tile--skeleton' },
1964
+ h('div', { className: 'ase-skeleton ase-thumb' }),
1965
+ h('figcaption', null, h('div', { className: 'ase-skeleton', style: { height: 10, width: '70%' } })))))
1966
+ : null,
1967
+ status === 'idle' && !results.length
1968
+ ? h('div', { className: 'ase-idle' },
1969
+ h('div', { className: 'ase-idle-art', 'aria-hidden': true },
1970
+ h('span'), h('span'), h('span')),
1971
+ h('p', { className: 'ase-idle-title' }, T('idleTitle')),
1972
+ h('p', { className: 'ase-idle-body' }, T('idleBody')))
1973
+ : null,
1974
+ status === 'needs-ai'
1975
+ ? h('div', { className: 'ase-empty' },
1976
+ h('h3', null, T('aiNeeded')),
1977
+ h('p', null, T('aiNeededBody')),
1978
+ h('button', {
1979
+ type: 'button',
1980
+ className: 'ase-btn ase-btn--primary',
1981
+ onClick: () => { onToggleAi(true); onAiRetry() },
1982
+ }, T('aiNeededCta')))
1983
+ : null,
1984
+ status === 'error'
1985
+ ? h('div', { className: 'ase-empty' },
1986
+ h('h3', null, T('searchFail')),
1987
+ understood ? h('p', { className: 'ase-fail-query' }, T('queryTerms') + ' ' + understood) : null,
1988
+ h('p', null, failureMessage(failure, T)),
1989
+ hint ? h('p', null, hint) : null,
1990
+ h('button', { type: 'button', className: 'ase-btn', onClick: submit }, T('retry')))
1991
+ : null,
1992
+ status === 'ok' && results.length
1993
+ ? h('div', { className: 'ase-grid' },
1994
+ results.map((frame) => h(FrameTile, {
1995
+ key: frameKey(frame),
1996
+ frame,
1997
+ busy: busyId === frameKey(frame),
1998
+ onAdd: openBoardModal,
1999
+ onSimilar: frame.src === 'chrome' ? null : similar,
2000
+ onZoom: openZoom,
2001
+ })))
2002
+ : null,
2003
+ status === 'ok' && results.length
2004
+ ? (canLoadMore
2005
+ ? h('button', {
2006
+ type: 'button',
2007
+ className: 'ase-btn ase-more',
2008
+ disabled: loadingMore,
2009
+ onClick: loadMore,
2010
+ }, loadingMore ? T('loadMore') + '…' : T('loadMore'))
2011
+ : h('p', { className: 'ase-nomore' }, T('noMore')))
2012
+ : null,
2013
+ status === 'ok' && !results.length
2014
+ ? h('div', { className: 'ase-empty' },
2015
+ h('h3', null, T('resultsEmpty')),
2016
+ h('p', null, hint || failureMessage(null, T)),
2017
+ canLoose && onLooseRetry
2018
+ ? h('button', { type: 'button', className: 'ase-btn', onClick: onLooseRetry }, T('retryLoose'))
2019
+ : null)
2020
+ : null)),
2021
+ h('div', { className: 'ase-dock' }, compose),
2022
+ attachPreview && attachment
2023
+ ? h(AttachmentPreview, {
2024
+ src: attachment.dataUrl || attachment.preview,
2025
+ alt: '',
2026
+ onClose: () => setAttachPreview(false),
2027
+ onToast,
2028
+ })
2029
+ : null)
2030
+ }
2031
+
2032
+ function Page({ translator, lang, onChooseLang }) {
2033
+ const t = translator
2034
+ const [restored] = useState(loadView)
2035
+ const [tab, setTab] = useState(restored.tab === 'boards' ? 'boards' : 'discover')
2036
+ const [boards, setBoards] = useState(loadBoards)
2037
+ const sortedBoards = [...boards].sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0) || b.updatedAt - a.updatedAt)
2038
+ const [activeBoard, setActiveBoard] = useState(typeof restored.activeBoard === 'string' ? restored.activeBoard : null)
2039
+ const [query, setQuery] = useState(typeof restored.query === 'string' ? restored.query : '')
2040
+ const [vocabulary, setVocabulary] = useState([])
2041
+ const [kwStore, setKwStore] = useState(loadKeywords)
2042
+ const [vocabVersion, setVocabVersion] = useState(0)
2043
+ const [results, setResults] = useState(Array.isArray(restored.results) ? restored.results : [])
2044
+ const [status, setStatus] = useState(Array.isArray(restored.results) && restored.results.length ? 'ok' : 'idle')
2045
+ const [hint, setHint] = useState('')
2046
+ const [warnings, setWarnings] = useState(Array.isArray(restored.warnings) ? restored.warnings : [])
2047
+ const [lastKind, setLastKind] = useState(restored.lastKind === 'similar' ? 'similar' : 'text')
2048
+ const [zoom, setZoom] = useState(null)
2049
+ const [expanding, setExpanding] = useState(null)
2050
+ const [page, setPage] = useState(Number(restored.page) || 0)
2051
+ const [loadingMore, setLoadingMore] = useState(false)
2052
+ const [noMore, setNoMore] = useState(Boolean(restored.noMore))
2053
+ const [total, setTotal] = useState(Number(restored.total) || 0)
2054
+ const lastSearchText = useRef('')
2055
+ // bumps on "清空" so any in-flight search cannot repopulate a cleared grid
2056
+ const genRef = useRef(0)
2057
+ const lastSearch = useRef(
2058
+ restored.params && typeof restored.params === 'object' && typeof restored.params.query === 'string'
2059
+ ? restored.params
2060
+ : null,
2061
+ )
2062
+ const [attachment, setAttachment] = useState(null)
2063
+ const [understood, setUnderstood] = useState(typeof restored.understood === 'string' ? restored.understood : '')
2064
+ const [aiNote, setAiNote] = useState(null)
2065
+ const [failure, setFailure] = useState(null)
2066
+ const [aiOn, setAiOn] = useState(() => {
2067
+ try { return window.localStorage.getItem(AI_KEY) !== '0' } catch { return true }
2068
+ })
2069
+ const [aiCount, setAiCount] = useState(() => {
2070
+ try {
2071
+ const saved = Number(window.localStorage.getItem(AI_COUNT_KEY))
2072
+ return saved === 1 || saved === 2 || saved === 3 ? saved : 3
2073
+ } catch { return 3 }
2074
+ })
2075
+ const [modalFrame, setModalFrame] = useState(null)
2076
+ const [suggests, setSuggests] = useState(null)
2077
+ const [presets, setPresets] = useState([])
2078
+ const [savingPreset, setSavingPreset] = useState(false)
2079
+ const [suggestBusy, setSuggestBusy] = useState(null)
2080
+ const [toast, setToast] = useState('')
2081
+ const [settingsOpen, setSettingsOpen] = useState(false)
2082
+ const [communityOpen, setCommunityOpen] = useState(false)
2083
+ const [drafts, setDrafts] = useState({})
2084
+ const [enabledSources, setEnabledSources] = useState(loadSources)
2085
+ const [version, setVersion] = useState('')
2086
+ // a git checkout can push saved boards back to the repo; an npm install cannot
2087
+ const [canPushPresets, setCanPushPresets] = useState(true)
2088
+ const toggleSource = useCallback((id) => {
2089
+ setEnabledSources((list) => {
2090
+ const next = list.includes(id)
2091
+ ? list.filter((item) => item !== id)
2092
+ : SOURCE_IDS.filter((sourceId) => list.includes(sourceId) || sourceId === id)
2093
+ saveSources(next)
2094
+ return next
2095
+ })
2096
+ }, [])
2097
+
2098
+ // the settings card owns the shared FrameThrower token, so its switch
2099
+ // turns the whole animation / film / commercial family on or off
2100
+ const toggleSourceGroup = useCallback((ids, nextOn) => {
2101
+ setEnabledSources((list) => {
2102
+ const keep = list.filter((id) => !ids.includes(id))
2103
+ const next = nextOn
2104
+ ? SOURCE_IDS.filter((id) => keep.includes(id) || ids.includes(id))
2105
+ : keep
2106
+ saveSources(next)
2107
+ return next
2108
+ })
2109
+ }, [])
2110
+
2111
+ useEffect(() => {
2112
+ const token = readToken()
2113
+ if (!token) { setVocabulary([]); return undefined }
2114
+ let alive = true
2115
+ fetch('/anistyle/vocabulary', { headers: { 'x-ft-token': token, Accept: 'application/json' } })
2116
+ .then((response) => (response.ok ? response.json() : null))
2117
+ .then((data) => { if (alive && data && data.source === 'vocabulary') setVocabulary(parseVocabulary(data)) })
2118
+ .catch(() => {})
2119
+ return () => { alive = false }
2120
+ }, [vocabVersion])
2121
+
2122
+ useEffect(() => {
2123
+ try { window.localStorage.setItem(BOARDS_KEY, JSON.stringify(boards)) } catch {}
2124
+ }, [boards])
2125
+
2126
+ useEffect(() => {
2127
+ let alive = true
2128
+ fetch('/anistyle/presets', { headers: { Accept: 'application/json' } })
2129
+ .then((response) => (response.ok ? response.json() : null))
2130
+ .then((data) => {
2131
+ if (!alive || !data) return
2132
+ if (Array.isArray(data.boards)) setPresets(data.boards)
2133
+ if (typeof data.checkout === 'boolean') setCanPushPresets(data.checkout)
2134
+ })
2135
+ .catch(() => {})
2136
+ return () => { alive = false }
2137
+ }, [])
2138
+
2139
+ useEffect(() => {
2140
+ let alive = true
2141
+ fetch('/anistyle/version', { headers: { Accept: 'application/json' } })
2142
+ .then((response) => (response.ok ? response.json() : null))
2143
+ .then((data) => { if (alive && data && typeof data.version === 'string') setVersion(data.version) })
2144
+ .catch(() => {})
2145
+ return () => { alive = false }
2146
+ }, [])
2147
+
2148
+ useEffect(() => {
2149
+ try {
2150
+ window.localStorage.setItem(VIEW_KEY, JSON.stringify({
2151
+ tab, query, activeBoard, results, warnings, lastKind, understood,
2152
+ params: lastSearch.current, page, total, noMore,
2153
+ }))
2154
+ } catch {}
2155
+ }, [tab, query, activeBoard, results, warnings, lastKind, understood, page, total, noMore])
2156
+
2157
+ useEffect(() => {
2158
+ if (!toast) return undefined
2159
+ const timer = window.setTimeout(() => setToast(''), 2400)
2160
+ return () => window.clearTimeout(timer)
2161
+ }, [toast])
2162
+
2163
+ const activeBoardData = activeBoard ? boards.find((board) => board.id === activeBoard) : null
2164
+
2165
+ const runSearch = useCallback(async (mode, styleValue) => {
2166
+ const gen = genRef.current
2167
+ const text = String(mode && mode.text ? mode.text : query).trim()
2168
+ if (!text) return
2169
+ // pressing Enter again on the same words must start a fresh search,
2170
+ // so let the interpret step re-roll instead of reading its cache
2171
+ const repeated = lastSearchText.current === text
2172
+ lastSearchText.current = text
2173
+ setStatus('loading')
2174
+ setHint('')
2175
+ setWarnings([])
2176
+ setResults([])
2177
+ setUnderstood('')
2178
+ setAiNote(null)
2179
+ setFailure(null)
2180
+ setLastKind('text')
2181
+ setPage(0)
2182
+ setNoMore(false)
2183
+ setTotal(0)
2184
+
2185
+ // a source that is on but not configured (no token) is skipped, not fatal
2186
+ const chosen = enabledSources.filter((id) => id === 'chrome' || Boolean(readToken()))
2187
+ if (!chosen.length) {
2188
+ const missingToken = FRAME_SOURCE_IDS.some((id) => enabledSources.includes(id)) && !readToken()
2189
+ setStatus('error')
2190
+ setFailure({ source: 'unconfigured', hint: missingToken ? t('noTokenHint') : t('noSources') })
2191
+ return
2192
+ }
2193
+
2194
+ const useAi = aiOn || Boolean(mode && mode.forceAi)
2195
+ let aiParams = null
2196
+ let aiNote = null
2197
+ if (useAi) {
2198
+ try {
2199
+ const interpreted = await api('/anistyle/interpret', { text, count: aiCount, refresh: repeated })
2200
+ aiParams = interpreted && interpreted.source === 'model' ? interpreted.params : null
2201
+ if (!aiParams) {
2202
+ aiNote = { reason: (interpreted && interpreted.reason) || 'no-model', detail: (interpreted && interpreted.detail) || '' }
2203
+ }
2204
+ } catch (error) {
2205
+ aiNote = { reason: 'unreachable', detail: String((error && error.message) || '').slice(0, 120) }
2206
+ }
2207
+ }
2208
+ const wanted = aiCount === 1 || aiCount === 2 ? aiCount : 3
2209
+ const modelQueries = aiParams && Array.isArray(aiParams.queries)
2210
+ ? aiParams.queries.filter((item) => typeof item === 'string' && item.trim())
2211
+ : []
2212
+ if (!modelQueries.length && aiParams && aiParams.query) modelQueries.push(aiParams.query)
2213
+ const modelWeb = aiParams && Array.isArray(aiParams.web)
2214
+ ? aiParams.web.filter((item) => typeof item === 'string' && item.trim()).slice(0, wanted)
2215
+ : []
2216
+
2217
+ const runFrameSource = async (id) => {
2218
+ const kind = FRAME_SOURCE_KIND[id]
2219
+ const label = frameSourceLabel(id, t)
2220
+ const queryText = modelQueries[0] || (aiParams && aiParams.query) || text
2221
+ const modelStyle = (aiParams && aiParams.style) || ''
2222
+ const modelTitle = (aiParams && aiParams.title) || ''
2223
+ const modelGenre = (aiParams && aiParams.genre) || ''
2224
+ const modelPerTitle = (aiParams && aiParams.perTitle) || 3
2225
+ if (looksNonEnglish(queryText)) {
2226
+ // the libraries match English frame descriptions; a non-English query
2227
+ // only returns its default page, so report "nothing found" instead of showing it
2228
+ return {
2229
+ id,
2230
+ ok: false,
2231
+ needsAi: true,
2232
+ frames: [],
2233
+ understood: label + ' · ' + text,
2234
+ aiNote,
2235
+ }
2236
+ }
2237
+ const loose = Boolean(mode && mode.loose)
2238
+ const queries = loose
2239
+ ? [queryText]
2240
+ : (modelQueries.length ? modelQueries.slice(0, wanted) : [queryText])
2241
+ // split one page between the readings, then interleave them below
2242
+ const perQuery = queries.length > 1 ? Math.max(6, Math.ceil(PAGE_SIZE / queries.length)) : PAGE_SIZE
2243
+ const shared = { source: kind, limit: perQuery, offset: 0 }
2244
+ if (kind === 'animation') {
2245
+ shared.perTitle = loose ? 3 : modelPerTitle
2246
+ if (!loose && modelTitle) shared.title = modelTitle
2247
+ if (!loose && modelGenre) shared.genre = modelGenre
2248
+ const style = loose ? '' : styleValue === undefined ? modelStyle : styleValue
2249
+ if (style) shared.style = style
2250
+ } else if (kind === 'film') {
2251
+ // no filter hand-off: the helper's title / AniList genre are written
2252
+ // for the animation library and would wrongly empty the film results
2253
+ }
2254
+ const framePage = { source: kind, queries, perQuery }
2255
+ if (shared.perTitle) framePage.perTitle = shared.perTitle
2256
+ if (shared.title) framePage.title = shared.title
2257
+ if (shared.genre) framePage.genre = shared.genre
2258
+ if (shared.style) framePage.style = shared.style
2259
+ const responses = await Promise.all(queries.map((item) => api('/anistyle/frames', { ...shared, query: item })
2260
+ .catch((error) => ({ source: 'client', hint: String((error && error.message) || error) }))))
2261
+ const usable = responses.filter((item) => item && item.source === 'framethrower')
2262
+ if (!usable.length) {
2263
+ const first = responses.find(Boolean) || {}
2264
+ return {
2265
+ id,
2266
+ ok: false,
2267
+ frames: [],
2268
+ failure: { source: first.source, status: first.status, hint: first.hint },
2269
+ framePage,
2270
+ aiNote,
2271
+ }
2272
+ }
2273
+ const frames = mergeGroups(usable.map((item) => item.frames || []))
2274
+ const sum = usable.reduce((total, item) => total + (Number(item.total) || 0), 0)
2275
+ const warningList = []
2276
+ for (const item of usable) {
2277
+ for (const warning of item.warnings || []) {
2278
+ if (!warningList.includes(warning)) warningList.push(warning)
2279
+ }
2280
+ }
2281
+ // a source with no first page is not paged again: each page costs credits
2282
+ framePage.empty = frames.length === 0
2283
+ return {
2284
+ id,
2285
+ ok: true,
2286
+ frames,
2287
+ total: sum,
2288
+ warnings: warningList,
2289
+ framePage,
2290
+ aiNote,
2291
+ understood: label + ' · ' + queries.join(' · '),
2292
+ }
2293
+ }
2294
+
2295
+ const runWeb = async () => {
2296
+ // the model supplies image-search phrases; without them the brief goes as-is
2297
+ const webQueries = modelWeb.length ? modelWeb.slice(0, wanted) : [text]
2298
+ const perQuery = webQueries.length > 1 ? Math.max(8, Math.ceil(PAGE_SIZE / webQueries.length)) : PAGE_SIZE
2299
+ const responses = await Promise.all(webQueries.map((query) => api('/anistyle/web', { query, limit: perQuery, offset: 0, lang })
2300
+ .catch((error) => ({ source: 'client', hint: String((error && error.message) || error) }))))
2301
+ const usable = responses.filter((item) => item && item.source === 'chrome')
2302
+ if (!usable.length) {
2303
+ const first = responses.find(Boolean) || {}
2304
+ return {
2305
+ id: 'chrome',
2306
+ ok: false,
2307
+ frames: [],
2308
+ failure: { source: first.source || 'unreachable', status: first.status, hint: first.hint },
2309
+ aiNote,
2310
+ }
2311
+ }
2312
+ const warnings = webWarnings(usable, responses, t)
2313
+ return {
2314
+ id: 'chrome',
2315
+ ok: true,
2316
+ frames: mergeGroups(usable.map((item) => item.frames || [])),
2317
+ total: 0,
2318
+ warnings,
2319
+ params: { webQueries, webLimit: perQuery },
2320
+ aiNote,
2321
+ understood: t('srcWeb') + ' · ' + webQueries.join(' · '),
2322
+ }
2323
+ }
2324
+
2325
+ try {
2326
+ const tasks = []
2327
+ for (const id of FRAME_SOURCE_IDS) {
2328
+ if (chosen.includes(id)) tasks.push(runFrameSource(id))
2329
+ }
2330
+ if (chosen.includes('chrome')) tasks.push(runWeb())
2331
+ const outcomes = await Promise.all(tasks)
2332
+ if (gen !== genRef.current) return
2333
+ const ok = outcomes.filter((outcome) => outcome.ok)
2334
+ const failed = outcomes.filter((outcome) => !outcome.ok)
2335
+
2336
+ const warningList = []
2337
+ for (const outcome of ok) {
2338
+ const label = outcome.id === 'chrome' ? t('srcWeb') : frameSourceLabel(outcome.id, t)
2339
+ for (const warning of outcome.warnings || []) {
2340
+ const line = label + ' · ' + warning
2341
+ if (!warningList.includes(line)) warningList.push(line)
2342
+ }
2343
+ }
2344
+ for (const outcome of failed) {
2345
+ const line = sourceFailureLine(outcome, t)
2346
+ if (line && !warningList.includes(line)) warningList.push(line)
2347
+ }
2348
+ for (const id of FRAME_SOURCE_IDS) {
2349
+ if (enabledSources.includes(id) && !readToken()) {
2350
+ const line = frameSourceLabel(id, t) + ' · ' + t('srcSkipped')
2351
+ if (!warningList.includes(line)) warningList.push(line)
2352
+ }
2353
+ }
2354
+
2355
+ if (ok.length) {
2356
+ const params = { text, sources: chosen.slice(), query: text, pages: {} }
2357
+ for (const outcome of ok) {
2358
+ if (outcome.framePage) params.pages[outcome.id] = outcome.framePage
2359
+ if (outcome.params) Object.assign(params, outcome.params)
2360
+ }
2361
+ params.sources = chosen.slice()
2362
+ params.query = text
2363
+ lastSearch.current = params
2364
+ setResults(mergeGroups(ok.map((outcome) => outcome.frames)))
2365
+ // the web source has no total, so the count is only meaningful for FrameThrower
2366
+ const total = chosen.includes('chrome')
2367
+ ? 0
2368
+ : ok.reduce((sum, outcome) => sum + (Number(outcome.total) || 0), 0)
2369
+ setTotal(total)
2370
+ setWarnings(warningList)
2371
+ setUnderstood(ok.map((outcome) => outcome.understood).filter(Boolean).join('\n'))
2372
+ const firstNote = ok.map((outcome) => outcome.aiNote).find(Boolean) || aiNote
2373
+ if (firstNote) setAiNote(firstNote)
2374
+ setNoMore(ok.every((outcome) => !(outcome.frames && outcome.frames.length)))
2375
+ setStatus('ok')
2376
+ return
2377
+ }
2378
+
2379
+ const needsAi = failed.some((outcome) => outcome.needsAi)
2380
+ const primary = failed.find((outcome) => !outcome.needsAi) || failed[0] || {}
2381
+ setResults([])
2382
+ setUnderstood(failed.map((outcome) => outcome.understood).filter(Boolean).join('\n'))
2383
+ setFailure(primary.failure || null)
2384
+ const firstNote = failed.map((outcome) => outcome.aiNote).find(Boolean) || aiNote
2385
+ if (firstNote) setAiNote(firstNote)
2386
+ setStatus(!primary.failure && needsAi ? 'needs-ai' : 'error')
2387
+ } catch (error) {
2388
+ if (gen !== genRef.current) return
2389
+ setStatus('error')
2390
+ setFailure({ source: 'client', hint: String(error.message || error) })
2391
+ setResults([])
2392
+ }
2393
+ }, [query, aiOn, aiCount, enabledSources, lang, t])
2394
+
2395
+ const submit = useCallback((override) => {
2396
+ const gen = genRef.current
2397
+ const image = (override && override.image) || (attachment && attachment.dataUrl)
2398
+ if (image) {
2399
+ const imageFrameIds = readToken()
2400
+ ? FRAME_SOURCE_IDS.filter((id) => enabledSources.includes(id))
2401
+ : []
2402
+ const framesReady = imageFrameIds.length > 0
2403
+ const webReady = enabledSources.includes('chrome')
2404
+ if (!framesReady && !webReady) {
2405
+ const anyOn = FRAME_SOURCE_IDS.some((id) => enabledSources.includes(id)) || enabledSources.includes('chrome')
2406
+ setStatus('error')
2407
+ setFailure({ source: 'unconfigured', hint: anyOn ? t('imageNeedsFrames') : t('noSources') })
2408
+ return
2409
+ }
2410
+ const note = String(query || '').trim()
2411
+ lastSearch.current = null
2412
+ setStatus('loading')
2413
+ setHint('')
2414
+ setWarnings([])
2415
+ setResults([])
2416
+ setUnderstood('')
2417
+ setAiNote(null)
2418
+ setFailure(null)
2419
+ setLastKind('similar')
2420
+ setPage(0)
2421
+ setTotal(0)
2422
+ setNoMore(true)
2423
+
2424
+ const runFrameImage = async (id) => {
2425
+ const label = frameSourceLabel(id, t)
2426
+ if (FRAME_SOURCE_KIND[id] === 'film') {
2427
+ return {
2428
+ id,
2429
+ ok: false,
2430
+ failure: { source: 'unsupported', hint: t('imageFilmUnsupported') },
2431
+ }
2432
+ }
2433
+ const understood = label + ' · ' + t('imageSimilar')
2434
+ let data
2435
+ try {
2436
+ data = await api('/anistyle/similar', { source: FRAME_SOURCE_KIND[id], image, limit: 24 })
2437
+ } catch (error) {
2438
+ return {
2439
+ id,
2440
+ ok: false,
2441
+ failure: { source: 'client', hint: String((error && error.message) || error) },
2442
+ }
2443
+ }
2444
+ if (data && data.source === 'framethrower') {
2445
+ return {
2446
+ id,
2447
+ ok: true,
2448
+ frames: Array.isArray(data.frames) ? data.frames : [],
2449
+ warnings: Array.isArray(data.warnings) ? data.warnings : [],
2450
+ understood,
2451
+ }
2452
+ }
2453
+ return {
2454
+ id,
2455
+ ok: false,
2456
+ failure: { source: (data && data.source) || 'client', status: data && data.status, hint: (data && data.hint) || '' },
2457
+ }
2458
+ }
2459
+
2460
+ const runWeb = async () => {
2461
+ const aiFailure = (reason, detail) => ({
2462
+ id: 'chrome',
2463
+ ok: false,
2464
+ failure: {
2465
+ source: 'ai',
2466
+ hint: t('imageWebNeedsAi') + ' · ' + imageAiFailureMessage(reason, t, detail),
2467
+ },
2468
+ })
2469
+ let webQueries = []
2470
+ let aiOffText = false
2471
+ if (!aiOn) {
2472
+ // turning AI off must stop the vision call; a typed note is still usable as a text query
2473
+ if (!note) return { id: 'chrome', ok: false, failure: { source: 'ai', hint: t('imageAiOff') } }
2474
+ webQueries = [note]
2475
+ aiOffText = true
2476
+ } else {
2477
+ let described
2478
+ try {
2479
+ described = await api('/anistyle/interpret-image', { image, text: note, count: aiCount, lang })
2480
+ } catch (error) {
2481
+ const detail = String((error && error.message) || error)
2482
+ return aiFailure(/^HTTP (400|413|415|422)\b/.test(detail) ? 'bad-image' : 'unreachable', detail)
2483
+ }
2484
+ const wanted = Math.min(3, Math.max(1, Number(aiCount) || 3))
2485
+ webQueries = described && described.source === 'model' && described.params && Array.isArray(described.params.web)
2486
+ ? described.params.web
2487
+ .filter((item) => typeof item === 'string' && item.trim())
2488
+ .map((item) => item.trim())
2489
+ .slice(0, wanted)
2490
+ : []
2491
+ if (!webQueries.length) {
2492
+ return aiFailure(
2493
+ (described && described.reason) || 'unparsed',
2494
+ (described && described.detail) || '',
2495
+ )
2496
+ }
2497
+ }
2498
+
2499
+ const perQuery = webQueries.length > 1 ? Math.max(8, Math.ceil(PAGE_SIZE / webQueries.length)) : PAGE_SIZE
2500
+ const responses = await Promise.all(webQueries.map((queryText) => api('/anistyle/web', {
2501
+ query: queryText,
2502
+ limit: perQuery,
2503
+ offset: 0,
2504
+ lang,
2505
+ }).catch((error) => ({ source: 'client', hint: String((error && error.message) || error) }))))
2506
+ const usable = responses.filter((item) => item && item.source === 'chrome')
2507
+
2508
+ if (!usable.length) {
2509
+ const first = responses.find(Boolean) || {}
2510
+ const hint = first.source === 'unreachable'
2511
+ ? t('failWebNetwork')
2512
+ : (first.status === 429 ? t('failTooMany') : (first.hint || t('failWebEmpty')))
2513
+ return { id: 'chrome', ok: false, failure: { source: 'web', hint } }
2514
+ }
2515
+
2516
+ const warnings = webWarnings(usable, responses, t)
2517
+ if (aiOffText) warnings.unshift(t('imageAiOffText'))
2518
+ return {
2519
+ id: 'chrome',
2520
+ ok: true,
2521
+ frames: mergeGroups(usable.map((item) => item.frames || [])),
2522
+ warnings,
2523
+ understood: t('srcWeb') + ' · ' + webQueries.join(' · '),
2524
+ }
2525
+ }
2526
+
2527
+ const tasks = []
2528
+ for (const id of imageFrameIds) tasks.push(runFrameImage(id))
2529
+ if (webReady) tasks.push(runWeb())
2530
+ Promise.all(tasks).then((outcomes) => {
2531
+ if (gen !== genRef.current) return
2532
+ const ok = outcomes.filter((outcome) => outcome.ok)
2533
+ const failed = outcomes.filter((outcome) => !outcome.ok)
2534
+ const warningList = []
2535
+ for (const outcome of ok) {
2536
+ const label = outcome.id === 'chrome' ? t('srcWeb') : frameSourceLabel(outcome.id, t)
2537
+ for (const warning of outcome.warnings || []) {
2538
+ const line = label + ' · ' + warning
2539
+ if (!warningList.includes(line)) warningList.push(line)
2540
+ }
2541
+ }
2542
+ for (const outcome of failed) {
2543
+ const line = sourceFailureLine(outcome, t)
2544
+ if (line && !warningList.includes(line)) warningList.push(line)
2545
+ }
2546
+ for (const id of FRAME_SOURCE_IDS) {
2547
+ if (enabledSources.includes(id) && !readToken()) {
2548
+ const line = frameSourceLabel(id, t) + ' · ' + t('srcSkipped')
2549
+ if (!warningList.includes(line)) warningList.push(line)
2550
+ }
2551
+ }
2552
+
2553
+ if (ok.length) {
2554
+ setResults(mergeGroups(ok.map((outcome) => outcome.frames)))
2555
+ setWarnings(warningList)
2556
+ setUnderstood(ok.map((outcome) => outcome.understood).filter(Boolean).join('\n'))
2557
+ setNoMore(true)
2558
+ setTotal(0)
2559
+ setStatus('ok')
2560
+ return
2561
+ }
2562
+
2563
+ const primary = failed.find((outcome) => outcome.id === 'chrome') || failed[0] || {}
2564
+ setResults([])
2565
+ setWarnings(warningList)
2566
+ setUnderstood('')
2567
+ setFailure(primary.failure || null)
2568
+ setStatus('error')
2569
+ }).catch((error) => {
2570
+ if (gen !== genRef.current) return
2571
+ setStatus('error')
2572
+ setFailure({ source: 'client', hint: String((error && error.message) || error) })
2573
+ setResults([])
2574
+ })
2575
+ return
2576
+ }
2577
+ runSearch({ text: (override && override.text) || query })
2578
+ }, [runSearch, query, attachment, enabledSources, t, aiOn, aiCount, lang])
2579
+
2580
+ // Next-page requests for every source that took part in the last search.
2581
+ // FrameThrower pages by query batch; the web source pages by result offset.
2582
+ const pageRequests = useCallback((base, nextPage) => {
2583
+ const sources = Array.isArray(base.sources) && base.sources.length ? base.sources : ['framethrower']
2584
+ const pages = base.pages && typeof base.pages === 'object' ? base.pages : {}
2585
+ const requests = []
2586
+ for (const id of FRAME_SOURCE_IDS) {
2587
+ if (!sources.includes(id)) continue
2588
+ const saved = pages[id] && typeof pages[id] === 'object' ? pages[id] : null
2589
+ if (saved && saved.empty) continue
2590
+ const batches = saved && Array.isArray(saved.queries) && saved.queries.length
2591
+ ? saved.queries.slice(0, 3)
2592
+ : (Array.isArray(base.queries) && base.queries.length
2593
+ ? base.queries.slice(0, 3)
2594
+ : (base.query ? [base.query] : []))
2595
+ if (!batches.length) continue
2596
+ const perQuery = (saved && saved.perQuery) || base.perQuery || Math.ceil(PAGE_SIZE / batches.length)
2597
+ const cap = id === 'film' ? 500 : id === 'commercial' ? 150 : 200
2598
+ if (nextPage * perQuery > cap) continue
2599
+ const shared = { source: FRAME_SOURCE_KIND[id], limit: perQuery, offset: nextPage * perQuery }
2600
+ if (saved && saved.perTitle) shared.perTitle = saved.perTitle
2601
+ if (saved && saved.title) shared.title = saved.title
2602
+ if (saved && saved.genre) shared.genre = saved.genre
2603
+ if (saved && saved.style) shared.style = saved.style
2604
+ for (const item of batches) {
2605
+ requests.push(api('/anistyle/frames', { ...shared, query: item })
2606
+ .then((data) => (data && data.source === 'framethrower' ? data : null))
2607
+ .catch(() => null))
2608
+ }
2609
+ }
2610
+ if (sources.includes('chrome')) {
2611
+ const limit = base.webLimit || PAGE_SIZE
2612
+ const list = Array.isArray(base.webQueries) && base.webQueries.length
2613
+ ? base.webQueries.slice(0, 3)
2614
+ : [base.webQuery || base.text || base.query].filter(Boolean)
2615
+ if (list.length && nextPage * limit <= 200) {
2616
+ for (const query of list) {
2617
+ requests.push(api('/anistyle/web', { query, limit, offset: nextPage * limit, lang })
2618
+ .then((data) => (data && data.source === 'chrome' ? data : null))
2619
+ .catch(() => null))
2620
+ }
2621
+ }
2622
+ }
2623
+ return requests
2624
+ }, [lang])
2625
+
2626
+ const loadMore = useCallback(async () => {
2627
+ const gen = genRef.current
2628
+ if (loadingMore || !results.length || noMore) return
2629
+ // a reload restores the grid, so rebuild the request from what is on screen
2630
+ const base = lastSearch.current || (query.trim()
2631
+ ? { text: query.trim(), query: query.trim(), sources: enabledSources.slice() }
2632
+ : null)
2633
+ if (!base) {
2634
+ setToast(t('resultsEmptyBody'))
2635
+ return
2636
+ }
2637
+ const nextPage = page + 1
2638
+ const requests = pageRequests(base, nextPage)
2639
+ if (!requests.length) { setNoMore(true); return }
2640
+ setLoadingMore(true)
2641
+ try {
2642
+ const settled = (await Promise.all(requests)).filter(Boolean)
2643
+ if (gen !== genRef.current) return
2644
+ const seen = new Set(results.map(frameKey))
2645
+ const fresh = mergeGroups(settled.map((item) => item.frames || []))
2646
+ .filter((frame) => !seen.has(frameKey(frame)))
2647
+ if (!fresh.length) {
2648
+ setNoMore(true)
2649
+ if (!settled.length) setToast(t('resultsEmpty'))
2650
+ } else {
2651
+ setResults(results.concat(fresh))
2652
+ setPage(nextPage)
2653
+ const sources = Array.isArray(base.sources) && base.sources.length ? base.sources : ['framethrower']
2654
+ const sum = settled
2655
+ .filter((item) => item.source === 'framethrower')
2656
+ .reduce((total, item) => total + (Number(item.total) || 0), 0)
2657
+ if (sources.includes('chrome')) setTotal(0)
2658
+ else if (sum > 0) setTotal(sum)
2659
+ }
2660
+ } catch (error) {
2661
+ if (gen !== genRef.current) return
2662
+ setToast(String(error.message || error))
2663
+ } finally {
2664
+ setLoadingMore(false)
2665
+ }
2666
+ }, [loadingMore, noMore, results, page, query, enabledSources, pageRequests, t])
2667
+
2668
+ const more = useCallback(async () => {
2669
+ const gen = genRef.current
2670
+ if (!results.length || status !== 'ok') return
2671
+ const base = lastSearch.current || { query: query.trim(), sources: enabledSources.slice() }
2672
+ const nextPage = page + 1
2673
+ const requests = pageRequests(base, nextPage)
2674
+ if (!requests.length) {
2675
+ setToast(t('noMore'))
2676
+ return
2677
+ }
2678
+ setStatus('loading')
2679
+ try {
2680
+ const settled = (await Promise.all(requests)).filter(Boolean)
2681
+ if (gen !== genRef.current) return
2682
+ const frames = mergeGroups(settled.map((item) => item.frames || []))
2683
+ // 换一批 replaces the current grid instead of stacking another page onto it
2684
+ setResults(frames)
2685
+ setStatus('ok')
2686
+ if (!frames.length) {
2687
+ setPage(0)
2688
+ setToast(t('resultsEmpty'))
2689
+ } else {
2690
+ setPage(nextPage)
2691
+ }
2692
+ } catch (error) {
2693
+ if (gen !== genRef.current) return
2694
+ setStatus('ok')
2695
+ setToast(String(error.message || error))
2696
+ }
2697
+ }, [results, status, query, page, enabledSources, pageRequests, t])
2698
+
2699
+ // "清空" — one silent click clears the visible frames and every cache:
2700
+ // the in-memory grid, the persisted view (so reload stays empty) and the
2701
+ // host-side search / image / interpret caches. No confirm, no toast.
2702
+ const clearAll = useCallback(() => {
2703
+ genRef.current += 1
2704
+ setResults([])
2705
+ setStatus('idle')
2706
+ setHint('')
2707
+ setWarnings([])
2708
+ setUnderstood('')
2709
+ setAiNote(null)
2710
+ setFailure(null)
2711
+ setLastKind('text')
2712
+ setPage(0)
2713
+ setTotal(0)
2714
+ setNoMore(false)
2715
+ setLoadingMore(false)
2716
+ setExpanding(null)
2717
+ setSuggests(null)
2718
+ setZoom(null)
2719
+ setModalFrame(null)
2720
+ lastSearch.current = null
2721
+ lastSearchText.current = ''
2722
+ try { window.localStorage.removeItem(VIEW_KEY) } catch {}
2723
+ // best-effort: the host may not have restarted with the new route yet
2724
+ api('/anistyle/clear-cache', {}).catch(() => {})
2725
+ }, [])
2726
+
2727
+ const similar = useCallback((frame) => {
2728
+ if (!frame || expanding) return
2729
+ const gen = genRef.current
2730
+ // visual similarity is a FrameThrower endpoint; web tiles have no equivalent
2731
+ if (!frame.id || frame.src === 'chrome') { setToast(t('noSimilar')); return }
2732
+ const anchor = frameKey(frame)
2733
+ setExpanding(anchor)
2734
+ api('/anistyle/similar', { source: frame.kind || 'animation', frameId: frame.id, limit: 12 })
2735
+ .then((data) => {
2736
+ if (gen !== genRef.current) return
2737
+ const incoming = data.source === 'framethrower' ? (data.frames || []) : []
2738
+ const have = new Set(results.map(frameKey))
2739
+ have.delete(anchor)
2740
+ const pick = incoming.find((item) => !have.has(frameKey(item)))
2741
+ if (!pick) {
2742
+ setToast(data.hint || t('noSimilar'))
2743
+ return
2744
+ }
2745
+ // swap just this card: the rest of the grid stays exactly as it was
2746
+ setResults((current) => current.map((item) => (frameKey(item) === anchor ? pick : item)))
2747
+ })
2748
+ .catch((error) => {
2749
+ if (gen !== genRef.current) return
2750
+ setToast(String(error.message || error))
2751
+ })
2752
+ .finally(() => setExpanding(null))
2753
+ }, [expanding, results, t])
2754
+
2755
+ const runSuggest = useCallback((anchors) => {
2756
+ const picks = (anchors || []).filter((frame) => frame && frame.id && frame.src !== 'chrome')
2757
+ if (!picks.length) { setToast(t('noSimilar')); return }
2758
+ const gen = genRef.current
2759
+ setSuggestBusy(picks.length === 1 ? frameKey(picks[0]) : '__multi__')
2760
+ setSuggests({ anchors: picks, frames: [], loading: true })
2761
+ // frame ids only make sense inside their own library: a mixed board
2762
+ // asks each library once and interleaves the answers
2763
+ const byKind = new Map()
2764
+ for (const frame of picks) {
2765
+ const kind = ['animation', 'film', 'commercial'].includes(frame.kind) ? frame.kind : 'animation'
2766
+ if (!byKind.has(kind)) byKind.set(kind, [])
2767
+ byKind.get(kind).push(frame.id)
2768
+ }
2769
+ const calls = Array.from(byKind.entries()).map(([kind, ids]) => api('/anistyle/similar', {
2770
+ source: kind,
2771
+ frameIds: ids,
2772
+ limit: 12,
2773
+ }).catch(() => null))
2774
+ Promise.all(calls)
2775
+ .then((answers) => {
2776
+ if (gen !== genRef.current) return
2777
+ const usable = answers.filter((data) => data && data.source === 'framethrower')
2778
+ const incoming = mergeGroups(usable.map((data) => data.frames || []))
2779
+ setSuggests({ anchors: picks, frames: incoming, loading: false })
2780
+ if (!incoming.length) setToast((answers.find(Boolean) && answers.find(Boolean).hint) || t('noSimilar'))
2781
+ })
2782
+ .catch((error) => {
2783
+ if (gen !== genRef.current) return
2784
+ setSuggests(null)
2785
+ setToast(String(error.message || error))
2786
+ })
2787
+ .finally(() => setSuggestBusy(null))
2788
+ }, [t])
2789
+
2790
+ const suggestFrom = useCallback((frame) => runSuggest([frame]), [runSuggest])
2791
+
2792
+ // a few random frames of this board used together as one visual reference
2793
+ const sampleSuggest = useCallback(() => {
2794
+ const pool = (activeBoardData ? activeBoardData.items : []).filter((frame) => frame && frame.id && frame.src !== 'chrome')
2795
+ if (!pool.length) { setToast(t('noSimilar')); return }
2796
+ const rest = pool.slice()
2797
+ const picks = []
2798
+ while (picks.length < Math.min(3, rest.length)) {
2799
+ picks.push(rest.splice(Math.floor(Math.random() * rest.length), 1)[0])
2800
+ }
2801
+ runSuggest(picks)
2802
+ }, [activeBoardData, runSuggest])
2803
+
2804
+ const rememberKeywords = useCallback((next) => {
2805
+ setKwStore(next)
2806
+ try { window.localStorage.setItem(KW_KEY, JSON.stringify(next)) } catch {}
2807
+ }, [])
2808
+
2809
+ const addGroup = useCallback(() => {
2810
+ const id = 'g_' + Date.now().toString(36)
2811
+ rememberKeywords({ ...kwStore, groups: kwStore.groups.concat({ id, name: t('kwAddGroup'), terms: [] }) })
2812
+ return id
2813
+ }, [kwStore, rememberKeywords, t])
2814
+
2815
+ const renameGroup = useCallback((groupId, builtin, next) => {
2816
+ const name = String(next || '').trim()
2817
+ if (!name) return
2818
+ if (builtin) {
2819
+ rememberKeywords({ ...kwStore, labels: { ...kwStore.labels, [groupId]: name } })
2820
+ return
2821
+ }
2822
+ rememberKeywords({
2823
+ ...kwStore,
2824
+ groups: kwStore.groups.map((group) => (group.id === groupId ? { ...group, name } : group)),
2825
+ })
2826
+ }, [kwStore, rememberKeywords])
2827
+
2828
+ const deleteGroup = useCallback((groupId, builtin) => {
2829
+ if (builtin) {
2830
+ rememberKeywords({ ...kwStore, hiddenGroups: Array.from(new Set(kwStore.hiddenGroups.concat(groupId))) })
2831
+ return
2832
+ }
2833
+ rememberKeywords({ ...kwStore, groups: kwStore.groups.filter((group) => group.id !== groupId) })
2834
+ }, [kwStore, rememberKeywords])
2835
+
2836
+ const addTerm = useCallback((groupId, builtin, term) => {
2837
+ const value = String(term || '').trim()
2838
+ if (!value) return
2839
+ if (builtin) {
2840
+ const own = (kwStore.extra && kwStore.extra[groupId]) || []
2841
+ rememberKeywords({
2842
+ ...kwStore,
2843
+ hidden: (kwStore.hidden || []).filter((item) => item !== value),
2844
+ extra: { ...(kwStore.extra || {}), [groupId]: Array.from(new Set(own.concat(value))) },
2845
+ })
2846
+ return
2847
+ }
2848
+ const target = kwStore.groups.find((group) => group.id === groupId)
2849
+ if (!target) return
2850
+ rememberKeywords({
2851
+ ...kwStore,
2852
+ groups: kwStore.groups.map((group) => (group.id === target.id
2853
+ ? { ...group, terms: Array.from(new Set(group.terms.concat(value))) }
2854
+ : group)),
2855
+ })
2856
+ }, [kwStore, rememberKeywords])
2857
+
2858
+ const removeTerm = useCallback((groupId, builtin, term) => {
2859
+ if (builtin) {
2860
+ const own = (kwStore.extra && kwStore.extra[groupId]) || []
2861
+ if (own.includes(term)) {
2862
+ rememberKeywords({
2863
+ ...kwStore,
2864
+ extra: { ...kwStore.extra, [groupId]: own.filter((item) => item !== term) },
2865
+ })
2866
+ return
2867
+ }
2868
+ rememberKeywords({ ...kwStore, hidden: Array.from(new Set(kwStore.hidden.concat(term))) })
2869
+ return
2870
+ }
2871
+ rememberKeywords({
2872
+ ...kwStore,
2873
+ groups: kwStore.groups.map((group) => (group.id === groupId
2874
+ ? { ...group, terms: group.terms.filter((item) => item !== term) }
2875
+ : group)),
2876
+ })
2877
+ }, [kwStore, rememberKeywords])
2878
+
2879
+ const renameTerm = useCallback((groupId, builtin, term, next) => {
2880
+ const value = String(next || '').trim()
2881
+ if (!value || value === term) return
2882
+ if (!builtin) {
2883
+ rememberKeywords({
2884
+ ...kwStore,
2885
+ groups: kwStore.groups.map((group) => (group.id === groupId
2886
+ ? { ...group, terms: group.terms.map((item) => (item === term ? value : item)) }
2887
+ : group)),
2888
+ })
2889
+ return
2890
+ }
2891
+ // built-in term: drop the original, keep the edited wording in the same group
2892
+ removeTerm(groupId, true, term)
2893
+ addTerm(groupId, true, value)
2894
+ }, [kwStore, rememberKeywords, removeTerm, addTerm])
2895
+
2896
+ const restoreKeywords = useCallback(() => {
2897
+ rememberKeywords({ ...kwStore, hidden: [], hiddenGroups: [], labels: {}, extra: {} })
2898
+ }, [kwStore, rememberKeywords])
2899
+
2900
+ const createBoard = useCallback((rawName) => {
2901
+ const name = String(rawName || '').trim() || untitledName(boards, t('untitled'))
2902
+ const board = { id: newId(), name, createdAt: Date.now(), updatedAt: Date.now(), items: [], pinned: false }
2903
+ setBoards((list) => [board, ...list])
2904
+ return board
2905
+ }, [boards, t])
2906
+
2907
+ const togglePin = useCallback((boardId) => {
2908
+ setBoards((list) => list.map((board) => (board.id === boardId ? { ...board, pinned: !board.pinned } : board)))
2909
+ }, [])
2910
+
2911
+ const addPresetBoard = useCallback((preset) => {
2912
+ if (!preset || !preset.name || !Array.isArray(preset.items)) return
2913
+ if (boards.some((board) => board.name === preset.name)) return
2914
+ const board = {
2915
+ ...preset,
2916
+ id: newId(),
2917
+ pinned: false,
2918
+ createdAt: Date.now(),
2919
+ updatedAt: Date.now(),
2920
+ }
2921
+ setBoards((list) => [board, ...list])
2922
+ setToast(t('added') + ' 《' + preset.name + '》')
2923
+ }, [boards, t])
2924
+
2925
+ const savePresets = useCallback(() => {
2926
+ if (savingPreset) return
2927
+ setSavingPreset(true)
2928
+ api('/anistyle/presets', { boards })
2929
+ .then((data) => {
2930
+ const count = data?.saved ?? boards.length
2931
+ if (data?.pushed) setToast(t('presetPushed'))
2932
+ else if (data?.pushError) setToast(t('presetPushFailed') + ' · ' + data.pushError)
2933
+ else setToast(t('presetSaved') + ' ' + count)
2934
+ })
2935
+ .catch((error) => setToast(String(error.message || error)))
2936
+ .finally(() => setSavingPreset(false))
2937
+ }, [boards, savingPreset, t])
2938
+
2939
+ const addToBoard = useCallback((boardId, frame, keepOpen) => {
2940
+ if (!frame) { setModalFrame(null); return }
2941
+ const target = boards.find((board) => board.id === boardId)
2942
+ const key = frameKey(frame)
2943
+ const duplicate = Boolean(target && target.items.some((item) => frameKey(item) === key))
2944
+ if (!duplicate) {
2945
+ setBoards((list) => list.map((board) => board.id === boardId
2946
+ ? { ...board, updatedAt: Date.now(), items: [frame, ...board.items] }
2947
+ : board))
2948
+ }
2949
+ setToast(duplicate ? t('duplicate') : t('added') + ' 《' + (target ? target.name : '') + '》')
2950
+ if (!keepOpen) setModalFrame(null)
2951
+ }, [boards, t])
2952
+
2953
+ const createBoardWithFrame = useCallback((rawName, frame) => {
2954
+ const board = createBoard(rawName)
2955
+ if (frame && frameKey(frame)) {
2956
+ setBoards((list) => list.map((entry) => entry.id === board.id
2957
+ ? { ...entry, updatedAt: Date.now(), items: [frame, ...entry.items] }
2958
+ : entry))
2959
+ }
2960
+ setToast(t('added') + ' 《' + board.name + '》')
2961
+ return board
2962
+ }, [createBoard, t])
2963
+
2964
+ const removeFromBoard = useCallback((boardId, frame) => {
2965
+ setBoards((list) => list.map((board) => board.id === boardId
2966
+ ? { ...board, updatedAt: Date.now(), items: board.items.filter((item) => frameKey(item) !== frameKey(frame)) }
2967
+ : board))
2968
+ }, [])
2969
+
2970
+ const deleteBoard = useCallback((boardId) => {
2971
+ if (typeof window !== 'undefined' && !window.confirm(t('confirmDelete'))) return
2972
+ setBoards((list) => list.filter((board) => board.id !== boardId))
2973
+ if (activeBoard === boardId) setActiveBoard(null)
2974
+ }, [activeBoard, t])
2975
+
2976
+ const exportMarkdown = useCallback((board) => {
2977
+ const palette = boardPalette(board.items)
2978
+ const lines = ['# ' + board.name, '', '_' + board.items.length + ' frames_', '']
2979
+ if (palette.length) lines.push('Palette: ' + palette.join(' '), '')
2980
+ board.items.forEach((frame, index) => {
2981
+ lines.push((index + 1) + '. **' + (stripGlyphs(frame.title) || 'frame') + '**' + (frame.year ? ' (' + frame.year + ')' : ''))
2982
+ if (stripGlyphs(frame.caption)) lines.push(' ' + stripGlyphs(frame.caption))
2983
+ if (frame.tags && frame.tags.length) lines.push(' ' + frame.tags.join(' · '))
2984
+ if (frame.palette && frame.palette.length) lines.push(' ' + frame.palette.join(' '))
2985
+ if (frame.link) lines.push(' [' + (frame.src === 'chrome' ? 'web' : (frame.kind || 'FrameThrower')) + '](' + frame.link + ')')
2986
+ lines.push(' ![frame](' + (frame.original || frame.image || frame.thumb) + ')')
2987
+ lines.push('')
2988
+ })
2989
+ download((board.name || 'moodboard') + '.md', lines.join('\n'), 'text/markdown;charset=utf-8')
2990
+ }, [])
2991
+
2992
+ const zoomInto = useCallback((frames, boardId) => (frame) => {
2993
+ const index = frames.findIndex((item) => frameKey(item) === frameKey(frame))
2994
+ setZoom({ frames, index: index < 0 ? 0 : index, boardId: boardId || null })
2995
+ }, [])
2996
+
2997
+ const keywordGroups = buildKeywordGroups(vocabulary, kwStore)
2998
+ const canRestoreKeywords = Boolean(
2999
+ (kwStore.hidden && kwStore.hidden.length)
3000
+ || (kwStore.hiddenGroups && kwStore.hiddenGroups.length)
3001
+ || Object.keys(kwStore.labels || {}).length
3002
+ || Object.keys(kwStore.extra || {}).length,
3003
+ )
3004
+
3005
+ const hasToken = Boolean(readToken())
3006
+
3007
+ return h('div', { className: 'ase-root', ref: scroller },
3008
+ h('div', { className: 'ase-wrap' },
3009
+ h('header', { className: 'ase-topbar' },
3010
+ h('nav', { className: 'ase-tabs' },
3011
+ h('button', { type: 'button', className: 'ase-tab' + (tab === 'discover' ? ' ase-tab--on' : ''), onClick: () => setTab('discover') }, t('discover')),
3012
+ h('button', {
3013
+ type: 'button',
3014
+ className: 'ase-tab' + (tab === 'boards' ? ' ase-tab--on' : ''),
3015
+ onClick: () => { setTab('boards'); setActiveBoard(null) },
3016
+ }, t('boards'))),
3017
+ h('button', {
3018
+ type: 'button',
3019
+ className: 'ase-iconbtn ase-tip',
3020
+ 'data-tip': t('settings'),
3021
+ 'aria-label': t('settings'),
3022
+ onClick: () => {
3023
+ setDrafts(Object.fromEntries(SOURCES.map((source) => [source.id, readKey(source.key)])))
3024
+ setSettingsOpen(true)
3025
+ },
3026
+ }, h(GearIcon, { size: 17 }))),
3027
+ tab === 'discover'
3028
+ ? h(Discover, {
3029
+ state: {
3030
+ query, setQuery, results, status, hint, warnings,
3031
+ submit, more, clearAll, similar,
3032
+ canMore: status === 'ok' && results.length > 0 && lastKind === 'text',
3033
+ openBoardModal: (frame) => setModalFrame(frame || {}),
3034
+ openZoom: zoomInto(results),
3035
+ busyId: expanding,
3036
+ attachment,
3037
+ onAttachment: (image) => setAttachment(image),
3038
+ onClearAttachment: () => setAttachment(null),
3039
+ onImageError: () => setToast(t('imageFailed')),
3040
+ onToast: (message) => setToast(message),
3041
+ understood, aiOn, aiNote, failure,
3042
+ aiCount,
3043
+ onAiCount: (next) => {
3044
+ if (next !== 1 && next !== 2 && next !== 3) return
3045
+ setAiCount(next)
3046
+ try { window.localStorage.setItem(AI_COUNT_KEY, String(next)) } catch {}
3047
+ },
3048
+ onToggleAi: (next) => {
3049
+ setAiOn(next)
3050
+ try { window.localStorage.setItem(AI_KEY, next ? '1' : '0') } catch {}
3051
+ },
3052
+ onAiRetry: () => runSearch({ text: query, forceAi: true }),
3053
+ loadMore,
3054
+ loadingMore,
3055
+ canLoadMore: status === 'ok' && results.length > 0 && lastKind === 'text' && !noMore
3056
+ && (!total || results.length < total),
3057
+ noMore,
3058
+ canLoose: Boolean(lastSearch.current && lastSearch.current.pages
3059
+ && lastSearch.current.pages.framethrower
3060
+ && (lastSearch.current.pages.framethrower.title
3061
+ || lastSearch.current.pages.framethrower.genre
3062
+ || lastSearch.current.pages.framethrower.style)),
3063
+ onLooseRetry: () => runSearch({ text: query, loose: true }),
3064
+ enabledSources, onToggleSource: toggleSource,
3065
+ hasToken, openToken: () => {
3066
+ setDrafts(Object.fromEntries(SOURCES.map((source) => [source.id, readKey(source.key)])))
3067
+ setSettingsOpen(true)
3068
+ },
3069
+ keywordGroups, addGroup, renameGroup, deleteGroup, addTerm, removeTerm, renameTerm,
3070
+ restoreKeywords, canRestore: canRestoreKeywords,
3071
+ },
3072
+ })
3073
+ : h('div', { className: 'ase-scroll ase-boards' },
3074
+ activeBoardData
3075
+ ? h(BoardView, {
3076
+ board: activeBoardData,
3077
+ onBack: () => { setSuggests(null); setActiveBoard(null) },
3078
+ onRename: (name) => setBoards((list) => list.map((board) => board.id === activeBoardData.id
3079
+ ? { ...board, name, updatedAt: Date.now() }
3080
+ : board)),
3081
+ onRemoveFrame: (frame) => removeFromBoard(activeBoardData.id, frame),
3082
+ onExport: () => exportMarkdown(activeBoardData),
3083
+ onDiscover: () => setTab('discover'),
3084
+ onZoomItem: zoomInto(activeBoardData.items, activeBoardData.id),
3085
+ onSimilar: suggestFrom,
3086
+ onSample: sampleSuggest,
3087
+ suggestBusy,
3088
+ suggests,
3089
+ onCloseSuggest: () => setSuggests(null),
3090
+ onZoomSuggest: (frame) => {
3091
+ if (suggests) zoomInto(suggests.frames, activeBoardData.id)(frame)
3092
+ },
3093
+ onAddSuggest: (frame) => addToBoard(activeBoardData.id, frame),
3094
+ })
3095
+ : h(BoardsHome, {
3096
+ boards: sortedBoards,
3097
+ onCreate: (name) => setActiveBoard(createBoard(name).id),
3098
+ onPin: togglePin,
3099
+ presets,
3100
+ onOpenPresets: () => setCommunityOpen(true),
3101
+ onSavePreset: canPushPresets ? savePresets : null,
3102
+ savingPreset,
3103
+ onOpen: (id) => setActiveBoard(id),
3104
+ onDelete: deleteBoard,
3105
+ })),
3106
+ ),
3107
+ modalFrame
3108
+ ? h(BoardModal, {
3109
+ frame: modalFrame,
3110
+ boards,
3111
+ onClose: () => setModalFrame(null),
3112
+ onAdd: (boardId) => addToBoard(boardId, modalFrame, true),
3113
+ onCreate: (name) => createBoardWithFrame(name, modalFrame),
3114
+ })
3115
+ : null,
3116
+ zoom
3117
+ ? h(Lightbox, {
3118
+ frames: zoom.frames,
3119
+ index: zoom.index,
3120
+ onClose: () => setZoom(null),
3121
+ onToast: (message) => setToast(message),
3122
+ addLabel: zoom.boardId ? t('addToBoard') : null,
3123
+ boards: zoom.boardId ? null : sortedBoards,
3124
+ onPickBoard: zoom.boardId ? null : (boardId, frame) => addToBoard(boardId, frame, true),
3125
+ onCreateBoard: zoom.boardId ? null : createBoardWithFrame,
3126
+ isAdded: (frame) => {
3127
+ if (!zoom.boardId) return false
3128
+ const board = boards.find((item) => item.id === zoom.boardId)
3129
+ return Boolean(board && board.items.some((item) => frameKey(item) === frameKey(frame)))
3130
+ },
3131
+ onAdd: zoom.boardId ? (frame) => addToBoard(zoom.boardId, frame) : null,
3132
+ onIndex: (index) => setZoom((current) => {
3133
+ if (!current) return current
3134
+ const next = Math.max(0, Math.min(current.frames.length - 1, index))
3135
+ return next === current.index ? current : { ...current, index: next }
3136
+ }),
3137
+ })
3138
+ : null,
3139
+ settingsOpen
3140
+ ? h(Settings, {
3141
+ drafts,
3142
+ configured: Object.fromEntries(SOURCES.map((source) => [source.id, Boolean(source.key && readKey(source.key))])),
3143
+ enabled: enabledSources,
3144
+ onToggle: toggleSource,
3145
+ onToggleGroup: toggleSourceGroup,
3146
+ onDraft: (id, value) => setDrafts((current) => ({ ...current, [id]: value })),
3147
+ onSave: (source) => {
3148
+ writeKey(source.key, String(drafts[source.id] || '').trim())
3149
+ setSettingsOpen(false)
3150
+ setVocabVersion((value) => value + 1)
3151
+ setToast(t('save') + ' ✓')
3152
+ },
3153
+ onClear: (source) => {
3154
+ writeKey(source.key, '')
3155
+ setDrafts((current) => ({ ...current, [source.id]: '' }))
3156
+ setVocabulary([])
3157
+ },
3158
+ onClose: () => setSettingsOpen(false),
3159
+ lang,
3160
+ onChooseLang,
3161
+ version,
3162
+ })
3163
+ : null,
3164
+ communityOpen
3165
+ ? h(CommunityBoards, {
3166
+ presets,
3167
+ existingNames: new Set(boards.map((board) => board.name)),
3168
+ onAdd: addPresetBoard,
3169
+ onClose: () => setCommunityOpen(false),
3170
+ })
3171
+ : null,
3172
+ toast ? h('div', { className: 'ase-toast' }, toast) : null)
3173
+ }
3174
+
3175
+ // ------------------------------------------------------------------ locale
3176
+
3177
+ function failureMessage(failure, T) {
3178
+ if (!failure) return T('resultsEmptyBody')
3179
+ const status = failure.status
3180
+ if (failure.source === 'unauthorized') return T('failAuth')
3181
+ if (status === 402) return T('failCredits')
3182
+ if (status === 429) return T('failTooMany')
3183
+ if (failure.source === 'unreachable') return T('failNetwork')
3184
+ if (failure.source === 'unsupported') return failure.hint || T('resultsEmptyBody')
3185
+ if (status === 400) return T('failRejected') + (failure.hint ? ' · ' + failure.hint : '')
3186
+ if (status) return T('failStatus') + ' ' + status + (failure.hint ? ' · ' + failure.hint : '')
3187
+ return failure.hint || T('resultsEmptyBody')
3188
+ }
3189
+
3190
+ function sourceFailureLine(outcome, T) {
3191
+ if (!outcome) return ''
3192
+ if (outcome.needsAi) return frameSourceLabel(outcome.id, T) + ' · ' + T('aiNeeded')
3193
+ const failure = outcome.failure || {}
3194
+ if (outcome.id === 'chrome') {
3195
+ if (failure.source === 'unreachable') return T('srcWeb') + ' · ' + T('failWebNetwork')
3196
+ if (failure.source === 'rate-limited' || failure.status === 429) return T('srcWeb') + ' · ' + T('failTooMany')
3197
+ if (failure.status) return T('srcWeb') + ' · ' + T('failStatus') + ' ' + failure.status
3198
+ return T('srcWeb') + ' · ' + (failure.hint || T('failWebEmpty'))
3199
+ }
3200
+ return frameSourceLabel(outcome.id, T) + ' · ' + failureMessage(failure, T)
3201
+ }
3202
+
3203
+ function webWarnings(items, responses, T) {
3204
+ const warnings = []
3205
+ let empty = 0
3206
+ for (const item of items) {
3207
+ if (!Array.isArray(item.frames) || !item.frames.length) empty += 1
3208
+ for (const warning of item.warnings || []) {
3209
+ if (warning === 'web-empty') continue
3210
+ if (!warnings.includes(warning)) warnings.push(warning)
3211
+ }
3212
+ }
3213
+ if (items.length && empty === items.length) warnings.push(T('failWebEmpty'))
3214
+ else if (empty > 0 || responses.some((item) => !item || item.source !== 'chrome')) {
3215
+ if (!warnings.includes(T('failWebPartial'))) warnings.push(T('failWebPartial'))
3216
+ }
3217
+ return warnings
3218
+ }
3219
+
3220
+ function aiNoteMessage(reason, T, detail) {
3221
+ const key = {
3222
+ 'no-llm': 'aiNoLlm',
3223
+ 'no-model': 'aiNoModel',
3224
+ unparsed: 'aiUnparsed',
3225
+ timeout: 'aiTimeout',
3226
+ 'call-failed': 'aiCallFailed',
3227
+ unreachable: 'aiUnreachable',
3228
+ }[reason]
3229
+ const base = key ? T(key) : T('aiCallFailed')
3230
+ const extra = detail ? '(' + String(detail).slice(0, 120) + ')' : ''
3231
+ return T('aiOff') + ':' + base + extra + ' · ' + T('aiFellBack')
3232
+ }
3233
+
3234
+ function imageAiFailureMessage(reason, T, detail) {
3235
+ const key = {
3236
+ 'no-llm': 'aiNoLlm',
3237
+ 'no-model': 'aiNoModel',
3238
+ 'no-vision': 'imageNoVision',
3239
+ 'no-attachments': 'imageNoAttachments',
3240
+ 'bad-image': 'imageBad',
3241
+ 'image-rejected': 'imageRejected',
3242
+ unparsed: 'imageUnparsed',
3243
+ timeout: 'imageTimeout',
3244
+ 'call-failed': 'aiCallFailed',
3245
+ unreachable: 'aiUnreachable',
3246
+ }[reason]
3247
+ const base = key ? T(key) : T('aiCallFailed')
3248
+ const extra = detail ? '(' + String(detail).slice(0, 120) + ')' : ''
3249
+ return base + extra
3250
+ }
3251
+
3252
+ function createTranslator(ctx, lang) {
3253
+ let translate = null
3254
+ try { translate = ctx.locale.bind(NS) } catch { translate = null }
3255
+ const dict = DICT[lang] || null
3256
+ return (key) => {
3257
+ if (dict && dict[key] !== undefined) return dict[key]
3258
+ if (!translate) return DICT.zh[key] || key
3259
+ try {
3260
+ const value = translate(key)
3261
+ return typeof value === 'string' && value && value !== key ? value : ((dict || DICT.zh)[key] || key)
3262
+ } catch {
3263
+ return (dict || DICT.zh)[key] || key
3264
+ }
3265
+ }
3266
+ }
3267
+
3268
+ function Content(props) {
3269
+ const ctx = props.__ctx
3270
+ const [version, setVersion] = useState(0)
3271
+ useEffect(() => ctx.locale.subscribe(() => setVersion((value) => value + 1)), [ctx])
3272
+ const lang = useMemo(() => {
3273
+ try {
3274
+ const active = String(ctx.locale.getLocale().active || '')
3275
+ return /^en/i.test(active) ? 'en' : 'zh'
3276
+ } catch { return 'zh' }
3277
+ }, [ctx, version])
3278
+ const t = useMemo(() => createTranslator(ctx, lang), [ctx, version, lang])
3279
+ const chooseLang = useCallback((next) => {
3280
+ if (next !== 'zh' && next !== 'en') return
3281
+ try {
3282
+ const snapshot = ctx.locale.getLocale()
3283
+ const definitions = Array.isArray(snapshot && snapshot.locales) ? snapshot.locales : []
3284
+ const exact = definitions.find((definition) => definition.id === next)
3285
+ const prefixed = definitions.find((definition) => String(definition.id).toLowerCase().indexOf(next + '-') === 0)
3286
+ ctx.locale.setLocale(exact ? exact.id : (prefixed ? prefixed.id : next))
3287
+ } catch {}
3288
+ }, [ctx])
3289
+ return h(I18n.Provider, { value: t }, h(Page, { translator: t, lang, onChooseLang: chooseLang }))
3290
+ }
3291
+
3292
+ function NavIcon({ size, active, label }) {
3293
+ const edge = size || 18
3294
+ return h('svg', {
3295
+ width: edge, height: edge, viewBox: '0 0 24 24', fill: 'none',
3296
+ 'aria-hidden': true, style: { display: 'block' },
3297
+ },
3298
+ h('rect', { x: 2.2, y: 4.2, width: 19.6, height: 14, rx: 3, stroke: 'currentColor', strokeWidth: active ? 1.9 : 1.6, opacity: active ? 1 : 0.85 }),
3299
+ h('path', { d: 'M7.6 4.4v13.6M16.4 4.4v13.6', stroke: 'currentColor', strokeWidth: 1.1, opacity: 0.4 }),
3300
+ label ? h('title', null, label) : null,
3301
+ h('path', { d: 'M10.4 9.1l4.4 2.4-4.4 2.4z', fill: 'currentColor', opacity: active ? 1 : 0.85 }))
3302
+ }
3303
+
3304
+ // --------------------------------------------------------------------- css
3305
+
3306
+ const CSS = `
3307
+ .ase-root{height:100%;min-height:0;display:flex;flex-direction:column;overflow:hidden;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary)}
3308
+ .ase-wrap{flex:1 1 auto;display:flex;flex-direction:column;min-height:0;width:100%;max-width:1240px;margin:0 auto;padding:24px 90px 0;box-sizing:border-box}
3309
+ .ase-discover{flex:1 1 auto;display:flex;flex-direction:column;min-height:0}
3310
+ .ase-scroll{flex:1 1 auto;min-height:0;overflow-y:auto;overflow-x:hidden;padding-bottom:12px}
3311
+ .ase-dock{position:relative;z-index:6;flex:0 0 auto;padding:0 0 18px;background:var(--dsw-alias-bg-base)}
3312
+ .ase-discover > .ase-scroll{padding:0 16px 24px}
3313
+ .ase-boards{padding-top:28px}
3314
+ .ase-topbar{position:relative;flex:0 0 auto;display:flex;align-items:center;justify-content:flex-end;gap:20px;margin-bottom:16px}
3315
+ .ase-tabs{display:flex;gap:2px;padding:3px;border-radius:9px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1)}
3316
+ .ase-tab{border:0;background:none;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12.5px;padding:6px 14px;border-radius:7px;cursor:pointer}
3317
+ .ase-tab:hover{color:var(--dsw-alias-label-primary)}
3318
+ .ase-tab--on{background:var(--dsw-alias-bg-overlay);color:var(--dsw-alias-label-primary);box-shadow:0 1px 2px rgba(0,0,0,.18)}
3319
+ .ase-ghost-link{background:none;border:0;padding:4px 2px;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12.5px;cursor:pointer;border-bottom:1px solid transparent}
3320
+ .ase-ghost-link:hover{color:var(--dsw-alias-label-primary);border-bottom-color:var(--dsw-alias-border-l2)}
3321
+ .ase-h1{font-size:32px;line-height:1.2;font-weight:650;letter-spacing:-.022em;margin:10px 0 8px}
3322
+ .ase-sub{color:var(--dsw-alias-label-secondary);font-size:13.5px;line-height:1.7;max-width:680px;margin:0}
3323
+ .ase-ask{position:relative;margin:0;border:1px solid var(--dsw-alias-border-l2);border-radius:15px;background:var(--dsw-alias-bg-layer-1);padding:14px 14px 10px;transition:border-color .16s,box-shadow .16s}
3324
+ .ase-ask:focus-within{border-color:var(--dsw-alias-brand-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-brand-primary) 14%,transparent)}
3325
+ .ase-textarea{width:100%;border:0;outline:0;background:transparent;color:inherit;font:inherit;font-size:15px;line-height:1.65;resize:none}
3326
+ .ase-textarea::placeholder,.ase-input::placeholder{color:var(--dsw-alias-label-secondary);opacity:.7}
3327
+ .ase-ask--open{box-shadow:0 20px 44px -30px rgba(0,0,0,.6)}
3328
+ .ase-ask-tools{position:relative;display:flex;align-items:center;gap:10px;margin-top:4px}
3329
+ .ase-ask-tools--no-ai .ase-send{margin-left:auto}
3330
+ .ase-ai-toggle{margin-left:auto;display:flex;align-items:center;gap:6px}
3331
+ .ase-ai-label{font-size:10.5px;letter-spacing:.04em;color:var(--dsw-alias-label-secondary)}
3332
+ .ase-ai-count{position:relative;display:inline-flex;align-items:center}
3333
+ .ase-ai-count-btn{display:inline-flex;align-items:center;gap:3px;height:22px;padding:0 6px;border:1px solid var(--dsw-alias-border-l1);border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:11px;line-height:1;cursor:pointer;transition:border-color .14s,color .14s,background .14s}
3334
+ .ase-ai-count-btn:hover{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1)}
3335
+ .ase-ai-count-chevron{opacity:.65;transition:transform .16s ease}
3336
+ .ase-ai-count-btn[aria-expanded="true"] .ase-ai-count-chevron{transform:rotate(180deg)}
3337
+ .ase-ai-count-menu{position:absolute;right:0;bottom:calc(100% + 6px);z-index:25;min-width:64px;padding:4px;border:1px solid var(--dsw-alias-border-l2);border-radius:9px;background:var(--dsw-alias-bg-overlay);box-shadow:0 16px 36px -20px rgba(0,0,0,.6);display:flex;flex-direction:column;gap:1px}
3338
+ .ase-ai-count-option{border:0;background:none;text-align:left;padding:5px 8px;border-radius:6px;font:inherit;font-size:11px;color:var(--dsw-alias-label-secondary);cursor:pointer}
3339
+ .ase-ai-count-option:hover{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
3340
+ .ase-ai-count-option.is-on{color:var(--dsw-alias-brand-primary)}
3341
+ .ase-modal-x{position:absolute;top:14px;right:14px;width:26px;height:26px;border-radius:50%;border:0;background:transparent;color:var(--dsw-alias-label-secondary);font-size:16px;line-height:1;cursor:pointer}
3342
+ .ase-modal-x:hover{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
3343
+ .ase-send{margin-left:0;display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;padding:0;border-radius:50%;border:0;background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-bg-base);cursor:pointer;transition:opacity .14s,transform .14s}
3344
+ .ase-send:hover:not(:disabled){transform:translateY(-1px)}
3345
+ .ase-send:disabled{opacity:.35;cursor:default}
3346
+ .ase-kw-trigger{display:inline-flex;align-items:center;gap:6px;font:inherit;font-size:12.5px;line-height:1;color:var(--dsw-alias-label-secondary);border:0;background:none;padding:6px 7px;border-radius:7px;cursor:pointer;transition:color .14s,background .14s}
3347
+ .ase-kw-trigger:hover,.ase-kw-trigger.is-open{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}
3348
+ .ase-kw-trigger svg{display:block;flex:0 0 auto}
3349
+ .ase-kw-trigger-label{white-space:nowrap}
3350
+ .ase-kw-chevron{opacity:.7;transition:transform .16s ease}
3351
+ .ase-kw-trigger.is-open .ase-kw-chevron{transform:rotate(180deg)}
3352
+ .ase-src-wrap{position:relative;display:flex;align-items:center}
3353
+ .ase-src-icon{display:block;flex:0 0 auto;opacity:.8}
3354
+ .ase-src-panel{position:absolute;left:0;bottom:calc(100% + 8px);z-index:22;width:min(300px,calc(100vw - 60px));padding:10px 11px 8px;border-radius:12px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-overlay);box-shadow:0 24px 60px -28px rgba(0,0,0,.65);box-sizing:border-box}
3355
+ .ase-src-head{display:flex;align-items:baseline;gap:8px;margin-bottom:6px}
3356
+ .ase-src-title{font-size:11.5px;font-weight:600}
3357
+ .ase-src-hint{font-size:10.5px;color:var(--dsw-alias-label-secondary)}
3358
+ .ase-src-row{display:flex;align-items:center;gap:8px;width:100%;padding:7px 6px;border:0;border-radius:8px;background:none;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;text-align:left;cursor:pointer;transition:background .14s}
3359
+ .ase-src-row:hover{background:var(--dsw-alias-bg-layer-1)}
3360
+ .ase-src-check{display:inline-flex;align-items:center;justify-content:center;width:15px;height:15px;flex:0 0 auto;border:1px solid var(--dsw-alias-border-l2);border-radius:4px;font-size:10px;line-height:1;color:var(--dsw-alias-bg-base)}
3361
+ .ase-src-row.is-on .ase-src-check{background:var(--dsw-alias-brand-primary);border-color:transparent}
3362
+ .ase-src-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
3363
+ .ase-src-state{flex:0 0 auto;font-size:10px;color:var(--dsw-alias-label-secondary)}
3364
+ .ase-src-group{display:flex;align-items:baseline;gap:6px;padding:7px 6px 2px;font-size:10px;font-weight:600;letter-spacing:.03em;text-transform:uppercase;color:var(--dsw-alias-label-secondary)}
3365
+ .ase-kw-panel{position:absolute;left:44px;bottom:calc(100% + 8px);z-index:20;width:min(620px,calc(100% - 44px));max-height:min(52vh,440px);overflow-y:auto;overscroll-behavior:contain;margin:0;padding:12px 13px;border-radius:12px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-overlay);box-shadow:0 24px 60px -28px rgba(0,0,0,.65);box-sizing:border-box}
3366
+ .ase-kw-head{display:flex;align-items:center;gap:12px;margin-bottom:8px}
3367
+ .ase-kw-head .ase-ghost-link{margin-left:auto}
3368
+ .ase-kw-hint{font-size:11px;color:var(--dsw-alias-label-secondary)}
3369
+ .ase-kw-group{display:flex;align-items:flex-start;gap:8px;margin:5px 0}
3370
+ .ase-kw-labelcell{flex:0 0 auto;display:flex;align-items:center;gap:2px;width:72px;padding-top:3px}
3371
+ .ase-kw-label{border:0;background:none;padding:0;font:inherit;font-size:10.5px;line-height:1.5;color:var(--dsw-alias-label-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:left;max-width:56px;cursor:default}
3372
+ .ase-kw-label--edit{cursor:pointer;text-decoration:underline;text-underline-offset:2px}
3373
+ .ase-kw-label-input{width:56px;font:inherit;font-size:10.5px;border:1px solid var(--dsw-alias-border-l2);border-radius:5px;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);padding:1px 3px;outline:none}
3374
+ .ase-kw-groupdel{border:0;background:none;color:var(--dsw-alias-label-secondary);font-size:11px;line-height:1;padding:0 2px;cursor:pointer}
3375
+ .ase-kw-groupdel:hover{color:var(--dsw-alias-brand-primary)}
3376
+ .ase-kw-newgroup{margin-top:8px;font-size:11px}
3377
+ .ase-kw-restore{margin-top:2px;font-size:11px;color:var(--dsw-alias-label-secondary)}
3378
+ .ase-kw-restore:hover{color:var(--dsw-alias-brand-primary)}
3379
+ .ase-kw-chips{display:flex;flex-wrap:wrap;gap:6px;flex:1;min-width:0}
3380
+ .ase-kw{display:inline-flex;align-items:center;border:1px solid var(--dsw-alias-border-l1);border-radius:999px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}
3381
+ .ase-kw--on{border-color:var(--dsw-alias-brand-primary);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 12%,transparent)}
3382
+ .ase-kw-main{border:0;background:none;color:var(--dsw-alias-label-secondary);font:inherit;font-size:11.5px;line-height:1;padding:5px 9px;cursor:pointer;white-space:nowrap}
3383
+ .ase-kw--on .ase-kw-main{color:var(--dsw-alias-label-primary)}
3384
+ .ase-kw-main:hover{color:var(--dsw-alias-label-primary)}
3385
+ .ase-kw-x{border:0;border-left:1px solid var(--dsw-alias-border-l1);background:none;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:1;padding:4px 7px;cursor:pointer}
3386
+ .ase-kw-x:hover{color:var(--dsw-alias-brand-primary)}
3387
+ .ase-kw-input{border:1px solid var(--dsw-alias-border-l2);border-radius:999px;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font:inherit;font-size:11.5px;padding:4px 9px;width:96px;outline:none}
3388
+ .ase-kw-input:focus{border-color:var(--dsw-alias-brand-primary)}
3389
+ .ase-kw-input::placeholder{color:var(--dsw-alias-label-secondary);opacity:.8}
3390
+ .ase-kw-add{flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;padding:0;border:1px dashed var(--dsw-alias-border-l2);border-radius:50%;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;transition:border-color .14s,color .14s,background .14s}
3391
+ .ase-kw-add:hover{border-color:var(--dsw-alias-label-primary);color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1)}
3392
+ .ase-kw-add svg{display:block}
3393
+ .ase-kw-empty{margin:4px 0 0;font-size:11.5px;color:var(--dsw-alias-label-secondary)}
3394
+ .ase-lens-label{flex:0 0 auto;font-size:10.5px;line-height:22px;color:var(--dsw-alias-label-secondary)}
3395
+ .ase-ask--drop{border-color:var(--dsw-alias-brand-primary)}
3396
+ .ase-attach{position:relative;flex:0 0 auto;width:64px;height:64px;margin-bottom:10px}
3397
+ .ase-attach-open{display:block;width:100%;height:100%;padding:0;border:0;border-radius:14px;background:transparent;line-height:0;cursor:zoom-in}
3398
+ .ase-attach-open:focus{outline:none}
3399
+ .ase-attach-open:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
3400
+ .ase-attach-open img{width:100%;height:100%;object-fit:cover;border:1px solid var(--dsw-alias-border-l1);border-radius:14px;box-sizing:border-box;display:block;transition:border-color .14s}
3401
+ .ase-attach:hover .ase-attach-open img{border-color:var(--dsw-alias-border-l2)}
3402
+ .ase-attach-x{position:absolute;top:4px;right:4px;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border:0;border-radius:50%;background:#fff;color:#1c1c1e;cursor:pointer;box-shadow:0 1px 3px rgba(0,0,0,.35);opacity:0;pointer-events:none;transform:scale(.9);transition:opacity .14s,transform .14s,background .14s}
3403
+ .ase-attach:hover .ase-attach-x,.ase-attach:focus-within .ase-attach-x{opacity:1;pointer-events:auto;transform:none}
3404
+ .ase-attach-x:hover{background:#ececee;transform:scale(1.06)}
3405
+ .ase-attach-x:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
3406
+ .ase-attach-x svg{display:block}
3407
+ .ase-results-title{display:flex;align-items:baseline}
3408
+ .ase-h2{font-size:13px;font-weight:600;margin:0}
3409
+ .ase-setting-row{display:flex;align-items:center;justify-content:space-between;gap:16px;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;padding:12px 13px;margin-bottom:10px;background:var(--dsw-alias-bg-layer-1)}
3410
+ .ase-setting-copy strong{font-size:12.5px;display:block;margin-bottom:3px}
3411
+ .ase-switch{width:44px;height:26px;flex-shrink:0;border-radius:999px;border:0;background:rgba(120,120,128,.32);position:relative;cursor:pointer;padding:0;transition:background .2s ease}
3412
+ .ase-switch--on{background:#0a84ff}
3413
+ .ase-switch-knob{position:absolute;top:2px;left:2px;width:22px;height:22px;border-radius:50%;background:#fff;box-shadow:0 2px 4px rgba(0,0,0,.22);transition:transform .2s cubic-bezier(.25,.1,.25,1)}
3414
+ .ase-switch--on .ase-switch-knob{transform:translateX(18px)}
3415
+ .ase-file{display:none}
3416
+ .ase-addimg{width:32px;height:32px;border:1px solid var(--dsw-alias-border-l1)}
3417
+
3418
+ .ase-input{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:9px;padding:7px 10px;color:inherit;font:inherit;font-size:12.5px;outline:none}
3419
+ .ase-input:focus{border-color:var(--dsw-alias-brand-primary)}
3420
+ .ase-input--hero{width:100%;border:0;background:transparent;font-size:15px;padding:6px 2px}
3421
+ .ase-input--wide{flex:1;min-width:200px;font-size:13px}
3422
+ .ase-btn{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:9px;padding:7px 14px;font:inherit;font-size:12.5px;cursor:pointer;transition:background .14s,opacity .14s}
3423
+ .ase-btn:hover{background:var(--dsw-alias-bg-overlay)}
3424
+ .ase-btn--primary{background:var(--dsw-alias-brand-primary);border-color:transparent;color:var(--dsw-alias-bg-base)}
3425
+ .ase-btn--primary:disabled{opacity:.45;cursor:default}
3426
+ .ase-hint{margin:10px 2px 0;font-size:11.5px;line-height:1.6;color:var(--dsw-alias-label-secondary)}
3427
+ .ase-section-head{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:8px 0 18px}
3428
+ .ase-section-actions{display:flex;align-items:center;gap:8px}
3429
+ .ase-section-head .ase-h1{margin:0}
3430
+ .ase-dots{display:inline-flex;border-radius:3px;overflow:hidden}
3431
+ .ase-dots i{display:block;width:6px;height:11px}
3432
+ .ase-dots--bar{display:flex;width:100%;height:5px;border-radius:0;margin-top:8px}
3433
+ .ase-dots--bar i{flex:1;height:100%;width:auto}
3434
+ .ase-tags{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px}
3435
+ .ase-tags span{font-size:9.5px;letter-spacing:.02em;color:var(--dsw-alias-label-secondary);border:1px solid var(--dsw-alias-border-l1);border-radius:4px;padding:1px 5px;white-space:nowrap}
3436
+ .ase-results{margin:2px 0 0}
3437
+ .ase-results-head{display:flex;align-items:flex-end;justify-content:space-between;gap:14px;margin-bottom:12px;padding-bottom:10px;border-bottom:1px solid var(--dsw-alias-border-l1)}
3438
+ .ase-results-count{margin-left:8px;font:500 11px/1 ui-monospace,Menlo,monospace;color:var(--dsw-alias-label-secondary)}
3439
+ .ase-results-actions{display:flex;gap:8px}
3440
+ .ase-understood-key{margin-right:6px;opacity:.65}
3441
+ .ase-fail-query{margin:0 0 6px;font:400 11px/1.5 ui-monospace,Menlo,monospace;color:var(--dsw-alias-label-secondary)}
3442
+ .ase-understood{display:flex;align-items:flex-start;gap:6px;margin:-6px 0 12px;font:400 11px/1.5 ui-monospace,Menlo,monospace;color:var(--dsw-alias-label-secondary)}
3443
+ .ase-understood-key{flex:0 0 auto;margin-right:0;white-space:nowrap;opacity:.65}
3444
+ .ase-understood-lines{display:flex;flex-direction:column;gap:1px;min-width:0}
3445
+ .ase-understood-line{margin:0;line-height:1.7;word-break:break-word}
3446
+ .ase-warn{margin:0 0 12px;font-size:11px;color:var(--dsw-alias-label-secondary)}
3447
+ .ase-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px}
3448
+ .ase-tile--busy .ase-thumb img{opacity:.4;filter:saturate(.6)}
3449
+ .ase-tile--busy .ase-thumb::after{content:'';position:absolute;inset:0;background:linear-gradient(100deg,transparent 20%,color-mix(in srgb,var(--dsw-alias-label-primary) 14%,transparent) 50%,transparent 80%);background-size:220% 100%;animation:ase-busy-sweep 1.1s linear infinite}
3450
+ @keyframes ase-busy-sweep{from{background-position:120% 0}to{background-position:-120% 0}}
3451
+ .ase-tile{margin:0;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;overflow:hidden;background:var(--dsw-alias-bg-layer-1);transition:border-color .16s;display:flex;flex-direction:column}
3452
+ .ase-tile:hover{border-color:var(--dsw-alias-border-l2)}
3453
+ .ase-thumb{position:relative;aspect-ratio:16/9;background:var(--dsw-alias-bg-layer-2);overflow:hidden;flex-shrink:0}
3454
+ .ase-thumb img{width:100%;height:100%;object-fit:cover;display:block}
3455
+ .ase-thumb-fallback{width:100%;height:100%;background:linear-gradient(135deg,var(--dsw-alias-bg-layer-2),var(--dsw-alias-bg-overlay))}
3456
+ .ase-thumb-actions{position:absolute;right:7px;top:7px;display:flex;gap:5px;opacity:0;transition:opacity .15s}
3457
+ .ase-tile:hover .ase-thumb-actions{opacity:1}
3458
+ .ase-mini{width:26px;height:26px;border-radius:7px;border:1px solid rgba(255,255,255,.22);background:rgba(12,12,16,.62);color:#fff;font:500 13px/1 ui-sans-serif,system-ui;display:inline-flex;align-items:center;justify-content:center;cursor:pointer;text-decoration:none;backdrop-filter:blur(6px)}
3459
+ .ase-mini:hover{background:rgba(12,12,16,.82)}
3460
+ .ase-mini--solid{background:var(--dsw-alias-brand-primary);border-color:transparent;color:var(--dsw-alias-bg-base)}
3461
+ .ase-mini--danger:hover{background:#c0392b;border-color:transparent}
3462
+ .ase-tile figcaption{padding:9px 11px 11px;display:flex;flex-direction:column;flex:1}
3463
+ .ase-tile figcaption strong{display:block;font-size:12px;font-weight:600;margin-bottom:3px}
3464
+ .ase-tile-head{display:flex;align-items:baseline;justify-content:space-between;gap:6px}
3465
+ .ase-badge{flex:0 0 auto;font:500 9.5px/1 ui-monospace,Menlo,monospace;letter-spacing:.04em;text-transform:uppercase;color:var(--dsw-alias-label-secondary);border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:2px 5px}
3466
+ .ase-tile figcaption p{margin:0;font-size:11px;line-height:1.5;color:var(--dsw-alias-label-secondary);display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
3467
+ .ase-tile--skeleton{border-style:dashed}
3468
+ .ase-skeleton,.ase-tile--skeleton .ase-thumb{background:linear-gradient(100deg,var(--dsw-alias-bg-layer-2) 40%,color-mix(in srgb,var(--dsw-alias-label-primary) 4%,var(--dsw-alias-bg-layer-2)) 50%,var(--dsw-alias-bg-layer-2) 60%);background-size:200% 100%;animation:ase-shimmer 1.4s infinite linear}
3469
+ [data-ds-dark-theme] .ase-skeleton,[data-ds-dark-theme] .ase-tile--skeleton .ase-thumb{background:linear-gradient(100deg,var(--dsw-alias-bg-layer-2) 40%,var(--dsw-alias-bg-layer-1) 50%,var(--dsw-alias-bg-layer-2) 60%);background-size:200% 100%}
3470
+ .ase-skeleton{border-radius:10px}
3471
+ @keyframes ase-shimmer{from{background-position:120% 0}to{background-position:-20% 0}}
3472
+ .ase-empty{border:1px dashed var(--dsw-alias-border-l2);border-radius:14px;padding:32px 24px;text-align:center}
3473
+ .ase-idle{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:min(44vh,360px);padding:40px 24px 56px;text-align:center}
3474
+ .ase-results--idle{min-height:100%;margin:0;display:flex;flex-direction:column}
3475
+ .ase-results--idle .ase-idle{flex:1 1 auto;min-height:240px}
3476
+ .ase-idle-art{position:relative;width:58px;height:36px;margin-bottom:18px}
3477
+ .ase-idle-art span{position:absolute;inset:0;border:1px solid var(--dsw-alias-border-l2);border-radius:7px;background:var(--dsw-alias-bg-base)}
3478
+ .ase-idle-art span:nth-child(1){transform:translate(-9px,-6px) rotate(-6deg);opacity:.34}
3479
+ .ase-idle-art span:nth-child(2){transform:translate(9px,-6px) rotate(6deg);opacity:.34}
3480
+ .ase-idle-art span:nth-child(3){background:var(--dsw-alias-bg-layer-1)}
3481
+ .ase-idle-art span:nth-child(3)::after{content:'';position:absolute;left:50%;top:50%;width:0;height:0;transform:translate(-35%,-50%);border-left:7px solid var(--dsw-alias-label-secondary);border-top:4.5px solid transparent;border-bottom:4.5px solid transparent;opacity:.5}
3482
+ .ase-idle-title{margin:0;font-size:14px;font-weight:500;line-height:1.5;color:var(--dsw-alias-label-primary);opacity:.9}
3483
+ .ase-idle-body{max-width:520px;margin:8px 0 0;font-size:12.5px;line-height:1.7;color:var(--dsw-alias-label-secondary)}
3484
+ .ase-suggest{margin:0 0 18px;border:1px solid var(--dsw-alias-border-l1);border-radius:13px;padding:12px;background:var(--dsw-alias-bg-layer-1)}
3485
+ .ase-suggest-head{display:flex;align-items:baseline;gap:10px;margin-bottom:10px}
3486
+ .ase-suggest-head h3{margin:0;font-size:12.5px;font-weight:600}
3487
+ .ase-suggest-hint{font-size:11px;color:var(--dsw-alias-label-secondary)}
3488
+ .ase-suggest-head .ase-ghost-link{font-size:11px}
3489
+ .ase-suggest-head .ase-ghost-link:first-of-type{margin-left:auto}
3490
+ .ase-suggest-head h3{margin-right:2px}
3491
+ .ase-suggest-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px}
3492
+ .ase-sug{position:relative;margin:0;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;overflow:hidden;background:var(--dsw-alias-bg-base)}
3493
+ .ase-sug img{width:100%;aspect-ratio:16/9;object-fit:cover;display:block;cursor:zoom-in}
3494
+ .ase-sug .ase-sug-add{position:absolute;right:5px;top:5px;width:22px;height:22px;display:inline-flex;align-items:center;justify-content:center;padding:0;border:0;border-radius:50%;background:transparent;color:#fff;font-size:15px;line-height:1;cursor:pointer;opacity:0;transition:opacity .14s,transform .14s;filter:drop-shadow(0 1px 3px rgba(0,0,0,.8))}
3495
+ .ase-sug:hover .ase-sug-add,.ase-sug:focus-within .ase-sug-add{opacity:1}
3496
+ .ase-sug .ase-sug-add:hover{transform:scale(1.12)}
3497
+ .ase-sug-anchors{display:flex;flex-shrink:0}
3498
+ .ase-sug-anchors img{width:24px;height:24px;border-radius:6px;object-fit:cover;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2)}
3499
+ .ase-sug-anchors img + img{margin-left:-7px}
3500
+ .ase-sug-meta{padding:6px 8px 7px;font-size:10.5px;line-height:1.35;color:var(--dsw-alias-label-secondary);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
3501
+ .ase-more{display:block;margin:18px auto 0;min-width:120px}
3502
+ .ase-nomore{margin:18px 0 0;text-align:center;font-size:11.5px;color:var(--dsw-alias-label-secondary)}
3503
+ .ase-empty h3{margin:0 0 8px;font-size:16px}
3504
+ .ase-empty p{margin:0 0 14px;color:var(--dsw-alias-label-secondary);font-size:12.5px;line-height:1.6}
3505
+ .ase-note{display:flex;align-items:center;gap:8px;margin:14px 0 0;font-size:11.5px;color:var(--dsw-alias-label-secondary)}
3506
+ .ase-iconbtn{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:8px;border:1px solid transparent;background:none;color:var(--dsw-alias-label-secondary);cursor:pointer;padding:0}
3507
+ .ase-iconbtn:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1)}
3508
+ .ase-settings{width:min(520px,100%)}
3509
+ .ase-about{display:flex;align-items:center;gap:10px;margin-top:14px;padding-top:12px;border-top:1px solid var(--dsw-alias-border-l1);font-size:11px;color:var(--dsw-alias-label-secondary)}
3510
+ .ase-about-label{flex:0 0 auto}
3511
+ .ase-about-ver{flex:0 0 auto;margin-left:auto;font:500 11px/1 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dsw-alias-label-primary)}
3512
+ .ase-btn:disabled{opacity:.45;cursor:default}
3513
+ .ase-settings-head{margin-bottom:14px}
3514
+ .ase-source{border:1px solid var(--dsw-alias-border-l1);border-radius:12px;padding:12px 13px;margin-bottom:10px;background:var(--dsw-alias-bg-layer-1)}
3515
+ .ase-source-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:9px}
3516
+ .ase-source-head strong{font-size:12.5px}
3517
+ .ase-pill{font-size:10px;color:var(--dsw-alias-label-secondary);border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:2px 7px}
3518
+ .ase-pill--on{color:var(--dsw-alias-brand-primary);border-color:color-mix(in srgb,var(--dsw-alias-brand-primary) 45%,transparent)}
3519
+ .ase-source-form{display:flex;gap:8px;align-items:center}
3520
+ .ase-source-form .ase-input{flex:1;min-width:0}
3521
+ .ase-source-title{display:flex;align-items:center;gap:8px;min-width:0}
3522
+ .ase-lang-opts{display:flex;gap:8px}
3523
+ .ase-source-docs{display:inline-block;margin-top:8px;font-size:11px;color:var(--dsw-alias-label-secondary);text-decoration:underline;text-underline-offset:2px}
3524
+ .ase-spin{animation:ase-spin .9s linear infinite}
3525
+ @keyframes ase-spin{to{transform:rotate(360deg)}}
3526
+ .ase-iconbtn:disabled{opacity:.5;cursor:default}
3527
+ .ase-board-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:14px}
3528
+ .ase-board-card{position:relative;border:1px solid var(--dsw-alias-border-l1);border-radius:13px;overflow:hidden;background:var(--dsw-alias-bg-layer-1)}
3529
+ .ase-board-card:hover{border-color:var(--dsw-alias-border-l2)}
3530
+ .ase-board-open{display:block;width:100%;border:0;background:none;padding:0;cursor:pointer;text-align:left;color:inherit;font:inherit}
3531
+ .ase-board-cover{width:100%;aspect-ratio:16/9;object-fit:cover;display:block;background:var(--dsw-alias-bg-layer-2)}
3532
+ .ase-board-cover--empty{display:flex;align-items:center;justify-content:center;font-size:22px;color:var(--dsw-alias-label-secondary)}
3533
+ .ase-board-meta{padding:10px 12px 12px}
3534
+ .ase-board-meta strong{display:block;font-size:13px;margin-bottom:4px}
3535
+ .ase-board-meta small{font-size:10.5px;color:var(--dsw-alias-label-secondary)}
3536
+ .ase-board-sub{display:flex;align-items:baseline;justify-content:space-between;gap:10px}
3537
+ .ase-board-date{margin-left:auto;white-space:nowrap;font-variant-numeric:tabular-nums}
3538
+ .ase-board-actions{position:absolute;right:8px;top:8px;display:flex;gap:6px;opacity:0;transform:translateY(-2px);transition:opacity .14s,transform .14s}
3539
+ .ase-board-card:hover .ase-board-actions,.ase-board-card:focus-within .ase-board-actions{opacity:1;transform:none}
3540
+ .ase-board-act{width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;border-radius:50%;border:1px solid rgba(255,255,255,.2);background:rgba(12,12,16,.6);color:#fff;cursor:pointer;font-size:13px;line-height:1;padding:0;backdrop-filter:blur(4px)}
3541
+ .ase-board-pin.is-on{background:var(--dsw-alias-brand-primary);border-color:transparent;color:var(--dsw-alias-bg-base)}
3542
+ .ase-board-pin:hover{background:rgba(12,12,16,.85)}
3543
+ .ase-board-del:hover{background:#c0392b}
3544
+ .ase-back{background:none;border:0;padding:0 0 14px;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12.5px;cursor:pointer}
3545
+ .ase-back:hover{color:var(--dsw-alias-label-primary)}
3546
+ .ase-board-head{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;flex-wrap:wrap;margin-bottom:20px}
3547
+ .ase-board-title-wrap{min-width:0}
3548
+ .ase-board-title{background:none;border:0;outline:0;color:inherit;font:inherit;font-size:26px;font-weight:640;letter-spacing:-.02em;padding:2px 0;border-bottom:1px solid transparent;min-width:240px;max-width:520px}
3549
+ .ase-board-title:hover{border-bottom-color:var(--dsw-alias-border-l2)}
3550
+ .ase-board-title:focus{border-bottom-color:var(--dsw-alias-brand-primary)}
3551
+ .ase-board-palette{display:flex;align-items:center;gap:8px;margin-top:8px}
3552
+ .ase-board-palette .ase-dots--bar{width:120px;margin-top:0}
3553
+ .ase-board-palette code{font-size:10px;color:var(--dsw-alias-label-secondary);letter-spacing:.02em}
3554
+ .ase-board-head-meta{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--dsw-alias-label-secondary)}
3555
+ .ase-scrim{position:fixed;inset:0;background:rgba(8,8,12,.52);backdrop-filter:blur(3px);display:flex;align-items:center;justify-content:center;z-index:60;padding:24px}
3556
+ .ase-modal{position:relative;width:min(460px,100%);background:var(--dsw-alias-bg-overlay);border:1px solid var(--dsw-alias-border-l2);border-radius:16px;padding:18px;box-shadow:0 30px 80px -30px rgba(0,0,0,.7);max-height:80vh;overflow:auto}
3557
+ .ase-modal-head{display:flex;gap:12px;align-items:center;margin-bottom:14px}
3558
+ .ase-community{width:min(640px,100%)}
3559
+ .ase-community .ase-modal-head{align-items:flex-start;padding-right:26px}
3560
+ .ase-community-back{margin:0 0 2px;padding:0;font-size:11.5px}
3561
+ .ase-community-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px;margin-top:2px}
3562
+ .ase-community-card{display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;overflow:hidden;background:var(--dsw-alias-bg-layer-1)}
3563
+ .ase-community-shot{position:relative;display:block;width:100%;aspect-ratio:16/9;padding:0;border:0;background:var(--dsw-alias-bg-layer-2);cursor:pointer;overflow:hidden}
3564
+ .ase-community-shot img{width:100%;height:100%;object-fit:cover;display:block;transition:transform .22s ease}
3565
+ .ase-community-shot:hover img{transform:scale(1.035)}
3566
+ .ase-community-badge{position:absolute;left:8px;bottom:8px;padding:2px 7px;border-radius:999px;background:rgba(12,12,16,.62);color:#fff;font-size:10.5px;line-height:1.4;backdrop-filter:blur(4px)}
3567
+ .ase-community-foot{display:flex;align-items:center;gap:8px;padding:7px 8px}
3568
+ .ase-community-name{flex:1;min-width:0;border:0;background:none;padding:0;font:inherit;font-size:12px;font-weight:600;color:inherit;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}
3569
+ .ase-community-name:hover{color:var(--dsw-alias-brand-primary)}
3570
+ .ase-community-foot .ase-btn{flex:0 0 auto;padding:4px 9px;font-size:11px;border-radius:7px}
3571
+ .ase-community-detail{display:flex;flex-direction:column;gap:10px}
3572
+ .ase-community-actions{display:flex;justify-content:flex-end}
3573
+ .ase-community-frames{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:8px;max-height:52vh;overflow-y:auto}
3574
+ .ase-community-frame{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;overflow:hidden;background:var(--dsw-alias-bg-layer-2);aspect-ratio:16/9}
3575
+ .ase-community-frame img{width:100%;height:100%;object-fit:cover;display:block}
3576
+ .ase-modal-thumb{width:104px;aspect-ratio:16/9;object-fit:cover;border-radius:8px;background:var(--dsw-alias-bg-layer-2)}
3577
+ .ase-modal-title{margin:0 0 4px;font-size:15px}
3578
+ .ase-modal-sub{margin:0;font-size:11.5px;color:var(--dsw-alias-label-secondary)}
3579
+ .ase-modal-empty{font-size:11.5px;color:var(--dsw-alias-label-secondary);line-height:1.6;margin:0 0 12px}
3580
+ .ase-chips{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:16px 0 2px;max-height:46vh;overflow-y:auto}
3581
+ .ase-chip{display:inline-flex;align-items:center;gap:8px;max-width:100%;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);border-radius:10px;padding:8px 11px;color:inherit;font:inherit;font-size:12.5px;cursor:pointer;transition:border-color .14s,background .14s}
3582
+ .ase-chip:hover{border-color:var(--dsw-alias-border-l2)}
3583
+ .ase-chip span{max-width:224px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
3584
+ .ase-chip small{font-size:10.5px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
3585
+ .ase-chip.is-in{border-color:var(--dsw-alias-brand-primary);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 10%,transparent)}
3586
+ .ase-chip.is-in small{color:var(--dsw-alias-label-primary)}
3587
+ .ase-chip--new{width:34px;height:34px;justify-content:center;padding:0;border:0;border-radius:10px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-base);font-size:15px;line-height:1}
3588
+ .ase-chip--new:hover{background:var(--dsw-alias-label-primary);opacity:.85}
3589
+ .ase-chip-input{width:150px;height:34px;border:1px solid var(--dsw-alias-brand-primary);border-radius:10px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:12.5px;padding:0 11px;outline:none}
3590
+ .ase-toast{position:fixed;left:50%;bottom:28px;transform:translateX(-50%);background:var(--dsw-alias-bg-overlay);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);padding:9px 16px;border-radius:999px;font-size:12px;box-shadow:0 14px 40px -16px rgba(0,0,0,.6);z-index:100}
3591
+ .ase-zoomable{cursor:zoom-in}
3592
+ .ase-tip{position:relative}
3593
+ .ase-tip::after{content:attr(data-tip);position:absolute;top:calc(100% + 6px);right:0;background:var(--dsw-alias-bg-overlay);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);font-size:11px;line-height:1;padding:5px 8px;border-radius:6px;white-space:nowrap;opacity:0;transform:translateY(-2px);transition:opacity .12s ease,transform .12s ease;pointer-events:none;z-index:6;box-shadow:0 10px 24px -12px rgba(0,0,0,.6)}
3594
+ .ase-tip:hover::after{opacity:1;transform:translateY(0)}
3595
+ .ase-tip--right::after{right:auto;left:0}
3596
+ .ase-tip--top::after{top:auto;bottom:calc(100% + 6px);right:auto;left:50%;transform:translate(-50%,2px)}
3597
+ .ase-tip--top:hover::after{transform:translate(-50%,0)}
3598
+ .ase-ask-tools .ase-tip::after{top:auto;bottom:calc(100% + 6px)}
3599
+ .ase-zoom-view{position:fixed;inset:0;z-index:90;background:rgba(6,6,9,.9);backdrop-filter:blur(8px);display:flex;align-items:center;justify-content:center;padding:44px}
3600
+ .ase-attach-view{padding:32px}
3601
+ .ase-attach-view-img{display:block;max-width:min(92vw,1024px);max-height:calc(100vh - 72px);width:auto;height:auto;object-fit:contain;border-radius:12px;box-shadow:0 30px 90px -30px rgba(0,0,0,.85)}
3602
+ .ase-zoom-figure{margin:0;max-width:min(1180px,92vw);display:flex;flex-direction:column;gap:11px}
3603
+ .ase-zoom-stage{position:relative;display:flex;align-items:center;justify-content:center;min-width:min(70vw,520px);min-height:min(46vh,340px);border-radius:8px;overflow:hidden;background:#000}
3604
+ .ase-zoom-blur{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;filter:blur(24px) saturate(1.15);transform:scale(1.06);opacity:.6}
3605
+ .ase-zoom-full{position:relative;display:block;max-width:100%;max-height:74vh;object-fit:contain;border-radius:8px;opacity:0;transition:opacity .16s ease}
3606
+ .ase-zoom-stage.is-ready .ase-zoom-full{opacity:1}
3607
+ .ase-zoom-stage.is-ready .ase-zoom-blur{opacity:.25}
3608
+ .ase-zoom-figure figcaption{display:flex;flex-direction:column;gap:7px;color:rgba(255,255,255,.86);font-size:12px;line-height:1.6}
3609
+ .ase-zoom-figure figcaption p{margin:0;color:rgba(255,255,255,.86)}
3610
+ .ase-zoom-meta{display:flex;align-items:baseline;justify-content:space-between;gap:16px;font-size:11.5px;color:rgba(255,255,255,.55)}
3611
+ .ase-zoom-meta strong{font-size:13px;font-weight:600;color:#fff}
3612
+ .ase-zoom-actions{display:flex;align-items:center;gap:14px;margin-top:2px}
3613
+ .ase-zoom-add{margin-left:auto;display:inline-flex;align-items:center;gap:6px;border:1px solid rgba(255,255,255,.26);background:rgba(255,255,255,.08);color:#fff;font:inherit;font-size:11.5px;line-height:1;padding:7px 13px;border-radius:999px;cursor:pointer;transition:background .14s,border-color .14s}
3614
+ .ase-zoom-add:hover{background:rgba(255,255,255,.17);border-color:rgba(255,255,255,.42)}
3615
+ .ase-zoom-add.is-open,.ase-zoom-add:focus-visible{background:rgba(255,255,255,.2);border-color:rgba(255,255,255,.5);outline:none}
3616
+ .ase-board-pop-wrap{position:relative;margin-left:auto;display:inline-flex}
3617
+ .ase-board-pop-wrap.is-open::before{content:"";position:absolute;left:-6px;right:-6px;bottom:100%;height:14px}
3618
+ .ase-board-pop{position:absolute;right:0;bottom:calc(100% + 8px);z-index:7;box-sizing:border-box;width:244px;max-height:min(52vh,320px);overflow-y:auto;display:flex;flex-direction:column;gap:2px;padding:6px;border-radius:12px;border:1px solid rgba(255,255,255,.16);background:rgba(26,26,31,.97);backdrop-filter:blur(18px);box-shadow:0 24px 64px -22px rgba(0,0,0,.9);color:#fff;text-align:left}
3619
+ .ase-board-pop-head{padding:6px 9px 5px;font-size:10px;letter-spacing:.05em;text-transform:uppercase;color:rgba(255,255,255,.42)}
3620
+ .ase-board-pop-list{display:flex;flex-direction:column;gap:1px}
3621
+ .ase-board-pop-item{display:flex;align-items:center;gap:8px;width:100%;padding:8px 9px;border:0;border-radius:8px;background:none;color:rgba(255,255,255,.9);font:inherit;font-size:12px;line-height:1.2;text-align:left;cursor:pointer}
3622
+ .ase-board-pop-item:hover,.ase-board-pop-item:focus-visible{background:rgba(255,255,255,.1);outline:none}
3623
+ .ase-board-pop-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
3624
+ .ase-board-pop-count{flex:none;font-size:10px;color:rgba(255,255,255,.42);white-space:nowrap}
3625
+ .ase-board-pop-item.is-in .ase-board-pop-name{color:rgba(255,255,255,.55)}
3626
+ .ase-board-pop-item.is-in .ase-board-pop-count{color:rgba(126,226,168,.9)}
3627
+ .ase-board-pop-check{flex:none;color:#7ee2a8;font-size:11px;line-height:1}
3628
+ .ase-board-pop-empty{margin:2px 9px 8px;font-size:11px;line-height:1.5;color:rgba(255,255,255,.45)}
3629
+ .ase-board-pop-new{display:flex;align-items:center;gap:6px;width:100%;margin-top:3px;padding:8px 9px;border:0;border-top:1px solid rgba(255,255,255,.1);border-radius:8px;background:none;color:rgba(255,255,255,.75);font:inherit;font-size:11.5px;line-height:1.2;text-align:left;cursor:pointer}
3630
+ .ase-board-pop-new:hover,.ase-board-pop-new:focus-visible{background:rgba(255,255,255,.1);color:#fff;outline:none}
3631
+ .ase-board-pop-input{box-sizing:border-box;width:100%;height:32px;margin-top:3px;border:1px solid rgba(255,255,255,.24);border-radius:8px;background:rgba(255,255,255,.06);color:#fff;font:inherit;font-size:12px;padding:0 9px;outline:none}
3632
+ .ase-board-pop-input:focus{border-color:rgba(255,255,255,.55)}
3633
+ .ase-zoom-added{margin-left:auto;display:inline-flex;align-items:center;gap:6px;font-size:11.5px;line-height:1;padding:7px 13px;border-radius:999px;border:1px solid rgba(120,220,160,.4);color:rgba(150,235,190,.95);background:rgba(60,160,110,.14)}
3634
+ .ase-zoom-link{align-self:flex-start;font-size:11px;color:rgba(255,255,255,.6);text-decoration:underline;text-underline-offset:2px}
3635
+ .ase-imgmenu-backdrop{position:fixed;inset:0;z-index:2}
3636
+ .ase-imgmenu{position:fixed;z-index:3;box-sizing:border-box;width:164px;max-width:calc(100vw - 16px);padding:4px;border-radius:10px;border:1px solid rgba(255,255,255,.16);background:rgba(30,30,35,.96);backdrop-filter:blur(14px);box-shadow:0 18px 48px -18px rgba(0,0,0,.8)}
3637
+ .ase-imgmenu-item{display:flex;align-items:center;gap:9px;width:100%;padding:8px 10px;border:0;border-radius:7px;background:none;color:#fff;font:inherit;font-size:12px;line-height:1.2;text-align:left;cursor:pointer;white-space:nowrap}
3638
+ .ase-imgmenu-item:hover{background:rgba(255,255,255,.12)}
3639
+ .ase-imgmenu-item:focus-visible{outline:2px solid rgba(255,255,255,.6);outline-offset:-2px}
3640
+ .ase-imgmenu-item svg{display:block;flex:none;opacity:.8}
3641
+ .ase-zoom-btn{position:absolute;display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;padding:0;border-radius:50%;border:1px solid rgba(255,255,255,.18);background:rgba(22,22,26,.65);color:#fff;font-size:17px;line-height:1;cursor:pointer}
3642
+ .ase-zoom-btn:hover{background:rgba(44,44,52,.85)}
3643
+ .ase-zoom-btn::after{border-color:rgba(255,255,255,.14)}
3644
+ .ase-zoom-btn--close{top:16px;right:16px}
3645
+ .ase-zoom-btn--prev{left:18px;top:50%;transform:translateY(-50%)}
3646
+ .ase-zoom-btn--next{right:18px;top:50%;transform:translateY(-50%)}
3647
+ @media (hover:none){.ase-tip::after{display:none}}
3648
+ @media (hover:none){.ase-thumb-actions{opacity:1}}
3649
+ @media (hover:none){.ase-attach-x{opacity:1;pointer-events:auto;transform:none}}
3650
+ @media (max-width:820px){.ase-topbar{flex-wrap:wrap}.ase-board-head{flex-direction:column;align-items:flex-start}}
3651
+ `
3652
+
3653
+ return {
3654
+ inject: ['slots', 'locale'],
3655
+ apply(ctx) {
3656
+ const disposers = []
3657
+ try {
3658
+ disposers.push(ctx.locale.register(NS, 'zh', DICT.zh))
3659
+ disposers.push(ctx.locale.register(NS, 'en', DICT.en))
3660
+ } catch {}
3661
+ ctx.effect(() => () => { for (const dispose of disposers) { try { dispose() } catch {} } })
3662
+
3663
+ ctx.effect(() => {
3664
+ const element = document.createElement('style')
3665
+ element.dataset.ase = 'anime-style-explorer'
3666
+ element.textContent = CSS
3667
+ document.head.appendChild(element)
3668
+ return () => element.remove()
3669
+ })
3670
+
3671
+ ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
3672
+ name: 'sidebar.panellist',
3673
+ id: 'anistyle',
3674
+ order: 25,
3675
+ label: () => createTranslator(ctx)('nav'),
3676
+ }, (props) => h(NavIcon, { ...props, label: createTranslator(ctx)('nav') })))
3677
+
3678
+ ctx.slots.inject('main', () => ctx.slots.register({
3679
+ name: 'main',
3680
+ key: 'anistyle',
3681
+ }, (props) => h(Content, { ...props, __ctx: ctx })))
3682
+ },
3683
+ }
3684
+ },
3685
+ })