@appaflytech/zeyrek-ai-mcp 0.0.49 → 0.0.50

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.
@@ -0,0 +1,990 @@
1
+ /*
2
+ * Mağaza görseli editörü (zeyrek-mcp ADR 0004 §Ek). Sunucu galeri uçlarını sunar: deck · preview · render · order ·
3
+ * duplicate · upload. Önizleme sunucunun ürettiği, son JPEG'i çizen HTML'in kendisidir; bu dosya ikinci bir çizici
4
+ * taşımaz. Çizilmemiş değişiklikler (taslak) tarayıcıda tutulur ve sayfa yenilense de localStorage'dan döner.
5
+ */
6
+ (() => {
7
+ 'use strict'
8
+
9
+ const FIELDS = ['raw', 'eyebrow', 'title', 'subtitle', 'style', 'layout', 'captionPosition', 'accent', 'text', 'frame']
10
+ const TEXT_FIELDS = [['eyebrow', 'Üst etiket'], ['title', 'Başlık'], ['subtitle', 'Alt başlık']]
11
+ const LAYOUT_LABELS = { classic: 'Klasik', bleed: 'Taşan', tilt: 'Eğik', text: 'Metin' }
12
+ const CAPTION_FIXED = new Set(['bleed', 'text'])
13
+ const DICE_ACCENTS = ['#FF5A5F', '#00C2A8', '#7C5CFF', '#FFB020', '#2F80ED', '#FF7A00', '#E4007C', '#1F7A5C', '#00A3FF', '#C6F432']
14
+ const LAYOUT_ICONS = {
15
+ classic: '<rect x="7" y="4" width="18" height="3" rx="1.5"/><rect x="10" y="12" width="12" height="27" rx="3"/>',
16
+ bleed: '<rect x="7" y="4" width="18" height="3" rx="1.5"/><path d="M6 44V15a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v29z"/>',
17
+ tilt: '<rect x="7" y="4" width="18" height="3" rx="1.5"/><rect x="10" y="12" width="12" height="27" rx="3" transform="rotate(-12 16 25)"/>',
18
+ text: '<rect x="11" y="15" width="10" height="2" rx="1"/><rect x="5" y="20" width="22" height="4" rx="2"/><rect x="7" y="26" width="18" height="4" rx="2"/><rect x="9" y="33" width="14" height="2" rx="1"/>'
19
+ }
20
+
21
+ const $ = (selector, root = document) => root.querySelector(selector)
22
+ const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector))
23
+ const ui = {
24
+ meta: $('#meta'),
25
+ cards: $('#cards'),
26
+ preview: $('#preview'),
27
+ previewBox: $('#previewBox'),
28
+ problem: $('#problem'),
29
+ panel: $('#panel'),
30
+ cardTitle: $('#cardTitle'),
31
+ dirtyBadge: $('#dirtyBadge'),
32
+ styles: $('#styles'),
33
+ layouts: $('#layouts'),
34
+ caption: $('#caption'),
35
+ raws: $('#raws'),
36
+ upload: $('#upload'),
37
+ renderAll: $('#renderAll'),
38
+ render: $('#render'),
39
+ toast: $('#toast'),
40
+ undo: $('#undo'),
41
+ redo: $('#redo')
42
+ }
43
+
44
+ const state = {
45
+ deck: null,
46
+ current: null,
47
+ drafts: new Map(),
48
+ selected: new Set(),
49
+ overflow: new Map(),
50
+ history: [],
51
+ future: [],
52
+ busy: false,
53
+ inline: false,
54
+ previewSeq: 0
55
+ }
56
+
57
+ const draftKey = 'store-art-drafts:' + location.pathname
58
+
59
+ /* ---------- yardımcılar ---------- */
60
+
61
+ const element = (tag, attributes = {}, children = []) => {
62
+ const node = document.createElement(tag)
63
+
64
+ for (const [key, value] of Object.entries(attributes)) {
65
+ if (value === undefined || value === null || value === false) continue
66
+ if (key === 'text') node.textContent = value
67
+ else if (key === 'html') node.innerHTML = value
68
+ else node.setAttribute(key, value === true ? '' : value)
69
+ }
70
+
71
+ node.append(...children)
72
+
73
+ return node
74
+ }
75
+
76
+ const normalize = spec => Object.fromEntries(FIELDS.filter(key => spec[key] !== undefined && spec[key] !== null && spec[key] !== '').map(key => [key, spec[key]]))
77
+ const same = (left, right) => JSON.stringify(normalize(left)) === JSON.stringify(normalize(right))
78
+ const cardOf = name => state.deck.cards.find(card => card.name === name)
79
+ const specOf = name => state.drafts.get(name) || cardOf(name).spec
80
+ const isDirty = name => state.drafts.has(name) && !same(state.drafts.get(name), cardOf(name).spec)
81
+ const styleOf = id => state.deck.styles.find(style => style.id === id)
82
+ const layoutOf = id => state.deck.layouts.find(layout => layout.id === id)
83
+ const pick = list => list[Math.floor(Math.random() * list.length)]
84
+ const isTyping = target => target instanceof HTMLElement && (target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName))
85
+
86
+ async function api(action, body) {
87
+ const response = await fetch(action, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
88
+ const json = (response.headers.get('content-type') || '').includes('json')
89
+ const payload = json ? await response.json() : await response.text()
90
+
91
+ if (!response.ok) throw new Error((payload && payload.error) || 'HTTP ' + response.status)
92
+
93
+ return payload
94
+ }
95
+
96
+ let toastTimer
97
+ function toast(message, kind = 'info') {
98
+ ui.toast.textContent = message
99
+ ui.toast.className = 'toast show ' + kind
100
+ clearTimeout(toastTimer)
101
+ toastTimer = setTimeout(() => { ui.toast.className = 'toast' }, kind === 'err' ? 5200 : 3200)
102
+ }
103
+
104
+ /**
105
+ * Taslak dayandığı kart sürümüyle saklanır: başka sekmede yeniden çizilmiş kartın eski taslağı geri yüklenmez.
106
+ * Sekmeler aynı anahtarı paylaşır; en son yazan kazanır, ama sürümü tutmayan taslak açılışta atılır.
107
+ */
108
+ function saveDrafts() {
109
+ try {
110
+ localStorage.setItem(draftKey, JSON.stringify([...state.drafts].map(([name, spec]) => [name, { spec, base: cardOf(name).version }])))
111
+ } catch (error) {
112
+ // Gizli pencere ya da kapalı depolama: taslak yalnız bu sekmede yaşar.
113
+ }
114
+ }
115
+
116
+ function restoreDrafts() {
117
+ try {
118
+ const stored = JSON.parse(localStorage.getItem(draftKey) || '[]')
119
+
120
+ for (const [name, entry] of stored) {
121
+ const card = cardOf(name)
122
+
123
+ if (card && entry && entry.base === card.version && !same(entry.spec, card.spec)) state.drafts.set(name, entry.spec)
124
+ }
125
+ } catch (error) {
126
+ // Okunamayan taslak yok sayılır.
127
+ }
128
+ }
129
+
130
+ /* ---------- değişiklik, geçmiş ---------- */
131
+
132
+ function apply(name, spec, { preview = true } = {}) {
133
+ if (same(spec, cardOf(name).spec)) state.drafts.delete(name)
134
+ else state.drafts.set(name, normalize(spec))
135
+
136
+ saveDrafts()
137
+ paintCard(name)
138
+ scheduleScan(name)
139
+
140
+ if (name === state.current) {
141
+ syncPanel()
142
+ if (preview) schedulePreview()
143
+ }
144
+
145
+ refreshActions()
146
+ }
147
+
148
+ function record(changes, group) {
149
+ const last = state.history[state.history.length - 1]
150
+
151
+ if (group && last && last.group === group && Date.now() - last.at < 1500 && last.changes.length === 1 && changes.length === 1 && last.changes[0].name === changes[0].name) {
152
+ last.changes[0].after = changes[0].after
153
+ last.at = Date.now()
154
+ } else {
155
+ state.history.push({ group, at: Date.now(), changes })
156
+ if (state.history.length > 50) state.history.shift()
157
+ }
158
+
159
+ state.future = []
160
+ refreshActions()
161
+ }
162
+
163
+ function change(name, patch, { group = null, preview = true } = {}) {
164
+ const before = specOf(name)
165
+ const after = normalize({ ...before, ...patch })
166
+
167
+ if (same(before, after)) return
168
+
169
+ record([{ name, before, after }], group)
170
+ apply(name, after, { preview })
171
+ }
172
+
173
+ function undo() {
174
+ const entry = state.history.pop()
175
+
176
+ if (!entry) return toast('Geri alınacak bir şey yok')
177
+
178
+ state.future.push(entry)
179
+ for (const item of entry.changes) apply(item.name, item.before)
180
+ toast(entry.changes.length > 1 ? 'Seri geri alındı' : 'Geri alındı')
181
+ }
182
+
183
+ function redo() {
184
+ const entry = state.future.pop()
185
+
186
+ if (!entry) return toast('Yinelenecek bir şey yok')
187
+
188
+ state.history.push(entry)
189
+ for (const item of entry.changes) apply(item.name, item.after)
190
+ toast('Yinelendi')
191
+ }
192
+
193
+ /* ---------- kartlar ---------- */
194
+
195
+ const thumbUrl = card => encodeURIComponent(card.name) + '.jpg?v=' + card.version
196
+
197
+ function cardNode(card) {
198
+ const box = element('input', { type: 'checkbox', class: 'pick', 'aria-label': 'Yüklemek için seç' })
199
+ const node = element('article', { class: 'card', draggable: 'true', 'data-name': card.name, tabindex: '0' }, [
200
+ element('div', { class: 'thumb' }, [element('img', { alt: '', loading: 'lazy' }), element('span', { class: 'changed', text: 'değişti' })]),
201
+ element('div', { class: 'card-row' }, [box, element('span', { class: 'card-name' })]),
202
+ element('div', { class: 'badges' })
203
+ ])
204
+
205
+ node.addEventListener('click', event => {
206
+ if (event.target !== box) select(card.name)
207
+ })
208
+ box.addEventListener('change', () => {
209
+ if (box.checked) state.selected.add(card.name)
210
+ else state.selected.delete(card.name)
211
+ refreshActions()
212
+ })
213
+ node.addEventListener('dragstart', event => {
214
+ event.dataTransfer.setData('text/plain', card.name)
215
+ event.dataTransfer.effectAllowed = 'move'
216
+ node.classList.add('dragging')
217
+ })
218
+ node.addEventListener('dragend', () => {
219
+ node.classList.remove('dragging')
220
+ clearDropMarks()
221
+ })
222
+ node.addEventListener('dragover', event => {
223
+ event.preventDefault()
224
+ clearDropMarks()
225
+ node.classList.add(dropAfter(event, node) ? 'drop-after' : 'drop-before')
226
+ })
227
+ node.addEventListener('dragleave', () => node.classList.remove('drop-before', 'drop-after'))
228
+ node.addEventListener('drop', event => {
229
+ event.preventDefault()
230
+ const dragged = event.dataTransfer.getData('text/plain')
231
+
232
+ clearDropMarks()
233
+ if (dragged && dragged !== card.name) move(dragged, card.name, dropAfter(event, node))
234
+ })
235
+
236
+ return node
237
+ }
238
+
239
+ const dropAfter = (event, node) => {
240
+ const box = node.getBoundingClientRect()
241
+
242
+ return event.clientX > box.left + box.width / 2
243
+ }
244
+
245
+ const clearDropMarks = () => $$('.drop-before, .drop-after', ui.cards).forEach(node => node.classList.remove('drop-before', 'drop-after'))
246
+
247
+ function paintCards() {
248
+ ui.meta.textContent = state.deck.slot + ' · ' + state.deck.cards.length + ' kart · ' + state.deck.canvas.width + '×' + state.deck.canvas.height
249
+ ui.cards.replaceChildren(...state.deck.cards.map(cardNode))
250
+ for (const card of state.deck.cards) paintCard(card.name)
251
+ fitThumbs()
252
+ refreshActions()
253
+ }
254
+
255
+ function paintCard(name) {
256
+ const node = ui.cards.querySelector('[data-name="' + CSS.escape(name) + '"]')
257
+
258
+ if (!node) return
259
+
260
+ const card = cardOf(name)
261
+ const spec = specOf(name)
262
+ const dirty = isDirty(name)
263
+ const uploadedNow = card.uploadedVersion === card.version
264
+ const cut = state.overflow.get(name) || []
265
+ const box = $('.pick', node)
266
+
267
+ node.classList.toggle('current', name === state.current)
268
+ node.classList.toggle('dirty', dirty)
269
+ $('.card-name', node).textContent = 'Kare ' + (card.slide + 1) + ' · ' + styleOf(spec.style).label
270
+ box.checked = state.selected.has(name)
271
+ box.disabled = uploadedNow && !dirty
272
+ paintThumb(node, card, dirty)
273
+
274
+ $('.badges', node).replaceChildren(...[
275
+ dirty && element('span', { class: 'badge warn', text: 'çizilmedi' }),
276
+ cut.length > 0 && element('span', { class: 'badge err', text: 'metin kesiliyor', title: cut.join(', ') }),
277
+ uploadedNow && element('span', { class: 'badge ok', text: 'yüklendi' }),
278
+ card.uploadedVersion !== undefined && !uploadedNow && element('span', { class: 'badge muted', text: 'eski sürümü yüklü' })
279
+ ].filter(Boolean))
280
+ }
281
+
282
+ /** Çizilmiş kart JPEG'iyle, taslak kart canlı önizlemesiyle görünür: seri karıştırınca bütün şerit değişir */
283
+ const thumbRequests = new Map()
284
+ function paintThumb(node, card, dirty) {
285
+ const thumb = $('.thumb', node)
286
+ const img = $('img', thumb)
287
+
288
+ if (!dirty) {
289
+ clearTimeout(thumbRequests.get(card.name))
290
+ $('iframe', thumb)?.remove()
291
+ img.hidden = false
292
+ if (img.getAttribute('src') !== thumbUrl(card)) img.setAttribute('src', thumbUrl(card))
293
+
294
+ return
295
+ }
296
+
297
+ clearTimeout(thumbRequests.get(card.name))
298
+ thumbRequests.set(card.name, setTimeout(async () => {
299
+ try {
300
+ const html = await api('preview', { spec: specOf(card.name) })
301
+
302
+ // Yanıt gelene kadar kart çizilmiş ya da geri alınmış olabilir: temiz kartın üstüne canlı önizleme konmaz.
303
+ if (!isDirty(card.name)) return
304
+
305
+ const frame = $('iframe', thumb) || thumb.insertBefore(element('iframe', { sandbox: 'allow-same-origin', tabindex: '-1', 'aria-hidden': 'true' }), img)
306
+
307
+ frame.srcdoc = html
308
+ img.hidden = true
309
+ fitThumbs()
310
+ } catch (error) {
311
+ // Geçersiz taslak (ör. boş başlık) önizlenemez; küçük resim son çizimde kalır.
312
+ }
313
+ }, 160))
314
+ }
315
+
316
+ function fitThumbs() {
317
+ const { width, height } = state.deck.canvas
318
+ const sample = $('.thumb', ui.cards)
319
+
320
+ if (!sample) return
321
+
322
+ const scale = sample.clientWidth / width
323
+
324
+ for (const frame of $$('.thumb iframe', ui.cards)) {
325
+ frame.style.width = width + 'px'
326
+ frame.style.height = height + 'px'
327
+ frame.style.transform = 'scale(' + scale + ')'
328
+ }
329
+ }
330
+
331
+ function select(name) {
332
+ if (!cardOf(name)) return
333
+
334
+ state.current = name
335
+ for (const card of state.deck.cards) ui.cards.querySelector('[data-name="' + CSS.escape(card.name) + '"]')?.classList.toggle('current', card.name === name)
336
+ ui.cards.querySelector('[data-name="' + CSS.escape(name) + '"]')?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
337
+ syncPanel()
338
+ schedulePreview(0)
339
+ }
340
+
341
+ function step(direction) {
342
+ const names = state.deck.cards.map(card => card.name)
343
+ const index = names.indexOf(state.current)
344
+
345
+ select(names[(index + direction + names.length) % names.length])
346
+ }
347
+
348
+ /**
349
+ * Sıra istekleri sırayla gider. Düşen bir istekte yerel sıra sunucudan yeniden okunur: yerel geri alma, arkasından
350
+ * başarılı olan ikinci taşımayı da geri alıp istemciyi sunucudan ayırıyordu (taze inceleme).
351
+ */
352
+ let moves = Promise.resolve()
353
+ function move(dragged, target, after) {
354
+ const previous = state.deck.cards
355
+ const names = previous.map(card => card.name).filter(name => name !== dragged)
356
+
357
+ names.splice(names.indexOf(target) + (after ? 1 : 0), 0, dragged)
358
+ state.deck.cards = names.map(name => previous.find(card => card.name === name))
359
+ paintCards()
360
+
361
+ moves = moves.then(async () => {
362
+ try {
363
+ await api('order', { cards: names })
364
+ toast('Sıra güncellendi — yükleme bu sırayla yapılır')
365
+ } catch (error) {
366
+ await reloadOrder()
367
+ toast('Sıra kaydedilemedi: ' + error.message, 'err')
368
+ }
369
+ })
370
+ }
371
+
372
+ async function reloadOrder() {
373
+ try {
374
+ const fresh = await (await fetch('deck')).json()
375
+ const known = new Map(state.deck.cards.map(card => [card.name, card]))
376
+
377
+ state.deck.cards = fresh.cards.map(card => known.get(card.name) || card)
378
+ paintCards()
379
+ } catch (error) {
380
+ // Okunamadıysa ekran olduğu gibi kalır; sayfa yenilemesi düzeltir.
381
+ }
382
+ }
383
+
384
+ function moveCurrent(direction) {
385
+ const names = state.deck.cards.map(card => card.name)
386
+ const index = names.indexOf(state.current)
387
+ const target = names[index + direction]
388
+
389
+ if (target) move(state.current, target, direction > 0)
390
+ }
391
+
392
+ /* ---------- panel ---------- */
393
+
394
+ function buildPanel() {
395
+ ui.styles.replaceChildren(...state.deck.styles.map(style => {
396
+ const swatch = element('span', { class: 'swatch' }, [element('i')])
397
+
398
+ swatch.style.background = 'linear-gradient(135deg, ' + style.colors.base + ' 0 58%, ' + style.colors.second + ' 58% 100%)'
399
+ swatch.firstChild.style.background = style.colors.accent
400
+
401
+ const chip = element('button', { type: 'button', class: 'chip', 'data-value': style.id, title: style.label }, [swatch, element('span', { text: style.label })])
402
+
403
+ chip.addEventListener('click', () => change(state.current, { style: style.id }))
404
+
405
+ return chip
406
+ }))
407
+
408
+ ui.layouts.replaceChildren(...state.deck.layouts.map(layout => {
409
+ const icon = '<svg viewBox="0 0 32 44" fill="currentColor" aria-hidden="true">' + (LAYOUT_ICONS[layout.id] || LAYOUT_ICONS.classic) + '</svg>'
410
+ const button = element('button', { type: 'button', class: 'layout-button', 'data-value': layout.id, html: icon }, [element('span', { text: LAYOUT_LABELS[layout.id] || layout.id })])
411
+
412
+ button.addEventListener('click', () => {
413
+ const spec = specOf(state.current)
414
+ const patch = { layout: layout.id }
415
+
416
+ if (layout.needsScreen && spec.raw === undefined) patch.raw = 0
417
+ change(state.current, patch)
418
+ })
419
+
420
+ return button
421
+ }))
422
+
423
+ for (const button of $$('button', ui.caption)) button.addEventListener('click', () => change(state.current, { captionPosition: button.dataset.value }))
424
+
425
+ for (const [field] of TEXT_FIELDS) {
426
+ const input = ui.panel.elements[field]
427
+
428
+ input.maxLength = state.deck.limits[field]
429
+ input.addEventListener('input', () => {
430
+ counter(field, input.value)
431
+ // Başlık boş bırakılamaz; panelde silinen başlık önizlemede son hâliyle kalır.
432
+ if (field === 'title' && input.value.trim() === '') return
433
+ change(state.current, { [field]: input.value }, { group: 'text:' + field })
434
+ })
435
+ }
436
+
437
+ ui.panel.elements.accent.addEventListener('input', event => change(state.current, { accent: event.target.value.toUpperCase() }, { group: 'accent' }))
438
+ ui.panel.elements.text.addEventListener('input', event => change(state.current, { text: event.target.value.toUpperCase() }, { group: 'text-color' }))
439
+ $('#resetColors').addEventListener('click', () => change(state.current, { accent: undefined, text: undefined }))
440
+ ui.panel.elements.frame.addEventListener('change', event => change(state.current, { frame: event.target.checked }))
441
+ ui.panel.addEventListener('submit', event => event.preventDefault())
442
+ }
443
+
444
+ function counter(field, value) {
445
+ const max = state.deck.limits[field]
446
+ const node = ui.panel.querySelector('[data-count="' + field + '"]')
447
+ const length = (value || '').length
448
+
449
+ node.textContent = length + '/' + max
450
+ node.classList.toggle('near', length >= max * 0.85 && length < max)
451
+ node.classList.toggle('full', length >= max)
452
+ }
453
+
454
+ function syncPanel() {
455
+ const name = state.current
456
+ const card = cardOf(name)
457
+ const spec = specOf(name)
458
+ const style = styleOf(spec.style)
459
+
460
+ ui.cardTitle.textContent = 'Kare ' + (card.slide + 1) + ' · ' + card.name
461
+ ui.dirtyBadge.hidden = !isDirty(name)
462
+
463
+ for (const [field] of TEXT_FIELDS) {
464
+ const input = ui.panel.elements[field]
465
+
466
+ if (document.activeElement !== input) input.value = spec[field] || ''
467
+ counter(field, input.value)
468
+ }
469
+
470
+ for (const chip of $$('.chip', ui.styles)) chip.setAttribute('aria-pressed', String(chip.dataset.value === spec.style))
471
+ for (const button of $$('.layout-button', ui.layouts)) {
472
+ const layout = layoutOf(button.dataset.value)
473
+
474
+ button.setAttribute('aria-pressed', String(layout.id === spec.layout))
475
+ button.disabled = layout.needsScreen && state.deck.raws.length === 0
476
+ }
477
+ for (const button of $$('button', ui.caption)) {
478
+ button.setAttribute('aria-pressed', String(button.dataset.value === spec.captionPosition))
479
+ button.disabled = CAPTION_FIXED.has(spec.layout)
480
+ }
481
+
482
+ ui.panel.elements.accent.value = (spec.accent || style.colors.accent).toLowerCase()
483
+ ui.panel.elements.text.value = (spec.text || style.colors.text).toLowerCase()
484
+ $('#resetColors').hidden = !spec.accent && !spec.text
485
+ ui.panel.elements.frame.checked = spec.frame
486
+ ui.panel.elements.frame.disabled = !layoutOf(spec.layout).needsScreen
487
+
488
+ ui.raws.replaceChildren(...state.deck.raws.map((raw, index) => {
489
+ const button = element('button', { type: 'button', class: 'raw', 'aria-pressed': String(spec.raw === index), title: 'Ekran ' + (index + 1) }, [element('img', { src: raw.name, alt: 'Ekran ' + (index + 1) })])
490
+
491
+ button.addEventListener('click', () => change(name, { raw: index }))
492
+
493
+ return button
494
+ }))
495
+ if (state.deck.raws.length === 0) ui.raws.append(element('span', { class: 'raw none', text: 'yok' }))
496
+
497
+ refreshActions()
498
+ }
499
+
500
+ /* ---------- önizleme, yerinde yazma, taşma ---------- */
501
+
502
+ let previewTimer
503
+ function schedulePreview(delay = 120) {
504
+ clearTimeout(previewTimer)
505
+ previewTimer = setTimeout(loadPreview, delay)
506
+ }
507
+
508
+ async function loadPreview() {
509
+ if (state.inline) return
510
+
511
+ const sequence = ++state.previewSeq
512
+ const name = state.current
513
+
514
+ try {
515
+ const html = await api('preview', { spec: specOf(name) })
516
+
517
+ if (sequence !== state.previewSeq) return
518
+ ui.preview.dataset.card = name
519
+ ui.preview.srcdoc = html
520
+ ui.problem.hidden = true
521
+ delete ui.problem.dataset.kind
522
+ } catch (error) {
523
+ if (sequence !== state.previewSeq) return
524
+ ui.problem.textContent = error.message
525
+ ui.problem.hidden = false
526
+ }
527
+ }
528
+
529
+ function fitPreview() {
530
+ const { width, height } = state.deck.canvas
531
+ const available = ui.previewBox.parentElement.clientWidth
532
+ const scale = Math.min(available / width, Math.max(window.innerHeight - 150, 320) / height)
533
+
534
+ ui.preview.style.width = width + 'px'
535
+ ui.preview.style.height = height + 'px'
536
+ ui.preview.style.transform = 'scale(' + scale + ')'
537
+ ui.previewBox.style.width = Math.round(width * scale) + 'px'
538
+ ui.previewBox.style.height = Math.round(height * scale) + 'px'
539
+ }
540
+
541
+ function wireInline() {
542
+ const doc = ui.preview.contentDocument
543
+
544
+ if (!doc || !doc.head) return
545
+
546
+ const hover = doc.createElement('style')
547
+
548
+ // Cihaz grubu (.phone) bütün tuvali kaplar ve DOM'da başlıktan sonra gelir: tıklamayı başlığın üstünde yutuyordu.
549
+ hover.textContent = '.phone{pointer-events:none}[data-edit]{cursor:text;outline:8px dashed transparent;outline-offset:18px;border-radius:14px}[data-edit]:hover{outline-color:rgba(128,128,128,.6)}[data-edit]:focus{outline:8px solid #4c8dff}'
550
+ doc.head.append(hover)
551
+
552
+ for (const [field, label] of TEXT_FIELDS) {
553
+ const node = doc.querySelector('.' + field)
554
+
555
+ if (!node) continue
556
+
557
+ node.dataset.edit = field
558
+ node.setAttribute('contenteditable', 'plaintext-only')
559
+ node.spellcheck = false
560
+ node.addEventListener('focus', () => { state.inline = true })
561
+ node.addEventListener('blur', () => {
562
+ state.inline = false
563
+ schedulePreview(0)
564
+ })
565
+ node.addEventListener('keydown', event => {
566
+ if (event.key === 'Enter') {
567
+ event.preventDefault()
568
+ if (event.metaKey || event.ctrlKey) renderNames([state.current])
569
+ else node.blur()
570
+ }
571
+ if (event.key === 'Escape') node.blur()
572
+ })
573
+ node.addEventListener('input', () => {
574
+ const max = state.deck.limits[field]
575
+ let value = node.textContent.replace(/\s+/g, ' ')
576
+
577
+ if (value.length > max) {
578
+ value = value.slice(0, max)
579
+ node.textContent = value
580
+ toast(label + ' en çok ' + max + ' karakter')
581
+ }
582
+
583
+ if (field === 'title' && value.trim() === '') return
584
+ change(state.current, { [field]: value }, { group: 'text:' + field, preview: false })
585
+ measure(state.current, doc)
586
+ })
587
+ }
588
+ }
589
+
590
+ /**
591
+ * Kesilme satır sayısıyla ölçülür: kırpmasız kopyanın yüksekliği kırpılmış kutuyu yarım satırdan fazla aşıyorsa metin
592
+ * kesiliyordur. `scrollHeight > clientHeight` ş, ç, g gibi harflerin alt uzantısını (iPhone başlığında 4 px) kesilme
593
+ * sayıyordu ve her kart "metin kesiliyor" çıkıyordu (2026-10-26).
594
+ */
595
+ function unclampedHeight(node) {
596
+ const clone = node.cloneNode(true)
597
+
598
+ clone.removeAttribute('contenteditable')
599
+ // Ortalanmış başlığın genişliği metnin kendisidir; clientWidth aşağı yuvarlar ve kopya bir satır fazla kırılırdı.
600
+ clone.style.cssText = 'display:block;-webkit-line-clamp:unset;overflow:visible;position:absolute;visibility:hidden;left:0;top:0;width:' + (Math.ceil(node.getBoundingClientRect().width) + 1) + 'px'
601
+ node.ownerDocument.body.append(clone)
602
+
603
+ const height = clone.getBoundingClientRect().height
604
+
605
+ clone.remove()
606
+
607
+ return height
608
+ }
609
+
610
+ function measure(name, doc) {
611
+ if (!doc || !cardOf(name) || !doc.defaultView) return
612
+
613
+ const cut = []
614
+
615
+ for (const [field, label] of [['title', 'Başlık'], ['subtitle', 'Alt başlık']]) {
616
+ const node = doc.querySelector('.' + field)
617
+
618
+ if (!node) continue
619
+
620
+ const lineHeight = parseFloat(doc.defaultView.getComputedStyle(node).lineHeight) || 0
621
+
622
+ if (unclampedHeight(node) > node.clientHeight + lineHeight / 2) cut.push(label)
623
+ }
624
+
625
+ const caption = doc.querySelector('.caption')
626
+ const title = doc.querySelector('.title')
627
+ const slack = title ? (parseFloat(doc.defaultView.getComputedStyle(title).lineHeight) || 0) / 2 : 0
628
+
629
+ if (caption && caption.scrollHeight > caption.clientHeight + slack) cut.push('başlık alanı')
630
+
631
+ state.overflow.set(name, cut)
632
+ paintCard(name)
633
+
634
+ if (name === state.current && cut.length > 0 && ui.problem.hidden) {
635
+ ui.problem.textContent = cut.join(', ') + ' kesiliyor — metni kısalt ya da yerleşimi "Metin" yap'
636
+ ui.problem.hidden = false
637
+ ui.problem.dataset.kind = 'overflow'
638
+ } else if (name === state.current && cut.length === 0 && ui.problem.dataset.kind === 'overflow') {
639
+ ui.problem.hidden = true
640
+ delete ui.problem.dataset.kind
641
+ }
642
+ }
643
+
644
+ /** Bütün kartların taşmasını arka planda gizli bir iframe'de ölçer (yalnız önizlenen kart değil) */
645
+ const scanQueue = new Set()
646
+ let scanTimer
647
+ let scanning = false
648
+ const scanFrame = element('iframe', { sandbox: 'allow-same-origin', tabindex: '-1', 'aria-hidden': 'true' })
649
+
650
+ scanFrame.style.cssText = 'position:fixed;left:-99999px;top:0;border:0;visibility:hidden'
651
+ document.body.append(scanFrame)
652
+
653
+ function scheduleScan(name) {
654
+ if (name) scanQueue.add(name)
655
+ else for (const card of state.deck.cards) scanQueue.add(card.name)
656
+ clearTimeout(scanTimer)
657
+ scanTimer = setTimeout(runScan, 400)
658
+ }
659
+
660
+ async function runScan() {
661
+ if (scanning) return
662
+
663
+ scanning = true
664
+ scanFrame.style.width = state.deck.canvas.width + 'px'
665
+ scanFrame.style.height = state.deck.canvas.height + 'px'
666
+
667
+ while (scanQueue.size > 0) {
668
+ const name = scanQueue.values().next().value
669
+
670
+ scanQueue.delete(name)
671
+ if (!cardOf(name)) continue
672
+
673
+ try {
674
+ const html = await api('preview', { spec: specOf(name) })
675
+
676
+ await new Promise(resolve => {
677
+ scanFrame.onload = resolve
678
+ scanFrame.srcdoc = html
679
+ })
680
+ measure(name, scanFrame.contentDocument)
681
+ } catch (error) {
682
+ // Önizlenemeyen taslak ölçülmez.
683
+ }
684
+ }
685
+
686
+ scanning = false
687
+ }
688
+
689
+ /* ---------- çizim, kopya, yükleme ---------- */
690
+
691
+ function busy(on, label) {
692
+ state.busy = on
693
+ document.body.setAttribute('aria-busy', String(on))
694
+ if (label) toast(label)
695
+ refreshActions()
696
+ }
697
+
698
+ async function renderNames(names) {
699
+ if (state.busy) {
700
+ toast('Önceki iş sürüyor — bitince yeniden dene')
701
+
702
+ return false
703
+ }
704
+
705
+ const targets = names.filter(isDirty)
706
+
707
+ if (targets.length === 0) {
708
+ toast('Çizilecek değişiklik yok')
709
+
710
+ return true
711
+ }
712
+
713
+ busy(true, targets.length + ' kart mağaza ölçüsünde çiziliyor…')
714
+
715
+ // Gönderilen spec saklanır: çizim sürerken yapılan düzenleme taslakta kalır, yanıt onu silmez (taze inceleme M1).
716
+ const sent = new Map(targets.map(name => [name, specOf(name)]))
717
+
718
+ try {
719
+ const { cards } = await api('render', { cards: targets.map(name => ({ name, version: cardOf(name).version, spec: sent.get(name) })) })
720
+
721
+ for (const rendered of cards) {
722
+ Object.assign(cardOf(rendered.name), rendered)
723
+ if (same(state.drafts.get(rendered.name) || rendered.spec, sent.get(rendered.name))) state.drafts.delete(rendered.name)
724
+ paintCard(rendered.name)
725
+ }
726
+
727
+ saveDrafts()
728
+ if (state.current) syncPanel()
729
+ toast(cards.length + ' kart çizildi', 'ok')
730
+
731
+ return true
732
+ } catch (error) {
733
+ toast('Çizilemedi: ' + error.message, 'err')
734
+
735
+ return false
736
+ } finally {
737
+ busy(false)
738
+ }
739
+ }
740
+
741
+ let duplicating = false
742
+ async function duplicateCurrent() {
743
+ if (duplicating) return
744
+ if (state.deck.cards.length >= state.deck.limits.cards) return toast('Deste en çok ' + state.deck.limits.cards + ' kart alır', 'err')
745
+
746
+ const name = state.current
747
+
748
+ duplicating = true
749
+
750
+ try {
751
+ const { card } = await api('duplicate', { card: name })
752
+ const draft = state.drafts.get(name)
753
+
754
+ state.deck.cards.splice(state.deck.cards.findIndex(item => item.name === name) + 1, 0, card)
755
+ if (draft) state.drafts.set(card.name, { ...draft })
756
+ saveDrafts()
757
+ paintCards()
758
+ select(card.name)
759
+ toast('Kopya hazır — A/B için başka bir başlık ya da stil dene')
760
+ } catch (error) {
761
+ toast('Kopyalanamadı: ' + error.message, 'err')
762
+ } finally {
763
+ duplicating = false
764
+ }
765
+ }
766
+
767
+ async function uploadSelected() {
768
+ const names = state.deck.cards.map(card => card.name).filter(name => state.selected.has(name))
769
+
770
+ if (names.length === 0 || state.busy) return
771
+
772
+ if (names.some(isDirty) && !(await renderNames(names))) return
773
+
774
+ busy(true, names.length + ' kart yuvaya yükleniyor…')
775
+
776
+ try {
777
+ const { results } = await api('upload', { files: names.map(name => name + '.jpg') })
778
+ const failed = []
779
+
780
+ for (const result of results) {
781
+ const name = result.file.replace(/\.jpg$/, '')
782
+ const card = cardOf(name)
783
+
784
+ if (result.ok) {
785
+ card.uploadedVersion = card.version
786
+ state.selected.delete(name)
787
+ } else {
788
+ failed.push(name + ': ' + result.error)
789
+ }
790
+
791
+ paintCard(name)
792
+ }
793
+
794
+ const summary = (results.length - failed.length) + ' / ' + results.length + ' kart yuvaya yüklendi'
795
+
796
+ toast(failed.length === 0 ? summary : summary + ' — ' + failed.join(' · '), failed.length === 0 ? 'ok' : 'err')
797
+ } catch (error) {
798
+ toast('Yükleme başarısız: ' + error.message, 'err')
799
+ } finally {
800
+ busy(false)
801
+ }
802
+ }
803
+
804
+ /* ---------- zar ---------- */
805
+
806
+ function roll(spec, previous) {
807
+ const hasRaw = spec.raw !== undefined
808
+ const layouts = state.deck.layouts.filter(layout => layout.needsScreen === hasRaw).map(layout => layout.id)
809
+ const freshLayouts = layouts.filter(id => id !== spec.layout && (!previous || id !== previous.layout))
810
+ const styles = state.deck.styles.map(style => style.id).filter(id => id !== spec.style && (!previous || id !== previous.style))
811
+
812
+ return normalize({
813
+ ...spec,
814
+ style: pick(styles.length ? styles : state.deck.styles.map(style => style.id)),
815
+ layout: pick(freshLayouts.length ? freshLayouts : layouts),
816
+ captionPosition: Math.random() < 0.25 ? 'bottom' : 'top',
817
+ accent: Math.random() < 0.45 ? pick(DICE_ACCENTS) : undefined,
818
+ text: undefined
819
+ })
820
+ }
821
+
822
+ function wobble() {
823
+ ui.previewBox.classList.remove('rolled')
824
+ void ui.previewBox.offsetWidth
825
+ ui.previewBox.classList.add('rolled')
826
+ }
827
+
828
+ function rollCurrent() {
829
+ const before = specOf(state.current)
830
+ const after = roll(before)
831
+
832
+ record([{ name: state.current, before, after }], null)
833
+ apply(state.current, after)
834
+ wobble()
835
+ }
836
+
837
+ function shuffleSeries() {
838
+ let previous = null
839
+ const changes = state.deck.cards.map(card => {
840
+ const before = specOf(card.name)
841
+ const after = roll(before, previous)
842
+
843
+ previous = after
844
+
845
+ return { name: card.name, before, after }
846
+ })
847
+
848
+ record(changes, null)
849
+ for (const item of changes) apply(item.name, item.after, { preview: item.name === state.current })
850
+ wobble()
851
+ toast('Seri karıştırıldı — beğenmezsen ⌘Z, beğendiysen "Değişenleri çiz"')
852
+ }
853
+
854
+ /* ---------- vitrin ---------- */
855
+
856
+ function openShowcase() {
857
+ const chosen = state.deck.cards.filter(card => state.selected.has(card.name))
858
+ const cards = (chosen.length > 0 ? chosen : state.deck.cards).slice(0, 3)
859
+ const first = styleOf(specOf(cards[0].name).style)
860
+ const icon = $('#showcaseIcon')
861
+ const dirty = cards.filter(card => isDirty(card.name)).length
862
+
863
+ icon.style.background = 'linear-gradient(135deg, ' + first.colors.accent + ', ' + first.colors.base + ')'
864
+ $('#showcaseShots').replaceChildren(...cards.map(card => element('img', { src: thumbUrl(card), alt: 'Kare ' + (card.slide + 1) })))
865
+ $('#showcaseNote').textContent = [
866
+ chosen.length > 0 ? 'Seçili kartların ilk üçü.' : 'Destenin ilk üç kartı.',
867
+ 'Aramada yalnız ilk üç kare görünür: hikâyeyi bu üçü anlatmalı.',
868
+ dirty > 0 ? dirty + ' kartın çizilmemiş değişikliği var; vitrin son çizimi gösterir.' : ''
869
+ ].join(' ')
870
+ $('#showcaseDialog').showModal()
871
+ }
872
+
873
+ /* ---------- eylem durumu, klavye ---------- */
874
+
875
+ function refreshActions() {
876
+ if (!state.deck) return
877
+
878
+ const dirty = state.deck.cards.filter(card => isDirty(card.name))
879
+ const selected = state.deck.cards.filter(card => state.selected.has(card.name))
880
+ const slides = selected.map(card => card.slide)
881
+
882
+ ui.upload.textContent = 'Seçilenleri yükle (' + selected.length + ')'
883
+ ui.upload.disabled = state.busy || selected.length === 0
884
+ ui.upload.title = new Set(slides).size < slides.length ? 'Aynı karenin iki varyantı seçili: ikisi de yuvaya eklenir' : 'Yuva en çok ' + state.deck.maxPerSlot + ' görsel alır'
885
+ ui.renderAll.textContent = dirty.length > 0 ? 'Değişenleri çiz (' + dirty.length + ')' : 'Değişenleri çiz'
886
+ ui.renderAll.disabled = state.busy || dirty.length === 0
887
+ ui.render.disabled = state.busy || !state.current || !isDirty(state.current)
888
+ ui.undo.disabled = state.history.length === 0
889
+ ui.redo.disabled = state.future.length === 0
890
+ if (state.current) ui.dirtyBadge.hidden = !isDirty(state.current)
891
+ }
892
+
893
+ function onKey(event) {
894
+ const mod = event.metaKey || event.ctrlKey
895
+ const key = event.key.toLowerCase()
896
+
897
+ if (mod && key === 'enter') {
898
+ event.preventDefault()
899
+ renderNames([state.current])
900
+
901
+ return
902
+ }
903
+
904
+ if (mod && key === 'd') {
905
+ event.preventDefault()
906
+ if (!event.repeat) duplicateCurrent()
907
+
908
+ return
909
+ }
910
+
911
+ if (isTyping(event.target) || $('#showcaseDialog').open) return
912
+ // Odaktaki düğme ve onay kutusu Space/oklarla kendi işini yapar.
913
+ if (event.target instanceof HTMLElement && ['BUTTON', 'A'].includes(event.target.tagName) && (key === ' ' || key === 'enter')) return
914
+
915
+ if (mod && key === 'z') {
916
+ event.preventDefault()
917
+ if (event.shiftKey) redo()
918
+ else undo()
919
+ } else if (key === 'arrowleft' || key === 'arrowright') {
920
+ event.preventDefault()
921
+ const direction = key === 'arrowleft' ? -1 : 1
922
+
923
+ if (event.altKey) moveCurrent(direction)
924
+ else step(direction)
925
+ } else if (key === ' ') {
926
+ event.preventDefault()
927
+ const card = cardOf(state.current)
928
+
929
+ if (card.uploadedVersion === card.version && !isDirty(card.name)) return
930
+ if (state.selected.has(card.name)) state.selected.delete(card.name)
931
+ else state.selected.add(card.name)
932
+ paintCard(card.name)
933
+ refreshActions()
934
+ } else if (key === 'r' && !mod) {
935
+ rollCurrent()
936
+ }
937
+ }
938
+
939
+ /* ---------- açılış ---------- */
940
+
941
+ async function load() {
942
+ const response = await fetch('deck')
943
+
944
+ if (!response.ok) throw new Error('Deste okunamadı (HTTP ' + response.status + '). Gateway yeniden başlamış olabilir.')
945
+
946
+ state.deck = await response.json()
947
+ document.documentElement.style.setProperty('--ratio', String(state.deck.canvas.width / state.deck.canvas.height))
948
+ document.title = 'Mağaza görselleri · ' + state.deck.slot
949
+ restoreDrafts()
950
+ buildPanel()
951
+ paintCards()
952
+ state.current = state.deck.cards[0].name
953
+ select(state.current)
954
+ fitPreview()
955
+ scheduleScan()
956
+
957
+ if (state.drafts.size > 0) toast(state.drafts.size + ' kartın çizilmemiş taslağı geri yüklendi')
958
+ }
959
+
960
+ // Önizlemenin kartı `data-card`'dadır: seçili kart değiştiyse eski kartın belgesine yerinde yazma bağlanmaz.
961
+ ui.preview.addEventListener('load', () => {
962
+ if (ui.preview.dataset.card !== state.current) return
963
+ wireInline()
964
+ measure(state.current, ui.preview.contentDocument)
965
+ })
966
+ $('#render').addEventListener('click', () => renderNames([state.current]))
967
+ ui.renderAll.addEventListener('click', () => renderNames(state.deck.cards.map(card => card.name)))
968
+ ui.upload.addEventListener('click', uploadSelected)
969
+ $('#dice').addEventListener('click', rollCurrent)
970
+ $('#shuffle').addEventListener('click', shuffleSeries)
971
+ $('#duplicate').addEventListener('click', duplicateCurrent)
972
+ $('#showcase').addEventListener('click', openShowcase)
973
+ $('#closeShowcase').addEventListener('click', () => $('#showcaseDialog').close())
974
+ ui.undo.addEventListener('click', undo)
975
+ ui.redo.addEventListener('click', redo)
976
+ document.addEventListener('keydown', onKey)
977
+ window.addEventListener('resize', () => {
978
+ if (!state.deck) return
979
+ fitPreview()
980
+ fitThumbs()
981
+ })
982
+ window.addEventListener('beforeunload', event => {
983
+ if (state.deck && state.deck.cards.some(card => isDirty(card.name))) event.preventDefault()
984
+ })
985
+
986
+ load().catch(error => {
987
+ ui.meta.textContent = error.message
988
+ toast(error.message, 'err')
989
+ })
990
+ })()