Add version history, screenshots, embed generator, collections, npm search, format-on-save, and custom fonts
This commit is contained in:
115
db.js
115
db.js
@@ -54,6 +54,47 @@ db.exec(`
|
||||
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_fiddles_listed_updated ON fiddles(listed, updated_at DESC)`);
|
||||
|
||||
// Migration: add screenshot column
|
||||
try {
|
||||
db.exec(`ALTER TABLE fiddles ADD COLUMN screenshot TEXT`);
|
||||
} catch (_) { /* column already exists */ }
|
||||
|
||||
// Version history table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS fiddle_versions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
fiddle_id TEXT NOT NULL REFERENCES fiddles(id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL,
|
||||
html TEXT NOT NULL DEFAULT '',
|
||||
css TEXT NOT NULL DEFAULT '',
|
||||
css_type TEXT NOT NULL DEFAULT 'css',
|
||||
js TEXT NOT NULL DEFAULT '',
|
||||
js_type TEXT NOT NULL DEFAULT 'javascript',
|
||||
options TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_versions_fiddle ON fiddle_versions(fiddle_id, version DESC)`);
|
||||
|
||||
// Collections tables
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS collections (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS collection_fiddles (
|
||||
collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
fiddle_id TEXT NOT NULL REFERENCES fiddles(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (collection_id, fiddle_id)
|
||||
)
|
||||
`);
|
||||
|
||||
export const stmts = {
|
||||
insert: db.prepare(`
|
||||
INSERT INTO fiddles (id, title, html, css, css_type, js, js_type, listed, options)
|
||||
@@ -86,6 +127,58 @@ export const stmts = {
|
||||
HAVING count > 0
|
||||
ORDER BY count DESC
|
||||
`),
|
||||
|
||||
// Versions
|
||||
insertVersion: db.prepare(`
|
||||
INSERT INTO fiddle_versions (fiddle_id, version, html, css, css_type, js, js_type, options)
|
||||
VALUES (@fiddle_id, @version, @html, @css, @css_type, @js, @js_type, @options)
|
||||
`),
|
||||
getMaxVersion: db.prepare('SELECT COALESCE(MAX(version), 0) as max_ver FROM fiddle_versions WHERE fiddle_id = ?'),
|
||||
listVersions: db.prepare('SELECT id, version, created_at FROM fiddle_versions WHERE fiddle_id = ? ORDER BY version DESC'),
|
||||
getVersion: db.prepare('SELECT * FROM fiddle_versions WHERE fiddle_id = ? AND version = ?'),
|
||||
deleteOldVersions: db.prepare(`
|
||||
DELETE FROM fiddle_versions WHERE fiddle_id = ? AND id NOT IN (
|
||||
SELECT id FROM fiddle_versions WHERE fiddle_id = ? ORDER BY version DESC LIMIT 50
|
||||
)
|
||||
`),
|
||||
|
||||
// Screenshot
|
||||
updateScreenshot: db.prepare('UPDATE fiddles SET screenshot = ? WHERE id = ?'),
|
||||
|
||||
// Collections
|
||||
insertCollection: db.prepare(`
|
||||
INSERT INTO collections (id, name, description) VALUES (@id, @name, @description)
|
||||
`),
|
||||
listCollections: db.prepare(`
|
||||
SELECT c.*, COUNT(cf.fiddle_id) as fiddle_count
|
||||
FROM collections c
|
||||
LEFT JOIN collection_fiddles cf ON cf.collection_id = c.id
|
||||
GROUP BY c.id
|
||||
ORDER BY c.updated_at DESC
|
||||
`),
|
||||
getCollection: db.prepare('SELECT * FROM collections WHERE id = ?'),
|
||||
updateCollection: db.prepare(`
|
||||
UPDATE collections SET name = @name, description = @description, updated_at = datetime('now') WHERE id = @id
|
||||
`),
|
||||
deleteCollection: db.prepare('DELETE FROM collections WHERE id = ?'),
|
||||
addFiddleToCollection: db.prepare(`
|
||||
INSERT OR IGNORE INTO collection_fiddles (collection_id, fiddle_id, position)
|
||||
VALUES (@collection_id, @fiddle_id, (SELECT COALESCE(MAX(position), 0) + 1 FROM collection_fiddles WHERE collection_id = @collection_id))
|
||||
`),
|
||||
removeFiddleFromCollection: db.prepare('DELETE FROM collection_fiddles WHERE collection_id = ? AND fiddle_id = ?'),
|
||||
getCollectionFiddles: db.prepare(`
|
||||
SELECT f.id, f.title, f.css_type, f.js_type, f.created_at, f.updated_at, f.screenshot,
|
||||
SUBSTR(f.html, 1, 200) as html_preview, SUBSTR(f.js, 1, 200) as js_preview
|
||||
FROM fiddles f
|
||||
JOIN collection_fiddles cf ON cf.fiddle_id = f.id
|
||||
WHERE cf.collection_id = ?
|
||||
ORDER BY cf.position
|
||||
`),
|
||||
getCollectionsForFiddle: db.prepare(`
|
||||
SELECT c.id, c.name FROM collections c
|
||||
JOIN collection_fiddles cf ON cf.collection_id = c.id
|
||||
WHERE cf.fiddle_id = ?
|
||||
`),
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -102,4 +195,26 @@ export function setFiddleTags(fiddleId, tagNames) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot current fiddle state as a version before overwriting.
|
||||
*/
|
||||
export function snapshotVersion(fiddleId) {
|
||||
const fiddle = stmts.get.get(fiddleId);
|
||||
if (!fiddle) return;
|
||||
const { max_ver } = stmts.getMaxVersion.get(fiddleId);
|
||||
const version = max_ver + 1;
|
||||
stmts.insertVersion.run({
|
||||
fiddle_id: fiddleId,
|
||||
version,
|
||||
html: fiddle.html,
|
||||
css: fiddle.css,
|
||||
css_type: fiddle.css_type,
|
||||
js: fiddle.js,
|
||||
js_type: fiddle.js_type,
|
||||
options: fiddle.options || '{}',
|
||||
});
|
||||
// Cap at 50 versions
|
||||
stmts.deleteOldVersions.run(fiddleId, fiddleId);
|
||||
}
|
||||
|
||||
export default db;
|
||||
|
||||
@@ -31,11 +31,26 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="browse-tabs">
|
||||
<button class="browse-tab active" data-view="fiddles">Fiddles</button>
|
||||
<button class="browse-tab" data-view="collections">Collections</button>
|
||||
</div>
|
||||
|
||||
<div id="fiddles-view">
|
||||
<div id="tags-bar" class="tags-bar"></div>
|
||||
|
||||
<main id="fiddle-grid" class="fiddle-grid"></main>
|
||||
|
||||
<div id="pagination" class="pagination"></div>
|
||||
</div>
|
||||
|
||||
<div id="collections-view" style="display:none">
|
||||
<div id="collections-grid" class="collections-grid"></div>
|
||||
<div id="collection-detail" style="display:none">
|
||||
<button id="collection-back" class="collection-back-btn">← Back to collections</button>
|
||||
<div id="collection-header" class="collection-header"></div>
|
||||
<div id="collection-desc" class="collection-desc"></div>
|
||||
<div id="collection-fiddles" class="fiddle-grid" style="padding:0 24px 24px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/browse.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -107,18 +107,22 @@ html, body {
|
||||
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;
|
||||
overflow: hidden;
|
||||
}
|
||||
.fiddle-card:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.card-thumbnail {
|
||||
width: 100%; height: 160px; object-fit: cover;
|
||||
background: #2a2a2a; display: block;
|
||||
}
|
||||
.card-body { padding: 12px 16px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
@@ -194,6 +198,52 @@ html, body {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Collections */
|
||||
.browse-tabs {
|
||||
display: flex; gap: 0; padding: 0 24px; border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.browse-tab {
|
||||
background: transparent; color: var(--text-dim); border: none;
|
||||
border-bottom: 2px solid transparent; padding: 8px 16px;
|
||||
font-size: 13px; font-weight: 500; cursor: pointer;
|
||||
}
|
||||
.browse-tab:hover { color: var(--text); }
|
||||
.browse-tab.active { color: var(--text); border-bottom-color: var(--accent); }
|
||||
|
||||
.collections-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 0 24px 24px;
|
||||
}
|
||||
.collection-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.collection-card:hover { border-color: var(--accent); }
|
||||
.collection-card-name { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
|
||||
.collection-card-desc { font-size: 12px; color: var(--text-dim); margin-bottom: 6px; }
|
||||
.collection-card-count { font-size: 11px; color: var(--text-dim); }
|
||||
.collection-back-btn {
|
||||
background: var(--surface); color: var(--text-dim); border: 1px solid var(--border);
|
||||
padding: 4px 12px; border-radius: 4px; font-size: 12px; cursor: pointer;
|
||||
margin: 0 24px 12px;
|
||||
}
|
||||
.collection-back-btn:hover { color: var(--text); border-color: var(--accent); }
|
||||
.collection-header {
|
||||
padding: 0 24px 8px; font-size: 16px; font-weight: 600;
|
||||
}
|
||||
.collection-desc {
|
||||
padding: 0 24px 12px; font-size: 12px; color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 768px) {
|
||||
.browse-header { padding: 10px 16px; }
|
||||
|
||||
@@ -137,6 +137,27 @@ body.resizing iframe { pointer-events: none; }
|
||||
background: rgba(255,255,255,0.06);
|
||||
}
|
||||
|
||||
/* Language color indicators on tabs */
|
||||
.tab-color-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
|
||||
}
|
||||
.tab-btn.active .tab-color-dot { box-shadow: 0 0 4px currentColor; }
|
||||
.tab-lang-html .tab-color-dot { background: #E44D26; }
|
||||
.tab-lang-css .tab-color-dot { background: #264DE4; }
|
||||
.tab-lang-javascript .tab-color-dot { background: #F7DF1E; }
|
||||
.tab-lang-typescript .tab-color-dot { background: #3178C6; }
|
||||
.tab-lang-markdown .tab-color-dot { background: #83B; }
|
||||
.tab-lang-html .tab-btn-label, .tab-lang-css .tab-btn-label,
|
||||
.tab-lang-javascript .tab-btn-label, .tab-lang-typescript .tab-btn-label,
|
||||
.tab-lang-markdown .tab-btn-label { /* no extra style needed, just grouping */ }
|
||||
|
||||
/* Active tab border matches language color */
|
||||
.tab-lang-html.active { border-bottom-color: #E44D26; }
|
||||
.tab-lang-css.active { border-bottom-color: #264DE4; }
|
||||
.tab-lang-javascript.active { border-bottom-color: #F7DF1E; }
|
||||
.tab-lang-typescript.active { border-bottom-color: #3178C6; }
|
||||
.tab-lang-markdown.active { border-bottom-color: #83B; }
|
||||
|
||||
/* CSS type selector inside tab bar */
|
||||
.tab-css-type {
|
||||
background: var(--bg);
|
||||
@@ -595,3 +616,134 @@ body.resizing iframe { pointer-events: none; }
|
||||
.templates-modal-content { min-width: unset !important; }
|
||||
.templates-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ===================== Format Save Toggle ===================== */
|
||||
.format-save-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.format-save-toggle input { cursor: pointer; }
|
||||
|
||||
/* ===================== Editor Font Select ===================== */
|
||||
#editor-font {
|
||||
background: var(--bg); color: var(--text); border: 1px solid var(--border);
|
||||
padding: 4px 6px; border-radius: 4px; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
|
||||
/* ===================== Version History Modal ===================== */
|
||||
.history-modal-content { min-width: 500px; max-width: 700px; text-align: left; }
|
||||
.history-list {
|
||||
max-height: 200px; overflow-y: auto;
|
||||
border: 1px solid var(--border); border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.history-item {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 8px 12px; cursor: pointer; border-bottom: 1px solid var(--border);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.history-item:last-child { border-bottom: none; }
|
||||
.history-item:hover { background: rgba(255,255,255,0.04); }
|
||||
.history-item.active { background: rgba(0,120,212,0.15); }
|
||||
.history-version {
|
||||
font-weight: 600; font-size: 13px; color: var(--accent);
|
||||
font-family: 'Cascadia Code', 'Fira Code', monospace;
|
||||
}
|
||||
.history-date { font-size: 11px; color: var(--text-dim); }
|
||||
.history-preview-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.history-preview-header span { font-size: 12px; color: var(--text-dim); }
|
||||
.history-diff-section { margin-bottom: 8px; }
|
||||
.history-diff-label {
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
color: var(--accent); margin-bottom: 4px;
|
||||
}
|
||||
.history-diff-code {
|
||||
background: var(--bg); border: 1px solid var(--border);
|
||||
border-radius: 4px; padding: 8px; font-size: 11px;
|
||||
font-family: 'Cascadia Code', 'Fira Code', monospace;
|
||||
max-height: 150px; overflow-y: auto; white-space: pre-wrap;
|
||||
word-break: break-all; color: var(--text-dim);
|
||||
}
|
||||
.history-diff { max-height: 300px; overflow-y: auto; }
|
||||
|
||||
/* ===================== Embed Modal ===================== */
|
||||
.embed-modal-content { min-width: 480px; max-width: 600px; text-align: left; }
|
||||
.embed-options {
|
||||
display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 12px;
|
||||
}
|
||||
.embed-options label {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
font-size: 12px; color: var(--text-dim);
|
||||
}
|
||||
.embed-options select, .embed-options input {
|
||||
background: var(--bg); color: var(--text); border: 1px solid var(--border);
|
||||
padding: 3px 6px; border-radius: 4px; font-size: 12px;
|
||||
}
|
||||
.embed-code-wrap {
|
||||
position: relative; margin-bottom: 12px;
|
||||
}
|
||||
.embed-code {
|
||||
background: var(--bg); border: 1px solid var(--border);
|
||||
border-radius: 4px; padding: 10px; font-size: 11px;
|
||||
font-family: 'Cascadia Code', 'Fira Code', monospace;
|
||||
white-space: pre-wrap; word-break: break-all; color: var(--text);
|
||||
max-height: 80px; overflow-y: auto;
|
||||
}
|
||||
.embed-code-wrap .btn-small {
|
||||
position: absolute; top: 6px; right: 6px;
|
||||
background: var(--border); padding: 2px 8px;
|
||||
}
|
||||
.embed-live-preview { margin-top: 8px; }
|
||||
|
||||
/* ===================== Collection Modal ===================== */
|
||||
.collection-new-row {
|
||||
display: flex; gap: 6px; margin-bottom: 12px;
|
||||
}
|
||||
.collection-new-row input {
|
||||
flex: 1; background: var(--bg); border: 1px solid var(--border); color: var(--text);
|
||||
padding: 5px 8px; border-radius: 4px; font-size: 12px;
|
||||
}
|
||||
.collection-new-row input:focus { border-color: var(--accent); outline: none; }
|
||||
.collection-list {
|
||||
max-height: 250px; overflow-y: auto;
|
||||
border: 1px solid var(--border); border-radius: 4px;
|
||||
}
|
||||
.collection-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 12px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.collection-item:last-child { border-bottom: none; }
|
||||
.collection-name { flex: 1; font-size: 13px; font-weight: 500; }
|
||||
.collection-count { font-size: 11px; color: var(--text-dim); }
|
||||
.collection-add-btn { flex-shrink: 0; }
|
||||
|
||||
/* ===================== npm Search ===================== */
|
||||
.npm-search-row { position: relative; }
|
||||
#npm-search-input {
|
||||
flex: 1; background: var(--bg); border: 1px solid var(--border); color: var(--text);
|
||||
padding: 5px 8px; border-radius: 4px; font-size: 12px; width: 100%;
|
||||
}
|
||||
#npm-search-input:focus { border-color: var(--accent); outline: none; }
|
||||
.npm-search-results {
|
||||
position: absolute; top: 100%; left: 0; right: 0; z-index: 10;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 4px;
|
||||
max-height: 220px; overflow-y: auto; box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
}
|
||||
.npm-search-results.hidden { display: none; }
|
||||
.npm-result {
|
||||
padding: 8px 10px; cursor: pointer; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.npm-result:last-child { border-bottom: none; }
|
||||
.npm-result:hover { background: rgba(0,120,212,0.1); }
|
||||
.npm-result-name { font-size: 12px; font-weight: 600; color: var(--text); }
|
||||
.npm-result-version { font-size: 10px; color: var(--text-dim); font-weight: 400; }
|
||||
.npm-result-desc { font-size: 11px; color: var(--text-dim); margin-top: 2px; }
|
||||
.npm-no-results { padding: 12px; text-align: center; color: var(--text-dim); font-size: 12px; }
|
||||
|
||||
@@ -40,6 +40,14 @@
|
||||
<option value="dracula">Dracula</option>
|
||||
<option value="github-dark">GitHub Dark</option>
|
||||
</select>
|
||||
<select id="editor-font" title="Editor font">
|
||||
<option value="default">Default Font</option>
|
||||
<option value="Fira Code">Fira Code</option>
|
||||
<option value="JetBrains Mono">JetBrains Mono</option>
|
||||
<option value="Source Code Pro">Source Code Pro</option>
|
||||
<option value="IBM Plex Mono">IBM Plex Mono</option>
|
||||
<option value="Cascadia Code">Cascadia Code</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<div class="tags-input-wrap">
|
||||
@@ -62,6 +70,10 @@
|
||||
<input type="checkbox" id="listed-checkbox" checked>
|
||||
Listed
|
||||
</label>
|
||||
<label class="format-save-toggle" title="Auto-format code on save">
|
||||
<input type="checkbox" id="format-save-checkbox">
|
||||
Fmt
|
||||
</label>
|
||||
<label class="auto-run-toggle" title="Auto-run on change">
|
||||
<input type="checkbox" id="auto-run-checkbox" checked>
|
||||
Auto
|
||||
@@ -69,6 +81,9 @@
|
||||
<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-history" title="Version history" class="btn-secondary">History</button>
|
||||
<button id="btn-embed" title="Embed code generator" class="btn-secondary">Embed</button>
|
||||
<button id="btn-collection" title="Add to collection" class="btn-secondary">Collection</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>
|
||||
@@ -142,6 +157,10 @@
|
||||
<input type="text" id="resource-js-input" placeholder="JS URL (e.g. https://cdn.jsdelivr.net/...)">
|
||||
<button id="btn-add-js" class="btn-small">+ JS</button>
|
||||
</div>
|
||||
<div class="resource-row npm-search-row">
|
||||
<input type="text" id="npm-search-input" placeholder="Search npm packages..." autocomplete="off">
|
||||
<div id="npm-search-results" class="npm-search-results hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="resource-list" class="resource-list"></div>
|
||||
</div>
|
||||
@@ -189,6 +208,60 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="history-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content history-modal-content">
|
||||
<div class="modal-header">
|
||||
<span>Version History</span>
|
||||
<button id="history-modal-close" class="btn-small">×</button>
|
||||
</div>
|
||||
<div id="history-list" class="history-list"></div>
|
||||
<div id="history-preview" class="history-preview hidden">
|
||||
<div class="history-preview-header">
|
||||
<span id="history-preview-label"></span>
|
||||
<button id="history-restore-btn">Restore This Version</button>
|
||||
</div>
|
||||
<div id="history-diff" class="history-diff"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="embed-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content embed-modal-content">
|
||||
<div class="modal-header">
|
||||
<span>Embed Code</span>
|
||||
<button id="embed-modal-close" class="btn-small">×</button>
|
||||
</div>
|
||||
<div class="embed-options">
|
||||
<label>Theme <select id="embed-theme"><option value="light">Light</option><option value="dark">Dark</option></select></label>
|
||||
<label>Show tabs <select id="embed-tabs"><option value="1">Yes</option><option value="0">No</option></select></label>
|
||||
<label>Auto-run <select id="embed-autorun"><option value="1">Yes</option><option value="0">No</option></select></label>
|
||||
<label>Width <input type="text" id="embed-width" value="100%" style="width:70px"></label>
|
||||
<label>Height <input type="text" id="embed-height" value="400" style="width:70px"></label>
|
||||
</div>
|
||||
<div class="embed-code-wrap">
|
||||
<pre id="embed-code" class="embed-code"></pre>
|
||||
<button id="embed-copy" class="btn-small">Copy</button>
|
||||
</div>
|
||||
<div class="embed-live-preview">
|
||||
<iframe id="embed-preview-frame" style="border:1px solid var(--border);border-radius:4px;width:100%;height:200px"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="collection-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content" style="min-width:340px">
|
||||
<div class="modal-header">
|
||||
<span>Add to Collection</span>
|
||||
<button id="collection-modal-close" class="btn-small">×</button>
|
||||
</div>
|
||||
<div class="collection-new-row">
|
||||
<input type="text" id="new-collection-name" placeholder="New collection name...">
|
||||
<button id="btn-create-collection" class="btn-small">Create</button>
|
||||
</div>
|
||||
<div id="collection-list" class="collection-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="share-toast" class="toast hidden"></div>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -39,3 +39,47 @@ export function listFiddles({ q, js_type, tag, page, limit, sort } = {}) {
|
||||
export function listTags() {
|
||||
return request('/api/tags');
|
||||
}
|
||||
|
||||
// Version history
|
||||
export function listVersions(id) {
|
||||
return request(`${BASE}/${id}/versions`);
|
||||
}
|
||||
|
||||
export function getVersion(id, ver) {
|
||||
return request(`${BASE}/${id}/versions/${ver}`);
|
||||
}
|
||||
|
||||
export function revertVersion(id, ver) {
|
||||
return request(`${BASE}/${id}/revert/${ver}`, { method: 'POST' });
|
||||
}
|
||||
|
||||
// Collections
|
||||
export function createCollection(data) {
|
||||
return request('/api/collections', { method: 'POST', body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export function listCollections() {
|
||||
return request('/api/collections');
|
||||
}
|
||||
|
||||
export function getCollection(id) {
|
||||
return request(`/api/collections/${id}`);
|
||||
}
|
||||
|
||||
export function updateCollection(id, data) {
|
||||
return request(`/api/collections/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export function deleteCollection(id) {
|
||||
return request(`/api/collections/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export function addToCollection(collectionId, fiddleId) {
|
||||
return request(`/api/collections/${collectionId}/fiddles`, {
|
||||
method: 'POST', body: JSON.stringify({ fiddle_id: fiddleId }),
|
||||
});
|
||||
}
|
||||
|
||||
export function removeFromCollection(collectionId, fiddleId) {
|
||||
return request(`/api/collections/${collectionId}/fiddles/${fiddleId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
291
public/js/app.js
291
public/js/app.js
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
initEditors, switchMode, getEditorValues, setEditorValues,
|
||||
setOnChange, setOnTabSwitch, getCurrentMode, getCssType, setCssType,
|
||||
setOnFormat, setOnDiff, setEditorTheme,
|
||||
setOnFormat, setOnDiff, setEditorTheme, setEditorFont,
|
||||
relayoutEditors,
|
||||
MODE_TABS, MODE_TO_JS_TYPE, JS_TYPE_TO_MODE,
|
||||
} from './editors.js';
|
||||
@@ -9,7 +9,11 @@ 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, listTags } from './api.js';
|
||||
import {
|
||||
createFiddle, loadFiddle, updateFiddle, listTags,
|
||||
listVersions, getVersion, revertVersion,
|
||||
listCollections, createCollection, addToCollection,
|
||||
} from './api.js';
|
||||
import { getPref, setPref } from './preferences.js';
|
||||
import { initEmmet } from './emmet.js';
|
||||
import { initKeybindings } from './keybindings.js';
|
||||
@@ -20,12 +24,13 @@ import { initDevtools } from './devtools.js';
|
||||
import { initNetwork, clearNetwork } from './network-panel.js';
|
||||
import { initElements, clearElements } from './elements-panel.js';
|
||||
import { initPerformance, clearPerformance } from './performance-panel.js';
|
||||
import { formatActiveEditor } from './formatter.js';
|
||||
import { formatActiveEditor, formatAll } from './formatter.js';
|
||||
import { initLinter, lintOnChange } from './linter.js';
|
||||
import { toggleDiff, snapshotValues, onTabSwitch as diffOnTabSwitch } from './diff-view.js';
|
||||
import { registerCustomThemes, THEMES } from './editor-themes.js';
|
||||
import { GALLERY_TEMPLATES } from './templates.js';
|
||||
import { configureTypeDefaults, registerSnippetProviders } from './autocomplete.js';
|
||||
import { initNpmSearch } from './npm-search.js';
|
||||
|
||||
let currentId = null;
|
||||
let debounceTimer = null;
|
||||
@@ -125,6 +130,11 @@ function showToast(msg) {
|
||||
}
|
||||
|
||||
async function save() {
|
||||
// Format on save if enabled
|
||||
if (getPref('formatOnSave')) {
|
||||
try { await formatAll(); } catch (_) { /* best effort */ }
|
||||
}
|
||||
|
||||
const { html, css, js } = getEditorValues();
|
||||
const title = $('#title-input').value || 'Untitled';
|
||||
const css_type = getCssType();
|
||||
@@ -132,14 +142,25 @@ async function save() {
|
||||
const listed = $('#listed-checkbox').checked ? 1 : 0;
|
||||
const tags = currentTags.slice();
|
||||
const options = JSON.stringify({ tailwind: getTailwindChecked(), resources: currentResources });
|
||||
|
||||
// Capture screenshot from preview iframe
|
||||
let screenshot = undefined;
|
||||
try {
|
||||
screenshot = await captureScreenshot();
|
||||
} catch (_) { /* screenshot is optional */ }
|
||||
|
||||
try {
|
||||
if (currentId) {
|
||||
await updateFiddle(currentId, { title, html, css, css_type, js, js_type, listed, options, tags });
|
||||
await updateFiddle(currentId, { title, html, css, css_type, js, js_type, listed, options, tags, screenshot });
|
||||
snapshotValues();
|
||||
showToast(`Saved! Share: ${location.origin}/f/${currentId}`);
|
||||
} else {
|
||||
const result = await createFiddle({ title, html, css, css_type, js, js_type, listed, options, tags });
|
||||
currentId = result.id;
|
||||
// Capture screenshot for new fiddle too
|
||||
if (screenshot) {
|
||||
try { await updateFiddle(currentId, { title, html, css, css_type, js, js_type, listed, options, tags, screenshot }); } catch (_) {}
|
||||
}
|
||||
snapshotValues();
|
||||
history.pushState(null, '', `/f/${currentId}`);
|
||||
showToast(`Saved! Share: ${location.origin}/f/${currentId}`);
|
||||
@@ -149,6 +170,36 @@ async function save() {
|
||||
}
|
||||
}
|
||||
|
||||
async function captureScreenshot() {
|
||||
const iframe = $('#preview-frame');
|
||||
if (!iframe || !iframe.contentDocument) return undefined;
|
||||
try {
|
||||
// Use html2canvas to capture the iframe content
|
||||
if (!window.html2canvas) {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js';
|
||||
await new Promise((resolve, reject) => {
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
const canvas = await html2canvas(iframe.contentDocument.body, {
|
||||
width: 600, height: 400, scale: 1,
|
||||
useCORS: true, logging: false, backgroundColor: '#ffffff',
|
||||
});
|
||||
// Resize to 600x400
|
||||
const resized = document.createElement('canvas');
|
||||
resized.width = 600;
|
||||
resized.height = 400;
|
||||
const ctx = resized.getContext('2d');
|
||||
ctx.drawImage(canvas, 0, 0, 600, 400);
|
||||
return resized.toDataURL('image/jpeg', 0.7);
|
||||
} catch (_) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function fork() {
|
||||
const { html, css, js } = getEditorValues();
|
||||
const title = ($('#title-input').value || 'Untitled') + ' (fork)';
|
||||
@@ -345,6 +396,24 @@ async function init() {
|
||||
handleModeChange(e.target.value);
|
||||
});
|
||||
|
||||
// Format on save toggle
|
||||
const fmtCb = $('#format-save-checkbox');
|
||||
fmtCb.checked = getPref('formatOnSave');
|
||||
fmtCb.addEventListener('change', (e) => setPref('formatOnSave', e.target.checked));
|
||||
|
||||
// Editor font selector
|
||||
const fontSel = $('#editor-font');
|
||||
const savedFont = getPref('editorFont') || 'default';
|
||||
fontSel.value = savedFont;
|
||||
if (savedFont !== 'default') loadGoogleFont(savedFont);
|
||||
setEditorFont(savedFont);
|
||||
fontSel.addEventListener('change', (e) => {
|
||||
const font = e.target.value;
|
||||
if (font !== 'default') loadGoogleFont(font);
|
||||
setEditorFont(font);
|
||||
setPref('editorFont', font);
|
||||
});
|
||||
|
||||
// Toolbar buttons
|
||||
$('#btn-run').addEventListener('click', run);
|
||||
$('#btn-save').addEventListener('click', save);
|
||||
@@ -429,6 +498,45 @@ async function init() {
|
||||
showQrModal(url);
|
||||
});
|
||||
|
||||
// Version History modal
|
||||
const histModal = $('#history-modal');
|
||||
$('#btn-history').addEventListener('click', () => openHistoryModal());
|
||||
$('#history-modal-close').addEventListener('click', () => histModal.classList.add('hidden'));
|
||||
histModal.addEventListener('click', (e) => { if (e.target === histModal) histModal.classList.add('hidden'); });
|
||||
|
||||
// Embed modal
|
||||
const embedModal = $('#embed-modal');
|
||||
$('#btn-embed').addEventListener('click', () => openEmbedModal());
|
||||
$('#embed-modal-close').addEventListener('click', () => embedModal.classList.add('hidden'));
|
||||
embedModal.addEventListener('click', (e) => { if (e.target === embedModal) embedModal.classList.add('hidden'); });
|
||||
['embed-theme', 'embed-tabs', 'embed-autorun', 'embed-width', 'embed-height'].forEach(id => {
|
||||
$(`#${id}`).addEventListener('change', updateEmbedCode);
|
||||
$(`#${id}`).addEventListener('input', updateEmbedCode);
|
||||
});
|
||||
$('#embed-copy').addEventListener('click', () => {
|
||||
navigator.clipboard.writeText($('#embed-code').textContent).then(() => showToast('Embed code copied!'));
|
||||
});
|
||||
|
||||
// Collection modal
|
||||
const colModal = $('#collection-modal');
|
||||
$('#btn-collection').addEventListener('click', () => openCollectionModal());
|
||||
$('#collection-modal-close').addEventListener('click', () => colModal.classList.add('hidden'));
|
||||
colModal.addEventListener('click', (e) => { if (e.target === colModal) colModal.classList.add('hidden'); });
|
||||
$('#btn-create-collection').addEventListener('click', async () => {
|
||||
const name = $('#new-collection-name').value.trim();
|
||||
if (!name) return;
|
||||
await createCollection({ name });
|
||||
$('#new-collection-name').value = '';
|
||||
openCollectionModal(); // refresh list
|
||||
});
|
||||
|
||||
// npm search in resources modal
|
||||
initNpmSearch((pkg) => {
|
||||
currentResources.push({ type: pkg.type, url: pkg.url });
|
||||
renderResourceList();
|
||||
scheduleRun();
|
||||
});
|
||||
|
||||
// Preview theme selector
|
||||
const themeSel = $('#preview-theme');
|
||||
const savedTheme = getPref('previewTheme');
|
||||
@@ -517,4 +625,179 @@ async function loadTagSuggestions() {
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
// ===================== Version History =====================
|
||||
|
||||
async function openHistoryModal() {
|
||||
if (!currentId) {
|
||||
showToast('Save the fiddle first to see history');
|
||||
return;
|
||||
}
|
||||
const modal = $('#history-modal');
|
||||
const list = $('#history-list');
|
||||
const preview = $('#history-preview');
|
||||
preview.classList.add('hidden');
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const { versions } = await listVersions(currentId);
|
||||
if (!versions.length) {
|
||||
list.innerHTML = '<div style="padding:16px;color:var(--text-dim);text-align:center">No versions yet. Versions are created each time you save.</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = versions.map(v => `
|
||||
<div class="history-item" data-version="${v.version}">
|
||||
<span class="history-version">v${v.version}</span>
|
||||
<span class="history-date">${new Date(v.created_at + 'Z').toLocaleString()}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
list.querySelectorAll('.history-item').forEach(item => {
|
||||
item.addEventListener('click', async () => {
|
||||
list.querySelectorAll('.history-item').forEach(i => i.classList.remove('active'));
|
||||
item.classList.add('active');
|
||||
const ver = parseInt(item.dataset.version, 10);
|
||||
const vData = await getVersion(currentId, ver);
|
||||
showVersionPreview(vData);
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
list.innerHTML = `<div style="padding:16px;color:var(--text-dim)">Error: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function showVersionPreview(vData) {
|
||||
const preview = $('#history-preview');
|
||||
const label = $('#history-preview-label');
|
||||
const diff = $('#history-diff');
|
||||
preview.classList.remove('hidden');
|
||||
label.textContent = `Version ${vData.version} — ${new Date(vData.created_at + 'Z').toLocaleString()}`;
|
||||
|
||||
const current = getEditorValues();
|
||||
diff.innerHTML = '';
|
||||
|
||||
const sections = [
|
||||
{ label: 'HTML', old: vData.html, cur: current.html },
|
||||
{ label: 'CSS', old: vData.css, cur: current.css },
|
||||
{ label: 'JS', old: vData.js, cur: current.js },
|
||||
];
|
||||
|
||||
for (const s of sections) {
|
||||
if (s.old === s.cur) continue;
|
||||
const sec = document.createElement('div');
|
||||
sec.className = 'history-diff-section';
|
||||
sec.innerHTML = `<div class="history-diff-label">${s.label}</div><pre class="history-diff-code">${escHtml(s.old || '(empty)')}</pre>`;
|
||||
diff.appendChild(sec);
|
||||
}
|
||||
|
||||
if (!diff.children.length) {
|
||||
diff.innerHTML = '<div style="padding:12px;color:var(--text-dim)">No changes from current version</div>';
|
||||
}
|
||||
|
||||
// Wire restore button
|
||||
const restoreBtn = $('#history-restore-btn');
|
||||
restoreBtn.onclick = async () => {
|
||||
try {
|
||||
const result = await revertVersion(currentId, vData.version);
|
||||
setEditorValues({ html: result.html, css: result.css, js: result.js });
|
||||
snapshotValues();
|
||||
$('#history-modal').classList.add('hidden');
|
||||
showToast('Restored version ' + vData.version);
|
||||
run();
|
||||
} catch (e) {
|
||||
showToast('Restore failed: ' + e.message);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// ===================== Embed Modal =====================
|
||||
|
||||
function openEmbedModal() {
|
||||
if (!currentId) {
|
||||
showToast('Save the fiddle first to generate embed code');
|
||||
return;
|
||||
}
|
||||
$('#embed-modal').classList.remove('hidden');
|
||||
updateEmbedCode();
|
||||
}
|
||||
|
||||
function updateEmbedCode() {
|
||||
if (!currentId) return;
|
||||
const theme = $('#embed-theme').value;
|
||||
const tabs = $('#embed-tabs').value;
|
||||
const autorun = $('#embed-autorun').value;
|
||||
const width = $('#embed-width').value || '100%';
|
||||
const height = $('#embed-height').value || '400';
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (theme !== 'light') params.set('theme', theme);
|
||||
if (tabs === '0') params.set('tabs', '0');
|
||||
if (autorun === '0') params.set('run', '0');
|
||||
const qs = params.toString();
|
||||
|
||||
const url = `${location.origin}/embed/${currentId}${qs ? '?' + qs : ''}`;
|
||||
const heightVal = /^\d+$/.test(height) ? height + 'px' : height;
|
||||
const snippet = `<iframe src="${url}" style="width:${width};height:${heightVal};border:0;border-radius:4px;overflow:hidden" sandbox="allow-scripts allow-same-origin"></iframe>`;
|
||||
|
||||
$('#embed-code').textContent = snippet;
|
||||
$('#embed-preview-frame').src = url;
|
||||
}
|
||||
|
||||
// ===================== Collection Modal =====================
|
||||
|
||||
async function openCollectionModal() {
|
||||
if (!currentId) {
|
||||
showToast('Save the fiddle first to add to a collection');
|
||||
return;
|
||||
}
|
||||
const modal = $('#collection-modal');
|
||||
const list = $('#collection-list');
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const { collections } = await listCollections();
|
||||
if (!collections.length) {
|
||||
list.innerHTML = '<div style="padding:12px;color:var(--text-dim);text-align:center">No collections yet</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = collections.map(c => `
|
||||
<div class="collection-item" data-id="${c.id}">
|
||||
<span class="collection-name">${escHtml(c.name)}</span>
|
||||
<span class="collection-count">${c.fiddle_count} fiddles</span>
|
||||
<button class="btn-small collection-add-btn">Add</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
list.querySelectorAll('.collection-add-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const id = btn.closest('.collection-item').dataset.id;
|
||||
try {
|
||||
await addToCollection(id, currentId);
|
||||
btn.textContent = 'Added';
|
||||
btn.disabled = true;
|
||||
} catch (e) {
|
||||
showToast('Failed to add: ' + e.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
list.innerHTML = `<div style="padding:12px;color:var(--text-dim)">Error: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== Google Fonts Loader =====================
|
||||
|
||||
const loadedFonts = new Set();
|
||||
function loadGoogleFont(fontName) {
|
||||
if (loadedFonts.has(fontName)) return;
|
||||
loadedFonts.add(fontName);
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = `https://fonts.googleapis.com/css2?family=${fontName.replace(/ /g, '+')}:wght@400;500;600;700&display=swap`;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { listFiddles, listTags } from './api.js';
|
||||
import { listFiddles, listTags, listCollections, getCollection } from './api.js';
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
let debounceTimer = null;
|
||||
let currentPage = 1;
|
||||
let activeTag = '';
|
||||
let currentView = 'fiddles';
|
||||
|
||||
const JS_TYPE_LABELS = {
|
||||
javascript: 'JS',
|
||||
@@ -27,8 +28,8 @@ function relativeTime(dateStr) {
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
|
||||
function renderCards(fiddles) {
|
||||
const grid = $('#fiddle-grid');
|
||||
function renderCards(fiddles, gridSelector = '#fiddle-grid') {
|
||||
const grid = $(gridSelector);
|
||||
if (!fiddles.length) {
|
||||
grid.innerHTML = '<div class="empty-state">No fiddles found. <a href="/new" style="color: var(--accent)">Create one!</a></div>';
|
||||
return;
|
||||
@@ -37,15 +38,20 @@ function renderCards(fiddles) {
|
||||
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('');
|
||||
const thumb = f.screenshot
|
||||
? `<img class="card-thumbnail" src="${f.screenshot}" alt="" loading="lazy">`
|
||||
: (preview ? `<div class="card-preview" style="padding:12px 16px">${esc(preview)}</div>` : '');
|
||||
return `
|
||||
<a href="/f/${f.id}" class="fiddle-card">
|
||||
${thumb}
|
||||
<div class="card-body">
|
||||
<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>` : ''}
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
}).join('');
|
||||
@@ -130,6 +136,64 @@ $('#filter-sort').addEventListener('change', () => {
|
||||
fetchAndRender();
|
||||
});
|
||||
|
||||
// Browse tabs
|
||||
document.querySelectorAll('.browse-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
currentView = tab.dataset.view;
|
||||
document.querySelectorAll('.browse-tab').forEach(t => t.classList.toggle('active', t === tab));
|
||||
$('#fiddles-view').style.display = currentView === 'fiddles' ? '' : 'none';
|
||||
$('#collections-view').style.display = currentView === 'collections' ? '' : 'none';
|
||||
if (currentView === 'collections') renderCollections();
|
||||
});
|
||||
});
|
||||
|
||||
async function renderCollections() {
|
||||
const grid = $('#collections-grid');
|
||||
const detail = $('#collection-detail');
|
||||
grid.style.display = '';
|
||||
detail.style.display = 'none';
|
||||
|
||||
try {
|
||||
const { collections } = await listCollections();
|
||||
if (!collections.length) {
|
||||
grid.innerHTML = '<div class="empty-state">No collections yet</div>';
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = collections.map(c => `
|
||||
<div class="collection-card" data-id="${c.id}">
|
||||
<div class="collection-card-name">${esc(c.name)}</div>
|
||||
<div class="collection-card-desc">${esc(c.description || '')}</div>
|
||||
<div class="collection-card-count">${c.fiddle_count} fiddle${c.fiddle_count !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
grid.querySelectorAll('.collection-card').forEach(card => {
|
||||
card.addEventListener('click', () => showCollectionDetail(card.dataset.id));
|
||||
});
|
||||
} catch (e) {
|
||||
grid.innerHTML = `<div class="empty-state">Error: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function showCollectionDetail(id) {
|
||||
const grid = $('#collections-grid');
|
||||
const detail = $('#collection-detail');
|
||||
grid.style.display = 'none';
|
||||
detail.style.display = '';
|
||||
|
||||
try {
|
||||
const col = await getCollection(id);
|
||||
$('#collection-header').textContent = col.name;
|
||||
$('#collection-desc').textContent = col.description || '';
|
||||
renderCards(col.fiddles || [], '#collection-fiddles');
|
||||
} catch (e) {
|
||||
$('#collection-header').textContent = 'Error';
|
||||
$('#collection-desc').textContent = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
$('#collection-back').addEventListener('click', () => renderCollections());
|
||||
|
||||
// Init
|
||||
renderTagsBar();
|
||||
fetchAndRender();
|
||||
|
||||
@@ -204,9 +204,16 @@ function renderTabBar(tabs) {
|
||||
|
||||
tabs.forEach((tab) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'tab-btn';
|
||||
btn.className = `tab-btn tab-lang-${tab.lang}`;
|
||||
btn.dataset.tab = tab.id;
|
||||
btn.textContent = tab.label;
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'tab-color-dot';
|
||||
btn.appendChild(dot);
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = tab.label;
|
||||
btn.appendChild(label);
|
||||
|
||||
// Add CSS type selector inside the CSS tab
|
||||
if (tab.id === 'css') {
|
||||
@@ -360,3 +367,13 @@ export function setEditorTheme(themeId) {
|
||||
editorOpts.theme = themeId;
|
||||
monaco.editor.setTheme(themeId);
|
||||
}
|
||||
|
||||
export function setEditorFont(fontFamily) {
|
||||
const family = fontFamily === 'default' ? "'Cascadia Code', 'Fira Code', monospace" : `'${fontFamily}', monospace`;
|
||||
editorOpts.fontFamily = family;
|
||||
const tabs = MODE_TABS[currentMode];
|
||||
if (!tabs) return;
|
||||
tabs.forEach((tab) => {
|
||||
if (editors[tab.id]) editors[tab.id].updateOptions({ fontFamily: family });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,9 +21,11 @@ async function init() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Theme
|
||||
// URL params
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get('theme') === 'dark') document.body.classList.add('dark');
|
||||
if (params.get('tabs') === '0') document.body.classList.add('no-tabs');
|
||||
const autoRun = params.get('run') !== '0';
|
||||
|
||||
try {
|
||||
const fiddle = await loadFiddle(match[1]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { loadScript } from './utils.js';
|
||||
import { getActiveEditor, getActiveTab, getCurrentMode, getCssType } from './editors.js';
|
||||
import { getActiveEditor, getActiveTab, getCurrentMode, getCssType, switchTab, getEditorValues } from './editors.js';
|
||||
|
||||
const PRETTIER_CDN = 'https://cdn.jsdelivr.net/npm/prettier@3';
|
||||
const PLUGINS = [
|
||||
@@ -79,3 +79,31 @@ export async function formatActiveEditor() {
|
||||
console.warn('Prettier format error:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function formatAll() {
|
||||
await ensurePrettier();
|
||||
const mode = getCurrentMode();
|
||||
const cssType = getCssType();
|
||||
const tabIds = ['html', 'css', 'js'];
|
||||
|
||||
for (const tabId of tabIds) {
|
||||
const config = getParser(tabId, mode, cssType);
|
||||
if (!config) continue;
|
||||
// Temporarily switch to this tab to get its editor
|
||||
switchTab(tabId);
|
||||
const editor = getActiveEditor();
|
||||
if (!editor) continue;
|
||||
const code = editor.getValue();
|
||||
if (!code.trim()) continue;
|
||||
try {
|
||||
const formatted = await prettier.format(code, {
|
||||
parser: config.parser,
|
||||
plugins: config.plugins,
|
||||
singleQuote: true,
|
||||
trailingComma: 'all',
|
||||
printWidth: 100,
|
||||
});
|
||||
editor.setValue(formatted);
|
||||
} catch (_) { /* skip tabs that fail */ }
|
||||
}
|
||||
}
|
||||
|
||||
74
public/js/npm-search.js
Normal file
74
public/js/npm-search.js
Normal file
@@ -0,0 +1,74 @@
|
||||
let debounceTimer = null;
|
||||
|
||||
export function initNpmSearch(onSelect) {
|
||||
const input = document.getElementById('npm-search-input');
|
||||
const results = document.getElementById('npm-search-results');
|
||||
if (!input || !results) return;
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(debounceTimer);
|
||||
const q = input.value.trim();
|
||||
if (q.length < 2) {
|
||||
results.classList.add('hidden');
|
||||
results.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
debounceTimer = setTimeout(() => searchNpm(q, results, input, onSelect), 300);
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
results.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!input.contains(e.target) && !results.contains(e.target)) {
|
||||
results.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function searchNpm(query, container, input, onSelect) {
|
||||
try {
|
||||
const res = await fetch(`https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(query)}&size=8`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const packages = data.objects || [];
|
||||
|
||||
if (!packages.length) {
|
||||
container.innerHTML = '<div class="npm-no-results">No packages found</div>';
|
||||
container.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = packages.map(p => {
|
||||
const pkg = p.package;
|
||||
const desc = (pkg.description || '').slice(0, 80);
|
||||
return `<div class="npm-result" data-name="${esc(pkg.name)}" data-version="${esc(pkg.version)}">
|
||||
<div class="npm-result-name">${esc(pkg.name)} <span class="npm-result-version">${esc(pkg.version)}</span></div>
|
||||
<div class="npm-result-desc">${esc(desc)}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
container.querySelectorAll('.npm-result').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const name = el.dataset.name;
|
||||
const url = `https://esm.sh/${name}`;
|
||||
onSelect({ name, url, type: 'js' });
|
||||
input.value = '';
|
||||
container.classList.add('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
container.classList.remove('hidden');
|
||||
} catch (_) {
|
||||
container.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
@@ -8,6 +8,8 @@ const DEFAULTS = {
|
||||
previewTheme: 'light',
|
||||
previewDevice: 'desktop',
|
||||
editorTheme: 'vs-dark',
|
||||
formatOnSave: false,
|
||||
editorFont: 'default',
|
||||
};
|
||||
|
||||
export function getPref(key) {
|
||||
|
||||
108
server.js
108
server.js
@@ -1,9 +1,9 @@
|
||||
import express from 'express';
|
||||
import { nanoid } from 'nanoid';
|
||||
import db, { stmts, setFiddleTags } from './db.js';
|
||||
import db, { stmts, setFiddleTags, snapshotVersion } from './db.js';
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
|
||||
// HTML routes must be defined before static middleware (which would serve index.html for /)
|
||||
app.get('/', (_req, res) => {
|
||||
@@ -66,7 +66,7 @@ app.get('/api/fiddles', (req, res) => {
|
||||
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,
|
||||
SELECT f.id, f.title, f.css_type, f.js_type, f.created_at, f.updated_at, f.screenshot,
|
||||
SUBSTR(f.html, 1, 200) as html_preview, SUBSTR(f.js, 1, 200) as js_preview
|
||||
FROM fiddles f ${where}
|
||||
ORDER BY ${orderBy}
|
||||
@@ -96,6 +96,10 @@ 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' });
|
||||
|
||||
// Snapshot current state as a version before overwriting
|
||||
snapshotVersion(req.params.id);
|
||||
|
||||
const {
|
||||
title = existing.title,
|
||||
html = existing.html,
|
||||
@@ -105,9 +109,11 @@ app.put('/api/fiddles/:id', (req, res) => {
|
||||
js_type = existing.js_type || 'javascript',
|
||||
listed = existing.listed,
|
||||
options = existing.options || '{}',
|
||||
screenshot,
|
||||
tags,
|
||||
} = req.body;
|
||||
stmts.update.run({ id: req.params.id, title, html, css, css_type, js, js_type, listed: listed ? 1 : 0, options });
|
||||
if (screenshot !== undefined) stmts.updateScreenshot.run(screenshot, req.params.id);
|
||||
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, options, tags: fiddleTags });
|
||||
@@ -118,5 +124,101 @@ app.get('/api/tags', (_req, res) => {
|
||||
res.json({ tags: stmts.listTags.all() });
|
||||
});
|
||||
|
||||
// ===================== Version History API =====================
|
||||
|
||||
app.get('/api/fiddles/:id/versions', (req, res) => {
|
||||
const fiddle = stmts.get.get(req.params.id);
|
||||
if (!fiddle) return res.status(404).json({ error: 'Not found' });
|
||||
const versions = stmts.listVersions.all(req.params.id);
|
||||
res.json({ versions });
|
||||
});
|
||||
|
||||
app.get('/api/fiddles/:id/versions/:ver', (req, res) => {
|
||||
const ver = parseInt(req.params.ver, 10);
|
||||
const version = stmts.getVersion.get(req.params.id, ver);
|
||||
if (!version) return res.status(404).json({ error: 'Version not found' });
|
||||
res.json(version);
|
||||
});
|
||||
|
||||
app.post('/api/fiddles/:id/revert/:ver', (req, res) => {
|
||||
const existing = stmts.get.get(req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'Not found' });
|
||||
const ver = parseInt(req.params.ver, 10);
|
||||
const version = stmts.getVersion.get(req.params.id, ver);
|
||||
if (!version) return res.status(404).json({ error: 'Version not found' });
|
||||
|
||||
// Snapshot current state before reverting
|
||||
snapshotVersion(req.params.id);
|
||||
|
||||
stmts.update.run({
|
||||
id: req.params.id,
|
||||
title: existing.title,
|
||||
html: version.html,
|
||||
css: version.css,
|
||||
css_type: version.css_type,
|
||||
js: version.js,
|
||||
js_type: version.js_type,
|
||||
listed: existing.listed,
|
||||
options: version.options || existing.options || '{}',
|
||||
});
|
||||
const updated = stmts.get.get(req.params.id);
|
||||
updated.tags = stmts.getTagsForFiddle.all(req.params.id);
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
// ===================== Collections API =====================
|
||||
|
||||
app.post('/api/collections', (req, res) => {
|
||||
const id = nanoid(10);
|
||||
const { name = 'Untitled Collection', description = '' } = req.body;
|
||||
try {
|
||||
stmts.insertCollection.run({ id, name, description });
|
||||
res.json({ id, name, description, fiddle_count: 0 });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/collections', (_req, res) => {
|
||||
res.json({ collections: stmts.listCollections.all() });
|
||||
});
|
||||
|
||||
app.get('/api/collections/:id', (req, res) => {
|
||||
const col = stmts.getCollection.get(req.params.id);
|
||||
if (!col) return res.status(404).json({ error: 'Not found' });
|
||||
const fiddles = stmts.getCollectionFiddles.all(req.params.id);
|
||||
for (const f of fiddles) f.tags = stmts.getTagsForFiddle.all(f.id);
|
||||
res.json({ ...col, fiddles });
|
||||
});
|
||||
|
||||
app.put('/api/collections/:id', (req, res) => {
|
||||
const col = stmts.getCollection.get(req.params.id);
|
||||
if (!col) return res.status(404).json({ error: 'Not found' });
|
||||
const { name = col.name, description = col.description } = req.body;
|
||||
stmts.updateCollection.run({ id: req.params.id, name, description });
|
||||
res.json({ id: req.params.id, name, description });
|
||||
});
|
||||
|
||||
app.delete('/api/collections/:id', (req, res) => {
|
||||
const col = stmts.getCollection.get(req.params.id);
|
||||
if (!col) return res.status(404).json({ error: 'Not found' });
|
||||
stmts.deleteCollection.run(req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/collections/:id/fiddles', (req, res) => {
|
||||
const col = stmts.getCollection.get(req.params.id);
|
||||
if (!col) return res.status(404).json({ error: 'Collection not found' });
|
||||
const { fiddle_id } = req.body;
|
||||
if (!fiddle_id) return res.status(400).json({ error: 'fiddle_id required' });
|
||||
stmts.addFiddleToCollection.run({ collection_id: req.params.id, fiddle_id });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.delete('/api/collections/:id/fiddles/:fid', (req, res) => {
|
||||
stmts.removeFiddleFromCollection.run(req.params.id, req.params.fid);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
app.listen(port, () => console.log(`Fiddle server running on http://localhost:${port}`));
|
||||
|
||||
Reference in New Issue
Block a user