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

63
db.js
View File

@@ -26,18 +26,75 @@ try {
db.exec(`ALTER TABLE fiddles ADD COLUMN js_type TEXT NOT NULL DEFAULT 'javascript'`);
} catch (_) { /* column already exists */ }
// Migration: add listed column
try {
db.exec(`ALTER TABLE fiddles ADD COLUMN listed INTEGER NOT NULL DEFAULT 1`);
} catch (_) { /* column already exists */ }
// Tags tables
db.exec(`
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE COLLATE NOCASE
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS fiddle_tags (
fiddle_id TEXT NOT NULL REFERENCES fiddles(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (fiddle_id, tag_id)
)
`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_fiddles_listed_updated ON fiddles(listed, updated_at DESC)`);
export const stmts = {
insert: db.prepare(`
INSERT INTO fiddles (id, title, html, css, css_type, js, js_type)
VALUES (@id, @title, @html, @css, @css_type, @js, @js_type)
INSERT INTO fiddles (id, title, html, css, css_type, js, js_type, listed)
VALUES (@id, @title, @html, @css, @css_type, @js, @js_type, @listed)
`),
get: db.prepare('SELECT * FROM fiddles WHERE id = ?'),
update: db.prepare(`
UPDATE fiddles SET title = @title, html = @html, css = @css,
css_type = @css_type, js = @js, js_type = @js_type, updated_at = datetime('now')
css_type = @css_type, js = @js, js_type = @js_type, listed = @listed,
updated_at = datetime('now')
WHERE id = @id
`),
list: db.prepare('SELECT id, title, css_type, js_type, created_at, updated_at FROM fiddles ORDER BY updated_at DESC LIMIT 50'),
// Tags
getTagsForFiddle: db.prepare(`
SELECT t.id, t.name FROM tags t
JOIN fiddle_tags ft ON ft.tag_id = t.id
WHERE ft.fiddle_id = ?
`),
insertTag: db.prepare('INSERT OR IGNORE INTO tags (name) VALUES (?)'),
getTagByName: db.prepare('SELECT id, name FROM tags WHERE name = ? COLLATE NOCASE'),
insertFiddleTag: db.prepare('INSERT OR IGNORE INTO fiddle_tags (fiddle_id, tag_id) VALUES (?, ?)'),
deleteFiddleTags: db.prepare('DELETE FROM fiddle_tags WHERE fiddle_id = ?'),
listTags: db.prepare(`
SELECT t.id, t.name, COUNT(ft.fiddle_id) as count
FROM tags t
LEFT JOIN fiddle_tags ft ON ft.tag_id = t.id
GROUP BY t.id
HAVING count > 0
ORDER BY count DESC
`),
};
/**
* Upsert tags for a fiddle. Accepts an array of tag name strings.
*/
export function setFiddleTags(fiddleId, tagNames) {
stmts.deleteFiddleTags.run(fiddleId);
for (const name of tagNames) {
const trimmed = name.trim();
if (!trimmed) continue;
stmts.insertTag.run(trimmed);
const tag = stmts.getTagByName.get(trimmed);
if (tag) stmts.insertFiddleTag.run(fiddleId, tag.id);
}
}
export default db;

40
public/browse.html Normal file
View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fiddle - Browse</title>
<link rel="stylesheet" href="/css/browse.css">
</head>
<body>
<header class="browse-header">
<a href="/" class="logo">Fiddle</a>
<a href="/new" class="btn-new">+ New Fiddle</a>
</header>
<div class="browse-toolbar">
<input type="text" id="search-input" placeholder="Search fiddles..." spellcheck="false">
<select id="filter-framework">
<option value="">All Frameworks</option>
<option value="javascript">HTML/CSS/JS</option>
<option value="typescript">TypeScript</option>
<option value="react">React (JSX)</option>
<option value="react-ts">React + TS</option>
<option value="vue">Vue</option>
<option value="svelte">Svelte</option>
</select>
<select id="filter-sort">
<option value="updated">Recently Updated</option>
<option value="created">Recently Created</option>
</select>
</div>
<div id="tags-bar" class="tags-bar"></div>
<main id="fiddle-grid" class="fiddle-grid"></main>
<div id="pagination" class="pagination"></div>
<script type="module" src="/js/browse.js"></script>
</body>
</html>

195
public/css/browse.css Normal file
View File

@@ -0,0 +1,195 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #1e1e1e;
--surface: #252526;
--border: #3c3c3c;
--text: #cccccc;
--text-dim: #888;
--accent: #0078d4;
--accent-hover: #1a8ceb;
}
html, body {
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
}
/* Header */
.browse-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 24px;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.logo {
font-weight: 700;
font-size: 18px;
color: var(--accent);
text-decoration: none;
}
.btn-new {
background: var(--accent);
color: #fff;
padding: 6px 16px;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
text-decoration: none;
transition: background 0.15s;
}
.btn-new:hover { background: var(--accent-hover); }
/* Toolbar */
.browse-toolbar {
display: flex;
gap: 8px;
padding: 12px 24px;
flex-wrap: wrap;
}
#search-input {
flex: 1;
min-width: 200px;
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
padding: 6px 12px;
border-radius: 4px;
font-size: 13px;
}
#search-input:focus { border-color: var(--accent); outline: none; }
#filter-framework, #filter-sort {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
padding: 6px 8px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
}
/* Tags bar */
.tags-bar {
display: flex;
gap: 6px;
padding: 0 24px 12px;
flex-wrap: wrap;
}
.tag-filter {
display: inline-block;
background: var(--surface);
color: var(--text-dim);
border: 1px solid var(--border);
padding: 2px 10px;
border-radius: 12px;
font-size: 11px;
cursor: pointer;
transition: all 0.15s;
text-decoration: none;
}
.tag-filter:hover { color: var(--text); border-color: var(--accent); }
.tag-filter.active { background: var(--accent); color: #fff; border-color: var(--accent); }
/* Grid */
.fiddle-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
padding: 0 24px 24px;
}
/* Cards */
.fiddle-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 16px;
text-decoration: none;
color: var(--text);
transition: border-color 0.15s, transform 0.1s;
display: flex;
flex-direction: column;
gap: 8px;
}
.fiddle-card:hover {
border-color: var(--accent);
transform: translateY(-1px);
}
.card-title {
font-size: 14px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--text-dim);
}
.card-badge {
background: var(--border);
padding: 1px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
}
.card-preview {
font-family: 'Cascadia Code', 'Fira Code', monospace;
font-size: 11px;
color: var(--text-dim);
line-height: 1.4;
max-height: 3.6em;
overflow: hidden;
white-space: pre-wrap;
word-break: break-all;
}
.card-tags {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.card-tag {
background: var(--border);
color: var(--text-dim);
padding: 1px 6px;
border-radius: 8px;
font-size: 10px;
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
gap: 6px;
padding: 0 24px 24px;
}
.page-btn {
background: var(--surface);
color: var(--text-dim);
border: 1px solid var(--border);
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
text-decoration: none;
}
.page-btn:hover { color: var(--text); border-color: var(--accent); }
.page-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.page-btn:disabled { opacity: 0.4; cursor: default; }
/* Empty state */
.empty-state {
grid-column: 1 / -1;
text-align: center;
padding: 60px 20px;
color: var(--text-dim);
font-size: 14px;
}

View File

@@ -204,6 +204,67 @@ body.resizing iframe { pointer-events: none; }
padding: 4px 6px; border-radius: 4px; font-size: 12px; cursor: pointer;
}
/* Listed toggle */
.listed-toggle {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-dim);
cursor: pointer;
user-select: none;
}
.listed-toggle input { cursor: pointer; }
/* Tags input */
.tags-input-wrap {
display: flex;
align-items: center;
gap: 4px;
max-width: 280px;
}
#tags-input {
background: transparent; border: 1px solid var(--border); color: var(--text);
padding: 3px 6px; border-radius: 4px; font-size: 12px; width: 100px;
}
#tags-input:focus { border-color: var(--accent); outline: none; }
.tags-display { display: flex; gap: 3px; flex-wrap: nowrap; overflow: hidden; }
.tag-pill {
display: inline-flex; align-items: center; gap: 2px;
background: var(--border); color: var(--text); padding: 1px 6px;
border-radius: 10px; font-size: 11px; white-space: nowrap;
}
.tag-pill .tag-remove {
cursor: pointer; color: var(--text-dim); font-size: 13px; line-height: 1;
background: none; border: none; padding: 0 0 0 2px;
}
.tag-pill .tag-remove:hover { color: #f44747; }
/* Secondary buttons */
.btn-secondary {
background: var(--surface); color: var(--text-dim); border: 1px solid var(--border);
}
.btn-secondary:hover { color: var(--text); background: var(--border); }
/* QR Modal */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.7);
display: flex; align-items: center; justify-content: center; z-index: 1000;
}
.modal-overlay.hidden { display: none; }
.modal-content {
background: var(--surface); border: 1px solid var(--border);
border-radius: 8px; padding: 20px; min-width: 280px; text-align: center;
}
.modal-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 16px; font-size: 14px; font-weight: 600;
}
.modal-header .btn-small { font-size: 18px; padding: 0 4px; }
#qr-canvas { display: flex; justify-content: center; margin-bottom: 12px; }
#qr-canvas img, #qr-canvas canvas { border-radius: 4px; }
.qr-url { font-size: 11px; color: var(--text-dim); word-break: break-all; }
/* Toast */
.toast {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);

23
public/embed.html Normal file
View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fiddle Embed</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
body { background: #fff; }
body.dark { background: #1e1e1e; }
iframe { width: 100%; height: 100%; border: none; }
.embed-error {
display: flex; align-items: center; justify-content: center;
height: 100%; font-family: sans-serif; color: #888; font-size: 14px;
}
</style>
</head>
<body>
<iframe id="preview-frame" sandbox="allow-scripts allow-same-origin"></iframe>
<script type="module" src="/js/embed.js"></script>
</body>
</html>

View File

@@ -9,7 +9,7 @@
<body>
<header class="toolbar">
<div class="toolbar-left">
<a href="/" class="logo">Fiddle</a>
<a href="/" class="logo" title="Browse fiddles">Fiddle</a>
<select id="framework-mode">
<option value="html-css-js">HTML / CSS / JS</option>
<option value="typescript">TypeScript</option>
@@ -32,6 +32,15 @@
</select>
</div>
<div class="toolbar-right">
<div class="tags-input-wrap">
<input type="text" id="tags-input" placeholder="Add tags..." list="tags-datalist" spellcheck="false">
<datalist id="tags-datalist"></datalist>
<div id="tags-display" class="tags-display"></div>
</div>
<label class="listed-toggle" title="Show in browse page">
<input type="checkbox" id="listed-checkbox" checked>
Listed
</label>
<label class="auto-run-toggle" title="Auto-run on change">
<input type="checkbox" id="auto-run-checkbox" checked>
Auto
@@ -39,6 +48,8 @@
<button id="btn-run" title="Run (Ctrl+Enter)">Run</button>
<button id="btn-save" title="Save (Ctrl+S)">Save</button>
<button id="btn-fork" title="Fork">Fork</button>
<button id="btn-export" title="Export standalone HTML" class="btn-secondary">Export</button>
<button id="btn-qr" title="QR code" class="btn-secondary">QR</button>
</div>
</header>
@@ -60,6 +71,17 @@
</div>
</main>
<div id="qr-modal" class="modal-overlay hidden">
<div class="modal-content">
<div class="modal-header">
<span>Share via QR Code</span>
<button id="qr-modal-close" class="btn-small">&times;</button>
</div>
<div id="qr-canvas"></div>
<div id="qr-url" class="qr-url"></div>
</div>
</div>
<div id="share-toast" class="toast hidden"></div>
<script>

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);
});
}

101
server.js
View File

@@ -1,32 +1,94 @@
import express from 'express';
import { nanoid } from 'nanoid';
import { stmts } from './db.js';
import db, { stmts, setFiddleTags } from './db.js';
const app = express();
app.use(express.json());
app.use(express.static('public'));
// HTML routes must be defined before static middleware (which would serve index.html for /)
app.get('/', (_req, res) => {
res.sendFile('browse.html', { root: 'public' });
});
app.get('/new', (_req, res) => {
res.sendFile('index.html', { root: 'public' });
});
app.get('/embed/:id', (_req, res) => {
res.sendFile('embed.html', { root: 'public' });
});
app.get('/f/:id', (_req, res) => {
res.sendFile('index.html', { root: 'public' });
});
app.use(express.static('public', { index: false }));
// API: Create fiddle
app.post('/api/fiddles', (req, res) => {
const id = nanoid(10);
const { title = 'Untitled', html = '', css = '', css_type = 'css', js = '', js_type = 'javascript' } = req.body;
const { title = 'Untitled', html = '', css = '', css_type = 'css', js = '', js_type = 'javascript', listed = 1, tags = [] } = req.body;
try {
stmts.insert.run({ id, title, html, css, css_type, js, js_type });
res.json({ id, title, html, css, css_type, js, js_type });
stmts.insert.run({ id, title, html, css, css_type, js, js_type, listed: listed ? 1 : 0 });
if (tags.length) setFiddleTags(id, tags);
const fiddleTags = stmts.getTagsForFiddle.all(id);
res.json({ id, title, html, css, css_type, js, js_type, listed, tags: fiddleTags });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// API: List recent fiddles
app.get('/api/fiddles', (_req, res) => {
res.json(stmts.list.all());
// API: List/search fiddles
app.get('/api/fiddles', (req, res) => {
const { q = '', js_type = '', tag = '', page = '1', limit = '20', sort = 'updated' } = req.query;
const pageNum = Math.max(1, parseInt(page, 10) || 1);
const limitNum = Math.min(100, Math.max(1, parseInt(limit, 10) || 20));
const offset = (pageNum - 1) * limitNum;
let where = 'WHERE f.listed = 1';
const params = {};
if (q) {
where += ' AND f.title LIKE @q';
params.q = `%${q}%`;
}
if (js_type) {
where += ' AND f.js_type = @js_type';
params.js_type = js_type;
}
if (tag) {
where += ' AND EXISTS (SELECT 1 FROM fiddle_tags ft2 JOIN tags t2 ON t2.id = ft2.tag_id WHERE ft2.fiddle_id = f.id AND t2.name = @tag COLLATE NOCASE)';
params.tag = tag;
}
const orderBy = sort === 'created' ? 'f.created_at DESC' : 'f.updated_at DESC';
try {
const countRow = db.prepare(`SELECT COUNT(*) as total FROM fiddles f ${where}`).get(params);
const fiddles = db.prepare(`
SELECT f.id, f.title, f.css_type, f.js_type, f.created_at, f.updated_at,
SUBSTR(f.html, 1, 200) as html_preview, SUBSTR(f.js, 1, 200) as js_preview
FROM fiddles f ${where}
ORDER BY ${orderBy}
LIMIT @limit OFFSET @offset
`).all({ ...params, limit: limitNum, offset });
// Attach tags to each fiddle
for (const f of fiddles) {
f.tags = stmts.getTagsForFiddle.all(f.id);
}
res.json({ fiddles, total: countRow.total, page: pageNum, limit: limitNum });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// API: Get fiddle
app.get('/api/fiddles/:id', (req, res) => {
const fiddle = stmts.get.get(req.params.id);
if (!fiddle) return res.status(404).json({ error: 'Not found' });
fiddle.tags = stmts.getTagsForFiddle.all(fiddle.id);
res.json(fiddle);
});
@@ -34,14 +96,25 @@ app.get('/api/fiddles/:id', (req, res) => {
app.put('/api/fiddles/:id', (req, res) => {
const existing = stmts.get.get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Not found' });
const { title = existing.title, html = existing.html, css = existing.css, css_type = existing.css_type, js = existing.js, js_type = existing.js_type || 'javascript' } = req.body;
stmts.update.run({ id: req.params.id, title, html, css, css_type, js, js_type });
res.json({ id: req.params.id, title, html, css, css_type, js, js_type });
const {
title = existing.title,
html = existing.html,
css = existing.css,
css_type = existing.css_type,
js = existing.js,
js_type = existing.js_type || 'javascript',
listed = existing.listed,
tags,
} = req.body;
stmts.update.run({ id: req.params.id, title, html, css, css_type, js, js_type, listed: listed ? 1 : 0 });
if (Array.isArray(tags)) setFiddleTags(req.params.id, tags);
const fiddleTags = stmts.getTagsForFiddle.all(req.params.id);
res.json({ id: req.params.id, title, html, css, css_type, js, js_type, listed, tags: fiddleTags });
});
// SPA route: serve index.html for /f/:id
app.get('/f/:id', (_req, res) => {
res.sendFile('index.html', { root: 'public' });
// API: List tags
app.get('/api/tags', (_req, res) => {
res.json({ tags: stmts.listTags.all() });
});
const port = process.env.PORT || 3000;