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:
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user