diff --git a/db.js b/db.js
index a66cdb5..7e81ccb 100644
--- a/db.js
+++ b/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;
diff --git a/public/browse.html b/public/browse.html
new file mode 100644
index 0000000..ea4b7bc
--- /dev/null
+++ b/public/browse.html
@@ -0,0 +1,40 @@
+
+
+
+
+
+ Fiddle - Browse
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/css/browse.css b/public/css/browse.css
new file mode 100644
index 0000000..489e726
--- /dev/null
+++ b/public/css/browse.css
@@ -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;
+}
diff --git a/public/css/style.css b/public/css/style.css
index 608f8c8..5a66aae 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -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%);
diff --git a/public/embed.html b/public/embed.html
new file mode 100644
index 0000000..cb66813
--- /dev/null
+++ b/public/embed.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+ Fiddle Embed
+
+
+
+
+
+
+
diff --git a/public/index.html b/public/index.html
index 8f46ebe..5f4f43a 100644
--- a/public/index.html
+++ b/public/index.html
@@ -9,7 +9,7 @@
@@ -60,6 +71,17 @@
+
+