refactor: migrate app to Alpine.js
This commit is contained in:
1 parent
766212f119
commit
bc236db952
9 files changed
+243
-190
No files matched your search
@@ -1,113 +1,12 @@
|
|||||||
|
import Alpine from './vendor/alpine.js';
|
||||||
import {render, styles, resolveFont} from './renderer.js';
|
import {render, styles, resolveFont} from './renderer.js';
|
||||||
import {translations} from './translations.js';
|
import {translations} from './translations.js';
|
||||||
import {setupPWA} from './pwa.js';
|
import {setupPWA} from './pwa.js';
|
||||||
|
|
||||||
const text = document.getElementById('text');
|
function savePreference(key, value) {
|
||||||
const style = document.getElementById('style');
|
|
||||||
const output = document.getElementById('output');
|
|
||||||
const status = document.getElementById('status');
|
|
||||||
const language = document.getElementById('language');
|
|
||||||
const theme = document.getElementById('theme');
|
|
||||||
let lang = document.documentElement.lang;
|
|
||||||
let statusKey = '';
|
|
||||||
const t = key => translations[lang][key];
|
|
||||||
const showStatus = key => {
|
|
||||||
statusKey = key;
|
|
||||||
status.textContent = key ? t(key) : '';
|
|
||||||
};
|
|
||||||
const savePreference = (key, value) => {
|
|
||||||
try { localStorage.setItem(`ascii-pwa-${key}`, value); } catch { /* Storage may be disabled. */ }
|
try { localStorage.setItem(`ascii-pwa-${key}`, value); } catch { /* Storage may be disabled. */ }
|
||||||
};
|
|
||||||
function translate() {
|
|
||||||
document.documentElement.lang = lang;
|
|
||||||
language.value = lang;
|
|
||||||
document.querySelectorAll('[data-i18n]').forEach(element => {
|
|
||||||
element.textContent = t(element.dataset.i18n);
|
|
||||||
});
|
|
||||||
output.setAttribute('aria-label', t('outputLabel'));
|
|
||||||
document.querySelector('.logo').setAttribute('aria-label', t('homeLabel'));
|
|
||||||
updateFontNavigation();
|
|
||||||
showStatus(statusKey);
|
|
||||||
}
|
|
||||||
let drawVersion = 0;
|
|
||||||
let drawTimer;
|
|
||||||
function updateFontNavigation() {
|
|
||||||
const next = styles[(styles.indexOf(style.value) + 1) % styles.length];
|
|
||||||
document.getElementById('next-font').textContent = t('nextFont').replace('{name}', next);
|
|
||||||
}
|
|
||||||
async function draw() {
|
|
||||||
const version = ++drawVersion;
|
|
||||||
const copy = document.getElementById('copy');
|
|
||||||
const download = document.getElementById('download');
|
|
||||||
copy.disabled = true;
|
|
||||||
download.disabled = true;
|
|
||||||
try {
|
|
||||||
const result = await render(text.value, style.value);
|
|
||||||
if (version !== drawVersion) return;
|
|
||||||
output.textContent = result;
|
|
||||||
showStatus('');
|
|
||||||
} catch {
|
|
||||||
if (version !== drawVersion) return;
|
|
||||||
output.textContent = '';
|
|
||||||
showStatus('renderFailed');
|
|
||||||
}
|
|
||||||
document.getElementById('copy').disabled = !output.textContent.trim();
|
|
||||||
document.getElementById('download').disabled = !output.textContent.trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Explicit shared URLs take precedence; a normal visit always opens the homepage.
|
|
||||||
const params = new URL(window.location.href).searchParams;
|
|
||||||
style.replaceChildren(...styles.map(font => {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = font;
|
|
||||||
option.textContent = font;
|
|
||||||
return option;
|
|
||||||
}));
|
|
||||||
style.value = 'Standard';
|
|
||||||
try {
|
|
||||||
const savedFont = localStorage.getItem('ascii-pwa-font');
|
|
||||||
if (savedFont) style.value = resolveFont(savedFont);
|
|
||||||
} catch { /* Use the default when saved preferences are unavailable. */ }
|
|
||||||
if (params.has('text')) text.value = params.get('text').slice(0, 500);
|
|
||||||
if (params.has('font')) style.value = resolveFont(params.get('font'));
|
|
||||||
savePreference('font', style.value);
|
|
||||||
theme.value = document.documentElement.dataset.theme;
|
|
||||||
translate();
|
|
||||||
draw();
|
|
||||||
|
|
||||||
document.getElementById('render-form').addEventListener('submit', event => {
|
|
||||||
event.preventDefault();
|
|
||||||
draw();
|
|
||||||
});
|
|
||||||
style.addEventListener('change', () => {
|
|
||||||
clearTimeout(drawTimer);
|
|
||||||
savePreference('font', style.value);
|
|
||||||
updateFontNavigation();
|
|
||||||
draw();
|
|
||||||
});
|
|
||||||
text.addEventListener('input', () => {
|
|
||||||
clearTimeout(drawTimer);
|
|
||||||
++drawVersion;
|
|
||||||
document.getElementById('copy').disabled = true;
|
|
||||||
document.getElementById('download').disabled = true;
|
|
||||||
drawTimer = setTimeout(draw, 150);
|
|
||||||
});
|
|
||||||
for (const [id, direction] of [['prev-font', -1], ['next-font', 1]]) {
|
|
||||||
document.getElementById(id).addEventListener('click', () => {
|
|
||||||
const index = (styles.indexOf(style.value) + direction + styles.length) % styles.length;
|
|
||||||
style.value = styles[index];
|
|
||||||
style.dispatchEvent(new Event('change'));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
language.addEventListener('change', () => {
|
|
||||||
lang = language.value;
|
|
||||||
savePreference('language', lang);
|
|
||||||
translate();
|
|
||||||
});
|
|
||||||
theme.addEventListener('change', () => {
|
|
||||||
document.documentElement.dataset.theme = theme.value;
|
|
||||||
savePreference('theme', theme.value);
|
|
||||||
});
|
|
||||||
function copyText(value) {
|
function copyText(value) {
|
||||||
const field = document.createElement('textarea');
|
const field = document.createElement('textarea');
|
||||||
const focused = document.activeElement;
|
const focused = document.activeElement;
|
||||||
@@ -126,20 +25,109 @@ function copyText(value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('copy').addEventListener('click', () => {
|
Alpine.data('asciiApp', () => {
|
||||||
|
let drawVersion = 0;
|
||||||
|
let drawTimer;
|
||||||
|
let pwa;
|
||||||
|
|
||||||
|
return {
|
||||||
|
styles,
|
||||||
|
text: 'hello world',
|
||||||
|
font: 'Standard',
|
||||||
|
ascii: '',
|
||||||
|
busy: true,
|
||||||
|
statusKey: '',
|
||||||
|
language: document.documentElement.lang,
|
||||||
|
theme: document.documentElement.dataset.theme,
|
||||||
|
installAvailable: false,
|
||||||
|
updateAvailable: false,
|
||||||
|
installed: false,
|
||||||
|
|
||||||
|
get nextFont() {
|
||||||
|
return styles[(styles.indexOf(this.font) + 1) % styles.length];
|
||||||
|
},
|
||||||
|
get canCopy() {
|
||||||
|
return !this.busy && !!this.ascii.trim();
|
||||||
|
},
|
||||||
|
t(key) {
|
||||||
|
return translations[this.language][key];
|
||||||
|
},
|
||||||
|
init() {
|
||||||
try {
|
try {
|
||||||
copyText(output.textContent);
|
const saved = localStorage.getItem('ascii-pwa-font');
|
||||||
showStatus('copied');
|
if (saved) this.font = resolveFont(saved);
|
||||||
|
} catch { /* Use the default when saved preferences are unavailable. */ }
|
||||||
|
const params = new URL(window.location.href).searchParams;
|
||||||
|
if (params.has('text')) this.text = params.get('text').slice(0, 500);
|
||||||
|
if (params.has('font')) this.font = resolveFont(params.get('font'));
|
||||||
|
savePreference('font', this.font);
|
||||||
|
|
||||||
|
this.$watch('text', () => this.scheduleDraw());
|
||||||
|
this.$watch('font', () => {
|
||||||
|
savePreference('font', this.font);
|
||||||
|
this.draw();
|
||||||
|
});
|
||||||
|
this.$watch('language', () => {
|
||||||
|
document.documentElement.lang = this.language;
|
||||||
|
savePreference('language', this.language);
|
||||||
|
});
|
||||||
|
this.$watch('theme', () => {
|
||||||
|
document.documentElement.dataset.theme = this.theme;
|
||||||
|
savePreference('theme', this.theme);
|
||||||
|
});
|
||||||
|
pwa = setupPWA({
|
||||||
|
installAvailable: value => { this.installAvailable = value; },
|
||||||
|
updateAvailable: value => { this.updateAvailable = value; },
|
||||||
|
installed: value => { this.installed = value; },
|
||||||
|
failed: () => { this.statusKey = 'offlineFailed'; },
|
||||||
|
});
|
||||||
|
this.draw();
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
clearTimeout(drawTimer);
|
||||||
|
++drawVersion;
|
||||||
|
pwa?.destroy();
|
||||||
|
},
|
||||||
|
scheduleDraw() {
|
||||||
|
clearTimeout(drawTimer);
|
||||||
|
++drawVersion;
|
||||||
|
this.busy = true;
|
||||||
|
drawTimer = setTimeout(() => this.draw(), 150);
|
||||||
|
},
|
||||||
|
async draw() {
|
||||||
|
clearTimeout(drawTimer);
|
||||||
|
const version = ++drawVersion;
|
||||||
|
this.busy = true;
|
||||||
|
try {
|
||||||
|
const result = await render(this.text, this.font);
|
||||||
|
if (version !== drawVersion) return;
|
||||||
|
this.ascii = result;
|
||||||
|
this.statusKey = '';
|
||||||
|
} catch {
|
||||||
|
if (version !== drawVersion) return;
|
||||||
|
this.ascii = '';
|
||||||
|
this.statusKey = 'renderFailed';
|
||||||
|
} finally {
|
||||||
|
if (version === drawVersion) this.busy = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cycleFont(direction) {
|
||||||
|
this.font = styles[(styles.indexOf(this.font) + direction + styles.length) % styles.length];
|
||||||
|
},
|
||||||
|
copy() {
|
||||||
|
try {
|
||||||
|
copyText(this.ascii);
|
||||||
|
this.statusKey = 'copied';
|
||||||
} catch {
|
} catch {
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
range.selectNodeContents(output);
|
range.selectNodeContents(this.$refs.output);
|
||||||
window.getSelection().removeAllRanges();
|
window.getSelection().removeAllRanges();
|
||||||
window.getSelection().addRange(range);
|
window.getSelection().addRange(range);
|
||||||
showStatus('copyFailed');
|
this.statusKey = 'copyFailed';
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
document.getElementById('download').addEventListener('click', () => {
|
download() {
|
||||||
const url = URL.createObjectURL(new Blob([output.textContent + '\n'], {type: 'text/plain;charset=utf-8'}));
|
const url = URL.createObjectURL(new Blob([this.ascii + '\n'], {type: 'text/plain;charset=utf-8'}));
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.download = 'ascii-art.txt';
|
link.download = 'ascii-art.txt';
|
||||||
@@ -147,21 +135,23 @@ document.getElementById('download').addEventListener('click', () => {
|
|||||||
link.click();
|
link.click();
|
||||||
link.remove();
|
link.remove();
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
showStatus('downloaded');
|
this.statusKey = 'downloaded';
|
||||||
});
|
},
|
||||||
document.getElementById('share').addEventListener('click', () => {
|
share() {
|
||||||
const url = new URL('./', window.location.href);
|
const url = new URL('./', window.location.href);
|
||||||
url.searchParams.set('text', text.value);
|
url.searchParams.set('text', this.text);
|
||||||
url.searchParams.set('font', style.value);
|
url.searchParams.set('font', this.font);
|
||||||
try {
|
try {
|
||||||
copyText(url.href);
|
copyText(url.href);
|
||||||
showStatus('linkCopied');
|
this.statusKey = 'linkCopied';
|
||||||
} catch {
|
} catch {
|
||||||
window.history.replaceState(null, '', url);
|
window.history.replaceState(null, '', url);
|
||||||
showStatus('linkFailed');
|
this.statusKey = 'linkFailed';
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
install() { return pwa.install(); },
|
||||||
|
update() { pwa.update(); },
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
setupPWA({
|
Alpine.start();
|
||||||
failed() { showStatus('offlineFailed'); },
|
|
||||||
});
|
|
||||||
+31
-29
@@ -14,58 +14,60 @@
|
|||||||
<script type="module" src="./app.js"></script>
|
<script type="module" src="./app.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main x-data="asciiApp" x-cloak>
|
||||||
<header>
|
<header>
|
||||||
<a class="logo" href="./" aria-label="ascii homepage">
|
<a class="logo" href="./" :aria-label="t('homeLabel')">
|
||||||
<img src="./icons/ascii.svg" width="64" height="64" alt="">
|
<img src="./icons/ascii.svg" width="64" height="64" alt="">
|
||||||
<h1>ascii!</h1>
|
<h1>ascii!</h1>
|
||||||
</a>
|
</a>
|
||||||
</header>
|
</header>
|
||||||
<form id="render-form">
|
<form id="render-form" @submit.prevent="draw()">
|
||||||
<label for="text" data-i18n="textLabel">your text</label>
|
<label for="text" x-text="t('textLabel')">your text</label>
|
||||||
<textarea id="text" name="text" rows="2" maxlength="500" spellcheck="false">hello world</textarea>
|
<textarea id="text" name="text" rows="2" maxlength="500" spellcheck="false" x-model="text"></textarea>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<label><span data-i18n="styleLabel">lettering</span>
|
<label><span x-text="t('styleLabel')">lettering</span>
|
||||||
<select id="style" name="font">
|
<select id="style" name="font" x-model="font">
|
||||||
<option value="Standard">Standard</option>
|
<template x-for="name in styles" :key="name">
|
||||||
|
<option :value="name" :selected="name === font" x-text="name"></option>
|
||||||
|
</template>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<div class="font-navigation">
|
<div class="font-navigation">
|
||||||
<button id="prev-font" type="button" data-i18n="prev">prev</button>
|
<button id="prev-font" type="button" @click="cycleFont(-1)" x-text="t('prev')">prev</button>
|
||||||
<button id="next-font" type="button">next font: 3-D</button>
|
<button id="next-font" type="button" @click="cycleFont(1)" x-text="t('nextFont').replace('{name}', nextFont)"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div class="output-heading">
|
<div class="output-heading">
|
||||||
<p data-i18n="preview">your ASCII art</p>
|
<p x-text="t('preview')">your ASCII art</p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button id="copy" type="button" data-i18n="copy">copy</button>
|
<button id="copy" type="button" @click="copy()" :disabled="!canCopy" x-text="t('copy')">copy</button>
|
||||||
<button id="download" type="button" data-i18n="download">download .txt</button>
|
<button id="download" type="button" @click="download()" :disabled="!canCopy" x-text="t('download')">download .txt</button>
|
||||||
<button id="share" type="button" data-i18n="share">copy link</button>
|
<button id="share" type="button" @click="share()" x-text="t('share')">copy link</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<pre id="output" tabindex="0" aria-label="ASCII art"></pre>
|
<pre id="output" x-ref="output" tabindex="0" :aria-label="t('outputLabel')" :aria-busy="busy" x-text="ascii"></pre>
|
||||||
<p class="note" data-i18n="glyphNote">Character support varies by font.</p>
|
<p class="note" x-text="t('glyphNote')">Character support varies by font.</p>
|
||||||
<p id="status" role="status" aria-live="polite"></p>
|
<p id="status" role="status" aria-live="polite" x-text="statusKey ? t(statusKey) : ''"></p>
|
||||||
<footer>
|
<footer>
|
||||||
<div class="preferences">
|
<div class="preferences">
|
||||||
<label><span data-i18n="theme">appearance</span>
|
<label><span x-text="t('theme')">appearance</span>
|
||||||
<select id="theme">
|
<select id="theme" x-model="theme">
|
||||||
<option value="system" data-i18n="system">system</option>
|
<option value="system" x-text="t('system')">system</option>
|
||||||
<option value="dark" data-i18n="dark">dark</option>
|
<option value="dark" x-text="t('dark')">dark</option>
|
||||||
<option value="light" data-i18n="light">light</option>
|
<option value="light" x-text="t('light')">light</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label><span data-i18n="language">language</span>
|
<label><span x-text="t('language')">language</span>
|
||||||
<select id="language"><option value="en">English</option><option value="tr">Türkçe</option></select>
|
<select id="language" x-model="language"><option value="en">English</option><option value="tr">Türkçe</option></select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="install-controls">
|
<div class="install-controls">
|
||||||
<button id="install" type="button" hidden data-i18n="install">install app</button>
|
<button id="install" type="button" x-show="installAvailable" @click="install()" x-text="t('install')">install app</button>
|
||||||
<button id="update" type="button" hidden data-i18n="update">update app</button>
|
<button id="update" type="button" x-show="updateAvailable" @click="update()" x-text="t('update')">update app</button>
|
||||||
<details id="install-guide">
|
<details id="install-guide" x-show="!installed && !installAvailable">
|
||||||
<summary data-i18n="install">install app</summary>
|
<summary x-text="t('install')">install app</summary>
|
||||||
<p data-i18n="installGuide">Use your browser’s install option. In Safari on iPhone or iPad, choose Share → Add to Home Screen.</p>
|
<p x-text="t('installGuide')">Use your browser’s install option. In Safari on iPhone or iPad, choose Share → Add to Home Screen.</p>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,51 +1,60 @@
|
|||||||
export function setupPWA({failed}) {
|
// Browser prompt and worker objects stay outside Alpine's reactive state.
|
||||||
const install = document.getElementById('install');
|
export function setupPWA({installAvailable, updateAvailable, installed, failed}) {
|
||||||
const guide = document.getElementById('install-guide');
|
|
||||||
const update = document.getElementById('update');
|
|
||||||
const standalone = () => window.matchMedia('(display-mode: standalone)').matches || navigator.standalone;
|
|
||||||
let prompt;
|
let prompt;
|
||||||
guide.hidden = !!standalone();
|
let registration;
|
||||||
window.addEventListener('beforeinstallprompt', event => {
|
const cleanup = [];
|
||||||
|
const listen = (target, name, handler) => {
|
||||||
|
target.addEventListener(name, handler);
|
||||||
|
cleanup.push(() => target.removeEventListener(name, handler));
|
||||||
|
};
|
||||||
|
const standalone = () => window.matchMedia('(display-mode: standalone)').matches || !!navigator.standalone;
|
||||||
|
installed(standalone());
|
||||||
|
listen(window, 'beforeinstallprompt', event => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
prompt = event;
|
prompt = event;
|
||||||
install.hidden = false;
|
installAvailable(true);
|
||||||
guide.hidden = true;
|
|
||||||
});
|
});
|
||||||
install.addEventListener('click', async () => {
|
listen(window, 'appinstalled', () => {
|
||||||
if (!prompt) return;
|
|
||||||
const pending = prompt;
|
|
||||||
prompt = undefined;
|
prompt = undefined;
|
||||||
install.hidden = true;
|
installAvailable(false);
|
||||||
try {
|
installed(true);
|
||||||
await pending.prompt();
|
|
||||||
await pending.userChoice;
|
|
||||||
} catch {
|
|
||||||
// Keep manual installation guidance available if the prompt fails.
|
|
||||||
} finally {
|
|
||||||
guide.hidden = !!standalone();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
window.addEventListener('appinstalled', () => {
|
|
||||||
prompt = undefined;
|
let destroyed = false;
|
||||||
install.hidden = true;
|
if ('serviceWorker' in navigator) {
|
||||||
guide.hidden = true;
|
|
||||||
});
|
|
||||||
if (!('serviceWorker' in navigator)) return;
|
|
||||||
let controlled = !!navigator.serviceWorker.controller;
|
let controlled = !!navigator.serviceWorker.controller;
|
||||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
listen(navigator.serviceWorker, 'controllerchange', () => {
|
||||||
if (controlled) window.location.reload();
|
if (controlled) window.location.reload();
|
||||||
controlled = true;
|
controlled = true;
|
||||||
});
|
});
|
||||||
navigator.serviceWorker.register('./sw.js', {scope: './', type: 'module', updateViaCache: 'none'})
|
navigator.serviceWorker.register('./sw.js', {scope: './', type: 'module', updateViaCache: 'none'})
|
||||||
.then(registration => {
|
.then(value => {
|
||||||
const showUpdate = () => { update.hidden = !registration.waiting; };
|
if (destroyed) return;
|
||||||
|
registration = value;
|
||||||
|
const showUpdate = () => updateAvailable(!!registration.waiting);
|
||||||
showUpdate();
|
showUpdate();
|
||||||
registration.addEventListener('updatefound', () => {
|
listen(registration, 'updatefound', () => {
|
||||||
const worker = registration.installing;
|
if (registration.installing) listen(registration.installing, 'statechange', showUpdate);
|
||||||
if (worker) worker.addEventListener('statechange', showUpdate);
|
|
||||||
});
|
});
|
||||||
update.addEventListener('click', () => {
|
}).catch(() => { if (!destroyed) failed(); });
|
||||||
if (registration.waiting) registration.waiting.postMessage('ACTIVATE_UPDATE');
|
}
|
||||||
});
|
return {
|
||||||
}).catch(failed);
|
async install() {
|
||||||
|
if (!prompt) return;
|
||||||
|
const pending = prompt;
|
||||||
|
prompt = undefined;
|
||||||
|
installAvailable(false);
|
||||||
|
try {
|
||||||
|
await pending.prompt();
|
||||||
|
await pending.userChoice;
|
||||||
|
} catch { /* Manual installation guidance remains available. */ }
|
||||||
|
},
|
||||||
|
update() {
|
||||||
|
registration?.waiting?.postMessage('ACTIVATE_UPDATE');
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
destroyed = true;
|
||||||
|
cleanup.forEach(remove => remove());
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
[hidden] { display: none !important; }
|
[hidden], [x-cloak] { display: none !important; }
|
||||||
body { margin: 0; padding: clamp(1rem, 4vw, 3rem); background: var(--background); color: var(--foreground); font-family: plexmono, Consolas, monospace; line-height: 1.6; }
|
body { margin: 0; padding: clamp(1rem, 4vw, 3rem); background: var(--background); color: var(--foreground); font-family: plexmono, Consolas, monospace; line-height: 1.6; }
|
||||||
main { max-width: 1120px; margin: auto; min-width: 0; }
|
main { max-width: 1120px; margin: auto; min-width: 0; }
|
||||||
header { margin-bottom: 2rem; }
|
header { margin-bottom: 2rem; }
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
// Bump VERSION whenever a shell file changes. New versions wait for approval.
|
// Bump VERSION whenever a shell file changes. New versions wait for approval.
|
||||||
const VERSION = 'v7';
|
const VERSION = 'v8';
|
||||||
const BASE = self.registration.scope;
|
const BASE = self.registration.scope;
|
||||||
const PREFIX = `ascii-pwa-${encodeURIComponent(BASE)}-`;
|
const PREFIX = `ascii-pwa-${encodeURIComponent(BASE)}-`;
|
||||||
const CACHE = `${PREFIX}${VERSION}`;
|
const CACHE = `${PREFIX}${VERSION}`;
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'./', './index.html', './style.css', './appearance.js', './app.js',
|
'./', './index.html', './style.css', './appearance.js', './app.js',
|
||||||
'./renderer.js', './font-catalog.js', './vendor/figlet.js',
|
'./renderer.js', './font-catalog.js', './vendor/figlet.js', './vendor/alpine.js',
|
||||||
'./translations.js', './pwa.js', './manifest.webmanifest',
|
'./translations.js', './pwa.js', './manifest.webmanifest',
|
||||||
'./fonts/IBMPlexMono-Regular.subset.woff2',
|
'./fonts/IBMPlexMono-Regular.subset.woff2',
|
||||||
'./fonts/IBMPlexMono-Bold.subset.woff2',
|
'./fonts/IBMPlexMono-Bold.subset.woff2',
|
||||||
|
|||||||
Vendored
+5
@@ -8,3 +8,8 @@ retain their embedded authorship and license notices unchanged.
|
|||||||
|
|
||||||
No CDN or npm installation is needed at runtime. Font data is loaded locally
|
No CDN or npm installation is needed at runtime. Font data is loaded locally
|
||||||
when selected and saved in a separate browser cache for later offline use.
|
when selected and saved in a separate browser cache for later offline use.
|
||||||
|
|
||||||
|
`alpine.js` is the unmodified ES module build of
|
||||||
|
[`Alpine.js 3.14.9`](https://github.com/alpinejs/alpine/tree/v3.14.9).
|
||||||
|
Its MIT license is retained as `alpine.LICENSE`. The bundled Vue reactivity
|
||||||
|
code's MIT license is retained as `vue.LICENSE`.
|
||||||
Vendored
+21
@@ -0,0 +1,21 @@
|
|||||||
|
# MIT License
|
||||||
|
|
||||||
|
Copyright © 2019-2021 Caleb Porzio and contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
var rt=!1,nt=!1,U=[],it=-1;function Vt(e){On(e)}function On(e){U.includes(e)||U.push(e),Cn()}function Ee(e){let t=U.indexOf(e);t!==-1&&t>it&&U.splice(t,1)}function Cn(){!nt&&!rt&&(rt=!0,queueMicrotask(Tn))}function Tn(){rt=!1,nt=!0;for(let e=0;e<U.length;e++)U[e](),it=e;U.length=0,it=-1,nt=!1}var R,D,L,st,ot=!0;function qt(e){ot=!1,e(),ot=!0}function Ut(e){R=e.reactive,L=e.release,D=t=>e.effect(t,{scheduler:r=>{ot?Vt(r):r()}}),st=e.raw}function at(e){D=e}function Wt(e){let t=()=>{};return[n=>{let i=D(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),L(i))},i},()=>{t()}]}function Se(e,t){let r=!0,n,i=D(()=>{let o=e();JSON.stringify(o),r?n=o:queueMicrotask(()=>{t(o,n),n=o}),r=!1});return()=>L(i)}var Gt=[],Jt=[],Yt=[];function Xt(e){Yt.push(e)}function ee(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Jt.push(t))}function Ae(e){Gt.push(e)}function Oe(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function ct(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}function Zt(e){if(e._x_cleanups)for(;e._x_cleanups.length;)e._x_cleanups.pop()()}var lt=new MutationObserver(pt),ut=!1;function le(){lt.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ut=!0}function ft(){Rn(),lt.disconnect(),ut=!1}var ce=[];function Rn(){let e=lt.takeRecords();ce.push(()=>e.length>0&&pt(e));let t=ce.length;queueMicrotask(()=>{if(ce.length===t)for(;ce.length>0;)ce.shift()()})}function _(e){if(!ut)return e();ft();let t=e();return le(),t}var dt=!1,ve=[];function Qt(){dt=!0}function er(){dt=!1,pt(ve),ve=[]}function pt(e){if(dt){ve=ve.concat(e);return}let t=new Set,r=new Set,n=new Map,i=new Map;for(let o=0;o<e.length;o++)if(!e[o].target._x_ignoreMutationObserver&&(e[o].type==="childList"&&(e[o].addedNodes.forEach(s=>s.nodeType===1&&t.add(s)),e[o].removedNodes.forEach(s=>s.nodeType===1&&r.add(s))),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{ct(s,o)}),n.forEach((o,s)=>{Gt.forEach(a=>a(s,o))});for(let o of r)t.has(o)||Jt.forEach(s=>s(o));t.forEach(o=>{o._x_ignoreSelf=!0,o._x_ignore=!0});for(let o of t)r.has(o)||o.isConnected&&(delete o._x_ignoreSelf,delete o._x_ignore,Yt.forEach(s=>s(o)),o._x_ignore=!0,o._x_ignoreSelf=!0);t.forEach(o=>{delete o._x_ignoreSelf,delete o._x_ignore}),t=null,r=null,n=null,i=null}function Ce(e){return F(j(e))}function P(e,t,r){return e._x_dataStack=[t,...j(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function j(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?j(e.host):e.parentNode?j(e.parentNode):[]}function F(e){return new Proxy({objects:e},Mn)}var Mn={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t)||Reflect.has(r,t))},get({objects:e},t,r){return t=="toJSON"?Nn:Reflect.get(e.find(n=>Reflect.has(n,t))||{},t,r)},set({objects:e},t,r,n){let i=e.find(s=>Object.prototype.hasOwnProperty.call(s,t))||e[e.length-1],o=Object.getOwnPropertyDescriptor(i,t);return o?.set&&o?.get?Reflect.set(i,t,r,n):Reflect.set(i,t,r)}};function Nn(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function Te(e){let t=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(e,c,o):t(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(e)}function Re(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return e(this.initialValue,()=>Dn(n,i),s=>mt(n,i,s),i,o)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function Dn(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function mt(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),mt(e[t[0]],t.slice(1),r)}}var tr={};function y(e,t){tr[e]=t}function ue(e,t){return Object.entries(tr).forEach(([r,n])=>{let i=null;function o(){if(i)return i;{let[s,a]=_t(t);return i={interceptor:Re,...s},ee(t,a),i}}Object.defineProperty(e,`$${r}`,{geLine truncated
|
||||||
|
|
||||||
|
${r?'Expression: "'+r+`"
|
||||||
|
|
||||||
|
`:""}`,t),setTimeout(()=>{throw e},0)}var Me=!0;function De(e){let t=Me;Me=!1;let r=e();return Me=t,r}function M(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return nr(...e)}var nr=gt;function ir(e){nr=e}function gt(e,t){let r={};ue(r,e);let n=[r,...j(e)],i=typeof t=="function"?Pn(n,t):kn(n,t,e);return rr.bind(null,e,t,i)}function Pn(e,t){return(r=()=>{},{scope:n={},params:i=[]}={})=>{let o=t.apply(F([n,...e]),i);Ne(r,o)}}var ht={};function In(e,t){if(ht[e])return ht[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${e}`}),s}catch(s){return te(s,t,e),Promise.resolve()}})();return ht[e]=o,o}function kn(e,t,r){let n=In(t,r);return(i=()=>{},{scope:o={},params:s=[]}={})=>{n.result=void 0,n.finished=!1;let a=F([o,...e]);if(typeof n=="function"){let c=n(n,a).catch(l=>te(l,r,t));n.finished?(Ne(i,n.result,a,s,r),n.result=void 0):c.then(l=>{Ne(i,l,a,s,r)}).catch(l=>te(l,r,t)).finally(()=>n.result=void 0)}}}function Ne(e,t,r,n,i){if(Me&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>Ne(e,s,r,n)).catch(s=>te(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}var bt="x-";function C(e=""){return bt+e}function or(e){bt=e}var Pe={};function d(e,t){return Pe[e]=t,{before(r){if(!Pe[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let n=W.indexOf(r);W.splice(n>=0?n:W.indexOf("DEFAULT"),0,e)}}}function sr(e){return Object.keys(Pe).includes(e)}function de(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=wt(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(lr((o,s)=>n[o]=s)).filter(fr).map($n(n,r)).sort(jn).map(o=>Ln(e,o))}function wt(e){return Array.from(e).map(lr()).filter(t=>!fr(t))}var xt=!1,fe=new Map,ar=Symbol();function cr(e){xt=!0;let t=Symbol();ar=t,fe.set(t,[]);let r=()=>{for(;fe.get(t).length;)fe.get(t).shift()();fe.delete(t)},n=()=>{xt=!1,r()};e(r),n()}function _t(e){let t=[],r=a=>t.push(a),[n,i]=Wt(e);return t.push(i),[{Alpine:B,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:M.bind(M,e)},()=>t.forEach(a=>a())]}function Ln(e,t){let r=()=>{},n=Pe[t.type]||r,[i,o]=_t(e);Oe(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),xt?fe.get(ar).push(n):n())};return s.runCleanups=o,s}var Ie=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),ke=e=>e;function lr(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=ur.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var ur=[];function re(e){ur.push(e)}function fr({name:e}){return dr().test(e)}var dr=()=>new RegExp(`^${bt}([^:^.]+)\\b`);function $n(e,t){return({name:r,value:n})=>{let i=r.match(dr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var yt="DEFAULT",W=["ignore","ref","data","id","anchor","bind","init","for","model","modelable","transition","show","if",yt,"teleport"];function jn(e,t){let r=W.indexOf(e.type)===-1?yt:e.type,n=W.indexOf(t.type)===-1?yt:t.type;return W.indexOf(r)-W.indexOf(n)}function G(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}function T(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>T(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)T(n,t,!1),n=n.nextElementSibling}function E(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var pr=!1;function mr(){pr&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),pr=!0,document.body||E("Unable to initialize. Trying to load Alpine before `<body>` is available. Did you forget to add `defer` in Alpine's `<script>` tag?"),G(document,"alpine:init"),G(document,"alpine:initializing"),le(),Xt(t=>v(t,T)),ee(t=>St(t)),Ae((t,r)=>{de(t,r).forEach(n=>n())});let e=t=>!J(t.parentElement,!0);Array.from(document.querySelectorAll(gr().join(","))).filter(e).forEach(t=>{v(t)}),G(document,"alpine:initialized"),setTimeout(()=>{Fn()})}var Et=[],_r=[];function hr(){return Et.map(e=>e())}function gr(){return Et.concat(_r).map(e=>e())}function Le(e){Et.push(e)}function $e(e){_r.push(e)}function J(e,t=!1){return z(e,r=>{if((t?gr():hr()).some(i=>r.matches(i)))return!0})}function z(e,t){if(e){if(t(e))return e;if(e._x_teleportBack&&(e=e._x_teleportBack),!!e.parentElement)return z(e.parentElement,t)}}function xLine truncated
|
||||||
Vendored
+21
@@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2018-present, Yuxi (Evan) You
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
Reference in new issue
Block a user