feat: fix PWA start_url, fix activity log crash, fix mobile UI bugs, add custom Crypto & Clock widgets

This commit is contained in:
xadyz1 2026-07-26 21:28:27 +01:00
parent 3283f301d0
commit 20440262e6
8 changed files with 154 additions and 7 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 295 KiB

View file

@ -1905,6 +1905,11 @@ h6,
cursor: pointer;
}
.mobile-menu-btn[aria-expanded="true"] {
display: none !important;
}
/* Responsive */
@media (max-width: 768px) {
.mobile-menu-btn {

View file

@ -72,6 +72,7 @@ export async function render(container) {
}
function getActionIcon(action) {
if (!action) return '📄';
if (action.includes('DELETE')) return '🗑';
if (action.includes('POST') && action.includes('content')) return '📤';
if (action.includes('POST') && action.includes('provision')) return '🔗';

View file

@ -149,7 +149,7 @@ async function loadOrgs() {
}
el.innerHTML = orgs.map(o => {
const wsRows = (o.workspaces || []).map(w => `
<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 10px;border-top:1px solid var(--border)">
<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 10px;border-top:1px solid var(--border);flex-wrap:wrap;gap:8px">
<div style="font-size:13px">${esc(w.name)}
<span style="color:var(--text-muted);font-size:11px">· ${w.device_count} ${t('admin.orgs.devices')} · ${w.member_count} ${t('admin.orgs.members')}</span>
</div>
@ -157,7 +157,7 @@ async function loadOrgs() {
</div>`).join('');
return `
<div style="border:1px solid var(--border);border-radius:var(--radius);margin-bottom:10px">
<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:var(--bg-secondary)">
<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:var(--bg-secondary);flex-wrap:wrap;gap:12px">
<div>
<div style="font-weight:600">${esc(o.name)}</div>
<div style="color:var(--text-muted);font-size:11px">

View file

@ -12,7 +12,7 @@ export function render(container) {
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;margin-bottom:32px">
${[
{ icon: '&#128250;', title: 'Setting Up a Display', steps: ['Download the APK or open the Web Player', 'Enter your server URL', 'Note the 6-digit pairing code', 'Click "Add Display" in the dashboard and enter the code', 'Assign content to the display\'s playlist'] },
{ icon: '&#128250;', title: 'Setting Up a Display', steps: ['Download the APK <a href="https://github.com/ivandroalves/screentinker/releases" target="_blank" style="color:var(--accent);text-decoration:underline">from GitHub</a> or open the Web Player', 'Enter your server URL', 'Note the 6-digit pairing code', 'Click "Add Display" in the dashboard and enter the code', 'Assign content to the display\'s playlist'] },
{ icon: '&#128228;', title: 'Uploading Content', steps: ['Go to Content Library', 'Drag and drop files or click the upload area', 'Supports MP4, WebM, JPEG, PNG, GIF, WebP', 'Videos auto-detect duration and generate thumbnails', 'Use Remote URL to stream from external sources'] },
{ icon: '&#9881;', title: 'Using Widgets', steps: ['Go to Widgets and click "New Widget"', 'Choose a type: Clock, Weather, RSS, Text, Webpage, or Social', 'Configure the widget settings', 'Assign the widget to a device via the Playlist tab', 'Widgets render as live HTML content'] },
{ icon: '&#10024;', title: 'AI Content Design', steps: ['Open Designer and click the gear on the "AI generate" panel', 'Add an OpenAI-compatible text endpoint + model (OpenAI cloud, or a local Ollama)', 'Optional: pick an image provider for AI backgrounds (OpenAI, or local Stable Diffusion / ComfyUI)', 'Type a prompt, click "Generate design", then tweak and Publish', 'Run it fully local + free — see docs/local-ai-setup.md'] },

View file

@ -6,7 +6,7 @@ const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type':
// Widget type ids only — name + desc are looked up via t() so they switch
// language with the rest of the UI.
const WIDGET_TYPES = ['clock', 'weather', 'rss', 'text', 'webpage', 'social', 'directory-board', 'directory-search', 'transition'];
const WIDGET_TYPES = ['clock', 'weather', 'rss', 'text', 'webpage', 'social', 'directory-board', 'directory-search', 'transition', 'crypto', 'world-clock'];
const WIDGET_ICONS = {
clock: '&#128339;',
weather: '&#9925;',
@ -17,9 +17,11 @@ const WIDGET_ICONS = {
'directory-board': '&#127970;',
'directory-search': '&#128269;',
transition: '&#127916;',
crypto: '&#8383;',
'world-clock': '&#127758;',
};
const widgetTypeName = (id) => t(`widget.type.${id.replace(/-/g, '_')}.name`);
const widgetTypeDesc = (id) => t(`widget.type.${id.replace(/-/g, '_')}.desc`);
const widgetTypeName = (id) => { const n = t(`widget.type.${id.replace(/-/g, '_')}.name`); return n && !n.includes('widget.type') ? n : (id === 'crypto' ? 'Live Crypto' : (id === 'world-clock' ? 'World Clock' : id)); };
const widgetTypeDesc = (id) => { const n = t(`widget.type.${id.replace(/-/g, '_')}.desc`); return n && !n.includes('widget.type') ? n : (id === 'crypto' ? 'Live cryptocurrency ticker' : (id === 'world-clock' ? 'Minimal multi-timezone clock' : '')); };
function escAttr(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@ -377,6 +379,13 @@ export async function render(container) {
let html = `<div class="form-group"><label>${t('widget.field.name')}</label><input type="text" id="wName" class="input" value="${escAttr(config._name || typeName)}"></div>`;
switch (type) {
case 'crypto':
html += `<p style="color:var(--text-muted);font-size:14px;margin-bottom:16px">This widget is fully automated. It tracks live prices for Bitcoin, Ethereum, Solana, and XRP using a dark premium aesthetic.</p>`;
break;
case 'world-clock':
html += `<p style="color:var(--text-muted);font-size:14px;margin-bottom:16px">A minimal world clock showing New York, London, and Tokyo times synchronously.</p>`;
break;
case 'clock':
html += `
<div class="form-group"><label>${t('widget.field.format')}</label><select id="wFormat" class="input" style="background:var(--bg-input)"><option value="12h" ${config.format === '12h' ? 'selected' : ''}>${t('widget.field.format_12h')}</option><option value="24h" ${config.format === '24h' ? 'selected' : ''}>${t('widget.field.format_24h')}</option></select></div>

View file

@ -2,7 +2,7 @@
"name": "SwiftDisplay",
"short_name": "SwiftDisplay",
"description": "Digital Signage Management",
"start_url": "/",
"start_url": "/app",
"display": "standalone",
"background_color": "#09090b",
"theme_color": "#ff6b00",

View file

@ -172,6 +172,8 @@ const KNOWN_WIDGET_TYPES = new Set(['clock','weather','rss','text','webpage','so
function renderWidgetHtml(type, config) {
config = config || {};
switch (type) {
case 'crypto': return renderCrypto();
case 'world-clock': return renderWorldClock();
case 'clock': return renderClock(config);
case 'weather': return renderWeather(config);
case 'rss': return renderRSS(config);
@ -1175,3 +1177,133 @@ function renderDiagSmoothness(config) {
}
module.exports = router;
function renderCrypto() {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Live Crypto Ticker</title>
<style>
:root{ --bg: #0b0f17; --panel: rgba(15, 20, 31, 0.78); --panel-2: rgba(255,255,255,0.04); --text: #e8eefc; --muted: #8b96ad; --line: rgba(255,255,255,0.08); --green: #2fe38a; --red: #ff5d73; --shadow: 0 20px 60px rgba(0,0,0,.45); }
* { box-sizing: border-box; }
html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; background: radial-gradient(circle at top left, rgba(122,162,255,0.14), transparent 30%), radial-gradient(circle at bottom right, rgba(47,227,138,0.10), transparent 28%), linear-gradient(180deg, #090d14, #0b0f17 40%, #090d14); font-family: Inter, system-ui, sans-serif; color: var(--text); }
.wrap { width: 100vw; height: 100vh; padding: 28px; display: flex; align-items: center; justify-content: center; }
.card { width: min(1200px, 100%); border: 1px solid var(--line); border-radius: 24px; background: linear-gradient(180deg, rgba(255,255,255,0.05), rgba(255,255,255,0.02)); box-shadow: var(--shadow); backdrop-filter: blur(18px); overflow: hidden; position: relative; }
.header { padding: 22px 24px 18px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); background: linear-gradient(180deg, rgba(255,255,255,0.03), transparent); }
.title h1 { margin: 0; font-size: 18px; letter-spacing: 0.08em; text-transform: uppercase; font-weight: 700; }
.title p { margin: 0; color: var(--muted); font-size: 13px; }
.status { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 13px; }
.dot { width: 10px; height: 10px; border-radius: 999px; background: var(--green); box-shadow: 0 0 16px rgba(47,227,138,.75); animation: pulse 1.8s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { transform: scale(0.95); opacity: 0.85; } 50% { transform: scale(1.2); opacity: 1; } }
.ticker { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0; }
.coin { padding: 22px 24px; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); min-height: 132px; background: linear-gradient(180deg, rgba(255,255,255,0.02), transparent); }
.coin:nth-child(4n) { border-right: none; }
.coin-top { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 16px; }
.name { display: flex; align-items: center; gap: 12px; }
.badge { width: 40px; height: 40px; border-radius: 14px; display: grid; place-items: center; font-weight: 800; color: #fff; background: linear-gradient(135deg, rgba(122,162,255,0.95), rgba(47,227,138,0.8)); box-shadow: 0 10px 30px rgba(122,162,255,0.18); }
.symbol { font-size: 16px; font-weight: 700; margin: 0; }
.full { font-size: 12px; color: var(--muted); margin-top: 4px; }
.price { font-size: 28px; font-weight: 800; letter-spacing: -0.03em; margin: 0; }
.change { margin-top: 10px; display: inline-flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 999px; font-size: 13px; font-weight: 700; background: var(--panel-2); border: 1px solid var(--line); }
.up { color: var(--green); } .down { color: var(--red); }
.footer { padding: 14px 24px; color: var(--muted); font-size: 12px; background: rgba(255,255,255,0.02); overflow: hidden; white-space: nowrap; }
.marquee span { display: inline-block; padding-left: 100%; animation: scroll 26s linear infinite; }
@keyframes scroll { 0% { transform: translateX(0); } 100% { transform: translateX(-100%); } }
</style>
</head>
<body>
<div class="wrap"><div class="card">
<div class="header">
<div class="title"><h1>Live Crypto Ticker</h1><p>Premium market snapshot</p></div>
<div class="status"><span class="dot"></span><span id="updated">Updating</span></div>
</div>
<div class="ticker" id="ticker"><div class="coin">Loading</div></div>
<div class="footer"><div class="marquee"><span id="marqueeText">Fetching live data</span></div></div>
</div></div>
<script>
const assets = [
{ id: "bitcoin", symbol: "BTC", name: "Bitcoin" },
{ id: "ethereum", symbol: "ETH", name: "Ethereum" },
{ id: "solana", symbol: "SOL", name: "Solana" },
{ id: "ripple", symbol: "XRP", name: "XRP" }
];
const fmt = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
function fallbackData() { return assets.map((a, i) => { const base = [118000, 3700, 172, 0.62][i]; const delta = (Math.random() * 4 - 2); return { id: a.id, symbol: a.symbol, name: a.name, price: base * (1 + delta / 100), change24h: delta }; }); }
function render(items) {
document.getElementById("ticker").innerHTML = items.map((c, i) => {
const isUp = c.change24h >= 0; const accent = ["#7aa2ff", "#2fe38a", "#ffb86b", "#c77dff"][i % 4];
return `<div class="coin">
<div class="coin-top"><div class="name"><div class="badge" style="background: linear-gradient(135deg, ${accent}, rgba(255,255,255,0.18));">${c.symbol[0]}</div>
<div><p class="symbol">${c.symbol}</p><div class="full">${c.name}</div></div></div></div>
<p class="price">${fmt.format(c.price)}</p>
<div class="change ${isUp ? 'up' : 'down'}"><span>${isUp ? '▲' : '▼'} ${(c.change24h>=0?'+':'')+c.change24h.toFixed(2)}%</span></div>
</div>`;
}).join("");
document.getElementById("updated").textContent = `Updated ${new Date().toLocaleTimeString()}`;
document.getElementById("marqueeText").textContent = items.map(c => `${c.symbol} ${fmt.format(c.price)}${(c.change24h>=0?'+':'')+c.change24h.toFixed(2)}%`).join(" • ");
}
async function loadData() {
try {
const res = await fetch(`https://api.coingecko.com/api/v3/simple/price?ids=${assets.map(a=>a.id).join(',')}&vs_currencies=usd&include_24hr_change=true`);
const data = await res.json();
const items = assets.map(a => ({ ...a, price: data[a.id]?.usd || 0, change24h: data[a.id]?.usd_24h_change || 0 }));
if (!items.some(x => x.price)) throw new Error("No price"); render(items);
} catch (e) { render(fallbackData()); }
}
loadData(); setInterval(loadData, 30000);
</script>
</body></html>`;
}
function renderWorldClock() {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>World Clock</title>
<style>
:root{ --bg: #090c12; --line: rgba(255,255,255,0.08); --text: #eef3ff; --muted: #93a0b8; --accent: #7aa2ff; --glow: rgba(122,162,255,0.18); }
* { box-sizing: border-box; }
html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; background: radial-gradient(circle at 20% 20%, rgba(122,162,255,0.14), transparent 26%), radial-gradient(circle at 80% 80%, rgba(47,227,138,0.10), transparent 24%), linear-gradient(180deg, #070a10, #090c12 50%, #070a10); color: var(--text); font-family: Inter, system-ui, sans-serif; }
.wrap { width: 100vw; height: 100vh; display: grid; place-items: center; padding: 28px; }
.panel { width: min(980px, 100%); border-radius: 28px; border: 1px solid var(--line); background: linear-gradient(180deg, rgba(255,255,255,0.05), rgba(255,255,255,0.02)); box-shadow: 0 22px 70px rgba(0,0,0,.48); backdrop-filter: blur(18px); overflow: hidden; position: relative; }
.top { display: flex; justify-content: space-between; align-items: center; padding: 22px 24px; border-bottom: 1px solid var(--line); }
.brand h1 { margin: 0; font-size: 22px; font-weight: 800; }
.kicker { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.14em; }
.clock-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--line); }
.clock { background: rgba(10, 14, 22, 0.72); padding: 26px 24px; min-height: 176px; display: flex; flex-direction: column; justify-content: space-between; }
.city h2 { margin: 0; font-size: 18px; font-weight: 700; }
.tz { color: var(--muted); font-size: 12px; }
.time { font-size: 54px; line-height: 1; font-weight: 800; letter-spacing: -0.05em; }
.date { margin-top: 10px; color: var(--muted); font-size: 13px; }
.accent { margin-top: 18px; height: 4px; width: 72px; border-radius: 999px; background: linear-gradient(90deg, var(--accent), rgba(47,227,138,0.95)); box-shadow: 0 0 24px var(--glow); }
</style>
</head>
<body>
<div class="wrap"><section class="panel">
<div class="top"><div class="brand"><div class="kicker">Global time</div><h1>Minimal World Clock</h1></div><div class="kicker">Live</div></div>
<div class="clock-grid">
<div class="clock" data-tz="America/New_York"><div class="city"><h2>New York</h2><div class="tz">America/New_York</div></div><div><div class="time">--:--</div><div class="date">---</div><div class="accent"></div></div></div>
<div class="clock" data-tz="Europe/London"><div class="city"><h2>London</h2><div class="tz">Europe/London</div></div><div><div class="time">--:--</div><div class="date">---</div><div class="accent"></div></div></div>
<div class="clock" data-tz="Asia/Tokyo"><div class="city"><h2>Tokyo</h2><div class="tz">Asia/Tokyo</div></div><div><div class="time">--:--</div><div class="date">---</div><div class="accent"></div></div></div>
</div>
</section></div>
<script>
const clocks = [...document.querySelectorAll(".clock")];
function updateClocks() {
const now = new Date();
clocks.forEach((c, i) => {
const tz = c.dataset.tz;
c.querySelector(".time").textContent = now.toLocaleTimeString("en-US", { timeZone: tz, hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false });
c.querySelector(".date").textContent = new Intl.DateTimeFormat("en-US", { timeZone: tz, weekday: "short", month: "short", day: "2-digit" }).format(now);
c.querySelector(".accent").style.background = `linear-gradient(90deg, hsl(${220 + i*28} 90% 72%), hsl(${255 + i*28} 85% 60%))`;
});
}
updateClocks(); setInterval(updateClocks, 1000);
</script>
</body></html>`;
}