feat(cluster+home): Add Node wizard, homepage tagline/logo/settings tweaks
Cluster: AddNodeModal on Admin->Cluster mints a node token via /auth/tokens and emits a ready-to-paste curl|bash onboarding command. New admin-only GET /cluster/onboard-info returns apiUrl/scriptUrl/branch. Role->PROFILES mapping (worker/capture/gpu); gate worker-l4 behind compose profile [gpu]. Home: restore "Let's Create" kicker + one-line "Media Asset Management & Production Platform" tagline; animated accent pulse behind the dragon logo (reduced-motion safe); move Settings tile to a centered bottom row. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f21bc490e8
commit
19f0abeabe
5 changed files with 275 additions and 27 deletions
|
|
@ -103,6 +103,7 @@ services:
|
||||||
# worker-l4: HEAVY tier (proxy/conform/trim) on the L4 (NVENC). Talks to
|
# worker-l4: HEAVY tier (proxy/conform/trim) on the L4 (NVENC). Talks to
|
||||||
# zampp1's Redis/Postgres/S3 over the LAN (.200). No promotion scanner here.
|
# zampp1's Redis/Postgres/S3 over the LAN (.200). No promotion scanner here.
|
||||||
worker-l4:
|
worker-l4:
|
||||||
|
profiles: [gpu]
|
||||||
build:
|
build:
|
||||||
context: ./services/worker
|
context: ./services/worker
|
||||||
dockerfile: Dockerfile.gpu
|
dockerfile: Dockerfile.gpu
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,23 @@
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
import pool from '../db/pool.js';
|
import pool from '../db/pool.js';
|
||||||
|
import { requireAdmin } from '../middleware/auth.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// GET /onboard-info – admin-only. Supplies the Add Node wizard with the bits it
|
||||||
|
// needs to build a `curl … | bash` onboarding command: the primary API URL the
|
||||||
|
// remote node-agent should heartbeat to, the raw URL of onboard-node.sh, and
|
||||||
|
// the deploy branch. apiUrl is a best guess the UI lets the operator edit.
|
||||||
|
router.get('/onboard-info', requireAdmin, (req, res) => {
|
||||||
|
const branch = process.env.DEPLOY_BRANCH || 'main';
|
||||||
|
const apiUrl = process.env.PUBLIC_API_URL
|
||||||
|
|| `${req.protocol}://${req.hostname}:${process.env.API_PORT || 47432}`;
|
||||||
|
const scriptUrl =
|
||||||
|
`https://forge.wilddragon.net/zgaetano/wild-dragon/raw/branch/${branch}/deploy/onboard-node.sh`;
|
||||||
|
res.json({ apiUrl, scriptUrl, branch });
|
||||||
|
});
|
||||||
|
|
||||||
// If the agent reported Docker's default bridge IP (172.17.x) but the request
|
// If the agent reported Docker's default bridge IP (172.17.x) but the request
|
||||||
// itself came from a real LAN address, prefer the request source IP instead.
|
// itself came from a real LAN address, prefer the request source IP instead.
|
||||||
// We only check 172.17.x — the default docker0 bridge — not the full RFC1918
|
// We only check 172.17.x — the default docker0 bridge — not the full RFC1918
|
||||||
|
|
|
||||||
|
|
@ -1181,18 +1181,8 @@ function Cluster() {
|
||||||
|
|
||||||
const [adviceModal, setAdviceModal] = React.useState(null); // {title, lines:[], commands?}
|
const [adviceModal, setAdviceModal] = React.useState(null); // {title, lines:[], commands?}
|
||||||
|
|
||||||
const addNode = () => setAdviceModal({
|
const [showAddNode, setShowAddNode] = React.useState(false);
|
||||||
title: 'Add a worker node',
|
const addNode = () => setShowAddNode(true);
|
||||||
lines: [
|
|
||||||
'Worker nodes auto-register with the cluster on first heartbeat.',
|
|
||||||
'Run these on the new host (replace 10.0.0.25 with this MAM\'s IP):',
|
|
||||||
],
|
|
||||||
commands: [
|
|
||||||
'git clone https://forge.wilddragon.net/zgaetano/dragonflight.git /opt/dragonflight',
|
|
||||||
'cd /opt/dragonflight && cp .env.example .env && nano .env # set NODE_ROLE=worker, point at MAM IP',
|
|
||||||
'docker compose -f docker-compose.worker.yml up -d',
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const drainNode = (node) => setAdviceModal({
|
const drainNode = (node) => setAdviceModal({
|
||||||
title: `Drain ${node.id}`,
|
title: `Drain ${node.id}`,
|
||||||
|
|
@ -1399,6 +1389,7 @@ function Cluster() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{showAddNode && <AddNodeModal onClose={() => setShowAddNode(false)} />}
|
||||||
{adviceModal && (
|
{adviceModal && (
|
||||||
<div className="modal-backdrop" onClick={() => setAdviceModal(null)}>
|
<div className="modal-backdrop" onClick={() => setAdviceModal(null)}>
|
||||||
<div className="modal" style={{ width: 560 }} onClick={e => e.stopPropagation()}>
|
<div className="modal" style={{ width: 560 }} onClick={e => e.stopPropagation()}>
|
||||||
|
|
@ -1429,6 +1420,165 @@ function Cluster() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddNodeModal — Approach A onboarding wizard. Collects a node name + role,
|
||||||
|
// mints a one-time auth token via /auth/tokens, and renders a ready-to-paste
|
||||||
|
// `curl … | bash` command that provisions the machine via deploy/onboard-node.sh.
|
||||||
|
//
|
||||||
|
// Role → compose PROFILES mapping (see docker-compose.worker.yml):
|
||||||
|
// Worker → "worker"
|
||||||
|
// Capture → "worker capture"
|
||||||
|
// GPU → "worker gpu" (worker-l4 service, profiles: [gpu])
|
||||||
|
const ADD_NODE_ROLES = [
|
||||||
|
{ id: 'worker', label: 'Worker', profiles: 'worker', desc: 'CPU transcode / general jobs' },
|
||||||
|
{ id: 'capture', label: 'Capture', profiles: 'worker capture', desc: 'SDI / DeckLink ingest' },
|
||||||
|
{ id: 'gpu', label: 'GPU', profiles: 'worker gpu', desc: 'NVENC-accelerated transcode' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function AddNodeModal({ onClose }) {
|
||||||
|
const [nodeName, setNodeName] = React.useState('');
|
||||||
|
const [role, setRole] = React.useState('worker');
|
||||||
|
const [apiUrl, setApiUrl] = React.useState('');
|
||||||
|
const [info, setInfo] = React.useState(null); // { scriptUrl, branch }
|
||||||
|
const [command, setCommand] = React.useState(null); // generated string
|
||||||
|
const [error, setError] = React.useState(null);
|
||||||
|
const [busy, setBusy] = React.useState(false);
|
||||||
|
const [copied, setCopied] = React.useState(false);
|
||||||
|
|
||||||
|
// On open, prefill the editable apiUrl + capture scriptUrl/branch.
|
||||||
|
React.useEffect(() => {
|
||||||
|
window.ZAMPP_API.fetch('/cluster/onboard-info')
|
||||||
|
.then(d => {
|
||||||
|
setInfo({ scriptUrl: d.scriptUrl, branch: d.branch });
|
||||||
|
if (d.apiUrl) setApiUrl(d.apiUrl);
|
||||||
|
})
|
||||||
|
.catch(() => {}); // leave apiUrl empty → user must fill it before Generate
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const roleDef = ADD_NODE_ROLES.find(r => r.id === role) || ADD_NODE_ROLES[0];
|
||||||
|
|
||||||
|
const generate = async () => {
|
||||||
|
setError(null);
|
||||||
|
if (!nodeName.trim()) { setError('Node name is required.'); return; }
|
||||||
|
if (!apiUrl.trim()) { setError('Primary API URL is required.'); return; }
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/v1/auth/tokens', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
|
||||||
|
body: JSON.stringify({ name: 'node: ' + nodeName.trim() }),
|
||||||
|
});
|
||||||
|
if (r.status !== 201) {
|
||||||
|
const body = await r.json().catch(() => ({}));
|
||||||
|
setError(body.error || ('Failed to mint token (' + r.status + ')'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { token } = await r.json();
|
||||||
|
const scriptUrl = (info && info.scriptUrl)
|
||||||
|
|| 'https://forge.wilddragon.net/zgaetano/wild-dragon/raw/branch/main/deploy/onboard-node.sh';
|
||||||
|
const cmd =
|
||||||
|
`curl -sL ${scriptUrl} | NODE_TOKEN=${token} MAM_API_URL=${apiUrl.trim()} ` +
|
||||||
|
`NODE_ROLE=${role} PROFILES="${roleDef.profiles}" bash`;
|
||||||
|
setCommand(cmd);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || 'Network error');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copy = () => {
|
||||||
|
if (!command || !navigator.clipboard) return;
|
||||||
|
navigator.clipboard.writeText(command)
|
||||||
|
.then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); })
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
|
<div className="modal" style={{ width: 620 }} onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="modal-head">
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 600 }}>Add cluster node</div>
|
||||||
|
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
{!command && (
|
||||||
|
<React.Fragment>
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<label style={{ display: 'block', fontSize: 11.5, color: 'var(--text-3)', marginBottom: 5 }}>Node name</label>
|
||||||
|
<input className="field-input" style={{ width: '100%' }} autoFocus
|
||||||
|
placeholder="e.g. zampp3"
|
||||||
|
value={nodeName} onChange={e => setNodeName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<label style={{ display: 'block', fontSize: 11.5, color: 'var(--text-3)', marginBottom: 5 }}>Role</label>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
{ADD_NODE_ROLES.map(rd => (
|
||||||
|
<button key={rd.id}
|
||||||
|
className={'btn sm' + (role === rd.id ? ' primary' : ' ghost')}
|
||||||
|
style={{ flex: 1, flexDirection: 'column', alignItems: 'flex-start', gap: 2, padding: '8px 10px' }}
|
||||||
|
onClick={() => setRole(rd.id)}>
|
||||||
|
<span style={{ fontWeight: 600 }}>{rd.label}</span>
|
||||||
|
<span style={{ fontSize: 10, opacity: 0.8 }}>{rd.desc}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
<label style={{ display: 'block', fontSize: 11.5, color: 'var(--text-3)', marginBottom: 5 }}>Primary API URL</label>
|
||||||
|
<input className="field-input mono" style={{ width: '100%', fontSize: 12 }}
|
||||||
|
placeholder="http://10.0.0.25:47432"
|
||||||
|
value={apiUrl} onChange={e => setApiUrl(e.target.value)} />
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-4)', marginTop: 4 }}>
|
||||||
|
The LAN address this new node will heartbeat to. Edit if the guess is wrong.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{command && (
|
||||||
|
<React.Fragment>
|
||||||
|
<div style={{ marginBottom: 10, padding: 10, border: '1px solid var(--accent)', background: 'var(--accent-soft)', borderRadius: 6 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--accent-text)' }}>
|
||||||
|
This token is shown only once — copy the command now.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<code className="mono" style={{ display: 'block', background: 'var(--bg-2)', padding: 12, borderRadius: 6, fontSize: 11.5, lineHeight: 1.5, wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}>{command}</code>
|
||||||
|
<ol style={{ margin: '12px 0 0', paddingLeft: 18, fontSize: 12, color: 'var(--text-2)', lineHeight: 1.6 }}>
|
||||||
|
<li>SSH into the fresh Ubuntu machine.</li>
|
||||||
|
<li>Paste and run this command.</li>
|
||||||
|
<li>The node appears in this Cluster view within ~30s.</li>
|
||||||
|
</ol>
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ marginTop: 10, fontSize: 11.5, color: 'var(--danger)' }}>{error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="modal-foot">
|
||||||
|
{!command && (
|
||||||
|
<React.Fragment>
|
||||||
|
<button className="btn ghost sm" onClick={onClose}>Cancel</button>
|
||||||
|
<button className="btn primary sm" disabled={busy} onClick={generate}>
|
||||||
|
{busy ? 'Generating…' : 'Generate command'}
|
||||||
|
</button>
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
{command && (
|
||||||
|
<React.Fragment>
|
||||||
|
<button className="btn ghost sm" onClick={copy}>{copied ? 'Copied' : 'Copy'}</button>
|
||||||
|
<button className="btn primary sm" onClick={onClose}>Done</button>
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function DetailRow({ k, v, mono }) {
|
function DetailRow({ k, v, mono }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "90px 1fr", alignItems: "center", fontSize: 12 }}>
|
<div style={{ display: "grid", gridTemplateColumns: "90px 1fr", alignItems: "center", fontSize: 12 }}>
|
||||||
|
|
|
||||||
|
|
@ -89,16 +89,17 @@ function Home({ navigate }) {
|
||||||
: (failedJobs > 0 ? failedJobs + ' failed' : 'All clear'),
|
: (failedJobs > 0 ? failedJobs + ' failed' : 'All clear'),
|
||||||
desc: 'Proxy + thumbnail queue. Retry failed jobs.',
|
desc: 'Proxy + thumbnail queue. Retry failed jobs.',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'settings',
|
|
||||||
label: 'Settings',
|
|
||||||
icon: 'settings',
|
|
||||||
tone: 'neutral',
|
|
||||||
sub: 'S3 · Encoder · Growing files',
|
|
||||||
desc: 'Storage, proxy encoder, capture SDK, growing-file mode.',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const settingsTile = {
|
||||||
|
id: 'settings',
|
||||||
|
label: 'Settings',
|
||||||
|
icon: 'settings',
|
||||||
|
tone: 'neutral',
|
||||||
|
sub: 'S3 · Encoder · Growing files',
|
||||||
|
desc: 'Storage, proxy encoder, capture SDK, growing-file mode.',
|
||||||
|
};
|
||||||
|
|
||||||
const clusterHealthy = !nodesTotal || nodesOnline >= nodesTotal;
|
const clusterHealthy = !nodesTotal || nodesOnline >= nodesTotal;
|
||||||
|
|
||||||
// Activity strip (#153): live recorders + last-24h assets + alerts.
|
// Activity strip (#153): live recorders + last-24h assets + alerts.
|
||||||
|
|
@ -118,15 +119,19 @@ function Home({ navigate }) {
|
||||||
<div className="launcher">
|
<div className="launcher">
|
||||||
<div className="launcher-inner">
|
<div className="launcher-inner">
|
||||||
<div className="launcher-hero">
|
<div className="launcher-hero">
|
||||||
<img
|
<span className="launcher-logo-wrap">
|
||||||
className="launcher-logo"
|
<span className="launcher-logo-pulse" aria-hidden="true" />
|
||||||
src="img/dragon-logo.png"
|
<img
|
||||||
alt="Dragonflight"
|
className="launcher-logo"
|
||||||
draggable="false"
|
src="img/dragon-logo.png"
|
||||||
/>
|
alt="Dragonflight"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
<h1 className="launcher-wordmark">DRAGONFLIGHT</h1>
|
<h1 className="launcher-wordmark">DRAGONFLIGHT</h1>
|
||||||
|
<p className="launcher-kicker">Let's Create</p>
|
||||||
<p className="launcher-tagline">
|
<p className="launcher-tagline">
|
||||||
Self-hosted broadcast media-asset management
|
Media Asset Management & Production Platform
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -167,6 +172,23 @@ function Home({ navigate }) {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="launcher-settings-row">
|
||||||
|
<button
|
||||||
|
className={'launcher-tile tone-' + settingsTile.tone + ' launcher-tile-settings'}
|
||||||
|
onClick={() => navigate(settingsTile.id)}
|
||||||
|
>
|
||||||
|
<span className="launcher-tile-icon">
|
||||||
|
<Icon name={settingsTile.icon} size={26} />
|
||||||
|
</span>
|
||||||
|
<span className="launcher-tile-label">{settingsTile.label}</span>
|
||||||
|
<span className="launcher-tile-sub">{settingsTile.sub}</span>
|
||||||
|
<span className="launcher-tile-desc">{settingsTile.desc}</span>
|
||||||
|
<span className="launcher-tile-arrow">
|
||||||
|
<Icon name="arrowRight" size={14} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{hasActivity && (
|
{hasActivity && (
|
||||||
<div className="launcher-activity">
|
<div className="launcher-activity">
|
||||||
{(failedCount > 0 || errCount > 0) && (
|
{(failedCount > 0 || errCount > 0) && (
|
||||||
|
|
|
||||||
|
|
@ -293,7 +293,40 @@
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
/* Logo wrapper holds the animated pulse halo behind the image. */
|
||||||
|
.launcher-logo-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
.launcher-logo-pulse {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
transform: translate(-50%, -50%) scale(0.85);
|
||||||
|
border-radius: 50%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
background: radial-gradient(
|
||||||
|
circle at center,
|
||||||
|
color-mix(in srgb, var(--accent) 55%, transparent) 0%,
|
||||||
|
color-mix(in srgb, var(--accent) 22%, transparent) 38%,
|
||||||
|
transparent 68%
|
||||||
|
);
|
||||||
|
filter: blur(2px);
|
||||||
|
animation: launcherLogoPulse 3.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes launcherLogoPulse {
|
||||||
|
0%, 100% { transform: translate(-50%, -50%) scale(0.82); opacity: 0.45; }
|
||||||
|
50% { transform: translate(-50%, -50%) scale(1.08); opacity: 0.9; }
|
||||||
|
}
|
||||||
.launcher-logo {
|
.launcher-logo {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
width: 180px;
|
width: 180px;
|
||||||
height: 180px;
|
height: 180px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
|
|
@ -308,6 +341,9 @@
|
||||||
from { opacity: 0; transform: translateY(8px) scale(0.96); }
|
from { opacity: 0; transform: translateY(8px) scale(0.96); }
|
||||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
}
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.launcher-logo-pulse { animation: none; opacity: 0.5; }
|
||||||
|
}
|
||||||
.launcher-wordmark {
|
.launcher-wordmark {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 44px;
|
font-size: 44px;
|
||||||
|
|
@ -317,11 +353,23 @@
|
||||||
color: var(--text-1);
|
color: var(--text-1);
|
||||||
text-shadow: 0 0 32px rgba(91, 124, 250, 0.15);
|
text-shadow: 0 0 32px rgba(91, 124, 250, 0.15);
|
||||||
}
|
}
|
||||||
|
.launcher-kicker {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
.launcher-tagline {
|
.launcher-tagline {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-size: 13.5px;
|
font-size: 13.5px;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.launcher-tagline { font-size: 11.5px; letter-spacing: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.launcher-grid {
|
.launcher-grid {
|
||||||
|
|
@ -333,6 +381,19 @@
|
||||||
@media (max-width: 960px) { .launcher-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
@media (max-width: 960px) { .launcher-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||||
@media (max-width: 620px) { .launcher-grid { grid-template-columns: 1fr; } }
|
@media (max-width: 620px) { .launcher-grid { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
/* Settings sits on its own centered row beneath the main grid. */
|
||||||
|
.launcher-settings-row {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.launcher-tile-settings {
|
||||||
|
width: 100%;
|
||||||
|
max-width: calc((100% - 28px) / 3);
|
||||||
|
}
|
||||||
|
@media (max-width: 960px) { .launcher-tile-settings { max-width: calc((100% - 14px) / 2); } }
|
||||||
|
@media (max-width: 620px) { .launcher-tile-settings { max-width: 100%; } }
|
||||||
|
|
||||||
.launcher-tile {
|
.launcher-tile {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue