<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Square Dance Caller โ Choreography Reader</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@300;400;500&display=swap');
:root {
--bg: #0d0d0d;
--surface: #141414;
--surface2: #1c1c1c;
--border: #2a2a2a;
--accent: #e8c84a;
--accent2: #c45c2a;
--text: #e8e4da;
--text-muted: #6b6760;
--text-dim: #3d3b37;
--active-bg: #1e1a0a;
--active-border: #e8c84a;
--active-text: #f5e88a;
--divider: #2e1f00;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: 'IBM Plex Mono', monospace;
min-height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* === HEADER === */
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 24px;
border-bottom: 1px solid var(--border);
background: var(--surface);
flex-shrink: 0;
gap: 16px;
}
.logo {
font-family: 'Bebas Neue', cursive;
font-size: 22px;
letter-spacing: 3px;
color: var(--accent);
white-space: nowrap;
}
.logo span { color: var(--text-muted); }
.file-controls {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.btn {
background: transparent;
border: 1px solid var(--border);
color: var(--text);
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
padding: 6px 14px;
cursor: pointer;
letter-spacing: 1px;
text-transform: uppercase;
transition: all 0.15s;
}
.btn:hover { border-color: var(--accent); color: var(--accent); }
.btn.primary {
border-color: var(--accent);
color: var(--accent);
background: rgba(232,200,74,0.06);
}
.btn.primary:hover { background: rgba(232,200,74,0.14); }
#fileInput, #folderInput { display: none; }
/* === STATUS BAR === */
.status-bar {
display: flex;
align-items: center;
gap: 0;
padding: 0 24px;
background: var(--surface2);
border-bottom: 1px solid var(--border);
font-size: 10px;
color: var(--text-muted);
letter-spacing: 0.8px;
text-transform: uppercase;
flex-shrink: 0;
height: 30px;
overflow: hidden;
}
.status-item {
padding: 0 16px 0 0;
margin: 0 16px 0 0;
border-right: 1px solid var(--border);
white-space: nowrap;
}
.status-item:last-child { border-right: none; }
.status-item strong { color: var(--accent); }
/* === LAYOUT === */
.main-layout {
display: flex;
flex: 1;
overflow: hidden;
}
/* === SIDEBAR === */
.sidebar {
width: 220px;
min-width: 220px;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
background: var(--surface);
overflow: hidden;
}
.sidebar-header {
padding: 10px 14px;
font-size: 9px;
letter-spacing: 2px;
color: var(--text-muted);
text-transform: uppercase;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.file-list { overflow-y: auto; flex: 1; }
.file-list::-webkit-scrollbar { width: 4px; }
.file-list::-webkit-scrollbar-track { background: transparent; }
.file-list::-webkit-scrollbar-thumb { background: var(--border); }
.file-item {
padding: 9px 14px;
cursor: pointer;
font-size: 11px;
color: var(--text-muted);
border-bottom: 1px solid #1a1a1a;
transition: all 0.1s;
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
}
.file-item:hover { background: var(--surface2); color: var(--text); }
.file-item.active {
background: rgba(232,200,74,0.07);
color: var(--accent);
border-left: 2px solid var(--accent);
padding-left: 12px;
}
.file-item .fi-icon { opacity: 0.5; flex-shrink: 0; }
.file-item .fi-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-item .fi-count { margin-left: auto; font-size: 9px; color: var(--text-dim); flex-shrink: 0; }
.file-item.active .fi-count { color: var(--accent); opacity: 0.5; }
/* === SEQ SIDEBAR === */
.seq-sidebar {
width: 60px;
min-width: 60px;
border-right: 1px solid var(--border);
background: var(--bg);
display: flex;
flex-direction: column;
overflow: hidden;
}
.seq-sidebar-header {
padding: 10px 0;
font-size: 9px;
letter-spacing: 1px;
color: var(--text-dim);
text-transform: uppercase;
border-bottom: 1px solid var(--border);
text-align: center;
flex-shrink: 0;
}
.seq-list { overflow-y: auto; flex: 1; }
.seq-list::-webkit-scrollbar { width: 3px; }
.seq-list::-webkit-scrollbar-track { background: var(--bg); }
.seq-list::-webkit-scrollbar-thumb { background: var(--border); }
.seq-item {
width: 100%;
padding: 8px 4px;
text-align: center;
font-size: 10px;
color: var(--text-dim);
cursor: pointer;
border-bottom: 1px solid #161616;
transition: all 0.1s;
font-family: 'IBM Plex Mono', monospace;
}
.seq-item:hover { color: var(--text-muted); background: var(--surface); }
.seq-item.active {
color: var(--accent);
background: rgba(232,200,74,0.07);
border-left: 2px solid var(--accent);
}
/* === READER === */
.reader-pane { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.reader-scroll {
flex: 1;
overflow-y: auto;
padding: 24px 0;
scroll-behavior: smooth;
}
.reader-scroll::-webkit-scrollbar { width: 6px; }
.reader-scroll::-webkit-scrollbar-track { background: transparent; }
.reader-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
.seq-title {
font-family: 'Bebas Neue', cursive;
font-size: 13px;
letter-spacing: 4px;
color: var(--text-dim);
padding: 0 40px 16px;
text-transform: uppercase;
}
.call-line {
display: flex;
align-items: center;
padding: 10px 40px;
gap: 20px;
cursor: pointer;
transition: background 0.08s;
position: relative;
border-left: 3px solid transparent;
}
.call-line:hover { background: rgba(255,255,255,0.02); }
.call-line .line-num {
font-size: 10px;
color: var(--text-dim);
min-width: 28px;
text-align: right;
flex-shrink: 0;
font-family: 'IBM Plex Mono', monospace;
user-select: none;
}
.call-line .line-text {
font-family: 'IBM Plex Mono', monospace;
font-size: 17px;
color: var(--text-muted);
letter-spacing: 0.5px;
line-height: 1.4;
transition: color 0.1s;
word-break: break-word;
}
.call-line.active {
background: var(--active-bg);
border-left: 3px solid var(--active-border);
}
.call-line.active .line-num { color: var(--accent); opacity: 0.6; }
.call-line.active .line-text {
color: var(--active-text);
font-size: 20px;
font-weight: 600;
text-shadow: 0 0 30px rgba(232,200,74,0.25);
}
.call-line.active::after {
content: 'โถ';
position: absolute;
right: 24px;
color: var(--accent);
opacity: 0.5;
font-size: 10px;
}
.call-line.prev1 .line-text { color: #5a5650; font-size: 18px; }
.call-line.prev2 .line-text { color: #3d3b37; }
.call-line.next1 .line-text { color: #5a5650; font-size: 18px; }
.call-line.next2 .line-text { color: #3d3b37; }
.seq-divider {
display: flex;
align-items: center;
padding: 20px 40px;
gap: 16px;
color: var(--text-dim);
font-size: 10px;
letter-spacing: 2px;
text-transform: uppercase;
user-select: none;
}
.seq-divider::before, .seq-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--divider);
}
/* === EMPTY STATE === */
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
padding: 40px;
text-align: center;
}
.empty-icon { font-size: 48px; opacity: 0.15; }
.empty-title {
font-family: 'Bebas Neue', cursive;
font-size: 28px;
letter-spacing: 5px;
color: var(--text-muted);
}
.empty-desc {
font-family: 'IBM Plex Sans', sans-serif;
font-size: 13px;
color: var(--text-dim);
line-height: 1.7;
max-width: 400px;
}
.empty-actions { display: flex; gap: 12px; flex-wrap: wrap; justify-content: center; }
.kbd {
display: inline-block;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 3px;
padding: 1px 6px;
font-size: 10px;
color: var(--text-muted);
font-family: 'IBM Plex Mono', monospace;
}
/* === FOOTER === */
footer {
border-top: 1px solid var(--border);
padding: 8px 24px;
display: flex;
gap: 24px;
font-size: 10px;
color: var(--text-dim);
background: var(--surface);
flex-shrink: 0;
letter-spacing: 0.5px;
flex-wrap: wrap;
}
footer .hint { display: flex; align-items: center; gap: 6px; }
.fade-in { animation: fadeIn 0.2s ease; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
</style>
</head>
<body>
<header>
<div class="logo">Square<span>ยท</span>Dance <span>// Caller</span></div>
<div class="file-controls">
<button class="btn primary" onclick="document.getElementById('folderInput').click()">๐ Open Folder</button>
<button class="btn" onclick="document.getElementById('fileInput').click()">๐ Open File(s)</button>
<input type="file" id="folderInput" accept=".txt" webkitdirectory multiple>
<input type="file" id="fileInput" accept=".txt" multiple>
<button class="btn" onclick="clearAll()">โ Clear</button>
</div>
</header>
<div class="status-bar" id="statusBar">
<div class="status-item">No file loaded</div>
</div>
<div class="main-layout">
<div class="sidebar" id="sidebar">
<div class="sidebar-header">Files</div>
<div class="file-list" id="fileList"></div>
</div>
<div class="seq-sidebar" id="seqSidebar">
<div class="seq-sidebar-header">Seq</div>
<div class="seq-list" id="seqList"></div>
</div>
<div class="reader-pane">
<div class="reader-scroll" id="readerScroll">
<div class="empty-state" id="emptyState">
<div class="empty-icon">๐ต</div>
<div class="empty-title">Square Dance Caller</div>
<div class="empty-desc">
Load a <strong>folder</strong> of <code>.txt</code> files or individual files.<br>
Sequences within each file are separated by <strong>@</strong>.<br><br>
Use <span class="kbd">โ</span> <span class="kbd">โ</span> to move between calls,
<span class="kbd">โ</span> <span class="kbd">โ</span> to switch sequences,
and <span class="kbd">Tab</span> to switch files.
</div>
<div class="empty-actions">
<button class="btn primary" onclick="document.getElementById('folderInput').click()">๐ Open Folder</button>
<button class="btn" onclick="document.getElementById('fileInput').click()">๐ Open File(s)</button>
</div>
</div>
<div id="linesContainer" style="display:none"></div>
</div>
</div>
</div>
<footer>
<div class="hint"><span class="kbd">โ</span><span class="kbd">โ</span> Move between calls</div>
<div class="hint"><span class="kbd">โ</span><span class="kbd">โ</span> Switch sequences</div>
<div class="hint"><span class="kbd">Tab</span> Switch files</div>
<div class="hint"><span class="kbd">Home</span> / <span class="kbd">End</span> First / last call</div>
<div class="hint"><span class="kbd">Space</span> Next call</div>
</footer>
<script>
// ============================================================
// STATE
// ============================================================
let files = [];
let currentFile = 0;
let currentSeq = 0;
let currentLine = 0;
let allLines = [];
let seqBoundaries = [];
// ============================================================
// FILE / FOLDER LOADING
// ============================================================
function handleFileList(fileList) {
const txtFiles = Array.from(fileList)
.filter(f => f.name.toLowerCase().endsWith('.txt'))
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }));
if (!txtFiles.length) {
alert('No .txt files found.');
return;
}
let loaded = 0;
const results = new Array(txtFiles.length);
txtFiles.forEach((f, i) => {
const reader = new FileReader();
reader.onload = ev => {
results[i] = { name: f.name, raw: ev.target.result };
loaded++;
if (loaded === txtFiles.length) processFiles(results);
};
reader.readAsText(f, 'UTF-8');
});
}
document.getElementById('folderInput').addEventListener('change', e => {
handleFileList(e.target.files);
e.target.value = '';
});
document.getElementById('fileInput').addEventListener('change', e => {
handleFileList(e.target.files);
e.target.value = '';
});
function processFiles(results) {
files = results.map(r => ({
name: r.name,
sequences: parseSequences(r.raw)
}));
currentFile = 0;
currentSeq = 0;
currentLine = 0;
renderFileList();
renderCurrentFile();
updateStatus();
}
function parseSequences(raw) {
return raw.split('@')
.map(block => block.split('\n')
.map(l => l.trim())
.filter(l => l.length > 0))
.filter(seq => seq.length > 0);
}
// ============================================================
// RENDERING
// ============================================================
function renderFileList() {
const container = document.getElementById('fileList');
container.innerHTML = '';
files.forEach((f, i) => {
const div = document.createElement('div');
div.className = 'file-item' + (i === currentFile ? ' active' : '');
div.innerHTML = `<span class="fi-icon">โ</span>
<span class="fi-name">${escapeHtml(f.name)}</span>
<span class="fi-count">${f.sequences.length}s</span>`;
div.onclick = () => switchFile(i);
container.appendChild(div);
});
}
function renderCurrentFile() {
if (!files.length) {
document.getElementById('emptyState').style.display = 'flex';
document.getElementById('linesContainer').style.display = 'none';
document.getElementById('seqList').innerHTML = '';
return;
}
document.getElementById('emptyState').style.display = 'none';
const container = document.getElementById('linesContainer');
container.style.display = 'block';
container.innerHTML = '';
container.classList.add('fade-in');
setTimeout(() => container.classList.remove('fade-in'), 300);
const file = files[currentFile];
allLines = [];
seqBoundaries = [];
const seqList = document.getElementById('seqList');
seqList.innerHTML = '';
file.sequences.forEach((seq, si) => {
const dividerEl = document.createElement('div');
dividerEl.className = 'seq-divider';
dividerEl.textContent = `Sequence ${si + 1}`;
container.appendChild(dividerEl);
const startLine = allLines.length;
seq.forEach((lineText, li) => {
const row = document.createElement('div');
row.className = 'call-line';
row.dataset.globalIdx = allLines.length;
row.dataset.seqIdx = si;
row.innerHTML = `<span class="line-num">${li + 1}</span>
<span class="line-text">${escapeHtml(lineText)}</span>`;
row.onclick = () => goToLine(parseInt(row.dataset.globalIdx));
container.appendChild(row);
allLines.push(row);
});
seqBoundaries.push({ seqIdx: si, startLine, endLine: allLines.length - 1 });
const sItem = document.createElement('div');
sItem.className = 'seq-item' + (si === currentSeq ? ' active' : '');
sItem.textContent = si + 1;
sItem.onclick = () => jumpToSeq(si);
seqList.appendChild(sItem);
});
goToLine(seqBoundaries[currentSeq]?.startLine ?? 0, false);
}
function escapeHtml(text) {
return text.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
}
function goToLine(globalIdx, doScroll = true) {
currentLine = Math.max(0, Math.min(globalIdx, allLines.length - 1));
for (const b of seqBoundaries) {
if (currentLine >= b.startLine && currentLine <= b.endLine) {
currentSeq = b.seqIdx;
break;
}
}
allLines.forEach((row, i) => {
row.classList.remove('active','prev1','prev2','next1','next2');
const diff = i - currentLine;
if (diff === 0) row.classList.add('active');
else if (diff === -1) row.classList.add('prev1');
else if (diff === -2) row.classList.add('prev2');
else if (diff === 1) row.classList.add('next1');
else if (diff === 2) row.classList.add('next2');
});
document.querySelectorAll('.seq-item').forEach((el, i) => {
el.classList.toggle('active', i === currentSeq);
});
if (doScroll && allLines[currentLine]) {
allLines[currentLine].scrollIntoView({ block: 'center', behavior: 'smooth' });
}
updateStatus();
}
function jumpToSeq(seqIdx) {
currentSeq = seqIdx;
const b = seqBoundaries[seqIdx];
if (b) goToLine(b.startLine);
}
function switchFile(idx) {
currentFile = idx;
currentSeq = 0;
currentLine = 0;
renderFileList();
renderCurrentFile();
updateStatus();
}
function clearAll() {
files = [];
currentFile = 0; currentSeq = 0; currentLine = 0;
allLines = []; seqBoundaries = [];
document.getElementById('fileList').innerHTML = '';
document.getElementById('seqList').innerHTML = '';
document.getElementById('linesContainer').innerHTML = '';
document.getElementById('linesContainer').style.display = 'none';
document.getElementById('emptyState').style.display = 'flex';
updateStatus();
}
function updateStatus() {
const bar = document.getElementById('statusBar');
if (!files.length) {
bar.innerHTML = '<div class="status-item">No file loaded</div>';
return;
}
const file = files[currentFile];
const b = seqBoundaries[currentSeq];
const lineInSeq = b ? currentLine - b.startLine + 1 : 0;
const linesInSeq = b ? b.endLine - b.startLine + 1 : 0;
bar.innerHTML = `
<div class="status-item">๐ <strong>${escapeHtml(file.name)}</strong></div>
<div class="status-item">File <strong>${currentFile+1}</strong> / ${files.length}</div>
<div class="status-item">Sequence <strong>${currentSeq+1}</strong> / ${file.sequences.length}</div>
<div class="status-item">Call <strong>${lineInSeq}</strong> / ${linesInSeq}</div>
`;
}
// ============================================================
// KEYBOARD
// ============================================================
document.addEventListener('keydown', e => {
if (!files.length) return;
// Ignore if focus is on a button/input
if (['INPUT','BUTTON','SELECT','TEXTAREA'].includes(e.target.tagName)) return;
switch (e.key) {
case 'ArrowDown':
case ' ':
e.preventDefault(); moveDown(); break;
case 'ArrowUp':
e.preventDefault(); moveUp(); break;
case 'ArrowRight':
e.preventDefault(); nextSeq(); break;
case 'ArrowLeft':
e.preventDefault(); prevSeq(); break;
case 'Tab':
e.preventDefault();
if (e.shiftKey) switchFile((currentFile - 1 + files.length) % files.length);
else switchFile((currentFile + 1) % files.length);
break;
case 'Home':
e.preventDefault();
goToLine(seqBoundaries[currentSeq]?.startLine ?? 0); break;
case 'End':
e.preventDefault();
goToLine(seqBoundaries[currentSeq]?.endLine ?? allLines.length - 1); break;
}
});
function moveDown() {
const b = seqBoundaries[currentSeq];
if (!b) return;
if (currentLine < b.endLine) goToLine(currentLine + 1);
else nextSeq();
}
function moveUp() {
const b = seqBoundaries[currentSeq];
if (!b) return;
if (currentLine > b.startLine) goToLine(currentLine - 1);
else prevSeq();
}
function nextSeq() {
const file = files[currentFile];
if (currentSeq < file.sequences.length - 1) {
jumpToSeq(currentSeq + 1);
} else if (currentFile < files.length - 1) {
switchFile(currentFile + 1);
}
}
function prevSeq() {
if (currentSeq > 0) {
jumpToSeq(currentSeq - 1);
} else if (currentFile > 0) {
const prevIdx = currentFile - 1;
switchFile(prevIdx);
jumpToSeq(files[prevIdx].sequences.length - 1);
}
}
</script>
</body>
</html>