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 + + + +
+ + + New Fiddle +
+ +
+ + + +
+ +
+ +
+ + + + + + 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 @@ + +