162 lines
5.4 KiB
HTML
162 lines
5.4 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}{% if is_new %}New theme{% else %}Edit {{ name }}{% endif %} · Wordsmith{% endblock %}
|
|
{% block content %}
|
|
<section class="container">
|
|
<div class="crumbs">
|
|
<a href="/themes">themes</a>
|
|
<span class="sep">/</span>
|
|
<span class="here">{% if is_new %}new{% else %}{{ slug }}{% endif %}</span>
|
|
</div>
|
|
|
|
<div class="page-head">
|
|
<div class="page-head__title">
|
|
<div class="eyebrow">№ 03 · Workbench</div>
|
|
<h1>{% if is_new %}New theme{% else %}Edit theme{% endif %}</h1>
|
|
</div>
|
|
{% if not is_new %}
|
|
<div class="page-head__meta">slug · {{ slug }}</div>
|
|
{% endif %}
|
|
</div>
|
|
<hr class="section-rule" />
|
|
|
|
{% if errors %}
|
|
<div class="notice">
|
|
<strong>Hold on</strong>
|
|
<ul>
|
|
{% for e in errors %}<li>{{ e }}</li>{% endfor %}
|
|
</ul>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<form method="post"
|
|
action="{% if is_new %}/themes{% else %}/themes/{{ slug }}{% endif %}">
|
|
|
|
<div class="row" style="margin-bottom: var(--s-4);">
|
|
<label class="field">
|
|
<span class="field__label">Theme name</span>
|
|
<input type="text" name="name" id="name" value="{{ name }}" required />
|
|
</label>
|
|
<label class="field">
|
|
<span class="field__label">Slug</span>
|
|
<input type="text" name="slug" id="slug" value="{{ slug }}"
|
|
{% if not is_new %}readonly{% endif %} />
|
|
<span class="field__hint">lowercase letters, digits, hyphens · auto-generated on create</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="workbench">
|
|
<label class="field">
|
|
<span class="field__label">Words · one per line</span>
|
|
<textarea name="words" id="words">{{ words_text }}</textarea>
|
|
</label>
|
|
|
|
<aside class="panel panel--inset" aria-label="Live preview">
|
|
<div class="preview-card__head" style="margin: -4px 0 12px;">
|
|
<span class="preview-card__title">Live preview</span>
|
|
<span class="mono muted" style="font-size:11px;" id="word-count">0 entries</span>
|
|
</div>
|
|
<div class="preview-list" id="preview-list">
|
|
<div class="muted">Type words to see the grid form.</div>
|
|
</div>
|
|
<div class="stat-row" id="word-stats">
|
|
<span>no words yet</span>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
|
|
<div class="bottom-bar">
|
|
<button class="btn" type="submit">Save</button>
|
|
<a class="btn btn--ghost" href="/themes">Cancel</a>
|
|
<span class="spacer"></span>
|
|
{% if not is_new %}
|
|
<form method="post" action="/themes/{{ slug }}/delete"
|
|
onsubmit="return confirm('Delete theme {{ name }}?');">
|
|
<button class="btn btn--danger" type="submit">⌫ Delete theme</button>
|
|
</form>
|
|
{% endif %}
|
|
</div>
|
|
</form>
|
|
</section>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
(function () {
|
|
const nameEl = document.getElementById('name');
|
|
const slugEl = document.getElementById('slug');
|
|
const wordsEl = document.getElementById('words');
|
|
const list = document.getElementById('preview-list');
|
|
const count = document.getElementById('word-count');
|
|
const stats = document.getElementById('word-stats');
|
|
const isNew = {{ 'true' if is_new else 'false' }};
|
|
|
|
function slugify(s) {
|
|
return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
}
|
|
|
|
if (isNew && nameEl && slugEl) {
|
|
nameEl.addEventListener('input', () => {
|
|
if (!slugEl.dataset.touched) slugEl.value = slugify(nameEl.value);
|
|
});
|
|
slugEl.addEventListener('input', () => { slugEl.dataset.touched = '1'; });
|
|
}
|
|
|
|
let timer = null;
|
|
async function refresh() {
|
|
const lines = wordsEl.value.split('\n').map(s => s.trim()).filter(Boolean);
|
|
if (lines.length === 0) {
|
|
list.innerHTML = '<div class="muted">Type words to see the grid form.</div>';
|
|
count.textContent = '0 entries';
|
|
stats.innerHTML = '<span>no words yet</span>';
|
|
return;
|
|
}
|
|
let data;
|
|
try {
|
|
const res = await fetch('/api/normalise', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({words: lines}),
|
|
});
|
|
data = await res.json();
|
|
} catch (e) {
|
|
list.innerHTML = '<div class="muted">Preview failed.</div>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = data.map(item => {
|
|
if (item.skipped) {
|
|
return `<div class="row skipped"><span class="src">${item.display}</span><span class="dst">(skipped)</span></div>`;
|
|
}
|
|
return `<div class="row"><span class="src">${item.display}</span><span class="dst">${item.grid}</span></div>`;
|
|
}).join('');
|
|
|
|
const placeable = data.filter(d => !d.skipped);
|
|
count.textContent = `${data.length} ${data.length === 1 ? 'entry' : 'entries'}`;
|
|
|
|
if (placeable.length) {
|
|
const lens = placeable.map(d => d.grid.length);
|
|
const minIdx = lens.indexOf(Math.min(...lens));
|
|
const maxIdx = lens.indexOf(Math.max(...lens));
|
|
const min = placeable[minIdx];
|
|
const max = placeable[maxIdx];
|
|
const avg = (lens.reduce((a, b) => a + b, 0) / lens.length).toFixed(1);
|
|
stats.innerHTML = `
|
|
<span>shortest · ${min.grid} (${min.grid.length})</span>
|
|
<span>longest · ${max.grid} (${max.grid.length})</span>
|
|
<span>avg · ${avg}</span>`;
|
|
} else {
|
|
stats.innerHTML = '<span>nothing placeable</span>';
|
|
}
|
|
}
|
|
|
|
if (wordsEl) {
|
|
wordsEl.addEventListener('input', () => {
|
|
clearTimeout(timer);
|
|
timer = setTimeout(refresh, 250);
|
|
});
|
|
refresh();
|
|
}
|
|
})();
|
|
</script>
|
|
{% endblock %}
|