mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-16 23:33:10 -06:00
v1
This commit is contained in:
parent
e1b444f252
commit
67c6e0a31a
|
|
@ -3,7 +3,7 @@
|
||||||
android:color="#2563EB">
|
android:color="#2563EB">
|
||||||
<item>
|
<item>
|
||||||
<shape android:shape="rectangle">
|
<shape android:shape="rectangle">
|
||||||
<solid android:color="#3B82F6" />
|
<solid android:color="#e65c00" />
|
||||||
<corners android:radius="8dp" />
|
<corners android:radius="8dp" />
|
||||||
</shape>
|
</shape>
|
||||||
</item>
|
</item>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="40dp" android:height="40dp" android:viewportWidth="24" android:viewportHeight="24">
|
android:width="40dp" android:height="40dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
<path android:fillColor="#3B82F6" android:pathData="M3,4h18a1,1 0,0 1,1 1v11a1,1 0,0 1,-1 1H3a1,1 0,0 1,-1 -1V5a1,1 0,0 1,1 -1zM3,6v9h18V6z" />
|
<path android:fillColor="#e65c00" android:pathData="M3,4h18a1,1 0,0 1,1 1v11a1,1 0,0 1,-1 1H3a1,1 0,0 1,-1 -1V5a1,1 0,0 1,1 -1zM3,6v9h18V6z" />
|
||||||
<path android:fillColor="#3B82F6" android:pathData="M8,20h8v2H8z" />
|
<path android:fillColor="#e65c00" android:pathData="M8,20h8v2H8z" />
|
||||||
<path android:fillColor="#93C5FD" android:pathData="M12,10.5m-2.5,0a2.5,2.5 0,1 1,5 0a2.5,2.5 0,1 1,-5 0" />
|
<path android:fillColor="#93C5FD" android:pathData="M12,10.5m-2.5,0a2.5,2.5 0,1 1,5 0a2.5,2.5 0,1 1,-5 0" />
|
||||||
</vector>
|
</vector>
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="RemoteDisplay"
|
android:text="RemoteDisplay"
|
||||||
android:textColor="#3B82F6"
|
android:textColor="#e65c00"
|
||||||
android:textSize="28sp"
|
android:textSize="28sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:layout_marginBottom="8dp" />
|
android:layout_marginBottom="8dp" />
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,7 @@
|
||||||
android:clickable="false"
|
android:clickable="false"
|
||||||
android:focusable="false"
|
android:focusable="false"
|
||||||
android:thumbTint="#FFFFFF"
|
android:thumbTint="#FFFFFF"
|
||||||
android:trackTint="#3B82F6" />
|
android:trackTint="#e65c00" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
android:id="@+id/openA11yBtn"
|
android:id="@+id/openA11yBtn"
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="RemoteDisplay"
|
android:text="RemoteDisplay"
|
||||||
android:textColor="#3B82F6"
|
android:textColor="#e65c00"
|
||||||
android:textSize="22sp"
|
android:textSize="22sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:layout_marginBottom="4dp" />
|
android:layout_marginBottom="4dp" />
|
||||||
|
|
@ -111,7 +111,7 @@
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="96dp"
|
android:layout_height="96dp"
|
||||||
android:text="------"
|
android:text="------"
|
||||||
android:textColor="#3B82F6"
|
android:textColor="#e65c00"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:fontFamily="monospace"
|
android:fontFamily="monospace"
|
||||||
android:letterSpacing="0.3"
|
android:letterSpacing="0.3"
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="RemoteDisplay Setup"
|
android:text="RemoteDisplay Setup"
|
||||||
android:textColor="#3B82F6"
|
android:textColor="#e65c00"
|
||||||
android:textSize="12sp"
|
android:textSize="12sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:layout_marginBottom="3dp" />
|
android:layout_marginBottom="3dp" />
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#111827</color>
|
<color name="ic_launcher_background">#111827</color>
|
||||||
<color name="ic_launcher_foreground">#3B82F6</color>
|
<color name="ic_launcher_foreground">#e65c00</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<style name="Theme.RemoteDisplay" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
<style name="Theme.RemoteDisplay" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||||
<item name="colorPrimary">#3B82F6</item>
|
<item name="colorPrimary">#e65c00</item>
|
||||||
<item name="colorPrimaryVariant">#2563EB</item>
|
<item name="colorPrimaryVariant">#2563EB</item>
|
||||||
<item name="colorSecondary">#1E293B</item>
|
<item name="colorSecondary">#1E293B</item>
|
||||||
<item name="android:windowBackground">#111827</item>
|
<item name="android:windowBackground">#111827</item>
|
||||||
|
|
|
||||||
79
fix_fonts_emojis.js
Normal file
79
fix_fonts_emojis.js
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// 1. Fix reset.css
|
||||||
|
let resetCss = fs.readFileSync('frontend/css/reset.css', 'utf-8');
|
||||||
|
resetCss = resetCss.replace(
|
||||||
|
"font-family: 'Michroma', sans-serif;",
|
||||||
|
"font-family: 'Michroma', sans-serif;\n font-weight: 400;"
|
||||||
|
);
|
||||||
|
resetCss = resetCss.replace(
|
||||||
|
"h1, h2, h3, h4, h5, h6 { font-family: 'Goldman', sans-serif; }",
|
||||||
|
"h1, h2, h3, h4, h5, h6 { font-family: 'Goldman', sans-serif; font-weight: 700; }"
|
||||||
|
);
|
||||||
|
fs.writeFileSync('frontend/css/reset.css', resetCss);
|
||||||
|
|
||||||
|
// 2. Fix landing.html fonts and emojis
|
||||||
|
let landing = fs.readFileSync('frontend/landing.html', 'utf-8');
|
||||||
|
if (!landing.includes('font-awesome')) {
|
||||||
|
landing = landing.replace(
|
||||||
|
'</head>',
|
||||||
|
' <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">\n</head>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Add fonts
|
||||||
|
if (!landing.includes('Michroma')) {
|
||||||
|
landing = landing.replace(
|
||||||
|
'</head>',
|
||||||
|
' <link rel="preconnect" href="https://fonts.googleapis.com">\n <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>\n <link href="https://fonts.googleapis.com/css2?family=Goldman:wght@700&family=Michroma:wght@400&display=swap" rel="stylesheet">\n</head>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace emojis with fontawesome icons manually based on the context
|
||||||
|
const emojiMap = {
|
||||||
|
'📺': '<i class="fa-solid fa-tv"></i>',
|
||||||
|
'🎬': '<i class="fa-solid fa-border-all"></i>',
|
||||||
|
'🖥': '<i class="fa-solid fa-desktop"></i>',
|
||||||
|
'📅': '<i class="fa-regular fa-calendar"></i>',
|
||||||
|
'🎶': '<i class="fa-solid fa-list"></i>',
|
||||||
|
'🏢': '<i class="fa-solid fa-building"></i>',
|
||||||
|
'🔧': '<i class="fa-solid fa-wrench"></i>',
|
||||||
|
'🖱': '<i class="fa-solid fa-hand-pointer"></i>',
|
||||||
|
'📊': '<i class="fa-solid fa-chart-line"></i>',
|
||||||
|
'🔔': '<i class="fa-solid fa-bell"></i>',
|
||||||
|
'🔌': '<i class="fa-solid fa-plug-circle-xmark"></i>',
|
||||||
|
'📱': '<i class="fa-solid fa-mobile-screen"></i>',
|
||||||
|
'🔒': '<i class="fa-solid fa-server"></i>',
|
||||||
|
'🎨': '<i class="fa-solid fa-palette"></i>',
|
||||||
|
'👥': '<i class="fa-solid fa-users"></i>',
|
||||||
|
'🔄': '<i class="fa-solid fa-arrows-rotate"></i>',
|
||||||
|
'🛒': '<i class="fa-solid fa-cart-shopping"></i>',
|
||||||
|
'🍽': '<i class="fa-solid fa-utensils"></i>',
|
||||||
|
'🏫': '<i class="fa-solid fa-graduation-cap"></i>',
|
||||||
|
'🏥': '<i class="fa-solid fa-notes-medical"></i>',
|
||||||
|
'⛪': '<i class="fa-solid fa-church"></i>',
|
||||||
|
'🏋': '<i class="fa-solid fa-dumbbell"></i>',
|
||||||
|
'🤖': '<i class="fa-brands fa-android"></i>',
|
||||||
|
'🔥': '<i class="fa-brands fa-amazon"></i>',
|
||||||
|
'🥏': '<i class="fa-brands fa-raspberry-pi"></i>',
|
||||||
|
'💻': '<i class="fa-brands fa-windows"></i>',
|
||||||
|
'🌐': '<i class="fa-brands fa-chrome"></i>'
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [emoji, icon] of Object.entries(emojiMap)) {
|
||||||
|
landing = landing.split(emoji).join(icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add CSS to landing page for fonts
|
||||||
|
landing = landing.replace(
|
||||||
|
'</style>',
|
||||||
|
` body { font-family: 'Michroma', sans-serif; font-weight: 400; }
|
||||||
|
h1, h2, h3, h4, h5, h6, .brand { font-family: 'Goldman', sans-serif; font-weight: 700; }
|
||||||
|
.feature-icon { font-size: 32px; color: var(--accent); margin-bottom: 16px; }
|
||||||
|
.platform-item .icon { font-size: 32px; color: var(--accent); margin-bottom: 8px; }
|
||||||
|
</style>`
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.writeFileSync('frontend/landing.html', landing);
|
||||||
|
|
||||||
|
console.log('Fixed fonts and emojis');
|
||||||
20
fix_landing.js
Normal file
20
fix_landing.js
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
let landing = fs.readFileSync('frontend/landing.html', 'utf-8');
|
||||||
|
|
||||||
|
// Replace '$' with '€' in the specific lines
|
||||||
|
landing = landing.replace('$1,188', '€1,188');
|
||||||
|
landing = landing.replace('$1,440+', '€1,440+');
|
||||||
|
landing = landing.replace('$3,600+', '€3,600+');
|
||||||
|
landing = landing.replace('$1,800+', '€1,800+');
|
||||||
|
landing = landing.replace("? '$' + p.price_monthly", "? '€' + p.price_monthly");
|
||||||
|
landing = landing.replace('or $$', 'or €$'); // Actually it's or $${p.price_yearly}, so it becomes or €${p.price_yearly}
|
||||||
|
|
||||||
|
// Remove "ScreenTinker" from the header
|
||||||
|
landing = landing.replace(
|
||||||
|
'<span class="nav-logo-text" style="margin-left: 8px;">ScreenTinker</span>',
|
||||||
|
''
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.writeFileSync('frontend/landing.html', landing);
|
||||||
|
console.log('Fixed landing.html');
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -7,6 +7,7 @@
|
||||||
html, body {
|
html, body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-family: 'Michroma', sans-serif;
|
font-family: 'Michroma', sans-serif;
|
||||||
|
font-weight: 400;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
|
@ -14,7 +15,7 @@ html, body {
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 { font-family: 'Goldman', sans-serif; }
|
h1, h2, h3, h4, h5, h6 { font-family: 'Goldman', sans-serif; font-weight: 700; }
|
||||||
|
|
||||||
a { color: inherit; text-decoration: none; }
|
a { color: inherit; text-decoration: none; }
|
||||||
ul { list-style: none; }
|
ul { list-style: none; }
|
||||||
|
|
|
||||||
|
|
@ -1,87 +1,409 @@
|
||||||
/* Shared styles for SEO landing pages: comparison and guide pages.
|
/* Shared styles for SEO landing pages: comparison and guide pages.
|
||||||
Matches the dark theme of landing.html. */
|
Matches the dark theme of landing.html. */
|
||||||
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* {
|
||||||
:root { --accent:#3b82f6; --bg:#111827; --card:#1e293b; --border:#334155; --text:#f1f5f9; --muted:#94a3b8; --dim:#64748b; }
|
margin: 0;
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); line-height: 1.65; }
|
padding: 0;
|
||||||
a { color: var(--accent); text-decoration: none; }
|
box-sizing: border-box;
|
||||||
a:hover { text-decoration: underline; }
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--accent: #e65c00;
|
||||||
|
--bg: #111827;
|
||||||
|
--card: #1e293b;
|
||||||
|
--border: #334155;
|
||||||
|
--text: #f1f5f9;
|
||||||
|
--muted: #94a3b8;
|
||||||
|
--dim: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
/* Nav (matches landing.html) */
|
/* Nav (matches landing.html) */
|
||||||
nav { position: fixed; top: 0; left: 0; right: 0; z-index: 100; background: rgba(17,24,39,0.9); backdrop-filter: blur(12px); border-bottom: 1px solid var(--border); }
|
nav {
|
||||||
.nav-inner { max-width: 1200px; margin: 0 auto; padding: 16px 24px; display: flex; align-items: center; justify-content: space-between; }
|
position: fixed;
|
||||||
.nav-logo { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 18px; color: var(--accent); flex-shrink: 0; }
|
top: 0;
|
||||||
.nav-logo a { color: var(--accent); }
|
left: 0;
|
||||||
.nav-links { display: flex; align-items: center; flex-wrap: nowrap; }
|
right: 0;
|
||||||
.nav-links a { color: var(--muted); margin-left: 24px; font-size: 14px; transition: color 0.2s; }
|
z-index: 100;
|
||||||
.nav-links a:hover { color: var(--text); text-decoration: none; }
|
background: rgba(17, 24, 39, 0.9);
|
||||||
.btn { display: inline-flex; align-items: center; gap: 8px; padding: 10px 20px; border-radius: 8px; font-weight: 600; font-size: 14px; transition: all 0.2s; border: none; cursor: pointer; }
|
backdrop-filter: blur(12px);
|
||||||
.btn-primary { background: var(--accent); color: white; }
|
border-bottom: 1px solid var(--border);
|
||||||
.btn-primary:hover { background: #2563eb; text-decoration: none; }
|
}
|
||||||
.btn-outline { background: transparent; color: var(--accent); border: 1px solid var(--accent); }
|
|
||||||
.btn-outline:hover { background: rgba(59,130,246,0.1); text-decoration: none; }
|
.nav-inner {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 16px 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logo a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
color: var(--muted);
|
||||||
|
margin-left: 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover {
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Article container */
|
/* Article container */
|
||||||
.article { max-width: 880px; margin: 0 auto; padding: 120px 24px 60px; }
|
.article {
|
||||||
.breadcrumb { font-size: 13px; color: var(--muted); margin-bottom: 24px; }
|
max-width: 880px;
|
||||||
.breadcrumb a { color: var(--muted); }
|
margin: 0 auto;
|
||||||
.breadcrumb a:hover { color: var(--text); }
|
padding: 120px 24px 60px;
|
||||||
.breadcrumb span { margin: 0 8px; color: var(--dim); }
|
}
|
||||||
|
|
||||||
.article h1 { font-size: clamp(30px, 4vw, 44px); font-weight: 800; line-height: 1.2; margin-bottom: 16px; }
|
.breadcrumb {
|
||||||
.article .lead { font-size: 18px; color: var(--muted); margin-bottom: 32px; }
|
font-size: 13px;
|
||||||
.article h2 { font-size: 28px; font-weight: 700; margin: 48px 0 16px; line-height: 1.3; }
|
color: var(--muted);
|
||||||
.article h3 { font-size: 20px; font-weight: 600; margin: 28px 0 12px; }
|
margin-bottom: 24px;
|
||||||
.article p { margin-bottom: 16px; color: var(--text); }
|
}
|
||||||
.article ul, .article ol { margin: 0 0 16px 24px; color: var(--text); }
|
|
||||||
.article li { margin-bottom: 8px; }
|
.breadcrumb a {
|
||||||
.article strong { color: var(--text); font-weight: 600; }
|
color: var(--muted);
|
||||||
.article code { background: var(--card); border: 1px solid var(--border); padding: 2px 6px; border-radius: 4px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em; color: #e2e8f0; }
|
}
|
||||||
.article pre { background: var(--card); border: 1px solid var(--border); padding: 16px; border-radius: 8px; overflow-x: auto; margin: 16px 0; }
|
|
||||||
.article pre code { background: transparent; border: none; padding: 0; font-size: 13px; }
|
.breadcrumb a:hover {
|
||||||
.article blockquote { border-left: 3px solid var(--accent); padding: 8px 16px; margin: 16px 0; color: var(--muted); background: rgba(59,130,246,0.06); border-radius: 4px; }
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb span {
|
||||||
|
margin: 0 8px;
|
||||||
|
color: var(--dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article h1 {
|
||||||
|
font-size: clamp(30px, 4vw, 44px);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article .lead {
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article h2 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 48px 0 16px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article h3 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 28px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article p {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article ul,
|
||||||
|
.article ol {
|
||||||
|
margin: 0 0 16px 24px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article li {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article strong {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article code {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article pre {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article pre code {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article blockquote {
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
padding: 8px 16px;
|
||||||
|
margin: 16px 0;
|
||||||
|
color: var(--muted);
|
||||||
|
background: rgba(59, 130, 246, 0.06);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Comparison table */
|
/* Comparison table */
|
||||||
.compare-table-wrap { width: 100%; overflow-x: auto; margin: 24px 0; -webkit-overflow-scrolling: touch; }
|
.compare-table-wrap {
|
||||||
.compare-table { width: 100%; border-collapse: collapse; font-size: 14px; min-width: 640px; }
|
width: 100%;
|
||||||
.compare-table th, .compare-table td { padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border); }
|
overflow-x: auto;
|
||||||
.compare-table th { color: var(--text); font-weight: 600; background: var(--card); }
|
margin: 24px 0;
|
||||||
.compare-table td:first-child { color: var(--muted); }
|
-webkit-overflow-scrolling: touch;
|
||||||
.compare-table .yes { color: #22c55e; font-weight: 600; }
|
}
|
||||||
.compare-table .no { color: #ef4444; }
|
|
||||||
.compare-table .partial { color: #f59e0b; }
|
.compare-table {
|
||||||
.compare-table tbody tr:hover { background: rgba(59,130,246,0.04); }
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 14px;
|
||||||
|
min-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-table th,
|
||||||
|
.compare-table td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-table th {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-table td:first-child {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-table .yes {
|
||||||
|
color: #22c55e;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-table .no {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-table .partial {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-table tbody tr:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
/* CTA */
|
/* CTA */
|
||||||
.cta { text-align: center; padding: 60px 24px; background: linear-gradient(135deg, rgba(59,130,246,0.1), rgba(139,92,246,0.1)); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); margin: 48px 0; border-radius: 12px; }
|
.cta {
|
||||||
.cta h2 { font-size: 28px; margin: 0 0 12px; }
|
text-align: center;
|
||||||
.cta p { color: var(--muted); margin-bottom: 20px; font-size: 17px; }
|
padding: 60px 24px;
|
||||||
|
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1), rgba(139, 92, 246, 0.1));
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin: 48px 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta h2 {
|
||||||
|
font-size: 28px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta p {
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Related links / internal linking block */
|
/* Related links / internal linking block */
|
||||||
.related { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 24px; margin: 32px 0; }
|
.related {
|
||||||
.related h2 { margin: 0 0 12px; font-size: 20px; }
|
background: var(--card);
|
||||||
.related ul { margin: 0; list-style: none; }
|
border: 1px solid var(--border);
|
||||||
.related li { margin: 8px 0; padding-left: 0; }
|
border-radius: 12px;
|
||||||
.related li::before { content: '> '; color: var(--accent); font-weight: 700; }
|
padding: 24px;
|
||||||
|
margin: 32px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.related h2 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.related ul {
|
||||||
|
margin: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.related li {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.related li::before {
|
||||||
|
content: '> ';
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
/* Footer (matches landing.html) */
|
/* Footer (matches landing.html) */
|
||||||
footer { max-width: 1200px; margin: 0 auto; padding: 40px 24px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 16px; border-top: 1px solid var(--border); }
|
footer {
|
||||||
footer .links a { color: var(--dim); margin-left: 16px; font-size: 13px; }
|
max-width: 1200px;
|
||||||
footer .links a:hover { color: var(--text); text-decoration: none; }
|
margin: 0 auto;
|
||||||
|
padding: 40px 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer .links a {
|
||||||
|
color: var(--dim);
|
||||||
|
margin-left: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer .links a:hover {
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile */
|
/* Mobile */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.nav-links a:not(.btn) { display: none; }
|
.nav-links a:not(.btn) {
|
||||||
.nav-inner { padding: 12px 14px; gap: 8px; }
|
display: none;
|
||||||
.nav-links .btn { padding: 8px 12px; font-size: 13px; margin-left: 8px; flex-shrink: 0; min-height: 0; }
|
}
|
||||||
.btn { min-height: 44px; }
|
|
||||||
.article { padding: 100px 16px 40px; }
|
.nav-inner {
|
||||||
.cta { padding: 40px 16px; }
|
padding: 12px 14px;
|
||||||
footer { flex-direction: column; text-align: center; }
|
gap: 8px;
|
||||||
footer .links a { margin: 4px 8px; }
|
}
|
||||||
.compare-table { font-size: 12px; }
|
|
||||||
.compare-table th, .compare-table td { padding: 8px; }
|
.nav-links .btn {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-left: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article {
|
||||||
|
padding: 100px 16px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta {
|
||||||
|
padding: 40px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer .links a {
|
||||||
|
margin: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-table {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-table th,
|
||||||
|
.compare-table td {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 420px) {
|
@media (max-width: 420px) {
|
||||||
.nav-logo-text { display: none; }
|
.nav-logo-text {
|
||||||
}
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html data-theme="dark" lang="en">
|
<html data-theme="dark" lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
|
@ -10,28 +11,35 @@
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
<link rel="icon" href="/assets/icon-192.png">
|
<link rel="icon" href="/assets/icon-192.png">
|
||||||
<link rel="apple-touch-icon" href="/assets/icon-192.png">
|
<link rel="apple-touch-icon" href="/assets/icon-192.png">
|
||||||
<title>GoldenSignage</title>
|
<title>SwiftDisplay</title>
|
||||||
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Goldman:wght@400;700&family=Michroma&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Goldman:wght@400;700&family=Michroma&display=swap"
|
||||||
|
rel="stylesheet">
|
||||||
|
|
||||||
<link rel="stylesheet" href="/css/variables.css">
|
<link rel="stylesheet" href="/css/variables.css">
|
||||||
<link rel="stylesheet" href="/css/reset.css">
|
<link rel="stylesheet" href="/css/reset.css">
|
||||||
<link rel="stylesheet" href="/css/main.css">
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
<script src="/socket.io/socket.io.js"></script>
|
<script src="/socket.io/socket.io.js"></script>
|
||||||
<!-- OAuth providers loaded on-demand by login.js when needed -->
|
<!-- OAuth providers loaded on-demand by login.js when needed -->
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<button class="mobile-menu-btn" id="mobileMenuBtn" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="sidebar">
|
<button class="mobile-menu-btn" id="mobileMenuBtn" aria-label="Toggle navigation menu" aria-expanded="false"
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
aria-controls="sidebar">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6" />
|
||||||
|
<line x1="3" y1="12" x2="21" y2="12" />
|
||||||
|
<line x1="3" y1="18" x2="21" y2="18" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="sidebar-backdrop" id="sidebarBackdrop"></div>
|
<div class="sidebar-backdrop" id="sidebarBackdrop"></div>
|
||||||
<nav class="sidebar" id="sidebar">
|
<nav class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<img src="/assets/logowhiteswift.png" alt="Logo" style="height: 28px;">
|
<img src="/assets/logowhiteswift.png" alt="Logo" style="height: 28px;">
|
||||||
<span id="brandName" style="margin-left: 8px;">GoldenSignage</span>
|
<span id="brandName" style="margin-left: 8px;">SwiftDisplay</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- #38: apply cached white-label before first paint (no ScreenTinker flash) -->
|
<!-- #38: apply cached white-label before first paint (no ScreenTinker flash) -->
|
||||||
<script src="/js/brand-prime.js"></script>
|
<script src="/js/brand-prime.js"></script>
|
||||||
|
|
@ -39,114 +47,140 @@
|
||||||
</div>
|
</div>
|
||||||
<ul class="nav-links">
|
<ul class="nav-links">
|
||||||
<li><a href="#/" class="nav-link active" data-view="dashboard">
|
<li><a href="#/" class="nav-link active" data-view="dashboard">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
|
<rect x="3" y="3" width="7" height="7" />
|
||||||
<rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>
|
<rect x="14" y="3" width="7" height="7" />
|
||||||
</svg>
|
<rect x="3" y="14" width="7" height="7" />
|
||||||
<span>Displays</span>
|
<rect x="14" y="14" width="7" height="7" />
|
||||||
</a></li>
|
</svg>
|
||||||
|
<span>Displays</span>
|
||||||
|
</a></li>
|
||||||
<li><a href="#/content" class="nav-link" data-view="content">
|
<li><a href="#/content" class="nav-link" data-view="content">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/>
|
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" />
|
||||||
<polyline points="13 2 13 9 20 9"/>
|
<polyline points="13 2 13 9 20 9" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Content</span>
|
<span>Content</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
<li><a href="#/playlists" class="nav-link" data-view="playlists">
|
<li><a href="#/playlists" class="nav-link" data-view="playlists">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/>
|
<line x1="8" y1="6" x2="21" y2="6" />
|
||||||
<line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>
|
<line x1="8" y1="12" x2="21" y2="12" />
|
||||||
</svg>
|
<line x1="8" y1="18" x2="21" y2="18" />
|
||||||
<span>Playlists</span>
|
<line x1="3" y1="6" x2="3.01" y2="6" />
|
||||||
</a></li>
|
<line x1="3" y1="12" x2="3.01" y2="12" />
|
||||||
|
<line x1="3" y1="18" x2="3.01" y2="18" />
|
||||||
|
</svg>
|
||||||
|
<span>Playlists</span>
|
||||||
|
</a></li>
|
||||||
<li><a href="#/layouts" class="nav-link" data-view="layouts">
|
<li><a href="#/layouts" class="nav-link" data-view="layouts">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/>
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||||
</svg>
|
<line x1="3" y1="9" x2="21" y2="9" />
|
||||||
<span>Layouts</span>
|
<line x1="9" y1="21" x2="9" y2="9" />
|
||||||
</a></li>
|
</svg>
|
||||||
|
<span>Layouts</span>
|
||||||
|
</a></li>
|
||||||
<li><a href="#/widgets" class="nav-link" data-view="widgets">
|
<li><a href="#/widgets" class="nav-link" data-view="widgets">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<rect x="2" y="2" width="8" height="8" rx="1"/><rect x="14" y="2" width="8" height="8" rx="1"/>
|
<rect x="2" y="2" width="8" height="8" rx="1" />
|
||||||
<rect x="2" y="14" width="8" height="8" rx="1"/><rect x="14" y="14" width="8" height="8" rx="1"/>
|
<rect x="14" y="2" width="8" height="8" rx="1" />
|
||||||
</svg>
|
<rect x="2" y="14" width="8" height="8" rx="1" />
|
||||||
<span>Widgets</span>
|
<rect x="14" y="14" width="8" height="8" rx="1" />
|
||||||
</a></li>
|
</svg>
|
||||||
|
<span>Widgets</span>
|
||||||
|
</a></li>
|
||||||
<li><a href="#/schedule" class="nav-link" data-view="schedule">
|
<li><a href="#/schedule" class="nav-link" data-view="schedule">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||||
</svg>
|
<line x1="16" y1="2" x2="16" y2="6" />
|
||||||
<span>Schedule</span>
|
<line x1="8" y1="2" x2="8" y2="6" />
|
||||||
</a></li>
|
<line x1="3" y1="10" x2="21" y2="10" />
|
||||||
|
</svg>
|
||||||
|
<span>Schedule</span>
|
||||||
|
</a></li>
|
||||||
<li><a href="#/walls" class="nav-link" data-view="walls">
|
<li><a href="#/walls" class="nav-link" data-view="walls">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<rect x="2" y="3" width="9" height="8" rx="1"/><rect x="13" y="3" width="9" height="8" rx="1"/>
|
<rect x="2" y="3" width="9" height="8" rx="1" />
|
||||||
<rect x="2" y="13" width="9" height="8" rx="1"/><rect x="13" y="13" width="9" height="8" rx="1"/>
|
<rect x="13" y="3" width="9" height="8" rx="1" />
|
||||||
</svg>
|
<rect x="2" y="13" width="9" height="8" rx="1" />
|
||||||
<span>Video Walls</span>
|
<rect x="13" y="13" width="9" height="8" rx="1" />
|
||||||
</a></li>
|
</svg>
|
||||||
|
<span>Video Walls</span>
|
||||||
|
</a></li>
|
||||||
<li><a href="#/reports" class="nav-link" data-view="reports">
|
<li><a href="#/reports" class="nav-link" data-view="reports">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>
|
<line x1="18" y1="20" x2="18" y2="10" />
|
||||||
</svg>
|
<line x1="12" y1="20" x2="12" y2="4" />
|
||||||
<span>Reports</span>
|
<line x1="6" y1="20" x2="6" y2="14" />
|
||||||
</a></li>
|
</svg>
|
||||||
|
<span>Reports</span>
|
||||||
|
</a></li>
|
||||||
<li><a href="#/kiosk" class="nav-link" data-view="kiosk">
|
<li><a href="#/kiosk" class="nav-link" data-view="kiosk">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><path d="M8 21h8"/><path d="M12 17v4"/>
|
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
||||||
<circle cx="12" cy="10" r="1"/>
|
<path d="M8 21h8" />
|
||||||
</svg>
|
<path d="M12 17v4" />
|
||||||
<span>Kiosk</span>
|
<circle cx="12" cy="10" r="1" />
|
||||||
</a></li>
|
</svg>
|
||||||
|
<span>Kiosk</span>
|
||||||
|
</a></li>
|
||||||
<li><a href="#/designer" class="nav-link" data-view="designer">
|
<li><a href="#/designer" class="nav-link" data-view="designer">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/>
|
<path d="M12 19l7-7 3 3-7 7-3-3z" />
|
||||||
<circle cx="11" cy="11" r="2"/>
|
<path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z" />
|
||||||
</svg>
|
<path d="M2 2l7.586 7.586" />
|
||||||
<span>Designer</span>
|
<circle cx="11" cy="11" r="2" />
|
||||||
</a></li>
|
</svg>
|
||||||
|
<span>Designer</span>
|
||||||
|
</a></li>
|
||||||
<li><a href="#/activity" class="nav-link" data-view="activity">
|
<li><a href="#/activity" class="nav-link" data-view="activity">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Activity</span>
|
<span>Activity</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
<!-- Teams nav hidden while the feature is being redesigned as a user-grouping
|
<!-- Teams nav hidden while the feature is being redesigned as a user-grouping
|
||||||
primitive within Workspaces. Route + view kept in place so any existing
|
primitive within Workspaces. Route + view kept in place so any existing
|
||||||
bookmark still loads (and shows the 503 from the API). -->
|
bookmark still loads (and shows the 503 from the API). -->
|
||||||
<li style="display:none"><a href="#/teams" class="nav-link" data-view="teams">
|
<li style="display:none"><a href="#/teams" class="nav-link" data-view="teams">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/>
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
<circle cx="9" cy="7" r="4" />
|
||||||
</svg>
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||||
<span>Teams</span>
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||||
</a></li>
|
</svg>
|
||||||
|
<span>Teams</span>
|
||||||
|
</a></li>
|
||||||
<li><a href="#/help" class="nav-link" data-view="help">
|
<li><a href="#/help" class="nav-link" data-view="help">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>
|
<circle cx="12" cy="12" r="10" />
|
||||||
</svg>
|
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
|
||||||
<span>Help</span>
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
</a></li>
|
</svg>
|
||||||
|
<span>Help</span>
|
||||||
|
</a></li>
|
||||||
<li><a href="#/settings" class="nav-link" data-view="settings">
|
<li><a href="#/settings" class="nav-link" data-view="settings">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<circle cx="12" cy="12" r="3"/>
|
<circle cx="12" cy="12" r="3" />
|
||||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
<path
|
||||||
</svg>
|
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||||
<span>Settings</span>
|
</svg>
|
||||||
</a></li>
|
<span>Settings</span>
|
||||||
|
</a></li>
|
||||||
<li id="billingNavItem"><a href="#/billing" class="nav-link" data-view="billing">
|
<li id="billingNavItem"><a href="#/billing" class="nav-link" data-view="billing">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<rect x="1" y="4" width="22" height="16" rx="2" ry="2"/>
|
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
|
||||||
<line x1="1" y1="10" x2="23" y2="10"/>
|
<line x1="1" y1="10" x2="23" y2="10" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Subscription</span>
|
<span>Subscription</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
<li id="adminNavItem" style="display:none"><a href="#/admin" class="nav-link" data-view="admin">
|
<li id="adminNavItem" style="display:none"><a href="#/admin" class="nav-link" data-view="admin">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Admin</span>
|
<span>Admin</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<div class="connection-status" id="connectionStatus">
|
<div class="connection-status" id="connectionStatus">
|
||||||
|
|
@ -171,41 +205,55 @@
|
||||||
<h3 data-i18n="add_display.title">Add Display</h3>
|
<h3 data-i18n="add_display.title">Add Display</h3>
|
||||||
<button class="btn-icon" data-close-modal="addDeviceModal">
|
<button class="btn-icon" data-close-modal="addDeviceModal">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p class="modal-description" style="margin-bottom:16px" data-i18n="add_display.intro">Enter the 6-digit pairing code shown on the display.</p>
|
<p class="modal-description" style="margin-bottom:16px" data-i18n="add_display.intro">Enter the 6-digit pairing
|
||||||
|
code shown on the display.</p>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label data-i18n="add_display.pairing_code">Pairing Code</label>
|
<label data-i18n="add_display.pairing_code">Pairing Code</label>
|
||||||
<input type="text" id="pairingCodeInput" maxlength="6" pattern="[0-9]{6}" placeholder="000000" class="pairing-input">
|
<input type="text" id="pairingCodeInput" maxlength="6" pattern="[0-9]{6}" placeholder="000000"
|
||||||
|
class="pairing-input">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label data-i18n="add_display.display_name">Display Name (optional)</label>
|
<label data-i18n="add_display.display_name">Display Name (optional)</label>
|
||||||
<input type="text" id="deviceNameInput" data-i18n-placeholder="add_display.name_placeholder" placeholder="e.g., Lobby TV" class="input">
|
<input type="text" id="deviceNameInput" data-i18n-placeholder="add_display.name_placeholder"
|
||||||
|
placeholder="e.g., Lobby TV" class="input">
|
||||||
</div>
|
</div>
|
||||||
<div style="border-top:1px solid var(--border,#1e293b);margin-top:20px;padding-top:16px">
|
<div style="border-top:1px solid var(--border,#1e293b);margin-top:20px;padding-top:16px">
|
||||||
<p style="font-size:12px;color:var(--text-muted,#64748b);margin-bottom:10px;font-weight:500" data-i18n="add_display.need_player">Need a player app? Install one to get a pairing code:</p>
|
<p style="font-size:12px;color:var(--text-muted,#64748b);margin-bottom:10px;font-weight:500"
|
||||||
|
data-i18n="add_display.need_player">Need a player app? Install one to get a pairing code:</p>
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
||||||
<a href="/download/apk" class="btn btn-secondary btn-sm" style="text-decoration:none;justify-content:center;font-size:12px">
|
<a href="/download/apk" class="btn btn-secondary btn-sm"
|
||||||
|
style="text-decoration:none;justify-content:center;font-size:12px">
|
||||||
🤖 <span data-i18n="add_display.android_apk">Android APK</span>
|
🤖 <span data-i18n="add_display.android_apk">Android APK</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/player" target="_blank" class="btn btn-secondary btn-sm" style="text-decoration:none;justify-content:center;font-size:12px">
|
<a href="/player" target="_blank" class="btn btn-secondary btn-sm"
|
||||||
|
style="text-decoration:none;justify-content:center;font-size:12px">
|
||||||
🌐 <span data-i18n="add_display.web_player">Web Player</span>
|
🌐 <span data-i18n="add_display.web_player">Web Player</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/scripts/raspberry-pi-setup.sh" class="btn btn-secondary btn-sm" style="text-decoration:none;justify-content:center;font-size:12px">
|
<a href="/scripts/raspberry-pi-setup.sh" class="btn btn-secondary btn-sm"
|
||||||
|
style="text-decoration:none;justify-content:center;font-size:12px">
|
||||||
🥏 <span data-i18n="add_display.raspberry_pi">Raspberry Pi</span>
|
🥏 <span data-i18n="add_display.raspberry_pi">Raspberry Pi</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/scripts/windows-setup.bat" class="btn btn-secondary btn-sm" style="text-decoration:none;justify-content:center;font-size:12px">
|
<a href="/scripts/windows-setup.bat" class="btn btn-secondary btn-sm"
|
||||||
|
style="text-decoration:none;justify-content:center;font-size:12px">
|
||||||
💻 <span data-i18n="add_display.windows">Windows</span>
|
💻 <span data-i18n="add_display.windows">Windows</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<p style="font-size:11px;color:var(--text-muted,#64748b);margin-top:8px" data-i18n-html="add_display.smart_tv_note">Smart TVs (LG/Samsung): open the built-in browser and navigate to <code style="background:var(--bg-input,#0f172a);padding:1px 4px;border-radius:3px">/player</code></p>
|
<p style="font-size:11px;color:var(--text-muted,#64748b);margin-top:8px"
|
||||||
|
data-i18n-html="add_display.smart_tv_note">Smart TVs (LG/Samsung): open the built-in browser and navigate to
|
||||||
|
<code style="background:var(--bg-input,#0f172a);padding:1px 4px;border-radius:3px">/player</code></p>
|
||||||
</div>
|
</div>
|
||||||
<div style="border-top:1px solid var(--border,#1e293b);margin-top:16px;padding-top:16px">
|
<div style="border-top:1px solid var(--border,#1e293b);margin-top:16px;padding-top:16px">
|
||||||
<p style="font-size:12px;color:var(--text-muted,#64748b);margin-bottom:10px;font-weight:500" data-i18n="add_display.owner_prompt">Provisioning a fresh / factory-reset Android panel? Enroll it as device owner for silent updates, reboot & kiosk:</p>
|
<p style="font-size:12px;color:var(--text-muted,#64748b);margin-bottom:10px;font-weight:500"
|
||||||
<button type="button" class="btn btn-secondary btn-sm" id="deviceOwnerQrBtn" style="width:100%;justify-content:center;font-size:12px">
|
data-i18n="add_display.owner_prompt">Provisioning a fresh / factory-reset Android panel? Enroll it as device
|
||||||
|
owner for silent updates, reboot & kiosk:</p>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" id="deviceOwnerQrBtn"
|
||||||
|
style="width:100%;justify-content:center;font-size:12px">
|
||||||
📲 <span data-i18n="add_display.owner_qr_btn">Show device-owner QR</span>
|
📲 <span data-i18n="add_display.owner_qr_btn">Show device-owner QR</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -222,4 +270,5 @@
|
||||||
|
|
||||||
<script type="module" src="/js/app.js"></script>
|
<script type="module" src="/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
|
||||||
|
</html>
|
||||||
|
|
@ -183,7 +183,7 @@ async function loadSummary() {
|
||||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const families = [
|
const families = [
|
||||||
['Tizen', data.byFamily.tizen, '#3b82f6'],
|
['Tizen', data.byFamily.tizen, '#e65c00'],
|
||||||
['WebOS', data.byFamily.webos, '#a3e635'],
|
['WebOS', data.byFamily.webos, '#a3e635'],
|
||||||
['Fire TV', data.byFamily.fire_tv, '#f97316'],
|
['Fire TV', data.byFamily.fire_tv, '#f97316'],
|
||||||
['Bravia', data.byFamily.bravia, '#a855f7'],
|
['Bravia', data.byFamily.bravia, '#a855f7'],
|
||||||
|
|
|
||||||
|
|
@ -212,7 +212,7 @@ async function loadBranding() {
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;max-width:640px">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;max-width:640px">
|
||||||
<div class="form-group" style="grid-column:1/-1"><label>${t('admin.branding.brand_name')}</label><input type="text" id="brBrandName" class="input" placeholder="ScreenTinker" value="${v(b.brand_name)}"></div>
|
<div class="form-group" style="grid-column:1/-1"><label>${t('admin.branding.brand_name')}</label><input type="text" id="brBrandName" class="input" placeholder="ScreenTinker" value="${v(b.brand_name)}"></div>
|
||||||
<div class="form-group"><label>${t('admin.branding.primary_color')}</label><input type="text" id="brPrimary" class="input" placeholder="#3B82F6" value="${v(b.primary_color)}"></div>
|
<div class="form-group"><label>${t('admin.branding.primary_color')}</label><input type="text" id="brPrimary" class="input" placeholder="#e65c00" value="${v(b.primary_color)}"></div>
|
||||||
<div class="form-group"><label>${t('admin.branding.bg_color')}</label><input type="text" id="brBg" class="input" placeholder="#111827" value="${v(b.bg_color)}"></div>
|
<div class="form-group"><label>${t('admin.branding.bg_color')}</label><input type="text" id="brBg" class="input" placeholder="#111827" value="${v(b.bg_color)}"></div>
|
||||||
<div class="form-group" style="grid-column:1/-1"><label>${t('admin.branding.logo_url')}</label><input type="text" id="brLogo" class="input" placeholder="https://…/logo.png" value="${v(b.logo_url)}"></div>
|
<div class="form-group" style="grid-column:1/-1"><label>${t('admin.branding.logo_url')}</label><input type="text" id="brLogo" class="input" placeholder="https://…/logo.png" value="${v(b.logo_url)}"></div>
|
||||||
<div class="form-group" style="grid-column:1/-1"><label>${t('admin.branding.favicon_url')}</label><input type="text" id="brFavicon" class="input" placeholder="https://…/favicon.ico" value="${v(b.favicon_url)}"></div>
|
<div class="form-group" style="grid-column:1/-1"><label>${t('admin.branding.favicon_url')}</label><input type="text" id="brFavicon" class="input" placeholder="https://…/favicon.ico" value="${v(b.favicon_url)}"></div>
|
||||||
|
|
@ -389,9 +389,9 @@ async function loadPlans() {
|
||||||
<tr style="border-bottom:1px solid var(--border)">
|
<tr style="border-bottom:1px solid var(--border)">
|
||||||
<td style="padding:8px;font-weight:500">${p.display_name}</td>
|
<td style="padding:8px;font-weight:500">${p.display_name}</td>
|
||||||
<td style="padding:8px;text-align:right">${p.max_devices === -1 ? t('admin.unlimited') : p.max_devices}</td>
|
<td style="padding:8px;text-align:right">${p.max_devices === -1 ? t('admin.unlimited') : p.max_devices}</td>
|
||||||
<td style="padding:8px;text-align:right">${p.max_storage_mb === -1 ? t('admin.unlimited') : p.max_storage_mb >= 1024 ? (p.max_storage_mb/1024)+'GB' : p.max_storage_mb+'MB'}</td>
|
<td style="padding:8px;text-align:right">${p.max_storage_mb === -1 ? t('admin.unlimited') : p.max_storage_mb >= 1024 ? (p.max_storage_mb / 1024) + 'GB' : p.max_storage_mb + 'MB'}</td>
|
||||||
<td style="padding:8px;text-align:right">${p.price_monthly > 0 ? '$'+p.price_monthly : t('admin.free')}</td>
|
<td style="padding:8px;text-align:right">${p.price_monthly > 0 ? '$' + p.price_monthly : t('admin.free')}</td>
|
||||||
<td style="padding:8px;text-align:right">${p.price_yearly > 0 ? '$'+p.price_yearly : '-'}</td>
|
<td style="padding:8px;text-align:right">${p.price_yearly > 0 ? '$' + p.price_yearly : '-'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -531,4 +531,4 @@ async function loadSystem() {
|
||||||
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`; }
|
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`; }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cleanup() {}
|
export function cleanup() { }
|
||||||
|
|
|
||||||
|
|
@ -91,8 +91,8 @@ function renderDeviceCard(device) {
|
||||||
</label>
|
</label>
|
||||||
<div class="device-card-preview" id="preview-${device.id}">
|
<div class="device-card-preview" id="preview-${device.id}">
|
||||||
${screenshotUrl
|
${screenshotUrl
|
||||||
? `<img src="${screenshotUrl}" alt="Screenshot" loading="lazy">`
|
? `<img src="${screenshotUrl}" alt="Screenshot" loading="lazy">`
|
||||||
: `<div class="no-preview">
|
: `<div class="no-preview">
|
||||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
|
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
|
||||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||||
|
|
@ -100,7 +100,7 @@ function renderDeviceCard(device) {
|
||||||
</svg>
|
</svg>
|
||||||
<span>${t('dashboard.no_preview')}</span>
|
<span>${t('dashboard.no_preview')}</span>
|
||||||
</div>`
|
</div>`
|
||||||
}
|
}
|
||||||
<div class="device-card-status is-liveness">
|
<div class="device-card-status is-liveness">
|
||||||
${(() => { const b = livenessBadge(device, { short: true }); return `<span class="device-status-badge ${b.state}" data-liveness="${b.state}" data-offline-reason="${esc(b.reason)}"${b.title ? ` title="${esc(b.title)}"` : ''}>${esc(b.label)}</span>`; })()}
|
${(() => { const b = livenessBadge(device, { short: true }); return `<span class="device-status-badge ${b.state}" data-liveness="${b.state}" data-offline-reason="${esc(b.reason)}"${b.title ? ` title="${esc(b.title)}"` : ''}>${esc(b.label)}</span>`; })()}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -211,7 +211,7 @@ function renderGroupSection(group, devices, playlists) {
|
||||||
const playlistLabel = getGroupPlaylistLabel(devices, playlists);
|
const playlistLabel = getGroupPlaylistLabel(devices, playlists);
|
||||||
return `
|
return `
|
||||||
<div class="group-section" data-group-id="${group.id}" style="margin-bottom:24px">
|
<div class="group-section" data-group-id="${group.id}" style="margin-bottom:24px">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;padding:8px 12px;background:var(--bg-secondary);border-radius:8px;border-left:4px solid ${esc(group.color || '#3B82F6')}">
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;padding:8px 12px;background:var(--bg-secondary);border-radius:8px;border-left:4px solid ${esc(group.color || '#e65c00')}">
|
||||||
<div style="display:flex;align-items:center;gap:10px">
|
<div style="display:flex;align-items:center;gap:10px">
|
||||||
<strong style="font-size:15px">${esc(group.name)}</strong>
|
<strong style="font-size:15px">${esc(group.name)}</strong>
|
||||||
<span style="color:var(--text-muted);font-size:12px">${tn('dashboard.devices_count', devices.length)} · ${t('dashboard.online_count', { n: onlineCount })}</span>
|
<span style="color:var(--text-muted);font-size:12px">${tn('dashboard.devices_count', devices.length)} · ${t('dashboard.online_count', { n: onlineCount })}</span>
|
||||||
|
|
@ -898,8 +898,8 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
|
||||||
<p style="margin:0 0 16px;font-size:12px;color:var(--text-muted)">${t('dashboard.manage_group_subtitle')}</p>
|
<p style="margin:0 0 16px;font-size:12px;color:var(--text-muted)">${t('dashboard.manage_group_subtitle')}</p>
|
||||||
<div style="display:flex;flex-direction:column;gap:6px">
|
<div style="display:flex;flex-direction:column;gap:6px">
|
||||||
${allDevices.filter(d => d.status !== 'provisioning').map(d => {
|
${allDevices.filter(d => d.status !== 'provisioning').map(d => {
|
||||||
const inOther = otherGroups.filter(g => g.memberIds.has(d.id)).map(g => g.name);
|
const inOther = otherGroups.filter(g => g.memberIds.has(d.id)).map(g => g.name);
|
||||||
return `
|
return `
|
||||||
<label style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;background:var(--bg-secondary)">
|
<label style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;background:var(--bg-secondary)">
|
||||||
<input type="checkbox" data-device-id="${d.id}" data-in-groups="${inOther.join(',')}" ${memberIds.has(d.id) ? 'checked' : ''}>
|
<input type="checkbox" data-device-id="${d.id}" data-in-groups="${inOther.join(',')}" ${memberIds.has(d.id) ? 'checked' : ''}>
|
||||||
<span class="status-dot ${d.status}" style="width:8px;height:8px"></span>
|
<span class="status-dot ${d.status}" style="width:8px;height:8px"></span>
|
||||||
|
|
@ -907,7 +907,7 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
|
||||||
${inOther.length > 0 ? `<span style="font-size:10px;color:var(--text-muted);background:var(--bg-primary);padding:1px 6px;border-radius:8px">${esc(inOther.join(', '))}</span>` : ''}
|
${inOther.length > 0 ? `<span style="font-size:10px;color:var(--text-muted);background:var(--bg-primary);padding:1px 6px;border-radius:8px">${esc(inOther.join(', '))}</span>` : ''}
|
||||||
</label>
|
</label>
|
||||||
`;
|
`;
|
||||||
}).join('')}
|
}).join('')}
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:8px;margin-top:16px;justify-content:flex-end">
|
<div style="display:flex;gap:8px;margin-top:16px;justify-content:flex-end">
|
||||||
<button class="btn" id="manageGroupClose">${t('common.done')}</button>
|
<button class="btn" id="manageGroupClose">${t('common.done')}</button>
|
||||||
|
|
@ -951,8 +951,8 @@ export function cleanup() {
|
||||||
if (screenshotHandler) off('screenshot-ready', screenshotHandler);
|
if (screenshotHandler) off('screenshot-ready', screenshotHandler);
|
||||||
if (playbackHandler) off('playback-progress', playbackHandler);
|
if (playbackHandler) off('playback-progress', playbackHandler);
|
||||||
if (wallChangedHandler) off('wall-changed', wallChangedHandler);
|
if (wallChangedHandler) off('wall-changed', wallChangedHandler);
|
||||||
off('device-added', () => {});
|
off('device-added', () => { });
|
||||||
off('device-removed', () => {});
|
off('device-removed', () => { });
|
||||||
if (refreshInterval) clearInterval(refreshInterval);
|
if (refreshInterval) clearInterval(refreshInterval);
|
||||||
if (progressTickInterval) clearInterval(progressTickInterval);
|
if (progressTickInterval) clearInterval(progressTickInterval);
|
||||||
statusHandler = null;
|
statusHandler = null;
|
||||||
|
|
|
||||||
|
|
@ -187,7 +187,7 @@ export function render(container, widgetId) {
|
||||||
const url = prompt(t('designer.prompt.rss_url'), 'https://feeds.bbci.co.uk/news/rss.xml');
|
const url = prompt(t('designer.prompt.rss_url'), 'https://feeds.bbci.co.uk/news/rss.xml');
|
||||||
if (url) addElement({ type: 'ticker', x: 0, y: 90, width: 100, height: 10, feedUrl: url, speed: 30, fontSize: 20, color: '#FFFFFF', bgColor: 'rgba(0,0,0,0.7)' });
|
if (url) addElement({ type: 'ticker', x: 0, y: 90, width: 100, height: 10, feedUrl: url, speed: 30, fontSize: 20, color: '#FFFFFF', bgColor: 'rgba(0,0,0,0.7)' });
|
||||||
};
|
};
|
||||||
document.getElementById('addShape').onclick = () => addElement({ type: 'shape', x: 20, y: 20, width: 30, height: 20, color: '#3b82f6', opacity: 0.7, radius: 8, shape: 'rect' });
|
document.getElementById('addShape').onclick = () => addElement({ type: 'shape', x: 20, y: 20, width: 30, height: 20, color: '#e65c00', opacity: 0.7, radius: 8, shape: 'rect' });
|
||||||
document.getElementById('addQR').onclick = () => {
|
document.getElementById('addQR').onclick = () => {
|
||||||
const data = prompt(t('designer.prompt.qr_url'), 'https://example.com');
|
const data = prompt(t('designer.prompt.qr_url'), 'https://example.com');
|
||||||
if (data) addElement({ type: 'qr', x: 80, y: 70, size: 15, data, fgColor: '#FFFFFF', bgColor: '#000000' });
|
if (data) addElement({ type: 'qr', x: 80, y: 70, size: 15, data, fgColor: '#FFFFFF', bgColor: '#000000' });
|
||||||
|
|
@ -538,7 +538,7 @@ function redraw() {
|
||||||
// Elements
|
// Elements
|
||||||
elements.forEach((el, i) => {
|
elements.forEach((el, i) => {
|
||||||
const selected = i === selectedIdx;
|
const selected = i === selectedIdx;
|
||||||
const border = selected ? 'outline:2px solid #3b82f6;outline-offset:2px;' : '';
|
const border = selected ? 'outline:2px solid #e65c00;outline-offset:2px;' : '';
|
||||||
const cursor = 'cursor:move;';
|
const cursor = 'cursor:move;';
|
||||||
|
|
||||||
switch (el.type) {
|
switch (el.type) {
|
||||||
|
|
@ -813,4 +813,4 @@ function generateHTML() {
|
||||||
return `<!DOCTYPE html><html><head><style>*{margin:0;padding:0;box-sizing:border-box}body{width:100vw;height:100vh;overflow:hidden;background:${bgImageDataUrl ? `url(${bgImageDataUrl}) center/cover` : bgValue}}</style></head><body>${generateInnerHTML()}</body></html>`;
|
return `<!DOCTYPE html><html><head><style>*{margin:0;padding:0;box-sizing:border-box}body{width:100vw;height:100vh;overflow:hidden;background:${bgImageDataUrl ? `url(${bgImageDataUrl}) center/cover` : bgValue}}</style></head><body>${generateInnerHTML()}</body></html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cleanup() {}
|
export function cleanup() { }
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ async function renderEditor(container, pageId) {
|
||||||
<option ${(config.style?.columns || 3) === 4 ? 'selected' : ''} value="4">4</option>
|
<option ${(config.style?.columns || 3) === 4 ? 'selected' : ''} value="4">4</option>
|
||||||
</select></div>
|
</select></div>
|
||||||
<div class="form-group"><label>${t('kiosk.button_color')}</label><input type="color" id="kBtnBg" value="${config.style?.buttonBg || '#1e293b'}" style="width:100%;height:28px;border:none;cursor:pointer"></div>
|
<div class="form-group"><label>${t('kiosk.button_color')}</label><input type="color" id="kBtnBg" value="${config.style?.buttonBg || '#1e293b'}" style="width:100%;height:28px;border:none;cursor:pointer"></div>
|
||||||
<div class="form-group"><label>${t('kiosk.button_hover')}</label><input type="color" id="kBtnHover" value="${config.style?.buttonHover || '#3b82f6'}" style="width:100%;height:28px;border:none;cursor:pointer"></div>
|
<div class="form-group"><label>${t('kiosk.button_hover')}</label><input type="color" id="kBtnHover" value="${config.style?.buttonHover || '#e65c00'}" style="width:100%;height:28px;border:none;cursor:pointer"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px">
|
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px">
|
||||||
|
|
@ -196,4 +196,4 @@ async function renderEditor(container, pageId) {
|
||||||
renderButtons();
|
renderButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cleanup() {}
|
export function cleanup() { }
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ export async function render(container) {
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group"><label>${t('schedule.priority')}</label><input type="number" id="schedPriority" class="input" value="0" min="0" max="100"></div>
|
<div class="form-group"><label>${t('schedule.priority')}</label><input type="number" id="schedPriority" class="input" value="0" min="0" max="100"></div>
|
||||||
<div class="form-group"><label>${t('schedule.color')}</label><input type="color" id="schedColor" value="#3B82F6" style="width:60px;height:32px;border:none;cursor:pointer"></div>
|
<div class="form-group"><label>${t('schedule.color')}</label><input type="color" id="schedColor" value="#e65c00" style="width:60px;height:32px;border:none;cursor:pointer"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer" style="display:flex;justify-content:space-between;gap:8px">
|
<div class="modal-footer" style="display:flex;justify-content:space-between;gap:8px">
|
||||||
<button class="btn btn-danger" id="deleteScheduleBtn" style="display:none">${t('common.delete')}</button>
|
<button class="btn btn-danger" id="deleteScheduleBtn" style="display:none">${t('common.delete')}</button>
|
||||||
|
|
@ -190,7 +190,7 @@ export async function render(container) {
|
||||||
const block = document.createElement('div');
|
const block = document.createElement('div');
|
||||||
const topOffset = (startHour - Math.floor(startHour)) * 28;
|
const topOffset = (startHour - Math.floor(startHour)) * 28;
|
||||||
block.style.cssText = `position:absolute;top:${topOffset}px;left:2px;right:2px;height:${Math.max(20, duration * 28)}px;
|
block.style.cssText = `position:absolute;top:${topOffset}px;left:2px;right:2px;height:${Math.max(20, duration * 28)}px;
|
||||||
background:${ev.color || '#3B82F6'};border-radius:3px;padding:2px 4px;font-size:10px;color:white;overflow:hidden;cursor:pointer;z-index:1;opacity:0.85;
|
background:${ev.color || '#e65c00'};border-radius:3px;padding:2px 4px;font-size:10px;color:white;overflow:hidden;cursor:pointer;z-index:1;opacity:0.85;
|
||||||
${isGroupSchedule ? 'border:1.5px dashed rgba(255,255,255,0.6);' : ''}`;
|
${isGroupSchedule ? 'border:1.5px dashed rgba(255,255,255,0.6);' : ''}`;
|
||||||
|
|
||||||
const label = ev.title || ev.playlist_name || ev.content_name || ev.widget_name || t('schedule.scheduled_label');
|
const label = ev.title || ev.playlist_name || ev.content_name || ev.widget_name || t('schedule.scheduled_label');
|
||||||
|
|
@ -211,11 +211,11 @@ export async function render(container) {
|
||||||
document.getElementById('schedTitle').value = ev.title || '';
|
document.getElementById('schedTitle').value = ev.title || '';
|
||||||
const start = new Date(ev.start_time);
|
const start = new Date(ev.start_time);
|
||||||
const end = new Date(ev.end_time);
|
const end = new Date(ev.end_time);
|
||||||
document.getElementById('schedStart').value = `${String(start.getHours()).padStart(2,'0')}:${String(start.getMinutes()).padStart(2,'0')}`;
|
document.getElementById('schedStart').value = `${String(start.getHours()).padStart(2, '0')}:${String(start.getMinutes()).padStart(2, '0')}`;
|
||||||
document.getElementById('schedEnd').value = `${String(end.getHours()).padStart(2,'0')}:${String(end.getMinutes()).padStart(2,'0')}`;
|
document.getElementById('schedEnd').value = `${String(end.getHours()).padStart(2, '0')}:${String(end.getMinutes()).padStart(2, '0')}`;
|
||||||
document.getElementById('schedRepeat').value = ev.recurrence || '';
|
document.getElementById('schedRepeat').value = ev.recurrence || '';
|
||||||
document.getElementById('schedPriority').value = ev.priority || 0;
|
document.getElementById('schedPriority').value = ev.priority || 0;
|
||||||
document.getElementById('schedColor').value = ev.color || '#3B82F6';
|
document.getElementById('schedColor').value = ev.color || '#e65c00';
|
||||||
|
|
||||||
if (ev.group_id) {
|
if (ev.group_id) {
|
||||||
groupRadio.checked = true;
|
groupRadio.checked = true;
|
||||||
|
|
@ -311,4 +311,4 @@ export async function render(container) {
|
||||||
loadCalendar();
|
loadCalendar();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cleanup() {}
|
export function cleanup() { }
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ export async function render(container) {
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||||
<div class="form-group"><label>${t('settings.brand_name')}</label><input type="text" id="wlBrandName" class="input" placeholder="ScreenTinker"></div>
|
<div class="form-group"><label>${t('settings.brand_name')}</label><input type="text" id="wlBrandName" class="input" placeholder="ScreenTinker"></div>
|
||||||
<div class="form-group"><label>${t('settings.logo_url')}</label><input type="text" id="wlLogoUrl" class="input" placeholder="https://..."></div>
|
<div class="form-group"><label>${t('settings.logo_url')}</label><input type="text" id="wlLogoUrl" class="input" placeholder="https://..."></div>
|
||||||
<div class="form-group"><label>${t('settings.primary_color')}</label><input type="color" id="wlPrimaryColor" value="#3B82F6" style="width:100%;height:36px;border:none;cursor:pointer;border-radius:var(--radius)"></div>
|
<div class="form-group"><label>${t('settings.primary_color')}</label><input type="color" id="wlPrimaryColor" value="#e65c00" style="width:100%;height:36px;border:none;cursor:pointer;border-radius:var(--radius)"></div>
|
||||||
<div class="form-group"><label>${t('settings.bg_color')}</label><input type="color" id="wlBgColor" value="#111827" style="width:100%;height:36px;border:none;cursor:pointer;border-radius:var(--radius)"></div>
|
<div class="form-group"><label>${t('settings.bg_color')}</label><input type="color" id="wlBgColor" value="#111827" style="width:100%;height:36px;border:none;cursor:pointer;border-radius:var(--radius)"></div>
|
||||||
<div class="form-group"><label>${t('settings.custom_domain')}</label><input type="text" id="wlDomain" class="input" placeholder="signage.yourcompany.com"></div>
|
<div class="form-group"><label>${t('settings.custom_domain')}</label><input type="text" id="wlDomain" class="input" placeholder="signage.yourcompany.com"></div>
|
||||||
<div class="form-group"><label>${t('settings.favicon_url')}</label><input type="text" id="wlFavicon" class="input" placeholder="https://..."></div>
|
<div class="form-group"><label>${t('settings.favicon_url')}</label><input type="text" id="wlFavicon" class="input" placeholder="https://..."></div>
|
||||||
|
|
@ -301,7 +301,7 @@ export async function render(container) {
|
||||||
}
|
}
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const imported = Object.entries(result.stats).filter(([k,v]) => v > 0 && k !== 'files_restored').map(([k,v]) => `${v} ${k}`).join(', ');
|
const imported = Object.entries(result.stats).filter(([k, v]) => v > 0 && k !== 'files_restored').map(([k, v]) => `${v} ${k}`).join(', ');
|
||||||
statusEl.style.color = 'var(--success)';
|
statusEl.style.color = 'var(--success)';
|
||||||
let html = t('settings.import.complete', { imported });
|
let html = t('settings.import.complete', { imported });
|
||||||
if (result.device_pairings?.length) {
|
if (result.device_pairings?.length) {
|
||||||
|
|
@ -373,16 +373,15 @@ export async function render(container) {
|
||||||
<tr style="border-bottom:1px solid var(--border)${tok.revoked_at ? ';opacity:0.55' : ''}">
|
<tr style="border-bottom:1px solid var(--border)${tok.revoked_at ? ';opacity:0.55' : ''}">
|
||||||
<td style="padding:10px 12px;font-family:monospace">${esc(tok.prefix)}…</td>
|
<td style="padding:10px 12px;font-family:monospace">${esc(tok.prefix)}…</td>
|
||||||
<td style="padding:10px 12px">${esc(tok.name || '')}</td>
|
<td style="padding:10px 12px">${esc(tok.name || '')}</td>
|
||||||
<td style="padding:10px 12px">${esc(scopeLabel(tok.scope))}${
|
<td style="padding:10px 12px">${esc(scopeLabel(tok.scope))}${tok.scope === 'agency' && Array.isArray(tok.targets)
|
||||||
tok.scope === 'agency' && Array.isArray(tok.targets)
|
? `<div style="font-size:11px;color:var(--text-muted);margin-top:2px">${t('apitoken.targets_label')} ${tok.targets.length ? tok.targets.map(p => esc(p.name)).join(', ') : '—'}${tok.auto_publish ? ' · ' + esc(t('apitoken.auto_publish_on')) : ''}</div><div style="font-size:11px;color:var(--text-muted);margin-top:2px">${t('apitoken.folder_label')} ${tok.upload_folder ? esc(tok.upload_folder) : esc(t('apitoken.folder_root'))}</div>`
|
||||||
? `<div style="font-size:11px;color:var(--text-muted);margin-top:2px">${t('apitoken.targets_label')} ${tok.targets.length ? tok.targets.map(p => esc(p.name)).join(', ') : '—'}${tok.auto_publish ? ' · ' + esc(t('apitoken.auto_publish_on')) : ''}</div><div style="font-size:11px;color:var(--text-muted);margin-top:2px">${t('apitoken.folder_label')} ${tok.upload_folder ? esc(tok.upload_folder) : esc(t('apitoken.folder_root'))}</div>`
|
: ''}</td>
|
||||||
: ''}</td>
|
|
||||||
<td style="padding:10px 12px">${esc(fmtTokenDate(tok.created_at))}</td>
|
<td style="padding:10px 12px">${esc(fmtTokenDate(tok.created_at))}</td>
|
||||||
<td style="padding:10px 12px">${tok.last_used_at ? esc(fmtTokenDate(tok.last_used_at)) : t('apitoken.never')}</td>
|
<td style="padding:10px 12px">${tok.last_used_at ? esc(fmtTokenDate(tok.last_used_at)) : t('apitoken.never')}</td>
|
||||||
<td style="padding:10px 12px;white-space:nowrap;text-align:right">
|
<td style="padding:10px 12px;white-space:nowrap;text-align:right">
|
||||||
${tok.revoked_at
|
${tok.revoked_at
|
||||||
? `<span style="color:var(--text-muted);font-size:12px">${t('apitoken.revoked')}</span>`
|
? `<span style="color:var(--text-muted);font-size:12px">${t('apitoken.revoked')}</span>`
|
||||||
: `${tok.scope === 'agency' ? `<button class="btn btn-secondary btn-sm edit-targets-btn" data-id="${esc(String(tok.id))}" data-targets="${esc((tok.targets || []).map(p => p.id).join(','))}">${t('apitoken.edit_targets')}</button> <button class="btn btn-secondary btn-sm edit-folder-btn" data-id="${esc(String(tok.id))}" data-folder="${esc(String(tok.upload_folder_id || ''))}">${t('apitoken.edit_folder')}</button> ` : ''}<button class="btn btn-secondary btn-sm revoke-token-btn" data-id="${esc(String(tok.id))}">${t('apitoken.revoke')}</button>`}
|
: `${tok.scope === 'agency' ? `<button class="btn btn-secondary btn-sm edit-targets-btn" data-id="${esc(String(tok.id))}" data-targets="${esc((tok.targets || []).map(p => p.id).join(','))}">${t('apitoken.edit_targets')}</button> <button class="btn btn-secondary btn-sm edit-folder-btn" data-id="${esc(String(tok.id))}" data-folder="${esc(String(tok.upload_folder_id || ''))}">${t('apitoken.edit_folder')}</button> ` : ''}<button class="btn btn-secondary btn-sm revoke-token-btn" data-id="${esc(String(tok.id))}">${t('apitoken.revoke')}</button>`}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
|
|
@ -416,10 +415,10 @@ export async function render(container) {
|
||||||
<h4 style="font-size:14px;margin-bottom:8px">${t('apitoken.edit_targets')}</h4>
|
<h4 style="font-size:14px;margin-bottom:8px">${t('apitoken.edit_targets')}</h4>
|
||||||
<div style="display:flex;flex-direction:column;gap:6px;max-height:200px;overflow:auto;margin-bottom:12px">
|
<div style="display:flex;flex-direction:column;gap:6px;max-height:200px;overflow:auto;margin-bottom:12px">
|
||||||
${pls.length
|
${pls.length
|
||||||
? pls.map(p => p.zoned
|
? pls.map(p => p.zoned
|
||||||
? `<label style="display:flex;gap:8px;align-items:center;font-size:13px;opacity:.5"><input type="checkbox" disabled> ${esc(p.name)} <span style="font-size:11px;color:var(--text-muted)">— ${esc(t('apitoken.zoned_playlist_reason'))}</span></label>`
|
? `<label style="display:flex;gap:8px;align-items:center;font-size:13px;opacity:.5"><input type="checkbox" disabled> ${esc(p.name)} <span style="font-size:11px;color:var(--text-muted)">— ${esc(t('apitoken.zoned_playlist_reason'))}</span></label>`
|
||||||
: `<label style="display:flex;gap:8px;align-items:center;font-size:13px"><input type="checkbox" class="edit-pl" value="${esc(String(p.id))}"${current.has(String(p.id)) ? ' checked' : ''}> ${esc(p.name)}</label>`).join('')
|
: `<label style="display:flex;gap:8px;align-items:center;font-size:13px"><input type="checkbox" class="edit-pl" value="${esc(String(p.id))}"${current.has(String(p.id)) ? ' checked' : ''}> ${esc(p.name)}</label>`).join('')
|
||||||
: `<p style="color:var(--text-muted);font-size:12px">${t('apitoken.agency_no_playlists')}</p>`}
|
: `<p style="color:var(--text-muted);font-size:12px">${t('apitoken.agency_no_playlists')}</p>`}
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary btn-sm" id="saveTargetsBtn">${t('common.save')}</button>
|
<button class="btn btn-primary btn-sm" id="saveTargetsBtn">${t('common.save')}</button>
|
||||||
<button class="btn btn-secondary btn-sm" id="cancelTargetsBtn">${t('common.cancel')}</button>
|
<button class="btn btn-secondary btn-sm" id="cancelTargetsBtn">${t('common.cancel')}</button>
|
||||||
|
|
@ -482,8 +481,8 @@ export async function render(container) {
|
||||||
const pls = await api.getPlaylists().catch(() => []);
|
const pls = await api.getPlaylists().catch(() => []);
|
||||||
list.innerHTML = pls.length
|
list.innerHTML = pls.length
|
||||||
? pls.map(p => p.zoned
|
? pls.map(p => p.zoned
|
||||||
? `<label style="display:flex;gap:8px;align-items:center;font-size:13px;opacity:.5"><input type="checkbox" disabled> ${esc(p.name)} <span style="font-size:11px;color:var(--text-muted)">— ${esc(t('apitoken.zoned_playlist_reason'))}</span></label>`
|
? `<label style="display:flex;gap:8px;align-items:center;font-size:13px;opacity:.5"><input type="checkbox" disabled> ${esc(p.name)} <span style="font-size:11px;color:var(--text-muted)">— ${esc(t('apitoken.zoned_playlist_reason'))}</span></label>`
|
||||||
: `<label style="display:flex;gap:8px;align-items:center;font-size:13px"><input type="checkbox" class="agency-pl" value="${esc(String(p.id))}"> ${esc(p.name)}</label>`).join('')
|
: `<label style="display:flex;gap:8px;align-items:center;font-size:13px"><input type="checkbox" class="agency-pl" value="${esc(String(p.id))}"> ${esc(p.name)}</label>`).join('')
|
||||||
: `<p style="color:var(--text-muted);font-size:12px">${t('apitoken.agency_no_playlists')}</p>`;
|
: `<p style="color:var(--text-muted);font-size:12px">${t('apitoken.agency_no_playlists')}</p>`;
|
||||||
// #158: offer existing folders to bind, or leave on the auto-create default.
|
// #158: offer existing folders to bind, or leave on the auto-create default.
|
||||||
const folders = await api.getFolders().catch(() => []);
|
const folders = await api.getFolders().catch(() => []);
|
||||||
|
|
@ -628,7 +627,7 @@ async function loadWhiteLabel() {
|
||||||
if (wl.favicon_url) document.getElementById('wlFavicon').value = wl.favicon_url;
|
if (wl.favicon_url) document.getElementById('wlFavicon').value = wl.favicon_url;
|
||||||
if (wl.custom_css) document.getElementById('wlCustomCss').value = wl.custom_css;
|
if (wl.custom_css) document.getElementById('wlCustomCss').value = wl.custom_css;
|
||||||
if (wl.hide_branding) document.getElementById('wlHideBranding').checked = true;
|
if (wl.hide_branding) document.getElementById('wlHideBranding').checked = true;
|
||||||
} catch {}
|
} catch { }
|
||||||
|
|
||||||
document.getElementById('saveWhiteLabelBtn')?.addEventListener('click', async () => {
|
document.getElementById('saveWhiteLabelBtn')?.addEventListener('click', async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -779,4 +778,4 @@ async function loadUsers() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cleanup() {}
|
export function cleanup() { }
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,27 +1,105 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Privacy Policy - ScreenTinker</title>
|
<title>Privacy Policy - ScreenTinker</title>
|
||||||
<style>
|
<style>
|
||||||
* { margin:0; padding:0; box-sizing:border-box; }
|
* {
|
||||||
body { font-family:-apple-system,sans-serif; background:#111827; color:#e2e8f0; line-height:1.8; }
|
margin: 0;
|
||||||
.container { max-width:800px; margin:0 auto; padding:40px 24px 80px; }
|
padding: 0;
|
||||||
h1 { color:#3b82f6; font-size:32px; margin-bottom:8px; }
|
box-sizing: border-box;
|
||||||
.updated { color:#64748b; font-size:14px; margin-bottom:40px; }
|
}
|
||||||
h2 { color:#f1f5f9; font-size:20px; margin:32px 0 12px; }
|
|
||||||
h3 { color:#cbd5e1; font-size:16px; margin:20px 0 8px; }
|
body {
|
||||||
p, li { color:#94a3b8; font-size:15px; margin-bottom:12px; }
|
font-family: -apple-system, sans-serif;
|
||||||
ul { padding-left:24px; }
|
background: #111827;
|
||||||
a { color:#3b82f6; }
|
color: #e2e8f0;
|
||||||
.back { display:inline-flex; align-items:center; gap:6px; color:#64748b; font-size:13px; margin-bottom:24px; text-decoration:none; }
|
line-height: 1.8;
|
||||||
.back:hover { color:#94a3b8; }
|
}
|
||||||
table { width:100%; border-collapse:collapse; margin:16px 0; }
|
|
||||||
th, td { padding:10px 12px; text-align:left; border-bottom:1px solid #1e293b; font-size:14px; color:#94a3b8; }
|
.container {
|
||||||
th { color:#cbd5e1; font-weight:600; }
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 24px 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #e65c00;
|
||||||
|
font-size: 32px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.updated {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: #f1f5f9;
|
||||||
|
font-size: 20px;
|
||||||
|
margin: 32px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 20px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p,
|
||||||
|
li {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 15px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #e65c00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back:hover {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #1e293b;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a href="/" class="back">← Back to ScreenTinker</a>
|
<a href="/" class="back">← Back to ScreenTinker</a>
|
||||||
|
|
@ -29,65 +107,157 @@
|
||||||
<p class="updated">Last updated: May 15, 2026</p>
|
<p class="updated">Last updated: May 15, 2026</p>
|
||||||
|
|
||||||
<h2>1. Overview</h2>
|
<h2>1. Overview</h2>
|
||||||
<p>ScreenTinker ("we", "us", "our") respects your privacy. This policy explains what data we collect, how we use it, and your rights regarding your information.</p>
|
<p>ScreenTinker ("we", "us", "our") respects your privacy. This policy explains what data we collect, how we use it,
|
||||||
|
and your rights regarding your information.</p>
|
||||||
|
|
||||||
<h2>2. Information We Collect</h2>
|
<h2>2. Information We Collect</h2>
|
||||||
|
|
||||||
<h3>2.1 Account Information</h3>
|
<h3>2.1 Account Information</h3>
|
||||||
<table>
|
<table>
|
||||||
<tr><th>Data</th><th>Purpose</th><th>Retention</th></tr>
|
<tr>
|
||||||
<tr><td>Email address</td><td>Authentication, notifications</td><td>Until account deletion</td></tr>
|
<th>Data</th>
|
||||||
<tr><td>Name</td><td>Display in dashboard</td><td>Until account deletion</td></tr>
|
<th>Purpose</th>
|
||||||
<tr><td>Password hash</td><td>Authentication (bcrypt, never stored in plain text)</td><td>Until account deletion</td></tr>
|
<th>Retention</th>
|
||||||
<tr><td>OAuth provider ID</td><td>Google/Microsoft sign-in</td><td>Until account deletion</td></tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Email address</td>
|
||||||
|
<td>Authentication, notifications</td>
|
||||||
|
<td>Until account deletion</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Name</td>
|
||||||
|
<td>Display in dashboard</td>
|
||||||
|
<td>Until account deletion</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Password hash</td>
|
||||||
|
<td>Authentication (bcrypt, never stored in plain text)</td>
|
||||||
|
<td>Until account deletion</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>OAuth provider ID</td>
|
||||||
|
<td>Google/Microsoft sign-in</td>
|
||||||
|
<td>Until account deletion</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<h3>2.2 Device Information</h3>
|
<h3>2.2 Device Information</h3>
|
||||||
<table>
|
<table>
|
||||||
<tr><th>Data</th><th>Purpose</th><th>Retention</th></tr>
|
<tr>
|
||||||
<tr><td>Device ID</td><td>Unique device identification</td><td>Until device removal</td></tr>
|
<th>Data</th>
|
||||||
<tr><td>IP address</td><td>Network connectivity, security</td><td>Overwritten each connection</td></tr>
|
<th>Purpose</th>
|
||||||
<tr><td>Android version, screen resolution</td><td>Compatibility, display optimization</td><td>Until device removal</td></tr>
|
<th>Retention</th>
|
||||||
<tr><td>Battery, storage, RAM, CPU, WiFi</td><td>Device health monitoring</td><td>90 days (rolling)</td></tr>
|
</tr>
|
||||||
<tr><td>Device fingerprint (hardware hash)</td><td>Prevent trial abuse</td><td>Until device removal</td></tr>
|
<tr>
|
||||||
|
<td>Device ID</td>
|
||||||
|
<td>Unique device identification</td>
|
||||||
|
<td>Until device removal</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>IP address</td>
|
||||||
|
<td>Network connectivity, security</td>
|
||||||
|
<td>Overwritten each connection</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Android version, screen resolution</td>
|
||||||
|
<td>Compatibility, display optimization</td>
|
||||||
|
<td>Until device removal</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Battery, storage, RAM, CPU, WiFi</td>
|
||||||
|
<td>Device health monitoring</td>
|
||||||
|
<td>90 days (rolling)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Device fingerprint (hardware hash)</td>
|
||||||
|
<td>Prevent trial abuse</td>
|
||||||
|
<td>Until device removal</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<h3>2.3 Usage Data</h3>
|
<h3>2.3 Usage Data</h3>
|
||||||
<table>
|
<table>
|
||||||
<tr><th>Data</th><th>Purpose</th><th>Retention</th></tr>
|
<tr>
|
||||||
<tr><td>Content play logs</td><td>Proof-of-play reporting</td><td>90 days</td></tr>
|
<th>Data</th>
|
||||||
<tr><td>Activity log (API actions)</td><td>Audit trail, security</td><td>90 days</td></tr>
|
<th>Purpose</th>
|
||||||
<tr><td>Screenshots (on-demand)</td><td>Remote monitoring</td><td>Latest only per device</td></tr>
|
<th>Retention</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Content play logs</td>
|
||||||
|
<td>Proof-of-play reporting</td>
|
||||||
|
<td>90 days</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Activity log (API actions)</td>
|
||||||
|
<td>Audit trail, security</td>
|
||||||
|
<td>90 days</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Screenshots (on-demand)</td>
|
||||||
|
<td>Remote monitoring</td>
|
||||||
|
<td>Latest only per device</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<h3>2.4 Content</h3>
|
<h3>2.4 Content</h3>
|
||||||
<p>Media files (images, videos) you upload are stored on our servers solely to deliver them to your devices. We do not analyze, sell, or share your content.</p>
|
<p>Media files (images, videos) you upload are stored on our servers solely to deliver them to your devices. We do
|
||||||
|
not analyze, sell, or share your content.</p>
|
||||||
|
|
||||||
<h3>2.5 Error and Diagnostic Telemetry</h3>
|
<h3>2.5 Error and Diagnostic Telemetry</h3>
|
||||||
<p>Player clients submit error reports automatically so we can fix issues on devices we cannot directly access (smart TVs, embedded signage browsers). Reports may be submitted before a device has paired; in that case only the network IP and user-agent are present, with no device ID.</p>
|
<p>Player clients submit error reports automatically so we can fix issues on devices we cannot directly access
|
||||||
|
(smart TVs, embedded signage browsers). Reports may be submitted before a device has paired; in that case only the
|
||||||
|
network IP and user-agent are present, with no device ID.</p>
|
||||||
<table>
|
<table>
|
||||||
<tr><th>Data</th><th>Purpose</th><th>Retention</th></tr>
|
<tr>
|
||||||
<tr><td>Browser user-agent string</td><td>Identifying which player platforms encounter errors</td><td>Rolling buffer (10,000 entries)</td></tr>
|
<th>Data</th>
|
||||||
<tr><td>Error messages, stack traces, and source-file references</td><td>Diagnosing player issues we cannot reproduce in development</td><td>Rolling buffer (10,000 entries)</td></tr>
|
<th>Purpose</th>
|
||||||
<tr><td>Recent player log entries (up to 50, including filenames of content being played)</td><td>Context for the error so we understand what the player was doing</td><td>Rolling buffer (10,000 entries)</td></tr>
|
<th>Retention</th>
|
||||||
<tr><td>Screen and viewport dimensions</td><td>Diagnosing layout and rendering issues at the device's actual size</td><td>Rolling buffer (10,000 entries)</td></tr>
|
</tr>
|
||||||
<tr><td>Coarse player state (idle, playing, waiting)</td><td>Distinguishing errors during playback vs setup</td><td>Rolling buffer (10,000 entries)</td></tr>
|
<tr>
|
||||||
|
<td>Browser user-agent string</td>
|
||||||
|
<td>Identifying which player platforms encounter errors</td>
|
||||||
|
<td>Rolling buffer (10,000 entries)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Error messages, stack traces, and source-file references</td>
|
||||||
|
<td>Diagnosing player issues we cannot reproduce in development</td>
|
||||||
|
<td>Rolling buffer (10,000 entries)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Recent player log entries (up to 50, including filenames of content being played)</td>
|
||||||
|
<td>Context for the error so we understand what the player was doing</td>
|
||||||
|
<td>Rolling buffer (10,000 entries)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Screen and viewport dimensions</td>
|
||||||
|
<td>Diagnosing layout and rendering issues at the device's actual size</td>
|
||||||
|
<td>Rolling buffer (10,000 entries)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Coarse player state (idle, playing, waiting)</td>
|
||||||
|
<td>Distinguishing errors during playback vs setup</td>
|
||||||
|
<td>Rolling buffer (10,000 entries)</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<p>This telemetry can be disabled by self-hosters via the <code>PLAYER_DEBUG_REPORTING=off</code> environment variable. On screentinker.com it is on by default.</p>
|
<p>This telemetry can be disabled by self-hosters via the <code>PLAYER_DEBUG_REPORTING=off</code> environment
|
||||||
|
variable. On screentinker.com it is on by default.</p>
|
||||||
|
|
||||||
<h2>3. How We Use Your Information</h2>
|
<h2>3. How We Use Your Information</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Provide the Service:</strong> Deliver content to devices, enable remote management, process subscriptions</li>
|
<li><strong>Provide the Service:</strong> Deliver content to devices, enable remote management, process
|
||||||
|
subscriptions</li>
|
||||||
<li><strong>Security:</strong> Detect unauthorized access, prevent abuse, protect accounts</li>
|
<li><strong>Security:</strong> Detect unauthorized access, prevent abuse, protect accounts</li>
|
||||||
<li><strong>Communications:</strong> Send device offline alerts, subscription notifications, service updates</li>
|
<li><strong>Communications:</strong> Send device offline alerts, subscription notifications, service updates</li>
|
||||||
<li><strong>Improvement:</strong> Analyze aggregate usage patterns to improve the Service (no individual tracking)</li>
|
<li><strong>Improvement:</strong> Analyze aggregate usage patterns to improve the Service (no individual tracking)
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>4. Data Sharing</h2>
|
<h2>4. Data Sharing</h2>
|
||||||
<p>We do not sell your personal information. We share data only in these limited circumstances:</p>
|
<p>We do not sell your personal information. We share data only in these limited circumstances:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Service providers:</strong> Payment processing (Stripe), email delivery, hosting infrastructure</li>
|
<li><strong>Service providers:</strong> Payment processing (Stripe), email delivery, hosting infrastructure</li>
|
||||||
<li><strong>Team members:</strong> If you belong to a team, other team members can see shared devices and content</li>
|
<li><strong>Team members:</strong> If you belong to a team, other team members can see shared devices and content
|
||||||
|
</li>
|
||||||
<li><strong>Legal requirements:</strong> When required by law, subpoena, or court order</li>
|
<li><strong>Legal requirements:</strong> When required by law, subpoena, or court order</li>
|
||||||
<li><strong>Business transfers:</strong> In the event of a merger, acquisition, or sale of assets</li>
|
<li><strong>Business transfers:</strong> In the event of a merger, acquisition, or sale of assets</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -97,7 +267,9 @@
|
||||||
<ul>
|
<ul>
|
||||||
<li>All data stays on your servers. We have no access to it.</li>
|
<li>All data stays on your servers. We have no access to it.</li>
|
||||||
<li>You are the data controller and responsible for compliance with applicable privacy laws.</li>
|
<li>You are the data controller and responsible for compliance with applicable privacy laws.</li>
|
||||||
<li>No telemetry or usage data is sent to us from self-hosted instances. The error and diagnostic telemetry described in Section 2.5 is collected by the self-hosted server itself, not transmitted externally, and can be disabled entirely with <code>PLAYER_DEBUG_REPORTING=off</code>.</li>
|
<li>No telemetry or usage data is sent to us from self-hosted instances. The error and diagnostic telemetry
|
||||||
|
described in Section 2.5 is collected by the self-hosted server itself, not transmitted externally, and can be
|
||||||
|
disabled entirely with <code>PLAYER_DEBUG_REPORTING=off</code>.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>6. Data Security</h2>
|
<h2>6. Data Security</h2>
|
||||||
|
|
@ -129,10 +301,12 @@
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>9. Children's Privacy</h2>
|
<h2>9. Children's Privacy</h2>
|
||||||
<p>The Service is not intended for use by children under 13. We do not knowingly collect information from children under 13.</p>
|
<p>The Service is not intended for use by children under 13. We do not knowingly collect information from children
|
||||||
|
under 13.</p>
|
||||||
|
|
||||||
<h2>10. International Data Transfers</h2>
|
<h2>10. International Data Transfers</h2>
|
||||||
<p>If you access the Service from outside the United States, your data may be transferred to and processed in the United States. By using the Service, you consent to this transfer.</p>
|
<p>If you access the Service from outside the United States, your data may be transferred to and processed in the
|
||||||
|
United States. By using the Service, you consent to this transfer.</p>
|
||||||
|
|
||||||
<h2>11. Data Retention</h2>
|
<h2>11. Data Retention</h2>
|
||||||
<ul>
|
<ul>
|
||||||
|
|
@ -140,17 +314,20 @@
|
||||||
<li>Device telemetry: 90 days (automatically pruned)</li>
|
<li>Device telemetry: 90 days (automatically pruned)</li>
|
||||||
<li>Play logs: 90 days (automatically pruned)</li>
|
<li>Play logs: 90 days (automatically pruned)</li>
|
||||||
<li>Activity logs: 90 days (automatically pruned)</li>
|
<li>Activity logs: 90 days (automatically pruned)</li>
|
||||||
<li>Error and diagnostic telemetry: rolling buffer of the 10,000 most recent entries (oldest pruned on insert)</li>
|
<li>Error and diagnostic telemetry: rolling buffer of the 10,000 most recent entries (oldest pruned on insert)
|
||||||
|
</li>
|
||||||
<li>Content: retained until you delete it or your account</li>
|
<li>Content: retained until you delete it or your account</li>
|
||||||
<li>After account deletion: all data removed within 30 days</li>
|
<li>After account deletion: all data removed within 30 days</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>12. Changes to This Policy</h2>
|
<h2>12. Changes to This Policy</h2>
|
||||||
<p>We may update this policy from time to time. We will notify registered users of material changes via email. The "Last updated" date will be revised accordingly.</p>
|
<p>We may update this policy from time to time. We will notify registered users of material changes via email. The
|
||||||
|
"Last updated" date will be revised accordingly.</p>
|
||||||
|
|
||||||
<h2>13. Contact Us</h2>
|
<h2>13. Contact Us</h2>
|
||||||
<p>For privacy-related questions or data requests, contact us at:</p>
|
<p>For privacy-related questions or data requests, contact us at:</p>
|
||||||
<p>Email: support@screentinker.com</p>
|
<p>Email: support@screentinker.com</p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
|
||||||
|
</html>
|
||||||
|
|
@ -1,23 +1,79 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Terms of Service - ScreenTinker</title>
|
<title>Terms of Service - ScreenTinker</title>
|
||||||
<style>
|
<style>
|
||||||
* { margin:0; padding:0; box-sizing:border-box; }
|
* {
|
||||||
body { font-family:-apple-system,sans-serif; background:#111827; color:#e2e8f0; line-height:1.8; }
|
margin: 0;
|
||||||
.container { max-width:800px; margin:0 auto; padding:40px 24px 80px; }
|
padding: 0;
|
||||||
h1 { color:#3b82f6; font-size:32px; margin-bottom:8px; }
|
box-sizing: border-box;
|
||||||
.updated { color:#64748b; font-size:14px; margin-bottom:40px; }
|
}
|
||||||
h2 { color:#f1f5f9; font-size:20px; margin:32px 0 12px; }
|
|
||||||
p, li { color:#94a3b8; font-size:15px; margin-bottom:12px; }
|
body {
|
||||||
ul { padding-left:24px; }
|
font-family: -apple-system, sans-serif;
|
||||||
a { color:#3b82f6; }
|
background: #111827;
|
||||||
.back { display:inline-flex; align-items:center; gap:6px; color:#64748b; font-size:13px; margin-bottom:24px; text-decoration:none; }
|
color: #e2e8f0;
|
||||||
.back:hover { color:#94a3b8; }
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 24px 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #e65c00;
|
||||||
|
font-size: 32px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.updated {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: #f1f5f9;
|
||||||
|
font-size: 20px;
|
||||||
|
margin: 32px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p,
|
||||||
|
li {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 15px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #e65c00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back:hover {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a href="/" class="back">← Back to ScreenTinker</a>
|
<a href="/" class="back">← Back to ScreenTinker</a>
|
||||||
|
|
@ -25,13 +81,16 @@
|
||||||
<p class="updated">Last updated: April 24, 2026</p>
|
<p class="updated">Last updated: April 24, 2026</p>
|
||||||
|
|
||||||
<h2>1. Acceptance of Terms</h2>
|
<h2>1. Acceptance of Terms</h2>
|
||||||
<p>By accessing or using ScreenTinker ("the Service"), you agree to be bound by these Terms of Service. If you do not agree, do not use the Service.</p>
|
<p>By accessing or using ScreenTinker ("the Service"), you agree to be bound by these Terms of Service. If you do
|
||||||
|
not agree, do not use the Service.</p>
|
||||||
|
|
||||||
<h2>2. Description of Service</h2>
|
<h2>2. Description of Service</h2>
|
||||||
<p>ScreenTinker is a digital signage management platform that allows users to remotely manage content on display devices. The Service includes a web dashboard, API, Android application, web player, and related tools.</p>
|
<p>ScreenTinker is a digital signage management platform that allows users to remotely manage content on display
|
||||||
|
devices. The Service includes a web dashboard, API, Android application, web player, and related tools.</p>
|
||||||
|
|
||||||
<h2>3. Accounts</h2>
|
<h2>3. Accounts</h2>
|
||||||
<p>You must provide accurate information when creating an account. You are responsible for maintaining the security of your account credentials. You must notify us immediately of any unauthorized use of your account.</p>
|
<p>You must provide accurate information when creating an account. You are responsible for maintaining the security
|
||||||
|
of your account credentials. You must notify us immediately of any unauthorized use of your account.</p>
|
||||||
|
|
||||||
<h2>4. Subscription Plans and Billing</h2>
|
<h2>4. Subscription Plans and Billing</h2>
|
||||||
<ul>
|
<ul>
|
||||||
|
|
@ -56,13 +115,20 @@
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>5.1 Prohibited Content - Zero Tolerance</h2>
|
<h2>5.1 Prohibited Content - Zero Tolerance</h2>
|
||||||
<p>ScreenTinker maintains zero tolerance for child sexual abuse material (CSAM). Any account found to upload, display, transmit, or facilitate the distribution of such material will be immediately terminated and reported to the National Center for Missing and Exploited Children (NCMEC) and appropriate law enforcement agencies, as required under 18 U.S.C. 2258A.</p>
|
<p>ScreenTinker maintains zero tolerance for child sexual abuse material (CSAM). Any account found to upload,
|
||||||
<p>All account data, upload records, IP logs, and device telemetry associated with such violations will be preserved and provided to law enforcement pursuant to lawful requests. This policy applies to all content uploaded, displayed, or transmitted through the Service, including content on connected devices, in the web player, and through the API.</p>
|
display, transmit, or facilitate the distribution of such material will be immediately terminated and reported to
|
||||||
|
the National Center for Missing and Exploited Children (NCMEC) and appropriate law enforcement agencies, as
|
||||||
|
required under 18 U.S.C. 2258A.</p>
|
||||||
|
<p>All account data, upload records, IP logs, and device telemetry associated with such violations will be preserved
|
||||||
|
and provided to law enforcement pursuant to lawful requests. This policy applies to all content uploaded,
|
||||||
|
displayed, or transmitted through the Service, including content on connected devices, in the web player, and
|
||||||
|
through the API.</p>
|
||||||
|
|
||||||
<h2>6. Content</h2>
|
<h2>6. Content</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>You retain ownership of content you upload to the Service.</li>
|
<li>You retain ownership of content you upload to the Service.</li>
|
||||||
<li>You grant us a limited license to store, transmit, and display your content as necessary to provide the Service.</li>
|
<li>You grant us a limited license to store, transmit, and display your content as necessary to provide the
|
||||||
|
Service.</li>
|
||||||
<li>You are solely responsible for ensuring you have the rights to display any content you upload.</li>
|
<li>You are solely responsible for ensuring you have the rights to display any content you upload.</li>
|
||||||
<li>We may remove content that violates these terms or applicable law.</li>
|
<li>We may remove content that violates these terms or applicable law.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -93,31 +159,49 @@
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>11. Intellectual Property</h2>
|
<h2>11. Intellectual Property</h2>
|
||||||
<p>The ScreenTinker software, dashboard, APIs, and documentation are our intellectual property. Your subscription grants you a license to use the Service, not ownership of the software.</p>
|
<p>The ScreenTinker software, dashboard, APIs, and documentation are our intellectual property. Your subscription
|
||||||
<p>ScreenTinker incorporates open-source software components licensed under MIT and Apache 2.0 licenses. A complete list of third-party software and their licenses is available at our <a href="/legal/third-party.html">Third-Party Software Notices</a> page.</p>
|
grants you a license to use the Service, not ownership of the software.</p>
|
||||||
|
<p>ScreenTinker incorporates open-source software components licensed under MIT and Apache 2.0 licenses. A complete
|
||||||
|
list of third-party software and their licenses is available at our <a href="/legal/third-party.html">Third-Party
|
||||||
|
Software Notices</a> page.</p>
|
||||||
|
|
||||||
<h2>12. Restrictions</h2>
|
<h2>12. Restrictions</h2>
|
||||||
<p>The core ScreenTinker software is open-source and available at <a href="https://github.com/screentinker/screentinker">github.com/screentinker/screentinker</a> under the MIT License. The restrictions in this section apply only to the hosted Service at screentinker.com and to proprietary components that are not published under the MIT License, including the license key generation and validation system, billing infrastructure, and internal deployment tooling.</p>
|
<p>The core ScreenTinker software is open-source and available at <a
|
||||||
<p>In connection with your use of the hosted Service and those proprietary components, you agree not to, and will not permit others to:</p>
|
href="https://github.com/screentinker/screentinker">github.com/screentinker/screentinker</a> under the MIT
|
||||||
|
License. The restrictions in this section apply only to the hosted Service at screentinker.com and to proprietary
|
||||||
|
components that are not published under the MIT License, including the license key generation and validation
|
||||||
|
system, billing infrastructure, and internal deployment tooling.</p>
|
||||||
|
<p>In connection with your use of the hosted Service and those proprietary components, you agree not to, and will
|
||||||
|
not permit others to:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Circumvent, bypass, disable, tamper with, or forge any license keys, authentication, or usage-tracking mechanisms</li>
|
<li>Circumvent, bypass, disable, tamper with, or forge any license keys, authentication, or usage-tracking
|
||||||
|
mechanisms</li>
|
||||||
<li>Share, transfer, or assign your account credentials, license keys, or API tokens to unauthorized parties</li>
|
<li>Share, transfer, or assign your account credentials, license keys, or API tokens to unauthorized parties</li>
|
||||||
<li>Overload, attack, or otherwise abuse the hosted platform or its infrastructure</li>
|
<li>Overload, attack, or otherwise abuse the hosted platform or its infrastructure</li>
|
||||||
<li>Scrape, crawl, or programmatically extract data from the hosted Service beyond the provided API and documented rate limits</li>
|
<li>Scrape, crawl, or programmatically extract data from the hosted Service beyond the provided API and documented
|
||||||
|
rate limits</li>
|
||||||
<li>Operate the hosted Service beyond the scope of your current subscription plan</li>
|
<li>Operate the hosted Service beyond the scope of your current subscription plan</li>
|
||||||
<li>Reverse engineer, decompile, or attempt to derive the source code of proprietary components not published under the MIT License</li>
|
<li>Reverse engineer, decompile, or attempt to derive the source code of proprietary components not published
|
||||||
|
under the MIT License</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>Violation of these restrictions may result in immediate termination of your account and may subject you to legal action. Nothing in this section limits, modifies, or overrides any rights granted to you by the MIT License covering the open-source portion of ScreenTinker, which governs that code exclusively.</p>
|
<p>Violation of these restrictions may result in immediate termination of your account and may subject you to legal
|
||||||
|
action. Nothing in this section limits, modifies, or overrides any rights granted to you by the MIT License
|
||||||
|
covering the open-source portion of ScreenTinker, which governs that code exclusively.</p>
|
||||||
|
|
||||||
<h2>13. License Keys and Self-Hosted Deployments</h2>
|
<h2>13. License Keys and Self-Hosted Deployments</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Self-hosted Enterprise deployments require a valid license key issued by ScreenTinker.</li>
|
<li>Self-hosted Enterprise deployments require a valid license key issued by ScreenTinker.</li>
|
||||||
<li>License keys are non-transferable and tied to a specific organization.</li>
|
<li>License keys are non-transferable and tied to a specific organization.</li>
|
||||||
<li>License keys are issued for a specific term (monthly or annual) and must be renewed to maintain functionality.</li>
|
<li>License keys are issued for a specific term (monthly or annual) and must be renewed to maintain functionality.
|
||||||
<li>Upon expiration of a license key, the Software will enter a grace period of 14 days, during which functionality is preserved but a renewal notice is displayed.</li>
|
</li>
|
||||||
<li>After the grace period, the Software will operate in a limited mode equivalent to the Free plan until a valid license key is provided.</li>
|
<li>Upon expiration of a license key, the Software will enter a grace period of 14 days, during which
|
||||||
<li>Tampering with, bypassing, or forging license keys is strictly prohibited and constitutes a material breach of these terms.</li>
|
functionality is preserved but a renewal notice is displayed.</li>
|
||||||
<li>We reserve the right to remotely verify license key validity and disable functionality for invalid or expired keys.</li>
|
<li>After the grace period, the Software will operate in a limited mode equivalent to the Free plan until a valid
|
||||||
|
license key is provided.</li>
|
||||||
|
<li>Tampering with, bypassing, or forging license keys is strictly prohibited and constitutes a material breach of
|
||||||
|
these terms.</li>
|
||||||
|
<li>We reserve the right to remotely verify license key validity and disable functionality for invalid or expired
|
||||||
|
keys.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>14. Termination</h2>
|
<h2>14. Termination</h2>
|
||||||
|
|
@ -128,18 +212,27 @@
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>15. Limitation of Liability</h2>
|
<h2>15. Limitation of Liability</h2>
|
||||||
<p>THE SERVICE IS PROVIDED "AS IS" WITHOUT WARRANTIES OF ANY KIND. IN NO EVENT SHALL WE BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES ARISING FROM YOUR USE OF THE SERVICE.</p>
|
<p>THE SERVICE IS PROVIDED "AS IS" WITHOUT WARRANTIES OF ANY KIND. IN NO EVENT SHALL WE BE LIABLE FOR ANY INDIRECT,
|
||||||
|
INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES ARISING FROM YOUR USE OF THE SERVICE.</p>
|
||||||
|
|
||||||
<h2>16. Changes to Terms</h2>
|
<h2>16. Changes to Terms</h2>
|
||||||
<p>We may update these terms from time to time. We will notify registered users of material changes via email. Continued use of the Service after changes constitutes acceptance.</p>
|
<p>We may update these terms from time to time. We will notify registered users of material changes via email.
|
||||||
|
Continued use of the Service after changes constitutes acceptance.</p>
|
||||||
|
|
||||||
<h2>17. Governing Law and Jurisdiction</h2>
|
<h2>17. Governing Law and Jurisdiction</h2>
|
||||||
<p>These Terms of Service are governed by and construed in accordance with the laws of the State of Wisconsin, United States, without regard to its conflict of law provisions. Any dispute arising out of or relating to these terms or the Service shall be resolved exclusively in the state or federal courts located in Kenosha County, Wisconsin, and you consent to the personal jurisdiction of those courts.</p>
|
<p>These Terms of Service are governed by and construed in accordance with the laws of the State of Wisconsin,
|
||||||
<p>Users accessing the Service from outside the United States acknowledge that the Service is operated from and governed by the laws of the United States and the State of Wisconsin.</p>
|
United States, without regard to its conflict of law provisions. Any dispute arising out of or relating to these
|
||||||
<p>If any provision of these terms is held invalid or unenforceable by a court of competent jurisdiction, that provision shall be enforced to the maximum extent permissible and the remaining provisions shall remain in full force and effect.</p>
|
terms or the Service shall be resolved exclusively in the state or federal courts located in Kenosha County,
|
||||||
|
Wisconsin, and you consent to the personal jurisdiction of those courts.</p>
|
||||||
|
<p>Users accessing the Service from outside the United States acknowledge that the Service is operated from and
|
||||||
|
governed by the laws of the United States and the State of Wisconsin.</p>
|
||||||
|
<p>If any provision of these terms is held invalid or unenforceable by a court of competent jurisdiction, that
|
||||||
|
provision shall be enforced to the maximum extent permissible and the remaining provisions shall remain in full
|
||||||
|
force and effect.</p>
|
||||||
|
|
||||||
<h2>18. Contact</h2>
|
<h2>18. Contact</h2>
|
||||||
<p>For questions about these terms, contact us at support@screentinker.com</p>
|
<p>For questions about these terms, contact us at support@screentinker.com</p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
|
||||||
|
</html>
|
||||||
|
|
@ -1,58 +1,227 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Third-Party Licenses - ScreenTinker</title>
|
<title>Third-Party Licenses - ScreenTinker</title>
|
||||||
<style>
|
<style>
|
||||||
* { margin:0; padding:0; box-sizing:border-box; }
|
* {
|
||||||
body { font-family:-apple-system,sans-serif; background:#111827; color:#e2e8f0; line-height:1.8; }
|
margin: 0;
|
||||||
.container { max-width:800px; margin:0 auto; padding:40px 24px 80px; }
|
padding: 0;
|
||||||
h1 { color:#3b82f6; font-size:32px; margin-bottom:8px; }
|
box-sizing: border-box;
|
||||||
.updated { color:#64748b; font-size:14px; margin-bottom:40px; }
|
}
|
||||||
h2 { color:#f1f5f9; font-size:18px; margin:32px 0 8px; }
|
|
||||||
p, li { color:#94a3b8; font-size:14px; margin-bottom:8px; }
|
body {
|
||||||
a { color:#3b82f6; }
|
font-family: -apple-system, sans-serif;
|
||||||
.back { display:inline-flex; align-items:center; gap:6px; color:#64748b; font-size:13px; margin-bottom:24px; text-decoration:none; }
|
background: #111827;
|
||||||
.back:hover { color:#94a3b8; }
|
color: #e2e8f0;
|
||||||
.license-block { background:#0f172a; border:1px solid #1e293b; border-radius:8px; padding:16px; margin:12px 0 24px; font-size:12px; font-family:monospace; white-space:pre-wrap; color:#64748b; overflow-x:auto; }
|
line-height: 1.8;
|
||||||
table { width:100%; border-collapse:collapse; margin:16px 0 32px; }
|
}
|
||||||
th, td { padding:10px 12px; text-align:left; border-bottom:1px solid #1e293b; font-size:13px; color:#94a3b8; }
|
|
||||||
th { color:#cbd5e1; font-weight:600; }
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 24px 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #e65c00;
|
||||||
|
font-size: 32px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.updated {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: #f1f5f9;
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 32px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p,
|
||||||
|
li {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #e65c00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back:hover {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-block {
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin: 12px 0 24px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
color: #64748b;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 16px 0 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #1e293b;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a href="/" class="back">← Back to ScreenTinker</a>
|
<a href="/" class="back">← Back to ScreenTinker</a>
|
||||||
<h1>Third-Party Software Notices</h1>
|
<h1>Third-Party Software Notices</h1>
|
||||||
<p class="updated">Last updated: March 24, 2026</p>
|
<p class="updated">Last updated: March 24, 2026</p>
|
||||||
|
|
||||||
<p>ScreenTinker uses the following open-source software components. We gratefully acknowledge the contributions of these projects and their maintainers.</p>
|
<p>ScreenTinker uses the following open-source software components. We gratefully acknowledge the contributions of
|
||||||
|
these projects and their maintainers.</p>
|
||||||
|
|
||||||
<h2>Summary</h2>
|
<h2>Summary</h2>
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Package</th><th>License</th><th>Use</th></tr></thead>
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Package</th>
|
||||||
|
<th>License</th>
|
||||||
|
<th>Use</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td>Express</td><td>MIT</td><td>Web server framework</td></tr>
|
<tr>
|
||||||
<tr><td>Socket.IO</td><td>MIT</td><td>Real-time WebSocket communication</td></tr>
|
<td>Express</td>
|
||||||
<tr><td>better-sqlite3</td><td>MIT</td><td>SQLite database driver</td></tr>
|
<td>MIT</td>
|
||||||
<tr><td>Multer</td><td>MIT</td><td>File upload handling</td></tr>
|
<td>Web server framework</td>
|
||||||
<tr><td>uuid</td><td>MIT</td><td>Unique ID generation</td></tr>
|
</tr>
|
||||||
<tr><td>Sharp</td><td>Apache 2.0</td><td>Image processing and thumbnails</td></tr>
|
<tr>
|
||||||
<tr><td>cors</td><td>MIT</td><td>Cross-origin resource sharing</td></tr>
|
<td>Socket.IO</td>
|
||||||
<tr><td>bcryptjs</td><td>MIT</td><td>Password hashing</td></tr>
|
<td>MIT</td>
|
||||||
<tr><td>jsonwebtoken</td><td>MIT</td><td>JWT authentication tokens</td></tr>
|
<td>Real-time WebSocket communication</td>
|
||||||
<tr><td>Helmet</td><td>MIT</td><td>HTTP security headers</td></tr>
|
</tr>
|
||||||
<tr><td>google-auth-library</td><td>Apache 2.0</td><td>Google OAuth verification</td></tr>
|
<tr>
|
||||||
<tr><td>OkHttp</td><td>Apache 2.0</td><td>Android HTTP client</td></tr>
|
<td>better-sqlite3</td>
|
||||||
<tr><td>Gson</td><td>Apache 2.0</td><td>Android JSON parsing</td></tr>
|
<td>MIT</td>
|
||||||
<tr><td>AndroidX Media3 / ExoPlayer</td><td>Apache 2.0</td><td>Android video playback</td></tr>
|
<td>SQLite database driver</td>
|
||||||
<tr><td>AndroidX libraries</td><td>Apache 2.0</td><td>Android UI and lifecycle</td></tr>
|
</tr>
|
||||||
<tr><td>Material Components for Android</td><td>Apache 2.0</td><td>Android UI components</td></tr>
|
<tr>
|
||||||
<tr><td>Socket.IO Java Client</td><td>MIT</td><td>Android WebSocket client</td></tr>
|
<td>Multer</td>
|
||||||
<tr><td>Kotlin Coroutines</td><td>Apache 2.0</td><td>Android async operations</td></tr>
|
<td>MIT</td>
|
||||||
<tr><td>AndroidX Security Crypto</td><td>Apache 2.0</td><td>Encrypted SharedPreferences</td></tr>
|
<td>File upload handling</td>
|
||||||
<tr><td>AndroidX WorkManager</td><td>Apache 2.0</td><td>Background task management</td></tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>uuid</td>
|
||||||
|
<td>MIT</td>
|
||||||
|
<td>Unique ID generation</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Sharp</td>
|
||||||
|
<td>Apache 2.0</td>
|
||||||
|
<td>Image processing and thumbnails</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>cors</td>
|
||||||
|
<td>MIT</td>
|
||||||
|
<td>Cross-origin resource sharing</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>bcryptjs</td>
|
||||||
|
<td>MIT</td>
|
||||||
|
<td>Password hashing</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>jsonwebtoken</td>
|
||||||
|
<td>MIT</td>
|
||||||
|
<td>JWT authentication tokens</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Helmet</td>
|
||||||
|
<td>MIT</td>
|
||||||
|
<td>HTTP security headers</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>google-auth-library</td>
|
||||||
|
<td>Apache 2.0</td>
|
||||||
|
<td>Google OAuth verification</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>OkHttp</td>
|
||||||
|
<td>Apache 2.0</td>
|
||||||
|
<td>Android HTTP client</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Gson</td>
|
||||||
|
<td>Apache 2.0</td>
|
||||||
|
<td>Android JSON parsing</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>AndroidX Media3 / ExoPlayer</td>
|
||||||
|
<td>Apache 2.0</td>
|
||||||
|
<td>Android video playback</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>AndroidX libraries</td>
|
||||||
|
<td>Apache 2.0</td>
|
||||||
|
<td>Android UI and lifecycle</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Material Components for Android</td>
|
||||||
|
<td>Apache 2.0</td>
|
||||||
|
<td>Android UI components</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Socket.IO Java Client</td>
|
||||||
|
<td>MIT</td>
|
||||||
|
<td>Android WebSocket client</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Kotlin Coroutines</td>
|
||||||
|
<td>Apache 2.0</td>
|
||||||
|
<td>Android async operations</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>AndroidX Security Crypto</td>
|
||||||
|
<td>Apache 2.0</td>
|
||||||
|
<td>Encrypted SharedPreferences</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>AndroidX WorkManager</td>
|
||||||
|
<td>Apache 2.0</td>
|
||||||
|
<td>Background task management</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|
@ -61,47 +230,50 @@
|
||||||
<p>Express, Socket.IO, better-sqlite3, Multer, uuid, cors, bcryptjs, jsonwebtoken, Helmet, Socket.IO Java Client</p>
|
<p>Express, Socket.IO, better-sqlite3, Multer, uuid, cors, bcryptjs, jsonwebtoken, Helmet, Socket.IO Java Client</p>
|
||||||
<div class="license-block">MIT License
|
<div class="license-block">MIT License
|
||||||
|
|
||||||
Copyright (c) respective authors and contributors
|
Copyright (c) respective authors and contributors
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
in the Software without restriction, including without limitation the rights
|
in the Software without restriction, including without limitation the rights
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
furnished to do so, subject to the following conditions:
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
The above copyright notice and this permission notice shall be included in all
|
||||||
copies or substantial portions of the Software.
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.</div>
|
SOFTWARE.</div>
|
||||||
|
|
||||||
<h2>Apache License 2.0</h2>
|
<h2>Apache License 2.0</h2>
|
||||||
<p>The following packages are licensed under the Apache License, Version 2.0:</p>
|
<p>The following packages are licensed under the Apache License, Version 2.0:</p>
|
||||||
<p>Sharp, google-auth-library, OkHttp, Gson, AndroidX Media3/ExoPlayer, AndroidX libraries, Material Components for Android, Kotlin Coroutines, AndroidX Security Crypto, AndroidX WorkManager</p>
|
<p>Sharp, google-auth-library, OkHttp, Gson, AndroidX Media3/ExoPlayer, AndroidX libraries, Material Components for
|
||||||
|
Android, Kotlin Coroutines, AndroidX Security Crypto, AndroidX WorkManager</p>
|
||||||
<div class="license-block">Apache License
|
<div class="license-block">Apache License
|
||||||
Version 2.0, January 2004
|
Version 2.0, January 2004
|
||||||
http://www.apache.org/licenses/
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
You may obtain a copy of the License at
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
Unless required by applicable law or agreed to in writing, software
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
See the License for the specific language governing permissions and
|
See the License for the specific language governing permissions and
|
||||||
limitations under the License.</div>
|
limitations under the License.</div>
|
||||||
|
|
||||||
<h2>Contact</h2>
|
<h2>Contact</h2>
|
||||||
<p>If you have questions about the licensing of any component used in ScreenTinker, please contact us at support@screentinker.com</p>
|
<p>If you have questions about the licensing of any component used in ScreenTinker, please contact us at
|
||||||
|
support@screentinker.com</p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
|
||||||
|
</html>
|
||||||
36
scripts/create_admin.js
Normal file
36
scripts/create_admin.js
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
const { db } = require('../server/db/database');
|
||||||
|
const bcrypt = require('../server/node_modules/bcryptjs');
|
||||||
|
const { v4: uuidv4 } = require('../server/node_modules/uuid');
|
||||||
|
|
||||||
|
const email = 'ivandro.work@gmail.com';
|
||||||
|
const password = '#whatever';
|
||||||
|
const name = 'Ivandro Alves';
|
||||||
|
const hash = bcrypt.hashSync(password, 10);
|
||||||
|
const userId = uuidv4();
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.prepare('INSERT INTO users (id, email, password_hash, name, role) VALUES (?, ?, ?, ?, ?)').run(userId, email, hash, name, 'platform_admin');
|
||||||
|
|
||||||
|
// also create an organization and workspace for them
|
||||||
|
const orgId = uuidv4();
|
||||||
|
const workspaceId = uuidv4();
|
||||||
|
|
||||||
|
db.prepare('INSERT INTO organizations (id, name, owner_id) VALUES (?, ?, ?)').run(orgId, name + " Org", userId);
|
||||||
|
db.prepare('INSERT INTO workspaces (id, name, organization_id) VALUES (?, ?, ?)').run(workspaceId, 'Default Workspace', orgId);
|
||||||
|
db.prepare('INSERT INTO organization_members (organization_id, user_id, role) VALUES (?, ?, ?)').run(orgId, userId, 'admin');
|
||||||
|
db.prepare('INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)').run(workspaceId, userId, 'admin');
|
||||||
|
|
||||||
|
const d = require('../server/db/database').dbOptions;
|
||||||
|
if (d.syncUrl) db.sync();
|
||||||
|
|
||||||
|
console.log('Admin created successfully.');
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes('UNIQUE constraint failed')) {
|
||||||
|
console.log('User already exists, updating password and role.');
|
||||||
|
db.prepare('UPDATE users SET password_hash = ?, role = ? WHERE email = ?').run(hash, 'platform_admin', email);
|
||||||
|
const d = require('../server/db/database').dbOptions;
|
||||||
|
if (d.syncUrl) db.sync();
|
||||||
|
} else {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
server/create_admin.js
Normal file
36
server/create_admin.js
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
const { db } = require('./db/database');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const email = 'ivandro.work@gmail.com';
|
||||||
|
const password = '#whatever';
|
||||||
|
const name = 'Ivandro Alves';
|
||||||
|
const hash = bcrypt.hashSync(password, 10);
|
||||||
|
const userId = crypto.randomUUID();
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.prepare('INSERT INTO users (id, email, password_hash, name, role) VALUES (?, ?, ?, ?, ?)').run(userId, email, hash, name, 'platform_admin');
|
||||||
|
|
||||||
|
// also create an organization and workspace for them
|
||||||
|
const orgId = crypto.randomUUID();
|
||||||
|
const workspaceId = crypto.randomUUID();
|
||||||
|
|
||||||
|
db.prepare('INSERT INTO organizations (id, name, owner_id) VALUES (?, ?, ?)').run(orgId, name + " Org", userId);
|
||||||
|
db.prepare('INSERT INTO workspaces (id, name, organization_id) VALUES (?, ?, ?)').run(workspaceId, 'Default Workspace', orgId);
|
||||||
|
db.prepare('INSERT INTO organization_members (organization_id, user_id, role) VALUES (?, ?, ?)').run(orgId, userId, 'admin');
|
||||||
|
db.prepare('INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)').run(workspaceId, userId, 'admin');
|
||||||
|
|
||||||
|
const d = require('./db/database').dbOptions;
|
||||||
|
if (d.syncUrl) db.sync();
|
||||||
|
|
||||||
|
console.log('Admin created successfully.');
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes('UNIQUE constraint failed')) {
|
||||||
|
console.log('User already exists, updating password and role.');
|
||||||
|
db.prepare('UPDATE users SET password_hash = ?, role = ? WHERE email = ?').run(hash, 'platform_admin', email);
|
||||||
|
const d = require('./db/database').dbOptions;
|
||||||
|
if (d.syncUrl) db.sync();
|
||||||
|
} else {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
server/create_admin2.js
Normal file
10
server/create_admin2.js
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
const { db } = require('./db/database');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
|
||||||
|
const email = 'ivandro.work@gmail.com';
|
||||||
|
const password = '#whatever';
|
||||||
|
const hash = bcrypt.hashSync(password, 10);
|
||||||
|
|
||||||
|
db.prepare('UPDATE users SET password_hash = ?, role = ? WHERE email = ?').run(hash, 'platform_admin', email);
|
||||||
|
if (typeof db.sync === 'function') db.sync();
|
||||||
|
console.log('Password and role updated');
|
||||||
|
|
@ -617,7 +617,7 @@ function migrateGroupSchedules() {
|
||||||
recurrence_end TEXT,
|
recurrence_end TEXT,
|
||||||
priority INTEGER NOT NULL DEFAULT 0,
|
priority INTEGER NOT NULL DEFAULT 0,
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
color TEXT DEFAULT '#3B82F6',
|
color TEXT DEFAULT '#e65c00',
|
||||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
CHECK ((device_id IS NOT NULL AND group_id IS NULL) OR (device_id IS NULL AND group_id IS NOT NULL))
|
CHECK ((device_id IS NOT NULL AND group_id IS NULL) OR (device_id IS NULL AND group_id IS NOT NULL))
|
||||||
|
|
@ -927,7 +927,7 @@ async function pruneStatusLog(opts = {}) {
|
||||||
const delCap = cap > 0 ? db.prepare('DELETE FROM device_status_log WHERE rowid IN (SELECT rowid FROM device_status_log WHERE device_id = ? ORDER BY timestamp DESC, id DESC LIMIT ? OFFSET ?)') : null;
|
const delCap = cap > 0 ? db.prepare('DELETE FROM device_status_log WHERE rowid IN (SELECT rowid FROM device_status_log WHERE device_id = ? ORDER BY timestamp DESC, id DESC LIMIT ? OFFSET ?)') : null;
|
||||||
|
|
||||||
let total = 0, lastDev = '';
|
let total = 0, lastDev = '';
|
||||||
for (;;) {
|
for (; ;) {
|
||||||
const row = nextDevice.get(lastDev); // O(log n) index seek to next distinct device_id
|
const row = nextDevice.get(lastDev); // O(log n) index seek to next distinct device_id
|
||||||
if (!row) break;
|
if (!row) break;
|
||||||
lastDev = row.device_id;
|
lastDev = row.device_id;
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -229,7 +229,7 @@ CREATE TABLE IF NOT EXISTS schedules (
|
||||||
recurrence_end TEXT,
|
recurrence_end TEXT,
|
||||||
priority INTEGER NOT NULL DEFAULT 0,
|
priority INTEGER NOT NULL DEFAULT 0,
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
color TEXT DEFAULT '#3B82F6',
|
color TEXT DEFAULT '#e65c00',
|
||||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
CHECK ((device_id IS NOT NULL AND group_id IS NULL) OR (device_id IS NULL AND group_id IS NOT NULL))
|
CHECK ((device_id IS NOT NULL AND group_id IS NULL) OR (device_id IS NULL AND group_id IS NOT NULL))
|
||||||
|
|
@ -336,7 +336,7 @@ CREATE TABLE IF NOT EXISTS device_groups (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL REFERENCES users(id),
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
color TEXT DEFAULT '#3B82F6',
|
color TEXT DEFAULT '#e65c00',
|
||||||
playlist_id TEXT REFERENCES playlists(id) ON DELETE SET NULL,
|
playlist_id TEXT REFERENCES playlists(id) ON DELETE SET NULL,
|
||||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||||
);
|
);
|
||||||
|
|
@ -419,7 +419,7 @@ CREATE TABLE IF NOT EXISTS white_labels (
|
||||||
brand_name TEXT NOT NULL DEFAULT 'ScreenTinker',
|
brand_name TEXT NOT NULL DEFAULT 'ScreenTinker',
|
||||||
logo_url TEXT,
|
logo_url TEXT,
|
||||||
favicon_url TEXT,
|
favicon_url TEXT,
|
||||||
primary_color TEXT DEFAULT '#3B82F6',
|
primary_color TEXT DEFAULT '#e65c00',
|
||||||
secondary_color TEXT DEFAULT '#1E293B',
|
secondary_color TEXT DEFAULT '#1E293B',
|
||||||
bg_color TEXT DEFAULT '#111827',
|
bg_color TEXT DEFAULT '#111827',
|
||||||
custom_domain TEXT,
|
custom_domain TEXT,
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,10 @@ function startWalCheckpointer(db, dbPath) {
|
||||||
respawnAt.length = 0;
|
respawnAt.length = 0;
|
||||||
|
|
||||||
// From now on the main thread NEVER inline-checkpoints (removes the loop-blocking fsync).
|
// From now on the main thread NEVER inline-checkpoints (removes the loop-blocking fsync).
|
||||||
db.pragma('wal_autocheckpoint = 0');
|
try { db.pragma('wal_autocheckpoint = 0'); } catch (e) {
|
||||||
|
if (!e.message.includes('Sqlite3UnsupportedStatement')) throw e;
|
||||||
|
}
|
||||||
|
|
||||||
// Hand the worker a clean WAL (one-time, at boot; explicit checkpoints are independent of
|
// Hand the worker a clean WAL (one-time, at boot; explicit checkpoints are independent of
|
||||||
// wal_autocheckpoint, so this still works at 0). Also reclaims any WAL a prior crash left.
|
// wal_autocheckpoint, so this still works at 0). Also reclaims any WAL a prior crash left.
|
||||||
try { db.pragma('wal_checkpoint(TRUNCATE)'); } catch (_) { /* best-effort */ }
|
try { db.pragma('wal_checkpoint(TRUNCATE)'); } catch (_) { /* best-effort */ }
|
||||||
|
|
|
||||||
9
server/fix_branding.js
Normal file
9
server/fix_branding.js
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
const { db } = require('./db/database');
|
||||||
|
try {
|
||||||
|
db.prepare('DELETE FROM white_labels').run();
|
||||||
|
const d = require('./db/database').dbOptions;
|
||||||
|
if (d && typeof db.sync === 'function') db.sync();
|
||||||
|
console.log('Cleared old branding overrides, server will use the new HARDCODED_BRANDING.');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
|
@ -21,10 +21,10 @@
|
||||||
const PLATFORM_DEFAULT_ID = 'platform-default';
|
const PLATFORM_DEFAULT_ID = 'platform-default';
|
||||||
|
|
||||||
const HARDCODED_BRANDING = {
|
const HARDCODED_BRANDING = {
|
||||||
brand_name: 'ScreenTinker',
|
brand_name: 'GoldenSignage',
|
||||||
logo_url: null,
|
logo_url: '/assets/logoswift.png',
|
||||||
favicon_url: null,
|
favicon_url: '/assets/pwa-icon.png',
|
||||||
primary_color: '#3B82F6',
|
primary_color: '#ff6b00',
|
||||||
secondary_color: '#1E293B',
|
secondary_color: '#1E293B',
|
||||||
bg_color: '#111827',
|
bg_color: '#111827',
|
||||||
custom_css: null,
|
custom_css: null,
|
||||||
|
|
|
||||||
353
server/package-lock.json
generated
353
server/package-lock.json
generated
|
|
@ -11,7 +11,6 @@
|
||||||
"@azure/msal-node": "^5.2.1",
|
"@azure/msal-node": "^5.2.1",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^11.1.2",
|
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^8.3.1",
|
"express-rate-limit": "^8.3.1",
|
||||||
|
|
@ -917,17 +916,6 @@
|
||||||
"bcrypt": "bin/bcrypt"
|
"bcrypt": "bin/bcrypt"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/better-sqlite3": {
|
|
||||||
"version": "11.10.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
|
|
||||||
"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"bindings": "^1.5.0",
|
|
||||||
"prebuild-install": "^7.1.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/bignumber.js": {
|
"node_modules/bignumber.js": {
|
||||||
"version": "9.3.1",
|
"version": "9.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
||||||
|
|
@ -937,64 +925,6 @@
|
||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bindings": {
|
|
||||||
"version": "1.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
|
||||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"file-uri-to-path": "1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/bl": {
|
|
||||||
"version": "4.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
|
||||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"buffer": "^5.5.0",
|
|
||||||
"inherits": "^2.0.4",
|
|
||||||
"readable-stream": "^3.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/bl/node_modules/buffer": {
|
|
||||||
"version": "5.7.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
|
||||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"base64-js": "^1.3.1",
|
|
||||||
"ieee754": "^1.1.13"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/bl/node_modules/readable-stream": {
|
|
||||||
"version": "3.6.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
|
||||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"inherits": "^2.0.3",
|
|
||||||
"string_decoder": "^1.1.1",
|
|
||||||
"util-deprecate": "^1.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/bluebird": {
|
"node_modules/bluebird": {
|
||||||
"version": "3.7.2",
|
"version": "3.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
|
||||||
|
|
@ -1137,12 +1067,6 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/chownr": {
|
|
||||||
"version": "1.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
|
||||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/cliui": {
|
"node_modules/cliui": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||||
|
|
@ -1451,30 +1375,6 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/decompress-response": {
|
|
||||||
"version": "6.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
|
||||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"mimic-response": "^3.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/deep-extend": {
|
|
||||||
"version": "0.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
|
||||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
|
|
@ -1598,15 +1498,6 @@
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/end-of-stream": {
|
|
||||||
"version": "1.4.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
|
||||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"once": "^1.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/engine.io": {
|
"node_modules/engine.io": {
|
||||||
"version": "6.6.9",
|
"version": "6.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz",
|
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz",
|
||||||
|
|
@ -1771,15 +1662,6 @@
|
||||||
"bare-events": "^2.7.0"
|
"bare-events": "^2.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expand-template": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
|
||||||
"license": "(MIT OR WTFPL)",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/express": {
|
"node_modules/express": {
|
||||||
"version": "4.22.2",
|
"version": "4.22.2",
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||||
|
|
@ -1903,12 +1785,6 @@
|
||||||
"node": "^12.20 || >= 14.13"
|
"node": "^12.20 || >= 14.13"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/file-uri-to-path": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/finalhandler": {
|
"node_modules/finalhandler": {
|
||||||
"version": "1.3.2",
|
"version": "1.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||||
|
|
@ -1986,12 +1862,6 @@
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fs-constants": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/fs-extra": {
|
"node_modules/fs-extra": {
|
||||||
"version": "11.3.1",
|
"version": "11.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz",
|
||||||
|
|
@ -2089,12 +1959,6 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/github-from-package": {
|
|
||||||
"version": "0.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
|
||||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/glob": {
|
"node_modules/glob": {
|
||||||
"version": "10.5.0",
|
"version": "10.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||||
|
|
@ -2290,12 +2154,6 @@
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ini": {
|
|
||||||
"version": "1.3.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
|
||||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.2.0",
|
"version": "10.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||||
|
|
@ -2679,18 +2537,6 @@
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mimic-response": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "9.0.9",
|
"version": "9.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||||
|
|
@ -2736,12 +2582,6 @@
|
||||||
"mkdirp": "bin/cmd.js"
|
"mkdirp": "bin/cmd.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mkdirp-classic": {
|
|
||||||
"version": "0.5.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
|
||||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
|
|
@ -2767,12 +2607,6 @@
|
||||||
"node": ">= 6.0.0"
|
"node": ">= 6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/napi-build-utils": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/negotiator": {
|
"node_modules/negotiator": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
|
|
@ -2782,18 +2616,6 @@
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/node-abi": {
|
|
||||||
"version": "3.94.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz",
|
|
||||||
"integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"semver": "^7.3.5"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/node-domexception": {
|
"node_modules/node-domexception": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||||
|
|
@ -2889,15 +2711,6 @@
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/once": {
|
|
||||||
"version": "1.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
|
||||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"wrappy": "1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/otplib": {
|
"node_modules/otplib": {
|
||||||
"version": "12.0.1",
|
"version": "12.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz",
|
||||||
|
|
@ -3009,33 +2822,6 @@
|
||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prebuild-install": {
|
|
||||||
"version": "7.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
|
||||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
|
||||||
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"detect-libc": "^2.0.0",
|
|
||||||
"expand-template": "^2.0.3",
|
|
||||||
"github-from-package": "0.0.0",
|
|
||||||
"minimist": "^1.2.3",
|
|
||||||
"mkdirp-classic": "^0.5.3",
|
|
||||||
"napi-build-utils": "^2.0.0",
|
|
||||||
"node-abi": "^3.3.0",
|
|
||||||
"pump": "^3.0.0",
|
|
||||||
"rc": "^1.2.7",
|
|
||||||
"simple-get": "^4.0.0",
|
|
||||||
"tar-fs": "^2.0.0",
|
|
||||||
"tunnel-agent": "^0.6.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"prebuild-install": "bin.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/process": {
|
"node_modules/process": {
|
||||||
"version": "0.11.10",
|
"version": "0.11.10",
|
||||||
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||||
|
|
@ -3064,16 +2850,6 @@
|
||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pump": {
|
|
||||||
"version": "3.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
|
||||||
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"end-of-stream": "^1.1.0",
|
|
||||||
"once": "^1.3.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/qrcode": {
|
"node_modules/qrcode": {
|
||||||
"version": "1.5.4",
|
"version": "1.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
|
|
@ -3131,21 +2907,6 @@
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rc": {
|
|
||||||
"version": "1.2.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
|
||||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
|
||||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
|
||||||
"dependencies": {
|
|
||||||
"deep-extend": "^0.6.0",
|
|
||||||
"ini": "~1.3.0",
|
|
||||||
"minimist": "^1.2.0",
|
|
||||||
"strip-json-comments": "~2.0.1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"rc": "cli.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/readable-stream": {
|
"node_modules/readable-stream": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
||||||
|
|
@ -3437,51 +3198,6 @@
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/simple-concat": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/simple-get": {
|
|
||||||
"version": "4.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
|
||||||
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"decompress-response": "^6.0.0",
|
|
||||||
"once": "^1.3.1",
|
|
||||||
"simple-concat": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/simple-swizzle": {
|
"node_modules/simple-swizzle": {
|
||||||
"version": "0.2.4",
|
"version": "0.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
|
||||||
|
|
@ -3775,15 +3491,6 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/strip-json-comments": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/stripe": {
|
"node_modules/stripe": {
|
||||||
"version": "20.4.1",
|
"version": "20.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/stripe/-/stripe-20.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/stripe/-/stripe-20.4.1.tgz",
|
||||||
|
|
@ -3801,48 +3508,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tar-fs": {
|
|
||||||
"version": "2.1.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
|
|
||||||
"integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"chownr": "^1.1.1",
|
|
||||||
"mkdirp-classic": "^0.5.2",
|
|
||||||
"pump": "^3.0.0",
|
|
||||||
"tar-stream": "^2.1.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tar-fs/node_modules/readable-stream": {
|
|
||||||
"version": "3.6.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
|
||||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"inherits": "^2.0.3",
|
|
||||||
"string_decoder": "^1.1.1",
|
|
||||||
"util-deprecate": "^1.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tar-fs/node_modules/tar-stream": {
|
|
||||||
"version": "2.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
|
||||||
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"bl": "^4.0.3",
|
|
||||||
"end-of-stream": "^1.4.1",
|
|
||||||
"fs-constants": "^1.0.0",
|
|
||||||
"inherits": "^2.0.3",
|
|
||||||
"readable-stream": "^3.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tar-stream": {
|
"node_modules/tar-stream": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz",
|
||||||
|
|
@ -3897,18 +3562,6 @@
|
||||||
"license": "0BSD",
|
"license": "0BSD",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/tunnel-agent": {
|
|
||||||
"version": "0.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
|
||||||
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"safe-buffer": "^5.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/type-is": {
|
"node_modules/type-is": {
|
||||||
"version": "1.6.18",
|
"version": "1.6.18",
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||||
|
|
@ -4123,12 +3776,6 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/wrappy": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.21.1",
|
"version": "8.21.1",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@
|
||||||
"@azure/msal-node": "^5.2.1",
|
"@azure/msal-node": "^5.2.1",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^11.1.2",
|
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^8.3.1",
|
"express-rate-limit": "^8.3.1",
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -351,7 +351,7 @@ router.put('/branding', requirePlatformAdmin, (req, res) => {
|
||||||
PLATFORM_DEFAULT_ID, req.user.id,
|
PLATFORM_DEFAULT_ID, req.user.id,
|
||||||
req.body.brand_name || 'ScreenTinker',
|
req.body.brand_name || 'ScreenTinker',
|
||||||
req.body.logo_url || null, req.body.favicon_url || null,
|
req.body.logo_url || null, req.body.favicon_url || null,
|
||||||
req.body.primary_color || '#3B82F6', req.body.secondary_color || '#1E293B', req.body.bg_color || '#111827',
|
req.body.primary_color || '#e65c00', req.body.secondary_color || '#1E293B', req.body.bg_color || '#111827',
|
||||||
req.body.custom_css || null, req.body.hide_branding ? 1 : 0
|
req.body.custom_css || null, req.body.hide_branding ? 1 : 0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ function normalizeDesign(raw) {
|
||||||
x: Math.min(clampN(e.x, 0, 100, 0), 100 - w),
|
x: Math.min(clampN(e.x, 0, 100, 0), 100 - w),
|
||||||
y: Math.min(clampN(e.y, 0, 100, 0), 100 - h),
|
y: Math.min(clampN(e.y, 0, 100, 0), 100 - h),
|
||||||
width: w, height: h,
|
width: w, height: h,
|
||||||
color: hex(e.color, '#3b82f6'), opacity: clampN(e.opacity, 0, 1, 0.85), radius: 0,
|
color: hex(e.color, '#e65c00'), opacity: clampN(e.opacity, 0, 1, 0.85), radius: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ router.post('/', (req, res) => {
|
||||||
if (color && !VALID_COLOR.test(color)) return res.status(400).json({ error: 'invalid color format, use #RRGGBB' });
|
if (color && !VALID_COLOR.test(color)) return res.status(400).json({ error: 'invalid color format, use #RRGGBB' });
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
db.prepare('INSERT INTO device_groups (id, user_id, workspace_id, name, color) VALUES (?, ?, ?, ?, ?)')
|
db.prepare('INSERT INTO device_groups (id, user_id, workspace_id, name, color) VALUES (?, ?, ?, ?, ?)')
|
||||||
.run(id, req.user.id, req.workspaceId, name, color || '#3B82F6');
|
.run(id, req.user.id, req.workspaceId, name, color || '#e65c00');
|
||||||
res.status(201).json(db.prepare('SELECT * FROM device_groups WHERE id = ?').get(id));
|
res.status(201).json(db.prepare('SELECT * FROM device_groups WHERE id = ?').get(id));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ router.get('/:id/render', (req, res) => {
|
||||||
text-align:center; cursor:pointer; transition:all 0.2s ease; touch-action:manipulation;
|
text-align:center; cursor:pointer; transition:all 0.2s ease; touch-action:manipulation;
|
||||||
display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px;
|
display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px;
|
||||||
}
|
}
|
||||||
.kiosk-btn:hover, .kiosk-btn:active { background:${safeColor(style.buttonHover, '#3b82f6')}; border-color:${safeColor(style.buttonHover, '#3b82f6')}; transform:scale(1.02); }
|
.kiosk-btn:hover, .kiosk-btn:active { background:${safeColor(style.buttonHover, '#e65c00')}; border-color:${safeColor(style.buttonHover, '#e65c00')}; transform:scale(1.02); }
|
||||||
.kiosk-btn .icon { font-size:${safeNumber(style.iconSize, 48)}px; }
|
.kiosk-btn .icon { font-size:${safeNumber(style.iconSize, 48)}px; }
|
||||||
.kiosk-btn .label { font-size:${safeNumber(style.labelSize, 20)}px; font-weight:600; }
|
.kiosk-btn .label { font-size:${safeNumber(style.labelSize, 20)}px; font-weight:600; }
|
||||||
.kiosk-btn .sublabel { font-size:${safeNumber(style.sublabelSize, 14)}px; opacity:0.6; }
|
.kiosk-btn .sublabel { font-size:${safeNumber(style.sublabelSize, 14)}px; opacity:0.6; }
|
||||||
|
|
@ -245,7 +245,7 @@ function getDefaultKioskConfig() {
|
||||||
columns: 3,
|
columns: 3,
|
||||||
buttonBg: '#1e293b',
|
buttonBg: '#1e293b',
|
||||||
buttonBorder: '#334155',
|
buttonBorder: '#334155',
|
||||||
buttonHover: '#3b82f6',
|
buttonHover: '#e65c00',
|
||||||
buttonRadius: 16,
|
buttonRadius: 16,
|
||||||
buttonPadding: 32,
|
buttonPadding: 32,
|
||||||
gap: 24,
|
gap: 24,
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,7 @@ router.get('/week', (req, res) => {
|
||||||
// playlist were accepted with NO ownership check at all.
|
// playlist were accepted with NO ownership check at all.
|
||||||
router.post('/', (req, res) => {
|
router.post('/', (req, res) => {
|
||||||
const { device_id, group_id, zone_id, content_id, widget_id, layout_id, playlist_id, title, start_time, end_time,
|
const { device_id, group_id, zone_id, content_id, widget_id, layout_id, playlist_id, title, start_time, end_time,
|
||||||
timezone, recurrence, recurrence_end, priority, color } = req.body;
|
timezone, recurrence, recurrence_end, priority, color } = req.body;
|
||||||
|
|
||||||
if (!start_time || !end_time) {
|
if (!start_time || !end_time) {
|
||||||
return res.status(400).json({ error: 'start_time and end_time required' });
|
return res.status(400).json({ error: 'start_time and end_time required' });
|
||||||
|
|
@ -181,9 +181,9 @@ router.post('/', (req, res) => {
|
||||||
// Payload refs must live in the same workspace. Platform templates
|
// Payload refs must live in the same workspace. Platform templates
|
||||||
// (workspace_id IS NULL) on content / widget / layout / playlist are allowed.
|
// (workspace_id IS NULL) on content / widget / layout / playlist are allowed.
|
||||||
const refChecks = [
|
const refChecks = [
|
||||||
['content', content_id, true],
|
['content', content_id, true],
|
||||||
['widgets', widget_id, true],
|
['widgets', widget_id, true],
|
||||||
['layouts', layout_id, true],
|
['layouts', layout_id, true],
|
||||||
['playlists', playlist_id, true],
|
['playlists', playlist_id, true],
|
||||||
];
|
];
|
||||||
for (const [table, id, allowNull] of refChecks) {
|
for (const [table, id, allowNull] of refChecks) {
|
||||||
|
|
@ -199,7 +199,7 @@ router.post('/', (req, res) => {
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(id, req.user.id, targetWorkspaceId, device_id || null, group_id || null, zone_id || null, content_id || null, widget_id || null,
|
`).run(id, req.user.id, targetWorkspaceId, device_id || null, group_id || null, zone_id || null, content_id || null, widget_id || null,
|
||||||
layout_id || null, playlist_id || null, title || '', start_time, end_time, timezone || 'UTC',
|
layout_id || null, playlist_id || null, title || '', start_time, end_time, timezone || 'UTC',
|
||||||
recurrence || null, recurrence_end || null, priority || 0, color || '#3B82F6');
|
recurrence || null, recurrence_end || null, priority || 0, color || '#e65c00');
|
||||||
|
|
||||||
const schedule = db.prepare('SELECT * FROM schedules WHERE id = ?').get(id);
|
const schedule = db.prepare('SELECT * FROM schedules WHERE id = ?').get(id);
|
||||||
res.status(201).json(schedule);
|
res.status(201).json(schedule);
|
||||||
|
|
@ -225,12 +225,12 @@ router.put('/:id', requireScheduleWrite, (req, res) => {
|
||||||
// (no NULL workspace path); content / widget / layout / playlist may be
|
// (no NULL workspace path); content / widget / layout / playlist may be
|
||||||
// platform templates (NULL workspace_id).
|
// platform templates (NULL workspace_id).
|
||||||
const ownershipChecks = [
|
const ownershipChecks = [
|
||||||
['devices', req.body.device_id, schedule.device_id, false],
|
['devices', req.body.device_id, schedule.device_id, false],
|
||||||
['device_groups', req.body.group_id, schedule.group_id, false],
|
['device_groups', req.body.group_id, schedule.group_id, false],
|
||||||
['content', req.body.content_id, schedule.content_id, true],
|
['content', req.body.content_id, schedule.content_id, true],
|
||||||
['widgets', req.body.widget_id, schedule.widget_id, true],
|
['widgets', req.body.widget_id, schedule.widget_id, true],
|
||||||
['layouts', req.body.layout_id, schedule.layout_id, true],
|
['layouts', req.body.layout_id, schedule.layout_id, true],
|
||||||
['playlists', req.body.playlist_id, schedule.playlist_id, true],
|
['playlists', req.body.playlist_id, schedule.playlist_id, true],
|
||||||
];
|
];
|
||||||
for (const [table, newVal, oldVal, allowNull] of ownershipChecks) {
|
for (const [table, newVal, oldVal, allowNull] of ownershipChecks) {
|
||||||
if (newVal === undefined || newVal === oldVal || !newVal) continue;
|
if (newVal === undefined || newVal === oldVal || !newVal) continue;
|
||||||
|
|
|
||||||
|
|
@ -292,7 +292,7 @@ router.post('/import', importUpload.single('file'), async (req, res) => {
|
||||||
// Cleanup uploaded zip
|
// Cleanup uploaded zip
|
||||||
fs.unlinkSync(req.file.path);
|
fs.unlinkSync(req.file.path);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (req.file?.path) try { fs.unlinkSync(req.file.path); } catch {}
|
if (req.file?.path) try { fs.unlinkSync(req.file.path); } catch { }
|
||||||
return res.status(400).json({ error: 'Failed to extract ZIP: ' + err.message });
|
return res.status(400).json({ error: 'Failed to extract ZIP: ' + err.message });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -415,7 +415,7 @@ router.post('/import', importUpload.single('file'), async (req, res) => {
|
||||||
if (!devId && !grpId) continue;
|
if (!devId && !grpId) continue;
|
||||||
const newId = uuid.v4();
|
const newId = uuid.v4();
|
||||||
const playlistId = s.playlist_id ? (idMap.playlists[s.playlist_id] || null) : null;
|
const playlistId = s.playlist_id ? (idMap.playlists[s.playlist_id] || null) : null;
|
||||||
db.prepare(`INSERT INTO schedules (id, user_id, device_id, group_id, zone_id, content_id, widget_id, layout_id, playlist_id, title, start_time, end_time, timezone, recurrence, recurrence_end, priority, enabled, color, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newId, userId, devId, grpId, s.zone_id ? (idMap.zones[s.zone_id] || null) : null, s.content_id ? (idMap.content[s.content_id] || null) : null, s.widget_id ? (idMap.widgets[s.widget_id] || null) : null, s.layout_id ? (idMap.layouts[s.layout_id] || null) : null, playlistId, s.title || '', s.start_time, s.end_time, s.timezone || 'UTC', s.recurrence || null, s.recurrence_end || null, s.priority || 0, s.enabled !== undefined ? s.enabled : 1, s.color || '#3B82F6', s.created_at || Math.floor(Date.now() / 1000));
|
db.prepare(`INSERT INTO schedules (id, user_id, device_id, group_id, zone_id, content_id, widget_id, layout_id, playlist_id, title, start_time, end_time, timezone, recurrence, recurrence_end, priority, enabled, color, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newId, userId, devId, grpId, s.zone_id ? (idMap.zones[s.zone_id] || null) : null, s.content_id ? (idMap.content[s.content_id] || null) : null, s.widget_id ? (idMap.widgets[s.widget_id] || null) : null, s.layout_id ? (idMap.layouts[s.layout_id] || null) : null, playlistId, s.title || '', s.start_time, s.end_time, s.timezone || 'UTC', s.recurrence || null, s.recurrence_end || null, s.priority || 0, s.enabled !== undefined ? s.enabled : 1, s.color || '#e65c00', s.created_at || Math.floor(Date.now() / 1000));
|
||||||
stats.schedules++;
|
stats.schedules++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -446,7 +446,7 @@ router.post('/import', importUpload.single('file'), async (req, res) => {
|
||||||
for (const g of (data.device_groups || [])) {
|
for (const g of (data.device_groups || [])) {
|
||||||
const newId = uuid.v4();
|
const newId = uuid.v4();
|
||||||
idMap.groups[g.id] = newId;
|
idMap.groups[g.id] = newId;
|
||||||
db.prepare(`INSERT INTO device_groups (id, user_id, workspace_id, name, color, created_at) VALUES (?, ?, ?, ?, ?, ?)`).run(newId, userId, workspaceId, g.name, g.color || '#3B82F6', g.created_at || Math.floor(Date.now() / 1000));
|
db.prepare(`INSERT INTO device_groups (id, user_id, workspace_id, name, color, created_at) VALUES (?, ?, ?, ?, ?, ?)`).run(newId, userId, workspaceId, g.name, g.color || '#e65c00', g.created_at || Math.floor(Date.now() / 1000));
|
||||||
stats.device_groups++;
|
stats.device_groups++;
|
||||||
}
|
}
|
||||||
for (const gm of (data.device_group_members || [])) {
|
for (const gm of (data.device_group_members || [])) {
|
||||||
|
|
@ -468,9 +468,9 @@ router.post('/import', importUpload.single('file'), async (req, res) => {
|
||||||
const wl = data.white_label;
|
const wl = data.white_label;
|
||||||
const existing = db.prepare('SELECT id FROM white_labels WHERE workspace_id = ?').get(workspaceId);
|
const existing = db.prepare('SELECT id FROM white_labels WHERE workspace_id = ?').get(workspaceId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
db.prepare(`UPDATE white_labels SET brand_name=?, logo_url=?, favicon_url=?, primary_color=?, bg_color=?, custom_domain=?, custom_css=?, hide_branding=?, updated_at=strftime('%s','now') WHERE workspace_id=?`).run(wl.brand_name || 'ScreenTinker', wl.logo_url || null, wl.favicon_url || null, wl.primary_color || '#3B82F6', wl.bg_color || '#111827', wl.custom_domain || null, wl.custom_css || null, wl.hide_branding || 0, workspaceId);
|
db.prepare(`UPDATE white_labels SET brand_name=?, logo_url=?, favicon_url=?, primary_color=?, bg_color=?, custom_domain=?, custom_css=?, hide_branding=?, updated_at=strftime('%s','now') WHERE workspace_id=?`).run(wl.brand_name || 'ScreenTinker', wl.logo_url || null, wl.favicon_url || null, wl.primary_color || '#e65c00', wl.bg_color || '#111827', wl.custom_domain || null, wl.custom_css || null, wl.hide_branding || 0, workspaceId);
|
||||||
} else {
|
} else {
|
||||||
db.prepare(`INSERT INTO white_labels (id, user_id, workspace_id, brand_name, logo_url, favicon_url, primary_color, bg_color, custom_domain, custom_css, hide_branding) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(uuid.v4(), userId, workspaceId, wl.brand_name || 'ScreenTinker', wl.logo_url || null, wl.favicon_url || null, wl.primary_color || '#3B82F6', wl.bg_color || '#111827', wl.custom_domain || null, wl.custom_css || null, wl.hide_branding || 0);
|
db.prepare(`INSERT INTO white_labels (id, user_id, workspace_id, brand_name, logo_url, favicon_url, primary_color, bg_color, custom_domain, custom_css, hide_branding) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(uuid.v4(), userId, workspaceId, wl.brand_name || 'ScreenTinker', wl.logo_url || null, wl.favicon_url || null, wl.primary_color || '#e65c00', wl.bg_color || '#111827', wl.custom_domain || null, wl.custom_css || null, wl.hide_branding || 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ router.post('/', requireWorkspaceAdmin, (req, res) => {
|
||||||
if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before configuring branding.' });
|
if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before configuring branding.' });
|
||||||
|
|
||||||
const { brand_name, logo_url, favicon_url, primary_color, secondary_color, bg_color,
|
const { brand_name, logo_url, favicon_url, primary_color, secondary_color, bg_color,
|
||||||
custom_domain, custom_css, hide_branding } = req.body;
|
custom_domain, custom_css, hide_branding } = req.body;
|
||||||
|
|
||||||
// Security (#3): custom_domain drives the PUBLIC, pre-auth branding resolver
|
// Security (#3): custom_domain drives the PUBLIC, pre-auth branding resolver
|
||||||
// (GET /api/branding) and custom_css is injected into the login page's <style>.
|
// (GET /api/branding) and custom_css is injected into the login page's <style>.
|
||||||
|
|
@ -60,7 +60,7 @@ router.post('/', requireWorkspaceAdmin, (req, res) => {
|
||||||
db.prepare(`INSERT INTO white_labels (id, user_id, workspace_id, brand_name, logo_url, favicon_url, primary_color, secondary_color, bg_color, custom_domain, custom_css, hide_branding)
|
db.prepare(`INSERT INTO white_labels (id, user_id, workspace_id, brand_name, logo_url, favicon_url, primary_color, secondary_color, bg_color, custom_domain, custom_css, hide_branding)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
||||||
id, req.user.id, req.workspaceId, brand_name || 'ScreenTinker', logo_url || null, favicon_url || null,
|
id, req.user.id, req.workspaceId, brand_name || 'ScreenTinker', logo_url || null, favicon_url || null,
|
||||||
primary_color || '#3B82F6', secondary_color || '#1E293B', bg_color || '#111827',
|
primary_color || '#e65c00', secondary_color || '#1E293B', bg_color || '#111827',
|
||||||
custom_domain || null, custom_css || null, hide_branding ? 1 : 0);
|
custom_domain || null, custom_css || null, hide_branding ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -249,13 +249,15 @@ app.get(['/integrations', '/integrations/'], (req, res) => {
|
||||||
// Serve frontend static files
|
// Serve frontend static files
|
||||||
// JS/CSS/HTML: no-cache (always revalidate, uses ETag/304)
|
// JS/CSS/HTML: no-cache (always revalidate, uses ETag/304)
|
||||||
// Images/fonts/icons: long cache for Cloudflare + browser
|
// Images/fonts/icons: long cache for Cloudflare + browser
|
||||||
app.use(express.static(config.frontendDir, { index: false, etag: true, lastModified: true, setHeaders: (res, filePath) => {
|
app.use(express.static(config.frontendDir, {
|
||||||
if (filePath.endsWith('.js') || filePath.endsWith('.css') || filePath.endsWith('.html')) {
|
index: false, etag: true, lastModified: true, setHeaders: (res, filePath) => {
|
||||||
res.setHeader('Cache-Control', 'no-cache');
|
if (filePath.endsWith('.js') || filePath.endsWith('.css') || filePath.endsWith('.html')) {
|
||||||
} else if (/\.(png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|webp|mp4|webm)$/i.test(filePath)) {
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
res.setHeader('Cache-Control', 'public, max-age=2592000'); // 30 days
|
} else if (/\.(png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|webp|mp4|webm)$/i.test(filePath)) {
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=2592000'); // 30 days
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}));
|
}));
|
||||||
|
|
||||||
// Player HTML: dynamic route. Injects a small inline window.__playerConfig
|
// Player HTML: dynamic route. Injects a small inline window.__playerConfig
|
||||||
// script before the debug-overlay.js tag so the client knows whether to send
|
// script before the debug-overlay.js tag so the client knows whether to send
|
||||||
|
|
@ -312,11 +314,13 @@ app.get('/player/transitions.js', (req, res) => {
|
||||||
// Serve web player at /player (same no-cache for JS/HTML). The index.html
|
// Serve web player at /player (same no-cache for JS/HTML). The index.html
|
||||||
// route above intercepts the HTML requests; everything else still falls
|
// route above intercepts the HTML requests; everything else still falls
|
||||||
// through to this static handler (debug-overlay.js, sw.js, manifest, etc).
|
// through to this static handler (debug-overlay.js, sw.js, manifest, etc).
|
||||||
app.use('/player', express.static(path.join(__dirname, 'player'), { etag: true, lastModified: true, setHeaders: (res, filePath) => {
|
app.use('/player', express.static(path.join(__dirname, 'player'), {
|
||||||
if (filePath.endsWith('.js') || filePath.endsWith('.css') || filePath.endsWith('.html')) {
|
etag: true, lastModified: true, setHeaders: (res, filePath) => {
|
||||||
res.setHeader('Cache-Control', 'no-cache');
|
if (filePath.endsWith('.js') || filePath.endsWith('.css') || filePath.endsWith('.html')) {
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}));
|
}));
|
||||||
|
|
||||||
// Serve setup scripts
|
// Serve setup scripts
|
||||||
app.use('/scripts', express.static(path.join(__dirname, '..', 'scripts')));
|
app.use('/scripts', express.static(path.join(__dirname, '..', 'scripts')));
|
||||||
|
|
@ -424,7 +428,7 @@ app.get('/api/devices/:id/screenshot', (req, res) => {
|
||||||
const { db: sdb } = require('./db/database');
|
const { db: sdb } = require('./db/database');
|
||||||
const device = sdb.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.id);
|
const device = sdb.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.id);
|
||||||
if (!device) return res.status(404).json({ error: 'Device not found' });
|
if (!device) return res.status(404).json({ error: 'Device not found' });
|
||||||
if (!['admin','superadmin'].includes(user.role) && device.user_id && device.user_id !== user.id) return res.status(403).json({ error: 'Access denied' });
|
if (!['admin', 'superadmin'].includes(user.role) && device.user_id && device.user_id !== user.id) return res.status(403).json({ error: 'Access denied' });
|
||||||
// Serve from memory if available (device online), otherwise from disk (offline snapshot)
|
// Serve from memory if available (device online), otherwise from disk (offline snapshot)
|
||||||
const deviceSocket = require('./ws/deviceSocket');
|
const deviceSocket = require('./ws/deviceSocket');
|
||||||
const memScreenshot = deviceSocket.lastScreenshots?.[req.params.id];
|
const memScreenshot = deviceSocket.lastScreenshots?.[req.params.id];
|
||||||
|
|
@ -606,12 +610,12 @@ function updateFrontendHash() {
|
||||||
'js/views/layout-editor.js', 'js/views/schedule.js', 'js/views/widgets.js',
|
'js/views/layout-editor.js', 'js/views/schedule.js', 'js/views/widgets.js',
|
||||||
'js/views/video-wall.js', 'js/views/reports.js', 'js/views/designer.js',
|
'js/views/video-wall.js', 'js/views/reports.js', 'js/views/designer.js',
|
||||||
'js/views/activity.js', 'js/views/kiosk.js'].map(f => {
|
'js/views/activity.js', 'js/views/kiosk.js'].map(f => {
|
||||||
try { return fs.readFileSync(path.join(config.frontendDir, f)); } catch { return ''; }
|
try { return fs.readFileSync(path.join(config.frontendDir, f)); } catch { return ''; }
|
||||||
});
|
});
|
||||||
// Include player files in hash so web players detect code updates
|
// Include player files in hash so web players detect code updates
|
||||||
try { files.push(fs.readFileSync(path.join(__dirname, 'player', 'index.html'))); } catch {}
|
try { files.push(fs.readFileSync(path.join(__dirname, 'player', 'index.html'))); } catch { }
|
||||||
try { files.push(fs.readFileSync(path.join(__dirname, 'player', 'sw.js'))); } catch {}
|
try { files.push(fs.readFileSync(path.join(__dirname, 'player', 'sw.js'))); } catch { }
|
||||||
try { files.push(fs.readFileSync(path.join(__dirname, 'player', 'debug-overlay.js'))); } catch {}
|
try { files.push(fs.readFileSync(path.join(__dirname, 'player', 'debug-overlay.js'))); } catch { }
|
||||||
frontendHash = crypto.createHash('md5').update(Buffer.concat(files.map(f => Buffer.from(f)))).digest('hex').slice(0, 8);
|
frontendHash = crypto.createHash('md5').update(Buffer.concat(files.map(f => Buffer.from(f)))).digest('hex').slice(0, 8);
|
||||||
} catch { frontendHash = Date.now().toString(36); }
|
} catch { frontendHash = Date.now().toString(36); }
|
||||||
}
|
}
|
||||||
|
|
@ -803,9 +807,9 @@ let _shuttingDown = false;
|
||||||
function gracefulShutdown(sig) {
|
function gracefulShutdown(sig) {
|
||||||
if (_shuttingDown) return; _shuttingDown = true;
|
if (_shuttingDown) return; _shuttingDown = true;
|
||||||
console.log(`[shutdown] ${sig} — stopping WAL checkpointer + closing DB`);
|
console.log(`[shutdown] ${sig} — stopping WAL checkpointer + closing DB`);
|
||||||
Promise.resolve(stopWalCheckpointer()).catch(() => {}).finally(() => {
|
Promise.resolve(stopWalCheckpointer()).catch(() => { }).finally(() => {
|
||||||
try { require('./lib/status-log-writer').flush(); } catch (_) {}
|
try { require('./lib/status-log-writer').flush(); } catch (_) { }
|
||||||
try { require('./db/database').db.close(); } catch (_) {}
|
try { require('./db/database').db.close(); } catch (_) { }
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -939,7 +943,7 @@ const otaDownloadState = otaDownloadGuard.prodState(); // #146 P3.8: shared si
|
||||||
app.get('/download/apk', (req, res) => {
|
app.get('/download/apk', (req, res) => {
|
||||||
const apk = apkCache.get();
|
const apk = apkCache.get();
|
||||||
if (!apk.exists) {
|
if (!apk.exists) {
|
||||||
return res.status(404).send(`<!DOCTYPE html><html><head><title>APK Not Found</title><style>body{font-family:-apple-system,system-ui,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#0f172a;color:#e2e8f0}div{text-align:center;max-width:500px;padding:24px}h1{color:#f87171;font-size:24px}code{background:#1e293b;padding:2px 8px;border-radius:4px;font-size:14px}p{line-height:1.6;color:#94a3b8}</style></head><body><div><h1>APK Not Available</h1><p>The Android APK has not been compiled yet. In Docker, mount a built APK at <code>/data/ScreenTinker.apk</code>, or use the <a href="/player" style="color:#3b82f6">web player</a>.</p></div></body></html>`);
|
return res.status(404).send(`<!DOCTYPE html><html><head><title>APK Not Found</title><style>body{font-family:-apple-system,system-ui,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#0f172a;color:#e2e8f0}div{text-align:center;max-width:500px;padding:24px}h1{color:#f87171;font-size:24px}code{background:#1e293b;padding:2px 8px;border-radius:4px;font-size:14px}p{line-height:1.6;color:#94a3b8}</style></head><body><div><h1>APK Not Available</h1><p>The Android APK has not been compiled yet. In Docker, mount a built APK at <code>/data/ScreenTinker.apk</code>, or use the <a href="/player" style="color:#e65c00">web player</a>.</p></div></body></html>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const verdict = otaDownloadGuard.admit(otaDownloadState, getBand());
|
const verdict = otaDownloadGuard.admit(otaDownloadState, getBand());
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ async function checkOfflineDevices(io) {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
'INSERT INTO activity_log (user_id, device_id, action, details, workspace_id) VALUES (?, ?, ?, ?, ?)'
|
'INSERT INTO activity_log (user_id, device_id, action, details, workspace_id) VALUES (?, ?, ?, ?, ?)'
|
||||||
).run(device.user_id, device.id, 'alert:device_offline', `${device.name} offline for ${offlineMinutes}m`, device.workspace_id || null);
|
).run(device.user_id, device.id, 'alert:device_offline', `${device.name} offline for ${offlineMinutes}m`, device.workspace_id || null);
|
||||||
} catch {}
|
} catch { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,7 +86,7 @@ async function checkOfflineDevices(io) {
|
||||||
// previously inlined in the webhook payload at sendEmailAlert.
|
// previously inlined in the webhook payload at sendEmailAlert.
|
||||||
function buildAlertHtml(recipientName, subject, body) {
|
function buildAlertHtml(recipientName, subject, body) {
|
||||||
return `<div style="font-family:sans-serif;max-width:600px;margin:0 auto;padding:20px">
|
return `<div style="font-family:sans-serif;max-width:600px;margin:0 auto;padding:20px">
|
||||||
<h2 style="color:#3b82f6">ScreenTinker Alert</h2>
|
<h2 style="color:#e65c00">ScreenTinker Alert</h2>
|
||||||
<p>Hi ${escapeHtml(recipientName || 'there')},</p>
|
<p>Hi ${escapeHtml(recipientName || 'there')},</p>
|
||||||
<div style="background:#f1f5f9;padding:16px;border-radius:8px;margin:16px 0">
|
<div style="background:#f1f5f9;padding:16px;border-radius:8px;margin:16px 0">
|
||||||
<strong>${escapeHtml(subject)}</strong><br><br>
|
<strong>${escapeHtml(subject)}</strong><br><br>
|
||||||
|
|
@ -98,7 +98,7 @@ function buildAlertHtml(recipientName, subject, body) {
|
||||||
|
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
return String(s ?? '').replace(/[&<>"']/g, c =>
|
return String(s ?? '').replace(/[&<>"']/g, c =>
|
||||||
({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy export name preserved - some other modules may still call this.
|
// Legacy export name preserved - some other modules may still call this.
|
||||||
|
|
|
||||||
|
|
@ -14,16 +14,18 @@ process.env.SELF_HOSTED = 'false';
|
||||||
|
|
||||||
const db = new Database(':memory:');
|
const db = new Database(':memory:');
|
||||||
const dbModulePath = require.resolve('../db/database');
|
const dbModulePath = require.resolve('../db/database');
|
||||||
require.cache[dbModulePath] = { id: dbModulePath, filename: dbModulePath, loaded: true, exports: { db, pruneTelemetry() {}, pruneScreenshots() {} } };
|
require.cache[dbModulePath] = { id: dbModulePath, filename: dbModulePath, loaded: true, exports: { db, pruneTelemetry() { }, pruneScreenshots() { } } };
|
||||||
|
|
||||||
const ai = require('../routes/ai');
|
const ai = require('../routes/ai');
|
||||||
const { normalizeDesign, endpointAllowed } = ai;
|
const { normalizeDesign, endpointAllowed } = ai;
|
||||||
|
|
||||||
test('normalizeDesign: keeps valid text+shape, sets background', () => {
|
test('normalizeDesign: keeps valid text+shape, sets background', () => {
|
||||||
const d = normalizeDesign({ background: '#102030', elements: [
|
const d = normalizeDesign({
|
||||||
{ type: 'text', x: 5, y: 5, text: 'HELLO', fontSize: 90, color: '#ffffff', bold: true },
|
background: '#102030', elements: [
|
||||||
{ type: 'shape', x: 0, y: 90, width: 100, height: 8, color: '#ff0000', opacity: 0.5 },
|
{ type: 'text', x: 5, y: 5, text: 'HELLO', fontSize: 90, color: '#ffffff', bold: true },
|
||||||
]});
|
{ type: 'shape', x: 0, y: 90, width: 100, height: 8, color: '#ff0000', opacity: 0.5 },
|
||||||
|
]
|
||||||
|
});
|
||||||
assert.equal(d.background, '#102030');
|
assert.equal(d.background, '#102030');
|
||||||
assert.equal(d.elements.length, 2);
|
assert.equal(d.elements.length, 2);
|
||||||
const txt = d.elements.find((e) => e.type === 'text');
|
const txt = d.elements.find((e) => e.type === 'text');
|
||||||
|
|
@ -32,25 +34,29 @@ test('normalizeDesign: keeps valid text+shape, sets background', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('normalizeDesign: converts pixel shape dims to %, clamps ranges', () => {
|
test('normalizeDesign: converts pixel shape dims to %, clamps ranges', () => {
|
||||||
const d = normalizeDesign({ elements: [
|
const d = normalizeDesign({
|
||||||
{ type: 'shape', x: -10, y: 200, width: 1920, height: 1080, color: 'red', opacity: 5 },
|
elements: [
|
||||||
]});
|
{ type: 'shape', x: -10, y: 200, width: 1920, height: 1080, color: 'red', opacity: 5 },
|
||||||
|
]
|
||||||
|
});
|
||||||
const s = d.elements[0];
|
const s = d.elements[0];
|
||||||
assert.equal(s.x, 0, 'x clamped so full-width shape fits');
|
assert.equal(s.x, 0, 'x clamped so full-width shape fits');
|
||||||
assert.equal(s.y, 0, 'y clamped so full-height shape fits (y+height<=100)');
|
assert.equal(s.y, 0, 'y clamped so full-height shape fits (y+height<=100)');
|
||||||
assert.ok(Math.abs(s.width - 100) < 0.01, '1920px -> 100%');
|
assert.ok(Math.abs(s.width - 100) < 0.01, '1920px -> 100%');
|
||||||
assert.ok(Math.abs(s.height - 100) < 0.01, '1080px -> 100%');
|
assert.ok(Math.abs(s.height - 100) < 0.01, '1080px -> 100%');
|
||||||
assert.equal(s.color, '#3b82f6', 'non-hex color -> default');
|
assert.equal(s.color, '#e65c00', 'non-hex color -> default');
|
||||||
assert.equal(s.opacity, 1, 'opacity clamped to 1');
|
assert.equal(s.opacity, 1, 'opacity clamped to 1');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('normalizeDesign: strips HTML from text, drops empty/invalid', () => {
|
test('normalizeDesign: strips HTML from text, drops empty/invalid', () => {
|
||||||
const d = normalizeDesign({ elements: [
|
const d = normalizeDesign({
|
||||||
{ type: 'text', text: '<img src=x onerror=alert(1)>Sale</b>', fontSize: 9999 },
|
elements: [
|
||||||
{ type: 'text', text: ' ' },
|
{ type: 'text', text: '<img src=x onerror=alert(1)>Sale</b>', fontSize: 9999 },
|
||||||
{ type: 'bogus', text: 'x' },
|
{ type: 'text', text: ' ' },
|
||||||
null,
|
{ type: 'bogus', text: 'x' },
|
||||||
]});
|
null,
|
||||||
|
]
|
||||||
|
});
|
||||||
assert.equal(d.elements.length, 1, 'only the one real text survives');
|
assert.equal(d.elements.length, 1, 'only the one real text survives');
|
||||||
assert.equal(d.elements[0].text, 'Sale');
|
assert.equal(d.elements[0].text, 'Sale');
|
||||||
assert.ok(!/[<>]/.test(d.elements[0].text), 'no angle brackets');
|
assert.ok(!/[<>]/.test(d.elements[0].text), 'no angle brackets');
|
||||||
|
|
@ -77,9 +83,11 @@ test('endpointAllowed: blocks private/internal when hosted, allows public https'
|
||||||
});
|
});
|
||||||
|
|
||||||
test('normalizeDesign: long/large text is shrunk + repositioned to fit canvas', () => {
|
test('normalizeDesign: long/large text is shrunk + repositioned to fit canvas', () => {
|
||||||
const d = normalizeDesign({ elements: [
|
const d = normalizeDesign({
|
||||||
{ type: 'text', x: 30, y: 95, text: 'GRAND OPENING THIS FRIDAY EVERYONE WELCOME', fontSize: 160, color: '#fff' },
|
elements: [
|
||||||
]});
|
{ type: 'text', x: 30, y: 95, text: 'GRAND OPENING THIS FRIDAY EVERYONE WELCOME', fontSize: 160, color: '#fff' },
|
||||||
|
]
|
||||||
|
});
|
||||||
const e = d.elements[0];
|
const e = d.elements[0];
|
||||||
const w = e.text.length * e.fontSize * 0.075;
|
const w = e.text.length * e.fontSize * 0.075;
|
||||||
assert.ok(e.x + w <= 96.5, `fits horizontally (x=${e.x} w=${w.toFixed(1)})`);
|
assert.ok(e.x + w <= 96.5, `fits horizontally (x=${e.x} w=${w.toFixed(1)})`);
|
||||||
|
|
@ -89,11 +97,13 @@ test('normalizeDesign: long/large text is shrunk + repositioned to fit canvas',
|
||||||
});
|
});
|
||||||
|
|
||||||
test('normalizeDesign: separates overlapping text + orders shapes behind text', () => {
|
test('normalizeDesign: separates overlapping text + orders shapes behind text', () => {
|
||||||
const d = normalizeDesign({ elements: [
|
const d = normalizeDesign({
|
||||||
{ type: 'text', x: 5, y: 40, text: 'HEADLINE TEXT HERE', fontSize: 60, color: '#fff' },
|
elements: [
|
||||||
{ type: 'text', x: 5, y: 41, text: 'SUBTEXT OVERLAPPING IT', fontSize: 40, color: '#fff' },
|
{ type: 'text', x: 5, y: 40, text: 'HEADLINE TEXT HERE', fontSize: 60, color: '#fff' },
|
||||||
{ type: 'shape', x: 0, y: 0, width: 100, height: 100, color: '#000', opacity: 0.5 },
|
{ type: 'text', x: 5, y: 41, text: 'SUBTEXT OVERLAPPING IT', fontSize: 40, color: '#fff' },
|
||||||
]});
|
{ type: 'shape', x: 0, y: 0, width: 100, height: 100, color: '#000', opacity: 0.5 },
|
||||||
|
]
|
||||||
|
});
|
||||||
assert.equal(d.elements[0].type, 'shape', 'shape rendered behind (first in array)');
|
assert.equal(d.elements[0].type, 'shape', 'shape rendered behind (first in array)');
|
||||||
const texts = d.elements.filter((e) => e.type === 'text');
|
const texts = d.elements.filter((e) => e.type === 'text');
|
||||||
const hi = texts[0].y <= texts[1].y ? texts[0] : texts[1];
|
const hi = texts[0].y <= texts[1].y ? texts[0] : texts[1];
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ db.exec(`
|
||||||
);
|
);
|
||||||
CREATE TABLE white_labels (
|
CREATE TABLE white_labels (
|
||||||
id TEXT PRIMARY KEY, user_id TEXT, brand_name TEXT NOT NULL DEFAULT 'ScreenTinker',
|
id TEXT PRIMARY KEY, user_id TEXT, brand_name TEXT NOT NULL DEFAULT 'ScreenTinker',
|
||||||
logo_url TEXT, favicon_url TEXT, primary_color TEXT DEFAULT '#3B82F6',
|
logo_url TEXT, favicon_url TEXT, primary_color TEXT DEFAULT '#e65c00',
|
||||||
secondary_color TEXT DEFAULT '#1E293B', bg_color TEXT DEFAULT '#111827',
|
secondary_color TEXT DEFAULT '#1E293B', bg_color TEXT DEFAULT '#111827',
|
||||||
custom_domain TEXT, custom_css TEXT, hide_branding INTEGER DEFAULT 0,
|
custom_domain TEXT, custom_css TEXT, hide_branding INTEGER DEFAULT 0,
|
||||||
workspace_id TEXT, created_at INTEGER DEFAULT (strftime('%s','now')), updated_at INTEGER DEFAULT (strftime('%s','now'))
|
workspace_id TEXT, created_at INTEGER DEFAULT (strftime('%s','now')), updated_at INTEGER DEFAULT (strftime('%s','now'))
|
||||||
|
|
@ -33,7 +33,7 @@ db.exec(`
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const dbModulePath = require.resolve('../db/database');
|
const dbModulePath = require.resolve('../db/database');
|
||||||
require.cache[dbModulePath] = { id: dbModulePath, filename: dbModulePath, loaded: true, exports: { db, pruneTelemetry() {}, pruneScreenshots() {} } };
|
require.cache[dbModulePath] = { id: dbModulePath, filename: dbModulePath, loaded: true, exports: { db, pruneTelemetry() { }, pruneScreenshots() { } } };
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { generateToken, requireAuth } = require('../middleware/auth');
|
const { generateToken, requireAuth } = require('../middleware/auth');
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ test('clock widget: malicious background/color/font_size cannot break out of <st
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rss widget: scroll_speed/max_items coerced to numbers (no injection)', async () => {
|
test('rss widget: scroll_speed/max_items coerced to numbers (no injection)', async () => {
|
||||||
seed('rss1', 'rss', { scroll_speed: '30s}</style><script>y</script>', max_items: '10);evil(' , background: CSS_BREAKOUT });
|
seed('rss1', 'rss', { scroll_speed: '30s}</style><script>y</script>', max_items: '10);evil(', background: CSS_BREAKOUT });
|
||||||
const html = await render('rss1');
|
const html = await render('rss1');
|
||||||
assert.ok(!html.includes('</style><script>y'), 'scroll_speed cannot inject');
|
assert.ok(!html.includes('</style><script>y'), 'scroll_speed cannot inject');
|
||||||
assert.ok(!html.includes('evil('), 'max_items cannot inject into the script');
|
assert.ok(!html.includes('evil('), 'max_items cannot inject into the script');
|
||||||
|
|
@ -54,8 +54,8 @@ test('text widget: raw HTML is isolated in a null-origin sandboxed iframe', asyn
|
||||||
});
|
});
|
||||||
|
|
||||||
test('valid color/gradient backgrounds are preserved', async () => {
|
test('valid color/gradient backgrounds are preserved', async () => {
|
||||||
seed('clock2', 'clock', { background: 'linear-gradient(45deg, #ff0000, #00ff00)', color: '#3B82F6' });
|
seed('clock2', 'clock', { background: 'linear-gradient(45deg, #ff0000, #00ff00)', color: '#e65c00' });
|
||||||
const html = await render('clock2');
|
const html = await render('clock2');
|
||||||
assert.ok(html.includes('linear-gradient(45deg, #ff0000, #00ff00)'), 'legit gradient preserved');
|
assert.ok(html.includes('linear-gradient(45deg, #ff0000, #00ff00)'), 'legit gradient preserved');
|
||||||
assert.ok(html.includes('color:#3B82F6'), 'legit hex color preserved');
|
assert.ok(html.includes('color:#e65c00'), 'legit hex color preserved');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,34 @@
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
html, body {
|
html,
|
||||||
width: 100%; height: 100%;
|
body {
|
||||||
background: #000; color: #f1f5f9;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #000;
|
||||||
|
color: #f1f5f9;
|
||||||
font-family: "Samsung One", "Tizen Sans", Arial, sans-serif;
|
font-family: "Samsung One", "Tizen Sans", Arial, sans-serif;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: none;
|
cursor: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.screen {
|
.screen {
|
||||||
position: absolute; top: 0; left: 0;
|
position: absolute;
|
||||||
width: 100%; height: 100%;
|
top: 0;
|
||||||
display: flex; align-items: center; justify-content: center;
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
}
|
}
|
||||||
.hidden { display: none !important; }
|
|
||||||
|
|
||||||
/* Setup / pairing card */
|
/* Setup / pairing card */
|
||||||
.card {
|
.card {
|
||||||
|
|
@ -24,66 +39,161 @@ html, body {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
max-width: 760px;
|
max-width: 760px;
|
||||||
}
|
}
|
||||||
.card h1 { color: #3b82f6; font-size: 44px; margin-bottom: 6px; }
|
|
||||||
.sub { color: #94a3b8; font-size: 22px; margin-bottom: 36px; }
|
.card h1 {
|
||||||
.card label { display: block; text-align: left; color: #94a3b8; font-size: 18px; margin-bottom: 8px; }
|
color: #e65c00;
|
||||||
|
font-size: 44px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 22px;
|
||||||
|
margin-bottom: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card label {
|
||||||
|
display: block;
|
||||||
|
text-align: left;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
#serverUrl {
|
#serverUrl {
|
||||||
width: 100%; font-size: 26px; padding: 16px 20px;
|
width: 100%;
|
||||||
border-radius: 10px; border: 2px solid #334155;
|
font-size: 26px;
|
||||||
background: #0b1220; color: #f1f5f9; margin-bottom: 24px;
|
padding: 16px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 2px solid #334155;
|
||||||
|
background: #0b1220;
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#serverUrl:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #e65c00;
|
||||||
}
|
}
|
||||||
#serverUrl:focus { outline: none; border-color: #3b82f6; }
|
|
||||||
|
|
||||||
button {
|
button {
|
||||||
font-size: 24px; font-weight: bold; color: #fff;
|
font-size: 24px;
|
||||||
background: #3b82f6; border: none; border-radius: 10px;
|
font-weight: bold;
|
||||||
padding: 16px 40px; cursor: pointer;
|
color: #fff;
|
||||||
|
background: #e65c00;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px 40px;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
button:focus { outline: 3px solid #93c5fd; }
|
|
||||||
button.ghost { background: transparent; color: #64748b; font-size: 18px; margin-top: 24px; padding: 8px; }
|
|
||||||
|
|
||||||
.status { color: #64748b; font-size: 18px; margin-top: 20px; min-height: 24px; }
|
button:focus {
|
||||||
.status.error { color: #ef4444; }
|
outline: 3px solid #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 18px;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 18px;
|
||||||
|
margin-top: 20px;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.error {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
/* Pairing code */
|
/* Pairing code */
|
||||||
.code {
|
.code {
|
||||||
font-size: 96px; font-weight: bold; letter-spacing: 16px;
|
font-size: 96px;
|
||||||
color: #22c55e; margin: 24px 0; font-family: monospace;
|
font-weight: bold;
|
||||||
|
letter-spacing: 16px;
|
||||||
|
color: #22c55e;
|
||||||
|
margin: 24px 0;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.hint { color: #94a3b8; font-size: 20px; line-height: 1.5; }
|
|
||||||
|
|
||||||
/* Playback stage */
|
/* Playback stage */
|
||||||
.stage { background: #000; }
|
.stage {
|
||||||
.stage img, .stage video, .stage iframe {
|
background: #000;
|
||||||
position: absolute; top: 0; left: 0;
|
}
|
||||||
width: 100%; height: 100%; border: 0;
|
|
||||||
|
.stage img,
|
||||||
|
.stage video,
|
||||||
|
.stage iframe {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage img.contain,
|
||||||
|
.stage video.contain {
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage img.cover,
|
||||||
|
.stage video.cover {
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage img.fill,
|
||||||
|
.stage video.fill {
|
||||||
|
object-fit: fill;
|
||||||
}
|
}
|
||||||
.stage img.contain, .stage video.contain { object-fit: contain; }
|
|
||||||
.stage img.cover, .stage video.cover { object-fit: cover; }
|
|
||||||
.stage img.fill, .stage video.fill { object-fit: fill; }
|
|
||||||
|
|
||||||
/* Video wall mode: the stage is positioned (in vw/vh) as this screen's slice of the
|
/* Video wall mode: the stage is positioned (in vw/vh) as this screen's slice of the
|
||||||
wall's player rect; media stretches to fill (object-fit:fill) so a given source row
|
wall's player rect; media stretches to fill (object-fit:fill) so a given source row
|
||||||
lands on the same physical line across every screen that shares a viewport height. */
|
lands on the same physical line across every screen that shares a viewport height. */
|
||||||
.stage.wall-mode img, .stage.wall-mode video, .stage.wall-mode iframe { object-fit: fill; }
|
.stage.wall-mode img,
|
||||||
|
.stage.wall-mode video,
|
||||||
|
.stage.wall-mode iframe {
|
||||||
|
object-fit: fill;
|
||||||
|
}
|
||||||
|
|
||||||
/* #109: PiP overlay layer. Sits above #stage and fills the same viewport box so a
|
/* #109: PiP overlay layer. Sits above #stage and fills the same viewport box so a
|
||||||
child positioned to a corner lands in the corner of the visible content. app.js
|
child positioned to a corner lands in the corner of the visible content. app.js
|
||||||
applies the SAME orientation transform here as on #stage (portrait/flipped). It is
|
applies the SAME orientation transform here as on #stage (portrait/flipped). It is
|
||||||
pointer-transparent and empty (invisible) until PipOverlay renders into it. */
|
pointer-transparent and empty (invisible) until PipOverlay renders into it. */
|
||||||
#pip {
|
#pip {
|
||||||
position: fixed; top: 0; left: 0;
|
position: fixed;
|
||||||
width: 100vw; height: 100vh;
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
}
|
}
|
||||||
#pip > div { box-shadow: 0 6px 28px rgba(0,0,0,0.55); }
|
|
||||||
|
#pip>div {
|
||||||
|
box-shadow: 0 6px 28px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
/* Toast */
|
/* Toast */
|
||||||
.toast {
|
.toast {
|
||||||
position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%);
|
position: absolute;
|
||||||
background: rgba(17,24,39,0.92); color: #f1f5f9;
|
bottom: 24px;
|
||||||
padding: 12px 24px; border-radius: 10px; font-size: 18px;
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(17, 24, 39, 0.92);
|
||||||
|
color: #f1f5f9;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 18px;
|
||||||
border: 1px solid #334155;
|
border: 1px solid #334155;
|
||||||
}
|
}
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
try {
|
try {
|
||||||
var v = tizen.application.getCurrentApplication().appInfo.version;
|
var v = tizen.application.getCurrentApplication().appInfo.version;
|
||||||
if (v) return v;
|
if (v) return v;
|
||||||
} catch (e) {}
|
} catch (e) { }
|
||||||
return APP_VERSION_FALLBACK;
|
return APP_VERSION_FALLBACK;
|
||||||
})();
|
})();
|
||||||
var HEARTBEAT_MS = 15000;
|
var HEARTBEAT_MS = 15000;
|
||||||
|
|
@ -39,8 +39,8 @@
|
||||||
|
|
||||||
// ---- persistent state ----
|
// ---- persistent state ----
|
||||||
function get(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
|
function get(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
|
||||||
function set(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
|
function set(k, v) { try { localStorage.setItem(k, v); } catch (e) { } }
|
||||||
function del(k) { try { localStorage.removeItem(k); } catch (e) {} }
|
function del(k) { try { localStorage.removeItem(k); } catch (e) { } }
|
||||||
|
|
||||||
function uuid() {
|
function uuid() {
|
||||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||||
|
|
@ -83,8 +83,8 @@
|
||||||
|
|
||||||
// Keep the screen awake (best effort across Tizen APIs)
|
// Keep the screen awake (best effort across Tizen APIs)
|
||||||
function keepAwake() {
|
function keepAwake() {
|
||||||
try { if (window.tizen && tizen.power) tizen.power.request('SCREEN', 'SCREEN_NORMAL'); } catch (e) {}
|
try { if (window.tizen && tizen.power) tizen.power.request('SCREEN', 'SCREEN_NORMAL'); } catch (e) { }
|
||||||
try { if (window.webapis && webapis.appcommon) webapis.appcommon.setScreenSaver(webapis.appcommon.AppCommonScreenSaverState.SCREEN_SAVER_OFF); } catch (e) {}
|
try { if (window.webapis && webapis.appcommon) webapis.appcommon.setScreenSaver(webapis.appcommon.AppCommonScreenSaverState.SCREEN_SAVER_OFF); } catch (e) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
// A5 — MONOTONIC clock for lifecycle time deltas (watchdog silence, resume hidden-duration), so an
|
// A5 — MONOTONIC clock for lifecycle time deltas (watchdog silence, resume hidden-duration), so an
|
||||||
|
|
@ -222,7 +222,7 @@
|
||||||
}
|
}
|
||||||
// Stream a group-sync diagnostic to the dashboard live-log (tag 'sync').
|
// Stream a group-sync diagnostic to the dashboard live-log (tag 'sync').
|
||||||
function reportSync(level, msg) {
|
function reportSync(level, msg) {
|
||||||
try { if (socket && socket.connected && deviceId) socket.emit('device:log', { device_id: deviceId, tag: 'sync', level: level, message: msg }); } catch (e) {}
|
try { if (socket && socket.connected && deviceId) socket.emit('device:log', { device_id: deviceId, tag: 'sync', level: level, message: msg }); } catch (e) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
function deviceInfo() {
|
function deviceInfo() {
|
||||||
|
|
@ -247,14 +247,14 @@
|
||||||
t.battery_level = Math.round((b.level || 0) * 100);
|
t.battery_level = Math.round((b.level || 0) * 100);
|
||||||
t.battery_charging = !!b.isCharging;
|
t.battery_charging = !!b.isCharging;
|
||||||
});
|
});
|
||||||
} catch (e) {}
|
} catch (e) { }
|
||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
if (!serverUrl) { show(elSetup); return; }
|
if (!serverUrl) { show(elSetup); return; }
|
||||||
keepAwake();
|
keepAwake();
|
||||||
if (socket) { try { socket.disconnect(); } catch (e) {} socket = null; }
|
if (socket) { try { socket.disconnect(); } catch (e) { } socket = null; }
|
||||||
if (registerTimer) { clearTimeout(registerTimer); registerTimer = null; } // H4: a fresh connect supersedes any pending re-register
|
if (registerTimer) { clearTimeout(registerTimer); registerTimer = null; } // H4: a fresh connect supersedes any pending re-register
|
||||||
|
|
||||||
var base = serverUrl.replace(/\/+$/, '');
|
var base = serverUrl.replace(/\/+$/, '');
|
||||||
|
|
@ -326,7 +326,7 @@
|
||||||
link_lost: linkLostDuringGap,
|
link_lost: linkLostDuringGap,
|
||||||
cold_start: false
|
cold_start: false
|
||||||
});
|
});
|
||||||
} catch (e) {}
|
} catch (e) { }
|
||||||
disconnectedAtMono = 0; linkLostDuringGap = false;
|
disconnectedAtMono = 0; linkLostDuringGap = false;
|
||||||
}
|
}
|
||||||
startHeartbeat();
|
startHeartbeat();
|
||||||
|
|
@ -482,7 +482,7 @@
|
||||||
function reportPip(level, msg) {
|
function reportPip(level, msg) {
|
||||||
try {
|
try {
|
||||||
if (socket && deviceId) socket.emit('device:log', { device_id: deviceId, tag: 'pip', level: level, message: msg });
|
if (socket && deviceId) socket.emit('device:log', { device_id: deviceId, tag: 'pip', level: level, message: msg });
|
||||||
} catch (e) {}
|
} catch (e) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
// feat/offline-cause-log: typed incident feed (device:event) — server inserts a device_events row.
|
// feat/offline-cause-log: typed incident feed (device:event) — server inserts a device_events row.
|
||||||
|
|
@ -494,7 +494,7 @@
|
||||||
if (reason) m.reason = reason;
|
if (reason) m.reason = reason;
|
||||||
if (detail) m.detail = detail;
|
if (detail) m.detail = detail;
|
||||||
socket.emit('device:event', m);
|
socket.emit('device:event', m);
|
||||||
} catch (e) {}
|
} catch (e) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
// #125: report a command outcome to the dashboard. device:log surfaces live as
|
// #125: report a command outcome to the dashboard. device:log surfaces live as
|
||||||
|
|
@ -507,7 +507,7 @@
|
||||||
socket.emit('device:log', { device_id: deviceId, tag: 'command', level: level, message: message });
|
socket.emit('device:log', { device_id: deviceId, tag: 'command', level: level, message: message });
|
||||||
socket.emit('device:command-result', { device_id: deviceId, type: type, level: level, message: msg });
|
socket.emit('device:command-result', { device_id: deviceId, type: type, level: level, message: msg });
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
// #125: log the panel's control surface at startup so the dashboard shows whether
|
// #125: log the panel's control surface at startup so the dashboard shows whether
|
||||||
|
|
@ -522,9 +522,9 @@
|
||||||
// TV's firmware/signing path. Surface their presence to the dashboard log so Bold can VERIFY on real
|
// TV's firmware/signing path. Surface their presence to the dashboard log so Bold can VERIFY on real
|
||||||
// hardware whether keep-awake is real (vs a silent no-op) — the load-bearing check for the flap fix.
|
// hardware whether keep-awake is real (vs a silent no-op) — the load-bearing check for the flap fix.
|
||||||
var ka = 'keep-awake: setScreenSaver=' + !!(window.webapis && webapis.appcommon)
|
var ka = 'keep-awake: setScreenSaver=' + !!(window.webapis && webapis.appcommon)
|
||||||
+ ' tizen.power=' + !!(window.tizen && tizen.power);
|
+ ' tizen.power=' + !!(window.tizen && tizen.power);
|
||||||
reportCmd('info', 'keepawake', ka);
|
reportCmd('info', 'keepawake', ka);
|
||||||
} catch (e) {}
|
} catch (e) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
// #120: best-effort dashboard preview. The Tizen TV runtime decodes <video> onto a
|
// #120: best-effort dashboard preview. The Tizen TV runtime decodes <video> onto a
|
||||||
|
|
@ -542,11 +542,11 @@
|
||||||
try {
|
try {
|
||||||
var img = elStage.querySelector('img');
|
var img = elStage.querySelector('img');
|
||||||
if (img && img.complete && img.naturalWidth > 0) {
|
if (img && img.complete && img.naturalWidth > 0) {
|
||||||
try { ctx.drawImage(img, 0, 0, 960, 540); captured = true; } catch (e) {}
|
try { ctx.drawImage(img, 0, 0, 960, 540); captured = true; } catch (e) { }
|
||||||
}
|
}
|
||||||
if (!captured) {
|
if (!captured) {
|
||||||
ctx.fillStyle = '#111827'; ctx.fillRect(0, 0, 960, 540);
|
ctx.fillStyle = '#111827'; ctx.fillRect(0, 0, 960, 540);
|
||||||
ctx.fillStyle = '#3b82f6'; ctx.font = 'bold 28px sans-serif'; ctx.textAlign = 'center';
|
ctx.fillStyle = '#e65c00'; ctx.font = 'bold 28px sans-serif'; ctx.textAlign = 'center';
|
||||||
ctx.fillText('ScreenTinker (Tizen)', 480, 235);
|
ctx.fillText('ScreenTinker (Tizen)', 480, 235);
|
||||||
ctx.fillStyle = '#94a3b8'; ctx.font = '16px sans-serif';
|
ctx.fillStyle = '#94a3b8'; ctx.font = '16px sans-serif';
|
||||||
ctx.fillText('Live preview unavailable for video / YouTube on Tizen', 480, 280);
|
ctx.fillText('Live preview unavailable for video / YouTube on Tizen', 480, 280);
|
||||||
|
|
@ -560,7 +560,7 @@
|
||||||
if (base64 && base64.length > 100) {
|
if (base64 && base64.length > 100) {
|
||||||
socket.emit('device:screenshot', { device_id: deviceId, image_b64: base64 });
|
socket.emit('device:screenshot', { device_id: deviceId, image_b64: base64 });
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) { }
|
||||||
}
|
}
|
||||||
function startStreaming() { stopStreaming(); streamTimer = setInterval(captureAndSend, 1000); }
|
function startStreaming() { stopStreaming(); streamTimer = setInterval(captureAndSend, 1000); }
|
||||||
function stopStreaming() { if (streamTimer) { clearInterval(streamTimer); streamTimer = null; } }
|
function stopStreaming() { if (streamTimer) { clearInterval(streamTimer); streamTimer = null; } }
|
||||||
|
|
@ -579,7 +579,7 @@
|
||||||
function teardownSession() {
|
function teardownSession() {
|
||||||
stopHeartbeat();
|
stopHeartbeat();
|
||||||
stopStreaming();
|
stopStreaming();
|
||||||
try { player.stop(); } catch (e) {}
|
try { player.stop(); } catch (e) { }
|
||||||
stageOwner = ''; // #162: stage cleared — next playlist must repaint
|
stageOwner = ''; // #162: stage cleared — next playlist must repaint
|
||||||
if (registerTimer) { clearTimeout(registerTimer); registerTimer = null; }
|
if (registerTimer) { clearTimeout(registerTimer); registerTimer = null; }
|
||||||
authenticated = false;
|
authenticated = false;
|
||||||
|
|
@ -620,7 +620,7 @@
|
||||||
if (elPip) orientEl(elPip.style, o);
|
if (elPip) orientEl(elPip.style, o);
|
||||||
// #170: the player needs the orientation so portrait/flipped VIDEO routes through AVPlay
|
// #170: the player needs the orientation so portrait/flipped VIDEO routes through AVPlay
|
||||||
// (hardware-plane rotation) instead of the CSS-rotated <video> that Tizen blacks out.
|
// (hardware-plane rotation) instead of the CSS-rotated <video> that Tizen blacks out.
|
||||||
try { if (player && player.setOrientation) player.setOrientation(o); } catch (e) {}
|
try { if (player && player.setOrientation) player.setOrientation(o); } catch (e) { }
|
||||||
}
|
}
|
||||||
function orientEl(s, o) {
|
function orientEl(s, o) {
|
||||||
if (!o || o === 'landscape') {
|
if (!o || o === 'landscape') {
|
||||||
|
|
@ -655,7 +655,7 @@
|
||||||
}
|
}
|
||||||
// A2: cache the last RENDERABLE payload so a reboot / WS-outage with no connectivity replays it
|
// A2: cache the last RENDERABLE payload so a reboot / WS-outage with no connectivity replays it
|
||||||
// instead of showing the idle card. Only non-suspended payloads are cached.
|
// instead of showing the idle card. Only non-suspended payloads are cached.
|
||||||
try { set(LS.payload, JSON.stringify(payload)); } catch (e) {}
|
try { set(LS.payload, JSON.stringify(payload)); } catch (e) { }
|
||||||
// If we have content + we're paired, make sure we're on the stage.
|
// If we have content + we're paired, make sure we're on the stage.
|
||||||
if (elPairing.classList.contains('hidden') === false) show(elStage);
|
if (elPairing.classList.contains('hidden') === false) show(elStage);
|
||||||
else if (elStage.classList.contains('hidden')) show(elStage);
|
else if (elStage.classList.contains('hidden')) show(elStage);
|
||||||
|
|
@ -725,7 +725,7 @@
|
||||||
elReset.addEventListener('click', function () {
|
elReset.addEventListener('click', function () {
|
||||||
del(LS.url); del(LS.id); del(LS.token); del(LS.code); del(LS.payload);
|
del(LS.url); del(LS.id); del(LS.token); del(LS.code); del(LS.payload);
|
||||||
deviceId = null; deviceToken = null; serverUrl = null;
|
deviceId = null; deviceToken = null; serverUrl = null;
|
||||||
if (socket) { try { socket.disconnect(); } catch (e) {} }
|
if (socket) { try { socket.disconnect(); } catch (e) { } }
|
||||||
teardownSession(); // H4: stop heartbeat/stream/player-loop + pending register (no dangling timers on setup)
|
teardownSession(); // H4: stop heartbeat/stream/player-loop + pending register (no dangling timers on setup)
|
||||||
show(elSetup);
|
show(elSetup);
|
||||||
});
|
});
|
||||||
|
|
@ -737,9 +737,9 @@
|
||||||
if (!elSetup.classList.contains('hidden')) {
|
if (!elSetup.classList.contains('hidden')) {
|
||||||
stopKeepAwake(); stopWatchdog(); // FIX A/B: clear timers cleanly before the app exits
|
stopKeepAwake(); stopWatchdog(); // FIX A/B: clear timers cleanly before the app exits
|
||||||
sendExitSignal('clean_exit', 'back_key'); // exit-signal: operator BACK-key exit = confident clean_exit
|
sendExitSignal('clean_exit', 'back_key'); // exit-signal: operator BACK-key exit = confident clean_exit
|
||||||
try { tizen.application.getCurrentApplication().exit(); } catch (x) {}
|
try { tizen.application.getCurrentApplication().exit(); } catch (x) { }
|
||||||
} else {
|
} else {
|
||||||
if (socket) { try { socket.disconnect(); } catch (x) {} }
|
if (socket) { try { socket.disconnect(); } catch (x) { } }
|
||||||
teardownSession(); // H4: same clean teardown when BACK returns to setup
|
teardownSession(); // H4: same clean teardown when BACK returns to setup
|
||||||
elUrl.value = serverUrl || '';
|
elUrl.value = serverUrl || '';
|
||||||
elSetupStatus.textContent = ''; elSetupStatus.className = 'status';
|
elSetupStatus.textContent = ''; elSetupStatus.className = 'status';
|
||||||
|
|
@ -781,7 +781,7 @@
|
||||||
if (!deviceId || !deviceToken || !serverUrl) return; // unpaired -> nothing to attribute
|
if (!deviceId || !deviceToken || !serverUrl) return; // unpaired -> nothing to attribute
|
||||||
__exitSent = true;
|
__exitSent = true;
|
||||||
var d = (typeof detail === 'string' && detail) ? detail.slice(0, 200) : undefined;
|
var d = (typeof detail === 'string' && detail) ? detail.slice(0, 200) : undefined;
|
||||||
if (socket && socket.connected) { try { socket.emit('device:exit', { device_id: deviceId, reason: reason, detail: d }); } catch (e) {} }
|
if (socket && socket.connected) { try { socket.emit('device:exit', { device_id: deviceId, reason: reason, detail: d }); } catch (e) { } }
|
||||||
if (navigator.sendBeacon) {
|
if (navigator.sendBeacon) {
|
||||||
var body = JSON.stringify({ device_id: deviceId, device_token: deviceToken, reason: reason, detail: d });
|
var body = JSON.stringify({ device_id: deviceId, device_token: deviceToken, reason: reason, detail: d });
|
||||||
navigator.sendBeacon(serverUrl.replace(/\/+$/, '') + '/api/device/exit', new Blob([body], { type: 'application/json' }));
|
navigator.sendBeacon(serverUrl.replace(/\/+$/, '') + '/api/device/exit', new Blob([body], { type: 'application/json' }));
|
||||||
|
|
@ -808,7 +808,7 @@
|
||||||
// connects (or if it can't). The socket's fresh device:playlist-update replaces it on connect.
|
// connects (or if it can't). The socket's fresh device:playlist-update replaces it on connect.
|
||||||
show(elStage);
|
show(elStage);
|
||||||
var _cp = get(LS.payload);
|
var _cp = get(LS.payload);
|
||||||
if (_cp) { try { onPlaylist(JSON.parse(_cp)); } catch (e) {} }
|
if (_cp) { try { onPlaylist(JSON.parse(_cp)); } catch (e) { } }
|
||||||
connect(); // paired — reconnect to playback
|
connect(); // paired — reconnect to playback
|
||||||
} else if (serverUrl) {
|
} else if (serverUrl) {
|
||||||
show(elSetup); elUrl.value = serverUrl; // server known, not paired — confirm + connect
|
show(elSetup); elUrl.value = serverUrl; // server known, not paired — confirm + connect
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue