@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.
- package/dist/knowledge/componentEvents.js +1 -1
- package/dist/knowledge/mobileComponentCatalog.js +6 -3
- package/dist/knowledge/mobileComponentEvents.js +9 -0
- package/dist/knowledge/mobileStore.js +14 -0
- package/dist/knowledge/platform/lint.js +36 -3
- package/dist/knowledge/platform/mobileRules.js +6 -0
- package/dist/platform/capture/actions.js +67 -0
- package/dist/platform/capture/capture.js +26 -21
- package/dist/platform/storeArt/compose.js +114 -0
- package/dist/platform/storeArt/deck.js +133 -0
- package/dist/platform/storeArt/gallery.js +331 -0
- package/dist/platform/storeArt/layouts.js +121 -0
- package/dist/platform/storeArt/styles.js +54 -0
- package/dist/resources/examples/mobile-reorder-persist.json +491 -0
- package/dist/resources/guides/microservice.md +7 -0
- package/dist/resources/guides/mobile-publish.md +13 -0
- package/dist/resources/guides/mobile.md +4 -0
- package/dist/resources/guides/page.md +62 -0
- package/dist/resources/prompts/mobile-publish-prepare.md +3 -1
- package/dist/resources/store-art/editor.css +389 -0
- package/dist/resources/store-art/editor.html +123 -0
- package/dist/resources/store-art/editor.js +990 -0
- package/dist/tools/mobileStore.js +56 -19
- package/dist/tools/pages.js +21 -5
- package/package.json +1 -1
|
@@ -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
|
+
})()
|