aboutsummaryrefslogtreecommitdiffstats
path: root/src/index.html
diff options
context:
space:
mode:
Diffstat (limited to 'src/index.html')
-rw-r--r--src/index.html419
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