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:
63
db.js
63
db.js
@@ -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
40
public/browse.html
Normal 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
195
public/css/browse.css
Normal 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;
|
||||
}
|
||||
@@ -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
23
public/embed.html
Normal 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>
|
||||
@@ -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">×</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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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">×</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
133
public/js/browse.js
Normal 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}">« 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 »</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
82
public/js/embed.js
Normal 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();
|
||||
@@ -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
58
public/js/export.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
42
public/js/qr.js
Normal 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
15
public/js/utils.js
Normal 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
101
server.js
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user