Add card-style mobile layout

This commit is contained in:
Ian Keane 2026-06-11 11:36:08 -04:00
parent 067272b797
commit 19cd65387d
3 changed files with 155 additions and 12 deletions

63
app.js
View file

@ -409,10 +409,32 @@ function renderTable() {
rows.forEach(({ tune, entry }) => {
const hasNotes = (tune.notes?.length > 0) || (entry?.notes?.length > 0);
// ── Mobile 2-line card data ──────────────────────────────────
const nameRaw = tune.name ?? '';
const sourceRaw = tune.source?.name ?? '';
const instRaw = entry?.instrument_name ?? '';
const tuningRaw = entry?.tuning ?? '';
const instStr = instRaw ? (tuningRaw ? `${instRaw} (${tuningRaw})` : instRaw) : '';
const keyStr = tune.key
? (tune.modal ? `${tune.key} modal` : tune.key)
: (tune.modal ? 'modal' : '');
const metaRaw = [instStr, keyStr].filter(Boolean).join(' · ');
const mobPills = entry ? [
`<span class="pill mob-pill ${entry.callable ? 'pill-yes' : 'pill-dim'}">Call</span>`,
`<span class="pill mob-pill ${entry.review ? 'pill-warn' : 'pill-dim'}">Rev</span>`,
`<span class="pill mob-pill ${entry.to_learn ? 'pill-warn' : 'pill-dim'}">Lrn</span>`,
].join('') : '';
const mobActions = [
hasNotes ? `<button class="action-btn btn-notes" data-tune-id="${tune.id}" data-entry-id="${entry?.id ?? ''}">📝</button>` : '',
tune.references?.length > 0 ? `<button class="action-btn btn-show-refs" data-tune-id="${tune.id}">🔗</button>` : '',
`<button class="action-btn btn-edit-row" data-tune-id="${tune.id}" data-entry-id="${entry?.id ?? ''}" ${unlocked ? '' : 'hidden'}>✏️</button>`,
].join('');
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${esc(tune.name ?? '')}</td>
<td>${esc(tune.source?.name ?? '')}</td>
<td><span class="cell-trunc cell-name-content mob-truncatable" data-full="${esc(nameRaw)}" title="${esc(nameRaw)}">${esc(nameRaw)}</span></td>
<td><span class="cell-trunc cell-source-content mob-truncatable" data-full="${esc(sourceRaw)}" title="${esc(sourceRaw)}">${esc(sourceRaw)}</span></td>
<td>${esc(tune.key ?? '')}</td>
<td>${tune.modal ? '<span class="pill pill-yes">Yes</span>' : '<span class="pill" style="background:#222">—</span>'}</td>
<td>${esc(entry?.instrument_name ?? '')}</td>
@ -430,7 +452,20 @@ function renderTable() {
data-tune-id="${tune.id}"
data-entry-id="${entry?.id ?? ''}"
title="Edit"
${unlocked ? '' : 'hidden'}></button></td>`;
${unlocked ? '' : 'hidden'}></button></td>
<td class="mobile-row" colspan="99">
<div class="mob-line1">
<div class="mob-name-wrap">
<span class="mob-name mob-truncatable" data-full="${esc(nameRaw)}" title="${esc(nameRaw)}">${esc(nameRaw)}</span>
${sourceRaw ? `<span class="mob-source mob-truncatable" data-full="${esc(sourceRaw)}" title="${esc(sourceRaw)}">(${esc(sourceRaw)})</span>` : ''}
</div>
<span class="mob-status">${mobPills}</span>
</div>
<div class="mob-line2">
<span class="mob-meta mob-truncatable" data-full="${esc(metaRaw)}" title="${esc(metaRaw)}">${esc(metaRaw)}</span>
<span class="mob-actions">${mobActions}</span>
</div>
</td>`;
tbody.appendChild(tr);
});
@ -1046,6 +1081,28 @@ function esc(str) {
.replace(/"/g, '&quot;');
}
// ── Mobile truncation tooltip ───────────────────────────────────
{
const tip = $('mob-tooltip');
let timer = null;
tbody.addEventListener('click', e => {
const el = e.target.closest('.mob-truncatable');
if (!el) return;
if (el.scrollWidth <= el.offsetWidth) return; // not truncated
clearTimeout(timer);
tip.textContent = el.dataset.full;
const rect = el.getBoundingClientRect();
tip.style.top = (rect.bottom + 4) + 'px';
tip.style.left = Math.max(8, Math.min(rect.left, window.innerWidth - 220)) + 'px';
tip.hidden = false;
timer = setTimeout(() => { tip.hidden = true; }, 2500);
e.stopPropagation();
});
document.addEventListener('click', () => { clearTimeout(timer); tip.hidden = true; });
}
// Pressing Tab in a datalist-backed input fills the top matching suggestion.
function attachTabAutocomplete(input, datalist) {
input.addEventListener('keydown', e => {

View file

@ -182,6 +182,9 @@
</div>
</dialog>
<!-- Tooltip for truncated mobile text -->
<div id="mob-tooltip" hidden></div>
<!-- config.js is gitignored — copy config.example.js to config.js for local dev -->
<script src="config.js" onerror="void 0"></script>
<script src="app.js"></script>

101
style.css
View file

@ -141,6 +141,27 @@ th[data-filter].filter-active::before {
/* Short/full column header label swap (see responsive section) */
.col-short { display: none; }
/* Dim pill for inactive mobile status indicators */
.pill-dim { background: rgba(255,255,255,.06); color: var(--muted); }
/* Mobile card row — hidden on desktop, shown in responsive block */
.mobile-row { display: none; }
/* Tooltip for truncated mobile text */
#mob-tooltip {
position: fixed;
z-index: 200;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: .35rem .65rem;
font-size: .8rem;
max-width: 80vw;
box-shadow: 0 2px 8px rgba(0,0,0,.4);
pointer-events: none;
color: var(--text);
}
/* ── Buttons ──────────────────────────────────────────────── */
button {
cursor: pointer;
@ -193,6 +214,16 @@ th.sort-desc .sort-indicator::after { content: '▼'; }
#tune-table tbody tr:nth-child(even) { background: rgba(255,255,255,.025); }
#tune-table tbody tr:hover { background: rgba(255,255,255,.055); }
/* Truncating spans for Name + Source columns */
.cell-trunc {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cell-name-content { max-width: min(22vw, 260px); }
.cell-source-content { max-width: min(26vw, 200px); }
.pill {
display: inline-block;
padding: .15rem .45rem;
@ -349,20 +380,72 @@ fieldset.active .fs-body { display: contents; }
/* ── Responsive ───────────────────────────────────────────── */
@media (max-width: 600px) {
/* Header: let actions wrap onto a second line */
/* Header: wrap onto second line if needed */
header { flex-wrap: wrap; }
#header-actions { flex-wrap: wrap; }
/* Table: scroll horizontally instead of hiding columns */
#tune-table { width: max-content; min-width: 100%; }
/* Table: block layout so the card has a concrete width to truncate against */
#tune-table,
#tune-table tbody,
#tune-table tr { display: block; width: 100%; }
#tune-table thead { display: none; }
#tune-table tr { border-bottom: 1px solid var(--border); }
#tune-table td:not(.mobile-row) { display: none; }
.mobile-row { display: block; padding: .45rem .6rem; }
/* Tighter cell padding on mobile */
#tune-table th,
#tune-table td { padding: .4rem .35rem; }
/* Line 1: Name (Source) … [Call][Rev][Lrn] */
.mob-line1 {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: .4rem;
}
.mob-name-wrap {
display: flex;
align-items: baseline;
gap: .25rem;
flex: 1;
min-width: 0;
}
.mob-name {
font-weight: 600;
font-size: .82rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 1 0;
min-width: 1.5rem;
}
.mob-source {
font-size: .75rem;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 0 1 auto;
min-width: 1.5rem;
}
.mob-status { display: flex; gap: .2rem; align-items: center; flex-shrink: 0; }
.mob-pill { font-size: .62rem; padding: .1rem .3rem; }
/* Short column headers: swap to abbreviated label on mobile */
.col-full { display: none; }
.col-short { display: inline; }
/* Line 2: Instrument (Tuning) · Key [actions] */
.mob-line2 {
display: flex;
justify-content: space-between;
align-items: center;
gap: .4rem;
margin-top: .25rem;
}
.mob-meta {
font-size: .72rem;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.mob-actions { display: flex; gap: .2rem; flex-shrink: 0; }
/* Modals: fullscreen */
dialog {