diff options
Diffstat (limited to 'src/index.html')
| -rw-r--r-- | src/index.html | 419 |
1 files changed, 386 insertions, 33 deletions
diff --git a/src/index.html b/src/index.html index a6f3ae4..cbcc7cd 100644 --- a/src/index.html +++ b/src/index.html @@ -7,7 +7,7 @@ <title>sent-web</title> <link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/kj-sh604/noir.css@latest/out/noir.min.css"> - <style> :root { --sent-fg: #000000; --sent-bg: #ffffff; --sent-font: 'Noto Color Emoji', 'DejaVu Sans', sans-serif; } body { max-width: 960px; margin: 0 auto; padding: 1rem; } .subtitle { opacity: 0.6; font-size: 0.9em; margin-top: -0.8em; } /* ── controls ── */ #controls { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; margin-bottom: 1rem; padding: 0.75rem; border: 1px solid currentColor; border-radius: 4px; } #controls label { display: flex; align-items: center; gap: 0.4rem; font-size: 0.9rem; } #controls input[type="color"] { width: 2rem; height: 2rem; padding: 0; border: 1px solid currentColor; cursor: pointer; background: none; } #controls select { max-width: 200px; } .upload-area { display: flex; align-items: center; gap: 0.5rem; } #upload-input { display: none; } #upload-status { font-size: 0.85rem; opacity: 0.7; } /* ── editor ── */ #input { width: 100%; min-height: 420px; font-family: monospace; font-size: 0.95rem; resize: vertical; tab-size: 4; } .btn-row { display: flex; gap: 0.5rem; margin-top: 0.5rem; } .hint { font-size: 0.8rem; opacity: 0.5; margin-top: 0.25rem; } /* ── presentation overlay ── */ #presentation { position: fixed; inset: 0; z-index: 9999; display: none; align-items: center; justify-content: flex-start; background: var(--sent-bg); color: var(--sent-fg); cursor: none; overflow: hidden; padding-left: 7.5%; } #presentation.active { display: flex; } #slide-content { text-align: left; white-space: break-spaces; tab-size: 4; font-family: var(--sent-font); } #slide-content img { display: block; max-width: 85vw; max-height: 85vh; object-fit: contain; } </style> + <style> :root { --sent-fg: #000000; --sent-bg: #ffffff; --sent-font: 'Noto Color Emoji', 'Segoe UI Emoji', 'Apple Color Emoji', 'DejaVu Sans', sans-serif; } body { max-width: 960px; margin: 0 auto; padding: 1rem; } .subtitle { opacity: 0.6; font-size: 0.9em; margin-top: -0.8em; } /* ── controls ── */ #controls { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; margin-bottom: 1rem; padding: 0.75rem; border: 1px solid currentColor; border-radius: 4px; } #controls label { display: flex; align-items: center; gap: 0.4rem; font-size: 0.9rem; } #controls input[type="color"] { width: 2rem; height: 2rem; padding: 0; border: 1px solid currentColor; cursor: pointer; background: none; } #controls select { max-width: 200px; } .upload-area { display: flex; align-items: center; gap: 0.5rem; } #upload-input { display: none; } #upload-status { font-size: 0.85rem; opacity: 0.7; } /* ── editor ── */ #input { width: 100%; min-height: 420px; font-family: monospace; font-size: 0.95rem; resize: vertical; tab-size: 4; } .btn-row { display: flex; gap: 0.5rem; margin-top: 0.5rem; } .hint { font-size: 0.8rem; opacity: 0.5; margin-top: 0.25rem; } /* ── presentation overlay ── */ #presentation { position: fixed; inset: 0; z-index: 9999; display: none; align-items: center; justify-content: flex-start; background: var(--sent-bg); color: var(--sent-fg); cursor: none; overflow: hidden; padding-left: 7.5%; } #presentation.active { display: flex; } #slide-content { text-align: left; white-space: break-spaces; tab-size: 4; font-family: var(--sent-font); } #slide-content img { display: block; max-width: 85vw; max-height: 85vh; object-fit: contain; } </style> <script> /* @licstart The following is the entire license notice for the @@ -65,8 +65,8 @@ </select> </label> <div class="upload-area"> - <button type="button" onclick="document.getElementById('upload-input').click()">upload image</button> - <input type="file" id="upload-input" accept="image/*"> + <button type="button" onclick="document.getElementById('upload-input').click()">insert image</button> + <input type="file" id="upload-input" accept="image/png,image/jpeg,image/gif,image/webp,image/bmp"> <span id="upload-status"></span> </div> </section> @@ -82,7 +82,7 @@ for the very sucky web 🌎 \ -@nyan_819cac51.png +@nyan.png why? • PPTX sucks @@ -116,7 +116,7 @@ questions?</textarea> <button type="button" onclick="App.downloadSent()">download for local sent usage</button> <button type="button" onclick="App.exportPDF()">export .pdf</button> </div> - <p class="hint">F5 to present · Esc to exit · ← → h l j k space enter to navigate</p> + <p class="hint">F5 to present · Esc to exit · ← → h l j k space enter to navigate · images are local to this browser</p> </main> <!-- presentation overlay --> @@ -132,6 +132,42 @@ questions?</textarea> idx: 0, presenting: false, loadedFonts: new Set(), + imageStore: {}, + uploadTimestamps: [], + + storageKeys: { + images: 'sent-web-images', + uploadRate: 'sent-web-upload-rate', + }, + + uploadMimeToExt: { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/bmp': 'bmp', + }, + + emojiFallbackFamilies: [ + 'Noto Color Emoji', + 'Segoe UI Emoji', + 'Apple Color Emoji', + ], + + uploadPolicy: { + maxImages: 1024, + maxImageBytes: 25 * 1000 * 1000, + maxTotalBytes: 25 * 1000 * 1000 * 1000, + rateLimitWindowMs: 60 * 1000, + maxUploadsPerWindow: 6, + }, + + defaultBundledImage: { + name: 'nyan.png', + aliases: ['nyan_819cac51.png'], + url: '/nyan.png', + mime: 'image/png', + }, settings: { fg: '#000000', @@ -143,7 +179,9 @@ questions?</textarea> }, init() { + this.restoreImageState(); this.restoreState(); + this.ensureBundledImageInLocalStore(); this.loadFonts(); this.bindEvents(); this.updateColors(); @@ -180,6 +218,255 @@ questions?</textarea> })); }, + canonicalImageName(name) { + if (!name) return name; + if (name === this.defaultBundledImage.name) return name; + if (this.defaultBundledImage.aliases.includes(name)) { + return this.defaultBundledImage.name; + } + return name; + }, + + isPinnedLocalImage(name) { + return this.canonicalImageName(name) === this.defaultBundledImage.name; + }, + + restoreImageState() { + this.imageStore = {}; + this.uploadTimestamps = []; + + try { + const rawImages = localStorage.getItem(this.storageKeys.images); + if (rawImages) { + const parsedImages = JSON.parse(rawImages); + if (parsedImages && typeof parsedImages === 'object') { + for (const [name, entry] of Object.entries(parsedImages)) { + if (!entry || typeof entry !== 'object') continue; + if (typeof entry.dataUrl !== 'string' || !entry.dataUrl.startsWith('data:image/')) continue; + if (typeof entry.mime !== 'string' || !(entry.mime in this.uploadMimeToExt)) continue; + if (!Number.isFinite(entry.bytes) || entry.bytes <= 0) continue; + const canonicalName = this.canonicalImageName(name); + this.imageStore[canonicalName] = { + dataUrl: entry.dataUrl, + mime: entry.mime, + bytes: entry.bytes, + createdAt: Number.isFinite(entry.createdAt) ? entry.createdAt : Date.now(), + }; + } + } + } + } catch (_) {} + + try { + const rawRate = localStorage.getItem(this.storageKeys.uploadRate); + if (rawRate) { + const parsedRate = JSON.parse(rawRate); + if (Array.isArray(parsedRate)) { + this.uploadTimestamps = parsedRate + .filter(ts => Number.isFinite(ts)) + .sort((a, b) => a - b); + } + } + } catch (_) {} + + this.pruneUploadTimestamps(); + try { + this.saveUploadRateState(); + } catch (_) {} + try { + this.saveImageStore(); + } catch (_) {} + }, + + saveImageStore() { + localStorage.setItem(this.storageKeys.images, JSON.stringify(this.imageStore)); + }, + + saveUploadRateState() { + localStorage.setItem(this.storageKeys.uploadRate, JSON.stringify(this.uploadTimestamps)); + }, + + pruneUploadTimestamps(now = Date.now()) { + const cutoff = now - this.uploadPolicy.rateLimitWindowMs; + this.uploadTimestamps = this.uploadTimestamps.filter(ts => ts >= cutoff); + }, + + canStoreUpload(now = Date.now()) { + this.pruneUploadTimestamps(now); + if (this.uploadTimestamps.length < this.uploadPolicy.maxUploadsPerWindow) { + return { + ok: true + }; + } + + const oldest = this.uploadTimestamps[0]; + const retryInMs = Math.max(0, oldest + this.uploadPolicy.rateLimitWindowMs - now); + return { + ok: false, + error: `rate limit hit, retry in ${Math.max(1, Math.ceil(retryInMs / 1000))}s`, + }; + }, + + imageStoreBytes(store = this.imageStore) { + return Object.values(store).reduce((sum, entry) => { + if (!entry || typeof entry !== 'object' || !Number.isFinite(entry.bytes)) { + return sum; + } + return sum + entry.bytes; + }, 0); + }, + + formatBytes(bytes) { + if (bytes < 1000) return `${bytes} B`; + const kb = bytes / 1000; + if (kb < 1000) return `${kb.toFixed(1)} KB`; + const mb = kb / 1000; + if (mb < 1000) return `${mb.toFixed(1)} MB`; + return `${(mb / 1000).toFixed(2)} GB`; + }, + + referencedImageNames(text) { + const refs = new Set(); + for (const rawLine of this.normalizeSentNewlines(text).split('\n')) { + const line = rawLine.trim(); + if (!line.startsWith('@')) continue; + const name = line.slice(1).trim(); + if (!name || name.startsWith('http://') || name.startsWith('https://')) continue; + refs.add(this.canonicalImageName(name)); + } + return refs; + }, + + pruneUnusedLocalImages(text) { + const refs = this.referencedImageNames(text); + let changed = false; + for (const name of Object.keys(this.imageStore)) { + if (!refs.has(this.canonicalImageName(name)) && !this.isPinnedLocalImage(name)) { + delete this.imageStore[name]; + changed = true; + } + } + + if (changed) { + try { + this.saveImageStore(); + } catch (_) {} + } + }, + + makeLocalImageName(file, ext) { + const stem = (file.name || 'image') + .replace(/\.[^.]+$/, '') + .replace(/[^a-zA-Z0-9_-]/g, '_') + .slice(0, 64) || 'image'; + const rand = (crypto.randomUUID ? crypto.randomUUID().replace(/-/g, '') : String(Math.random()).slice(2)).slice(0, 8); + return `${stem}_${rand}.${ext}`; + }, + + blobToDataUrl(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === 'string') { + resolve(reader.result); + } else { + reject(new Error('failed to encode image')); + } + }; + reader.onerror = () => reject(new Error('failed to read image')); + reader.readAsDataURL(blob); + }); + }, + + fileToDataUrl(file) { + return this.blobToDataUrl(file); + }, + + async ensureBundledImageInLocalStore() { + const key = this.defaultBundledImage.name; + const existing = this.imageStore[key]; + if (existing && typeof existing.dataUrl === 'string') { + return; + } + + try { + const res = await fetch(this.defaultBundledImage.url, { + cache: 'force-cache' + }); + if (!res.ok) { + throw new Error(`failed to fetch bundled image (${res.status})`); + } + + const blob = await res.blob(); + const mime = blob.type || this.defaultBundledImage.mime; + if (!(mime in this.uploadMimeToExt)) { + return; + } + + if (!Number.isFinite(blob.size) || blob.size <= 0) { + return; + } + + if (blob.size > this.uploadPolicy.maxImageBytes) { + return; + } + + const dataUrl = await this.blobToDataUrl(blob); + const nextStore = { + ...this.imageStore, + [key]: { + dataUrl, + mime, + bytes: blob.size, + createdAt: Date.now(), + }, + }; + + if (this.imageStoreBytes(nextStore) > this.uploadPolicy.maxTotalBytes) { + return; + } + + this.imageStore = nextStore; + this.saveImageStore(); + } catch (err) { + console.warn('could not seed bundled nyan image:', err); + } + }, + + resolveSlideImageSrc(imageRef) { + if (imageRef.startsWith('http://') || imageRef.startsWith('https://')) { + return imageRef; + } + + const canonicalName = this.canonicalImageName(imageRef); + const entry = this.imageStore[canonicalName] || this.imageStore[imageRef]; + if (entry && typeof entry.dataUrl === 'string') { + return entry.dataUrl; + } + + if (canonicalName === this.defaultBundledImage.name) { + return this.defaultBundledImage.url; + } + + return canonicalName; + }, + + buildSentFontStack(primaryFamily) { + const families = []; + const seen = new Set(); + const add = (family) => { + if (!family || seen.has(family)) return; + seen.add(family); + families.push(`'${family}'`); + }; + + add(primaryFamily); + this.emojiFallbackFamilies.forEach(add); + add('DejaVu Sans'); + + return `${families.join(', ')}, sans-serif`; + }, + async loadFonts() { try { const res = await fetch('/fonts'); @@ -188,10 +475,12 @@ questions?</textarea> sel.innerHTML = ''; // preload fallback fonts - const notoEmoji = data.find(f => f.family === 'Noto Color Emoji'); + const emojiFallback = this.emojiFallbackFamilies + .map(family => data.find(f => f.family === family)) + .find(Boolean); const dejavu = data.find(f => f.family.startsWith('DejaVu Sans') && !f.family.includes('Mono')); - if (notoEmoji) await this.loadFont(notoEmoji); + if (emojiFallback) await this.loadFont(emojiFallback); if (dejavu) await this.loadFont(dejavu); // populate dropdown @@ -242,7 +531,7 @@ questions?</textarea> this.settings.fontFamily = fontData.family; await this.loadFont(fontData); - const stack = `'${fontData.family}', 'Noto Color Emoji', 'DejaVu Sans', sans-serif`; + const stack = this.buildSentFontStack(fontData.family); document.documentElement.style.setProperty('--sent-font', stack); this.saveSettings(); @@ -259,6 +548,9 @@ questions?</textarea> input.addEventListener('input', () => { localStorage.setItem('sent-web-content', input.value); }); + input.addEventListener('blur', () => { + this.pruneUnusedLocalImages(input.value); + }); input.addEventListener('keydown', e => this.handleEditorKeydown(e)); document.addEventListener('keydown', e => this.handleKeydown(e)); @@ -405,6 +697,7 @@ questions?</textarea> // presentation controls startPresentation() { const text = document.getElementById('input').value; + this.pruneUnusedLocalImages(text); this.slides = this.parseSent(text); if (this.slides.length === 0) { @@ -461,11 +754,7 @@ questions?</textarea> pres.style.alignItems = 'center'; pres.style.paddingLeft = '0'; const img = document.createElement('img'); - if (slide.img.startsWith('http://') || slide.img.startsWith('https://')) { - img.src = slide.img; - } else { - img.src = 'uploads/' + slide.img; - } + img.src = this.resolveSlideImageSrc(slide.img); img.alt = slide.img; // changed to reflect the fullscreen viewport. const pw = pres.offsetWidth || window.innerWidth; @@ -586,48 +875,114 @@ questions?</textarea> if (!file) return; const status = document.getElementById('upload-status'); - status.textContent = 'uploading…'; + const done = () => { + e.target.value = ''; + }; + + const ext = this.uploadMimeToExt[file.type]; + if (!ext) { + status.textContent = `error: invalid file type: ${file.type || 'unknown'}`; + done(); + return; + } + + if (!Number.isFinite(file.size) || file.size <= 0) { + status.textContent = 'error: empty file'; + done(); + return; + } + + if (file.size > this.uploadPolicy.maxImageBytes) { + status.textContent = `error: file too large (max ${this.formatBytes(this.uploadPolicy.maxImageBytes)})`; + done(); + return; + } + + const rateCheck = this.canStoreUpload(); + if (!rateCheck.ok) { + status.textContent = `error: ${rateCheck.error}`; + done(); + return; + } - const fd = new FormData(); - fd.append('image', file); + status.textContent = 'storing locally...'; try { - const res = await fetch('/upload', { - method: 'POST', - body: fd - }); - const data = await res.json(); + const ta = document.getElementById('input'); + this.pruneUnusedLocalImages(ta.value); - if (data.error) { - status.textContent = 'error: ' + data.error; + if (Object.keys(this.imageStore).length >= this.uploadPolicy.maxImages) { + status.textContent = `error: local image limit reached (${this.uploadPolicy.maxImages})`; return; } - // insert @filename at cursor position - const ta = document.getElementById('input'); + const filename = this.makeLocalImageName(file, ext); + const dataUrl = await this.fileToDataUrl(file); + + const nextStore = { + ...this.imageStore, + [filename]: { + dataUrl, + mime: file.type, + bytes: file.size, + createdAt: Date.now(), + }, + }; + + const nextTotalBytes = this.imageStoreBytes(nextStore); + if (nextTotalBytes > this.uploadPolicy.maxTotalBytes) { + status.textContent = `error: local quota exceeded (max ${this.formatBytes(this.uploadPolicy.maxTotalBytes)} total)`; + return; + } + + this.imageStore = nextStore; + this.saveImageStore(); + + this.uploadTimestamps.push(Date.now()); + this.pruneUploadTimestamps(); + this.saveUploadRateState(); + const pos = ta.selectionStart; const txt = ta.value; - const ins = `\n@${data.filename}\n`; + const ins = `\n@${filename}\n`; ta.value = txt.substring(0, pos) + ins + txt.substring(pos); ta.selectionStart = ta.selectionEnd = pos + ins.length; ta.focus(); localStorage.setItem('sent-web-content', ta.value); - status.textContent = `uploaded: ${data.filename}`; + status.textContent = `stored: ${filename}`; setTimeout(() => { status.textContent = ''; }, 3000); } catch (err) { - status.textContent = 'upload failed'; + if (err && err.name === 'QuotaExceededError') { + status.textContent = 'error: browser storage quota reached'; + } else { + status.textContent = 'error: failed to store image locally'; + } console.error(err); + } finally { + done(); } - - e.target.value = ''; }, // download .sent file for local usage (base64-encoded to preserve unicode and avoid filename issues) downloadSent() { const text = document.getElementById('input').value; + let localRefs = 0; + for (const name of this.referencedImageNames(text)) { + if (this.imageStore[name]) { + localRefs += 1; + } + } + + if (localRefs > 0) { + const ok = window.confirm( + `this deck references ${localRefs} locally stored image(s). the .sent file will not include image binaries. continue?` + ); + if (!ok) return; + } + const blob = new Blob([text], { type: 'text/plain' }); @@ -739,9 +1094,7 @@ questions?</textarea> resolve(); }; img.onerror = resolve; // still produce a page even on failure - img.src = (slide.img.startsWith('http://') || slide.img.startsWith('https://')) ? - slide.img : - 'uploads/' + slide.img; + img.src = this.resolveSlideImageSrc(slide.img); }); } else { // text slide — left-aligned, same sizing logic as live view |
