Add browse dashboard, tags, visibility control, export, QR sharing, and embed mode

- Browse dashboard at / with search, framework filter, tag pills, and pagination
- Tags system with autocomplete datalist and per-fiddle tag management
- Listed/unlisted toggle for visibility control (unlisted still accessible via direct URL)
- Export standalone HTML with inlined CSS/JS and framework CDN tags
- QR code modal for sharing fiddle URLs
- Embed mode at /embed/:id for minimal preview-only rendering
- Extract shared loadScript() utility from 4 files into utils.js
- Database schema: listed column, tags and fiddle_tags tables with index
This commit is contained in:
root
2026-02-26 14:19:52 -06:00
parent 7f51af17a3
commit e41c3e7dc4
18 changed files with 921 additions and 78 deletions

View File

@@ -24,6 +24,18 @@ export function updateFiddle(id, data) {
return request(`${BASE}/${id}`, { method: 'PUT', body: JSON.stringify(data) });
}
export function listFiddles() {
return request(BASE);
export function listFiddles({ q, js_type, tag, page, limit, sort } = {}) {
const params = new URLSearchParams();
if (q) params.set('q', q);
if (js_type) params.set('js_type', js_type);
if (tag) params.set('tag', tag);
if (page) params.set('page', String(page));
if (limit) params.set('limit', String(limit));
if (sort) params.set('sort', sort);
const qs = params.toString();
return request(`${BASE}${qs ? '?' + qs : ''}`);
}
export function listTags() {
return request('/api/tags');
}

View File

@@ -8,14 +8,17 @@ import { renderPreview } from './preview.js';
import { initConsole, clearConsole } from './console-panel.js';
import { compileCss } from './preprocessors.js';
import { compileJs } from './js-preprocessors.js';
import { createFiddle, loadFiddle, updateFiddle } from './api.js';
import { createFiddle, loadFiddle, updateFiddle, listTags } from './api.js';
import { getPref, setPref } from './preferences.js';
import { initEmmet } from './emmet.js';
import { initKeybindings } from './keybindings.js';
import { initResizer, clearInlineSizes } from './resizer.js';
import { exportHtml } from './export.js';
import { showQrModal } from './qr.js';
let currentId = null;
let debounceTimer = null;
let currentTags = [];
const $ = (sel) => document.querySelector(sel);
@@ -89,12 +92,14 @@ async function save() {
const title = $('#title-input').value || 'Untitled';
const css_type = getCssType();
const js_type = MODE_TO_JS_TYPE[getCurrentMode()] || 'javascript';
const listed = $('#listed-checkbox').checked ? 1 : 0;
const tags = currentTags.slice();
try {
if (currentId) {
await updateFiddle(currentId, { title, html, css, css_type, js, js_type });
await updateFiddle(currentId, { title, html, css, css_type, js, js_type, listed, tags });
showToast(`Saved! Share: ${location.origin}/f/${currentId}`);
} else {
const result = await createFiddle({ title, html, css, css_type, js, js_type });
const result = await createFiddle({ title, html, css, css_type, js, js_type, listed, tags });
currentId = result.id;
history.pushState(null, '', `/f/${currentId}`);
showToast(`Saved! Share: ${location.origin}/f/${currentId}`);
@@ -109,8 +114,10 @@ async function fork() {
const title = ($('#title-input').value || 'Untitled') + ' (fork)';
const css_type = getCssType();
const js_type = MODE_TO_JS_TYPE[getCurrentMode()] || 'javascript';
const listed = $('#listed-checkbox').checked ? 1 : 0;
const tags = currentTags.slice();
try {
const result = await createFiddle({ title, html, css, css_type, js, js_type });
const result = await createFiddle({ title, html, css, css_type, js, js_type, listed, tags });
currentId = result.id;
$('#title-input').value = title;
history.pushState(null, '', `/f/${currentId}`);
@@ -136,6 +143,11 @@ async function loadFromUrl() {
// Restore CSS type
setCssType(fiddle.css_type || 'css');
// Restore listed/tags
$('#listed-checkbox').checked = fiddle.listed !== 0;
currentTags = (fiddle.tags || []).map(t => t.name);
renderTags();
setEditorValues(fiddle);
setTimeout(run, 100);
} catch (e) {
@@ -221,6 +233,43 @@ async function init() {
}
});
// Tags input
const tagsInput = $('#tags-input');
tagsInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
const val = tagsInput.value.trim().replace(/,/g, '');
if (val && !currentTags.includes(val)) {
currentTags.push(val);
renderTags();
}
tagsInput.value = '';
}
});
// Load tag suggestions
loadTagSuggestions();
// Export & QR buttons
$('#btn-export').addEventListener('click', async () => {
const mode = getCurrentMode();
const { html, css, js } = getEditorValues();
const cssType = getCssType();
const title = $('#title-input').value || 'Untitled';
try {
const compiledCss = await compileCss(css, cssType);
const result = await compileJs(js, mode);
exportHtml({ title, html, css: compiledCss, js: result.js, mode, extraCss: result.extraCss, isModule: result.isModule });
} catch (e) {
showToast(`Export failed: ${e.message}`);
}
});
$('#btn-qr').addEventListener('click', () => {
const url = currentId ? `${location.origin}/f/${currentId}` : location.href;
showQrModal(url);
});
// Load fiddle from URL if present
loadFromUrl();
@@ -231,4 +280,32 @@ async function init() {
});
}
function renderTags() {
const container = $('#tags-display');
container.innerHTML = '';
for (const tag of currentTags) {
const pill = document.createElement('span');
pill.className = 'tag-pill';
pill.innerHTML = `${tag}<button class="tag-remove">&times;</button>`;
pill.querySelector('.tag-remove').addEventListener('click', () => {
currentTags = currentTags.filter(t => t !== tag);
renderTags();
});
container.appendChild(pill);
}
}
async function loadTagSuggestions() {
try {
const { tags } = await listTags();
const datalist = $('#tags-datalist');
datalist.innerHTML = '';
for (const t of tags) {
const opt = document.createElement('option');
opt.value = t.name;
datalist.appendChild(opt);
}
} catch (_) { /* ignore */ }
}
init();

133
public/js/browse.js Normal file
View File

@@ -0,0 +1,133 @@
import { listFiddles, listTags } from './api.js';
const $ = (sel) => document.querySelector(sel);
let debounceTimer = null;
let currentPage = 1;
let activeTag = '';
const JS_TYPE_LABELS = {
javascript: 'JS',
typescript: 'TS',
react: 'React',
'react-ts': 'React+TS',
vue: 'Vue',
svelte: 'Svelte',
};
function relativeTime(dateStr) {
const now = Date.now();
const then = new Date(dateStr + 'Z').getTime();
const diff = Math.floor((now - then) / 1000);
if (diff < 60) return 'just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 2592000) return `${Math.floor(diff / 86400)}d ago`;
return new Date(dateStr).toLocaleDateString();
}
function renderCards(fiddles) {
const grid = $('#fiddle-grid');
if (!fiddles.length) {
grid.innerHTML = '<div class="empty-state">No fiddles found. <a href="/new" style="color: var(--accent)">Create one!</a></div>';
return;
}
grid.innerHTML = fiddles.map(f => {
const preview = f.js_preview || f.html_preview || '';
const badge = JS_TYPE_LABELS[f.js_type] || 'JS';
const tags = (f.tags || []).map(t => `<span class="card-tag">${esc(t.name)}</span>`).join('');
return `
<a href="/f/${f.id}" class="fiddle-card">
<div class="card-title">${esc(f.title)}</div>
<div class="card-meta">
<span class="card-badge">${badge}</span>
<span>${relativeTime(f.updated_at)}</span>
</div>
${preview ? `<div class="card-preview">${esc(preview)}</div>` : ''}
${tags ? `<div class="card-tags">${tags}</div>` : ''}
</a>
`;
}).join('');
}
function renderPagination(total, page, limit) {
const pages = Math.ceil(total / limit);
if (pages <= 1) { $('#pagination').innerHTML = ''; return; }
let html = '';
if (page > 1) html += `<button class="page-btn" data-page="${page - 1}">&laquo; Prev</button>`;
for (let i = 1; i <= pages; i++) {
if (pages > 7 && i > 2 && i < pages - 1 && Math.abs(i - page) > 1) {
if (i === 3 || i === pages - 2) html += '<span style="color:var(--text-dim)">...</span>';
continue;
}
html += `<button class="page-btn${i === page ? ' active' : ''}" data-page="${i}">${i}</button>`;
}
if (page < pages) html += `<button class="page-btn" data-page="${page + 1}">Next &raquo;</button>`;
const el = $('#pagination');
el.innerHTML = html;
el.querySelectorAll('.page-btn').forEach(btn => {
btn.addEventListener('click', () => {
currentPage = parseInt(btn.dataset.page, 10);
fetchAndRender();
});
});
}
async function renderTagsBar() {
try {
const { tags } = await listTags();
const bar = $('#tags-bar');
bar.innerHTML = tags.map(t =>
`<span class="tag-filter${activeTag === t.name ? ' active' : ''}" data-tag="${esc(t.name)}">${esc(t.name)} (${t.count})</span>`
).join('');
bar.querySelectorAll('.tag-filter').forEach(el => {
el.addEventListener('click', () => {
activeTag = activeTag === el.dataset.tag ? '' : el.dataset.tag;
currentPage = 1;
fetchAndRender();
renderTagsBar();
});
});
} catch (_) { /* ignore */ }
}
async function fetchAndRender() {
const q = $('#search-input').value.trim();
const js_type = $('#filter-framework').value;
const sort = $('#filter-sort').value;
try {
const { fiddles, total, page, limit } = await listFiddles({ q, js_type, tag: activeTag, page: currentPage, sort });
renderCards(fiddles);
renderPagination(total, page, limit);
} catch (e) {
$('#fiddle-grid').innerHTML = `<div class="empty-state">Error loading fiddles: ${esc(e.message)}</div>`;
}
}
function esc(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Event listeners
$('#search-input').addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
currentPage = 1;
fetchAndRender();
}, 300);
});
$('#filter-framework').addEventListener('change', () => {
currentPage = 1;
fetchAndRender();
});
$('#filter-sort').addEventListener('change', () => {
currentPage = 1;
fetchAndRender();
});
// Init
renderTagsBar();
fetchAndRender();

82
public/js/embed.js Normal file
View File

@@ -0,0 +1,82 @@
import { loadFiddle } from './api.js';
import { getFrameworkRuntime } from './js-preprocessors.js';
const MODE_MAP = {
javascript: 'html-css-js',
typescript: 'typescript',
react: 'react',
'react-ts': 'react-ts',
vue: 'vue',
svelte: 'svelte',
};
async function init() {
// Extract fiddle ID from URL: /embed/:id
const match = location.pathname.match(/^\/embed\/([a-zA-Z0-9_-]+)$/);
if (!match) {
document.body.innerHTML = '<div class="embed-error">Invalid embed URL</div>';
return;
}
// Theme
const params = new URLSearchParams(location.search);
if (params.get('theme') === 'dark') document.body.classList.add('dark');
try {
const fiddle = await loadFiddle(match[1]);
const mode = MODE_MAP[fiddle.js_type] || 'html-css-js';
const runtime = getFrameworkRuntime(mode);
// For embed, we compile client-side using the same preprocessors
const { compileCss } = await import('./preprocessors.js');
const { compileJs } = await import('./js-preprocessors.js');
const compiledCss = await compileCss(fiddle.css, fiddle.css_type || 'css');
const result = await compileJs(fiddle.js, mode);
const allCss = result.extraCss ? `${compiledCss}\n${result.extraCss}` : compiledCss;
let bodyContent;
if (mode === 'vue' || mode === 'svelte') {
bodyContent = fiddle.html ? `${fiddle.html}\n${runtime.bodyHtml}` : runtime.bodyHtml;
} else if (runtime.bodyHtml) {
bodyContent = `${fiddle.html}\n${runtime.bodyHtml}`;
} else {
bodyContent = fiddle.html;
}
let scripts = '';
if (result.js) {
if (result.isModule) {
scripts = `<script type="module">\n${escapeScriptClose(result.js)}\n<\/script>`;
} else {
for (const url of runtime.scripts) {
scripts += `<script src="${url}"><\/script>\n`;
}
scripts += `<script>\n${escapeScriptClose(result.js)}\n<\/script>`;
}
}
const doc = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>${allCss}</style>
</head>
<body>
${bodyContent}
${scripts}
</body>
</html>`;
document.getElementById('preview-frame').srcdoc = doc;
} catch (e) {
document.body.innerHTML = `<div class="embed-error">Failed to load fiddle: ${e.message}</div>`;
}
}
function escapeScriptClose(code) {
return code.replace(/<\/script/gi, '<\\/script');
}
init();

View File

@@ -1,17 +1,6 @@
let loaded = false;
import { loadScript } from './utils.js';
function loadScript(src) {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) { resolve(); return; }
const savedDefine = window.define;
window.define = undefined;
const s = document.createElement('script');
s.src = src;
s.onload = () => { window.define = savedDefine; resolve(); };
s.onerror = () => { window.define = savedDefine; reject(new Error(`Failed to load ${src}`)); };
document.head.appendChild(s);
});
}
let loaded = false;
export async function initEmmet() {
if (loaded) return;

58
public/js/export.js Normal file
View File

@@ -0,0 +1,58 @@
import { getFrameworkRuntime } from './js-preprocessors.js';
/**
* Export a fiddle as a standalone HTML file and trigger download.
*/
export function exportHtml({ title, html, css, js, mode, extraCss = '', isModule = false }) {
const runtime = getFrameworkRuntime(mode);
const allCss = extraCss ? `${css}\n${extraCss}` : css;
let bodyContent;
if (mode === 'vue' || mode === 'svelte') {
bodyContent = html ? `${html}\n${runtime.bodyHtml}` : runtime.bodyHtml;
} else if (runtime.bodyHtml) {
bodyContent = `${html}\n${runtime.bodyHtml}`;
} else {
bodyContent = html;
}
let scripts = '';
if (js) {
if (isModule) {
scripts = `<script type="module">\n${js}\n<\/script>`;
} else {
for (const url of runtime.scripts) {
scripts += `<script src="${url}"><\/script>\n`;
}
scripts += `<script>\n${js}\n<\/script>`;
}
}
const doc = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escHtml(title)}</title>
<style>
${allCss}
</style>
</head>
<body>
${bodyContent}
${scripts}
</body>
</html>`;
const blob = new Blob([doc], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title.replace(/[^a-zA-Z0-9_-]/g, '_')}.html`;
a.click();
URL.revokeObjectURL(url);
}
function escHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

View File

@@ -1,22 +1,9 @@
import { loadScript } from './utils.js';
let tsLoaded = false;
let babelLoaded = false;
let svelteLoaded = false;
function loadScript(src) {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) { resolve(); return; }
// Temporarily hide AMD define so UMD scripts register as globals
// instead of AMD modules (Monaco's RequireJS sets window.define)
const savedDefine = window.define;
window.define = undefined;
const s = document.createElement('script');
s.src = src;
s.onload = () => { window.define = savedDefine; resolve(); };
s.onerror = () => { window.define = savedDefine; reject(new Error(`Failed to load ${src}`)); };
document.head.appendChild(s);
});
}
async function ensureTypeScript() {
if (tsLoaded) return;
await loadScript('https://cdnjs.cloudflare.com/ajax/libs/typescript/5.6.3/typescript.min.js');

View File

@@ -1,5 +1,6 @@
import { getPref, setPref } from './preferences.js';
import { getActiveEditor, setOnTabSwitch, setOnModeChange } from './editors.js';
import { loadScript } from './utils.js';
let currentMode = 'default'; // 'default' | 'vim' | 'emacs'
let activeAdapter = null; // vim or emacs adapter instance
@@ -8,19 +9,6 @@ let emacsLoaded = false;
const statusBar = () => document.getElementById('vim-status-bar');
function loadScript(src) {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) { resolve(); return; }
const savedDefine = window.define;
window.define = undefined;
const s = document.createElement('script');
s.src = src;
s.onload = () => { window.define = savedDefine; resolve(); };
s.onerror = () => { window.define = savedDefine; reject(new Error(`Failed to load ${src}`)); };
document.head.appendChild(s);
});
}
async function ensureVim() {
if (vimLoaded) return;
window.monaco = monaco;

View File

@@ -1,19 +1,8 @@
import { loadScript } from './utils.js';
let sassLoaded = false;
let lessLoaded = false;
function loadScript(src) {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) { resolve(); return; }
const savedDefine = window.define;
window.define = undefined;
const s = document.createElement('script');
s.src = src;
s.onload = () => { window.define = savedDefine; resolve(); };
s.onerror = () => { window.define = savedDefine; reject(new Error(`Failed to load ${src}`)); };
document.head.appendChild(s);
});
}
async function ensureSass() {
if (sassLoaded) return;
await loadScript('https://cdnjs.cloudflare.com/ajax/libs/sass.js/0.11.1/sass.sync.min.js');

42
public/js/qr.js Normal file
View File

@@ -0,0 +1,42 @@
import { loadScript } from './utils.js';
let qrLoaded = false;
async function ensureQrLib() {
if (qrLoaded) return;
await loadScript('https://cdnjs.cloudflare.com/ajax/libs/qrcode-generator/1.4.4/qrcode.min.js');
qrLoaded = true;
}
/**
* Show a QR code modal for the given URL.
*/
export async function showQrModal(url) {
await ensureQrLib();
const modal = document.getElementById('qr-modal');
const canvas = document.getElementById('qr-canvas');
const urlDisplay = document.getElementById('qr-url');
// Generate QR
const qr = qrcode(0, 'M');
qr.addData(url);
qr.make();
canvas.innerHTML = qr.createSvgTag({ cellSize: 4, margin: 4 });
// Make QR SVG white on dark background
const svg = canvas.querySelector('svg');
if (svg) {
svg.style.background = '#fff';
svg.style.borderRadius = '4px';
svg.style.padding = '8px';
}
urlDisplay.textContent = url;
modal.classList.remove('hidden');
// Close handlers
const close = () => modal.classList.add('hidden');
document.getElementById('qr-modal-close').onclick = close;
modal.onclick = (e) => { if (e.target === modal) close(); };
}

15
public/js/utils.js Normal file
View File

@@ -0,0 +1,15 @@
/**
* Load an external script, hiding AMD define to avoid conflicts with Monaco's RequireJS.
*/
export function loadScript(src) {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) { resolve(); return; }
const savedDefine = window.define;
window.define = undefined;
const s = document.createElement('script');
s.src = src;
s.onload = () => { window.define = savedDefine; resolve(); };
s.onerror = () => { window.define = savedDefine; reject(new Error(`Failed to load ${src}`)); };
document.head.appendChild(s);
});
}