Initial commit
This commit is contained in:
41
app/templates/base.html
Normal file
41
app/templates/base.html
Normal file
@@ -0,0 +1,41 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{% block title %}Wordsmith — Puzzle Workshop{% endblock %}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500;600&family=Playfair+Display:wght@800;900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
{% set path = request.url.path %}
|
||||
<div class="shell">
|
||||
<header class="site-header">
|
||||
<div class="site-header__inner">
|
||||
<a class="brand" href="/">
|
||||
<span class="brand__mark">W</span>
|
||||
<span>
|
||||
<span class="brand__name">Wordsmith</span>
|
||||
<span class="brand__tagline">PUZZLE WORKSHOP</span>
|
||||
</span>
|
||||
</a>
|
||||
<nav class="tabs" aria-label="Primary">
|
||||
<a href="/" {% if path in ['/', '/generate'] %}aria-current="page"{% endif %}>Generate</a>
|
||||
<a href="/themes" {% if path.startswith('/themes') %}aria-current="page"{% endif %}>Themes</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
✦ WORDSMITH · PUZZLE WORKSHOP · EST. 2026 ✦
|
||||
</footer>
|
||||
</div>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
162
app/templates/index.html
Normal file
162
app/templates/index.html
Normal file
@@ -0,0 +1,162 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Generate · Wordsmith{% endblock %}
|
||||
{% block content %}
|
||||
<section class="container">
|
||||
<div class="page-head">
|
||||
<div class="page-head__title">
|
||||
<div class="eyebrow">№ 01 · Studio</div>
|
||||
<h1>Generate a puzzle</h1>
|
||||
</div>
|
||||
<div class="page-head__meta">{{ themes|length }} theme{{ '' if themes|length == 1 else 's' }} on file</div>
|
||||
</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 %}
|
||||
|
||||
{% if not themes %}
|
||||
<div class="panel">
|
||||
<p>No themes yet. <a href="/themes/new">Create one</a> to get started.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
<form class="studio" method="post" action="/generate">
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<span class="field__label">Theme</span>
|
||||
<select name="theme" id="theme" required>
|
||||
{% for t in themes %}
|
||||
<option value="{{ t.slug }}" data-words="{{ t.words|join(',') }}">{{ t.name }} ({{ t.words|length }} words)</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="row">
|
||||
<label class="field">
|
||||
<span class="field__label">Grid</span>
|
||||
<input type="number" name="grid_size" id="grid-size" value="12" min="5" max="25" required />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">Words</span>
|
||||
<input type="number" name="word_count" id="word-count-input" value="10" min="1" max="30" required />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">Min len</span>
|
||||
<input type="number" name="min_length" value="3" min="1" required />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">Max len</span>
|
||||
<input type="number" name="max_length" value="12" min="1" required />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span class="field__label">Title override</span>
|
||||
<input type="text" name="title" placeholder="leave blank to use theme name" />
|
||||
</label>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend>Directions</legend>
|
||||
<div class="directions-grid">
|
||||
<label class="check check--locked">
|
||||
<input type="checkbox" checked disabled />
|
||||
<span>→ Horizontal</span>
|
||||
<span class="lock">REQ</span>
|
||||
</label>
|
||||
<label class="check check--locked">
|
||||
<input type="checkbox" checked disabled />
|
||||
<span>↓ Vertical</span>
|
||||
<span class="lock">REQ</span>
|
||||
</label>
|
||||
<label class="check">
|
||||
<input type="checkbox" name="diagonal" />
|
||||
<span>↘ ↗ Diagonal</span>
|
||||
</label>
|
||||
<label class="check">
|
||||
<input type="checkbox" name="reversed" />
|
||||
<span>← ↑ ↖ ↙ Reversed</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="fieldset__divider">
|
||||
<label class="check">
|
||||
<input type="checkbox" name="overlap" />
|
||||
<span>Allow overlapping words</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="button-row">
|
||||
<button class="btn" type="submit">✦ Generate</button>
|
||||
<button class="btn btn--ghost" type="reset">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="preview-card" aria-label="Preview">
|
||||
<div class="preview-card__head">
|
||||
<span class="preview-card__title">Sample preview</span>
|
||||
<span class="mono muted" id="preview-stats" style="font-size:11px;">12 × 12 · 10 words</span>
|
||||
</div>
|
||||
<div class="ws-grid" id="ws-grid" style="grid-template-columns: repeat(12, 1fr);"></div>
|
||||
<div class="ws-words" id="ws-words"></div>
|
||||
</aside>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function () {
|
||||
const themeSel = document.getElementById('theme');
|
||||
const gridInput = document.getElementById('grid-size');
|
||||
const countInput = document.getElementById('word-count-input');
|
||||
const grid = document.getElementById('ws-grid');
|
||||
const wordsEl = document.getElementById('ws-words');
|
||||
const stats = document.getElementById('preview-stats');
|
||||
if (!themeSel || !grid) return;
|
||||
|
||||
function selectedWords() {
|
||||
const opt = themeSel.options[themeSel.selectedIndex];
|
||||
return (opt.dataset.words || '').split(',').filter(Boolean);
|
||||
}
|
||||
|
||||
function renderPreview() {
|
||||
const N = Math.max(5, Math.min(25, parseInt(gridInput.value || '12', 10) || 12));
|
||||
const want = Math.max(1, parseInt(countInput.value || '10', 10) || 10);
|
||||
grid.style.gridTemplateColumns = `repeat(${N}, 1fr)`;
|
||||
|
||||
const words = selectedWords();
|
||||
const grids = words
|
||||
.map(w => w.replace(/^(mr|mrs|ms|miss|dr|sir|dame|lord|lady|master|captain|capt|cpt|professor|prof|saint|st)\.?\s+/gi, '')
|
||||
.replace(/[^a-z0-9]/gi, '').toUpperCase())
|
||||
.filter(g => g.length > 0);
|
||||
|
||||
const pool = (grids.join('') || 'WORDSMITH').replace(/[^A-Z]/g, '') || 'WORDSMITH';
|
||||
|
||||
let html = '';
|
||||
for (let i = 0; i < N * N; i++) {
|
||||
html += `<span>${pool[(i * 7 + 3) % pool.length]}</span>`;
|
||||
}
|
||||
grid.innerHTML = html;
|
||||
|
||||
const sample = grids.slice(0, Math.min(want, grids.length));
|
||||
wordsEl.innerHTML = sample.map((g, i) =>
|
||||
`<b>${g}</b>${i < sample.length - 1 ? '<span class="sep">·</span>' : ''}`
|
||||
).join('');
|
||||
|
||||
stats.textContent = `${N} × ${N} · ${sample.length} word${sample.length === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
themeSel.addEventListener('change', renderPreview);
|
||||
gridInput.addEventListener('input', renderPreview);
|
||||
countInput.addEventListener('input', renderPreview);
|
||||
renderPreview();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
161
app/templates/theme_edit.html
Normal file
161
app/templates/theme_edit.html
Normal file
@@ -0,0 +1,161 @@
|
||||
{% 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 %}
|
||||
57
app/templates/themes.html
Normal file
57
app/templates/themes.html
Normal file
@@ -0,0 +1,57 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Themes · Wordsmith{% endblock %}
|
||||
{% block content %}
|
||||
{% set total_words = themes|map(attribute='words')|map('length')|sum %}
|
||||
<section class="container">
|
||||
<div class="page-head">
|
||||
<div class="page-head__title">
|
||||
<div class="eyebrow">№ 02 · Ledger</div>
|
||||
<h1>Themes</h1>
|
||||
<div class="muted mono" style="font-size:12px; margin-top:6px;">
|
||||
{{ themes|length }} theme{{ '' if themes|length == 1 else 's' }} · {{ total_words }} word{{ '' if total_words == 1 else 's' }} on file
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<a class="btn" href="/themes/new">+ New theme</a>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="section-rule" />
|
||||
|
||||
{% if themes %}
|
||||
<div class="panel" style="padding:0; overflow:hidden;">
|
||||
<table class="ledger">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Slug</th>
|
||||
<th class="col-words">Words</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in themes %}
|
||||
<tr>
|
||||
<td class="col-name">{{ t.name }}</td>
|
||||
<td class="col-slug">{{ t.slug }}</td>
|
||||
<td class="col-words">{{ t.words|length }}</td>
|
||||
<td class="col-actions">
|
||||
<span class="row-actions">
|
||||
<a href="/themes/{{ t.slug }}/edit">edit</a>
|
||||
<form method="post" action="/themes/{{ t.slug }}/delete"
|
||||
onsubmit="return confirm('Delete theme {{ t.name }}?');">
|
||||
<button type="submit" class="del">delete</button>
|
||||
</form>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="panel">
|
||||
<p>No themes yet. <a href="/themes/new">Create the first one</a>.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user