diff --git a/db.js b/db.js index fd7e44a..1875ff1 100644 --- a/db.js +++ b/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; diff --git a/public/browse.html b/public/browse.html index dd6a40b..bcfda7d 100644 --- a/public/browse.html +++ b/public/browse.html @@ -31,11 +31,26 @@ -
+
+ + +
-
+
+
+
+ +
- + diff --git a/public/css/browse.css b/public/css/browse.css index 2fb4f39..d527a84 100644 --- a/public/css/browse.css +++ b/public/css/browse.css @@ -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; } diff --git a/public/css/style.css b/public/css/style.css index 9caa02f..867075e 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -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; } diff --git a/public/index.html b/public/index.html index 9b78785..44d8c86 100644 --- a/public/index.html +++ b/public/index.html @@ -40,6 +40,14 @@ +
@@ -62,6 +70,10 @@ Listed +
@@ -142,6 +157,10 @@
+
+ + +
@@ -189,6 +208,60 @@ + + + + + +