Refactor code structure for improved readability and maintainability

This commit is contained in:
jhartworks
2026-06-16 12:00:27 +02:00
parent a65fc56b8c
commit a7f8bfb58f
7 changed files with 1596 additions and 426 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+18
View File
@@ -59,6 +59,24 @@ ipcMain.handle("project:load", async () => {
return { canceled: false, filePath, data }; return { canceled: false, filePath, data };
}); });
ipcMain.handle("audio:pickFile", async () => {
const result = await dialog.showOpenDialog({
properties: ["openFile"],
filters: [
{ name: "Audio", extensions: ["mp3", "m4a", "flac", "wma"] }
]
});
if (result.canceled || result.filePaths.length === 0) {
return { canceled: true };
}
return {
canceled: false,
filePath: result.filePaths[0]
};
});
app.whenReady().then(() => { app.whenReady().then(() => {
createWindow(); createWindow();
+1
View File
@@ -25,6 +25,7 @@
"main.js", "main.js",
"preload.js", "preload.js",
"src/**/*", "src/**/*",
"JHArtworks2023_wh.png",
"package.json" "package.json"
], ],
"directories": { "directories": {
+179 -36
View File
@@ -1,6 +1,9 @@
const { contextBridge, ipcRenderer } = require("electron"); const { contextBridge, ipcRenderer } = require("electron");
const crypto = require("node:crypto"); const crypto = require("node:crypto");
const dgram = require("node:dgram"); const dgram = require("node:dgram");
const fs = require("node:fs/promises");
const os = require("node:os");
const { pathToFileURL } = require("node:url");
const mqtt = require("mqtt"); const mqtt = require("mqtt");
const { SerialPort } = require("serialport"); const { SerialPort } = require("serialport");
const ModbusRTU = require("modbus-serial"); const ModbusRTU = require("modbus-serial");
@@ -44,11 +47,13 @@ function addStatusApi(target, handler) {
const artnet = { const artnet = {
...createStatusEmitter(), ...createStatusEmitter(),
sendSocket: dgram.createSocket("udp4"), sendSocket: null,
sendSocketSignature: "",
listenSocket: null, listenSocket: null,
listeners: new Set(), listeners: new Set(),
config: { config: {
enabled: true, enabled: true,
localAddress: "",
host: "255.255.255.255", host: "255.255.255.255",
port: ARTNET_PORT, port: ARTNET_PORT,
broadcast: true, broadcast: true,
@@ -59,9 +64,11 @@ const artnet = {
const sacn = { const sacn = {
...createStatusEmitter(), ...createStatusEmitter(),
socket: dgram.createSocket("udp4"), socket: null,
socketSignature: "",
config: { config: {
enabled: false, enabled: false,
localAddress: "",
host: "", host: "",
port: SACN_PORT, port: SACN_PORT,
multicast: true, multicast: true,
@@ -73,9 +80,11 @@ const sacn = {
const osc = { const osc = {
...createStatusEmitter(), ...createStatusEmitter(),
socket: dgram.createSocket("udp4"), socket: null,
socketSignature: "",
config: { config: {
enabled: false, enabled: false,
localAddress: "",
host: "127.0.0.1", host: "127.0.0.1",
port: 8000 port: 8000
} }
@@ -87,6 +96,7 @@ const mqttState = {
connected: false, connected: false,
config: { config: {
enabled: false, enabled: false,
localAddress: "",
brokerUrl: "mqtt://127.0.0.1:1883", brokerUrl: "mqtt://127.0.0.1:1883",
username: "", username: "",
password: "", password: "",
@@ -115,6 +125,7 @@ const modbusTcpState = {
signature: "", signature: "",
config: { config: {
enabled: false, enabled: false,
localAddress: "",
host: "127.0.0.1", host: "127.0.0.1",
port: 502, port: 502,
timeout: 1000 timeout: 1000
@@ -144,6 +155,7 @@ const knxState = {
connected: false, connected: false,
config: { config: {
enabled: false, enabled: false,
interface: "",
ipAddr: "224.0.23.12", ipAddr: "224.0.23.12",
ipPort: KNX_PORT, ipPort: KNX_PORT,
physAddr: "15.15.15", physAddr: "15.15.15",
@@ -154,6 +166,7 @@ const knxState = {
function normalizeArtNetConfig(nextConfig = {}) { function normalizeArtNetConfig(nextConfig = {}) {
return { return {
enabled: nextConfig.enabled !== false, enabled: nextConfig.enabled !== false,
localAddress: String(nextConfig.localAddress || "").trim(),
host: nextConfig.host || "255.255.255.255", host: nextConfig.host || "255.255.255.255",
port: Number.isFinite(Number(nextConfig.port)) ? Number(nextConfig.port) : ARTNET_PORT, port: Number.isFinite(Number(nextConfig.port)) ? Number(nextConfig.port) : ARTNET_PORT,
broadcast: Boolean(nextConfig.broadcast), broadcast: Boolean(nextConfig.broadcast),
@@ -166,6 +179,7 @@ function normalizeArtNetConfig(nextConfig = {}) {
function normalizeSacnConfig(nextConfig = {}) { function normalizeSacnConfig(nextConfig = {}) {
return { return {
enabled: Boolean(nextConfig.enabled), enabled: Boolean(nextConfig.enabled),
localAddress: String(nextConfig.localAddress || "").trim(),
host: String(nextConfig.host || ""), host: String(nextConfig.host || ""),
port: Number.isFinite(Number(nextConfig.port)) ? Number(nextConfig.port) : SACN_PORT, port: Number.isFinite(Number(nextConfig.port)) ? Number(nextConfig.port) : SACN_PORT,
multicast: nextConfig.multicast !== false, multicast: nextConfig.multicast !== false,
@@ -176,6 +190,7 @@ function normalizeSacnConfig(nextConfig = {}) {
function normalizeOscConfig(nextConfig = {}) { function normalizeOscConfig(nextConfig = {}) {
return { return {
enabled: Boolean(nextConfig.enabled), enabled: Boolean(nextConfig.enabled),
localAddress: String(nextConfig.localAddress || "").trim(),
host: nextConfig.host || "127.0.0.1", host: nextConfig.host || "127.0.0.1",
port: Number.isFinite(Number(nextConfig.port)) ? Number(nextConfig.port) : 8000 port: Number.isFinite(Number(nextConfig.port)) ? Number(nextConfig.port) : 8000
}; };
@@ -184,6 +199,7 @@ function normalizeOscConfig(nextConfig = {}) {
function normalizeMqttConfig(nextConfig = {}) { function normalizeMqttConfig(nextConfig = {}) {
return { return {
enabled: Boolean(nextConfig.enabled), enabled: Boolean(nextConfig.enabled),
localAddress: String(nextConfig.localAddress || "").trim(),
brokerUrl: String(nextConfig.brokerUrl || "").trim() || "mqtt://127.0.0.1:1883", brokerUrl: String(nextConfig.brokerUrl || "").trim() || "mqtt://127.0.0.1:1883",
username: String(nextConfig.username || ""), username: String(nextConfig.username || ""),
password: String(nextConfig.password || ""), password: String(nextConfig.password || ""),
@@ -206,6 +222,7 @@ function normalizeSerialConfig(nextConfig = {}) {
function normalizeModbusTcpConfig(nextConfig = {}) { function normalizeModbusTcpConfig(nextConfig = {}) {
return { return {
enabled: Boolean(nextConfig.enabled), enabled: Boolean(nextConfig.enabled),
localAddress: String(nextConfig.localAddress || "").trim(),
host: String(nextConfig.host || "127.0.0.1"), host: String(nextConfig.host || "127.0.0.1"),
port: Number.isFinite(Number(nextConfig.port)) ? Number(nextConfig.port) : 502, port: Number.isFinite(Number(nextConfig.port)) ? Number(nextConfig.port) : 502,
timeout: Number.isFinite(Number(nextConfig.timeout)) ? Number(nextConfig.timeout) : 1000 timeout: Number.isFinite(Number(nextConfig.timeout)) ? Number(nextConfig.timeout) : 1000
@@ -227,6 +244,7 @@ function normalizeModbusRtuConfig(nextConfig = {}) {
function normalizeKnxConfig(nextConfig = {}) { function normalizeKnxConfig(nextConfig = {}) {
return { return {
enabled: Boolean(nextConfig.enabled), enabled: Boolean(nextConfig.enabled),
interface: String(nextConfig.interface || "").trim(),
ipAddr: String(nextConfig.ipAddr || "224.0.23.12"), ipAddr: String(nextConfig.ipAddr || "224.0.23.12"),
ipPort: Number.isFinite(Number(nextConfig.ipPort)) ? Number(nextConfig.ipPort) : KNX_PORT, ipPort: Number.isFinite(Number(nextConfig.ipPort)) ? Number(nextConfig.ipPort) : KNX_PORT,
physAddr: String(nextConfig.physAddr || "15.15.15"), physAddr: String(nextConfig.physAddr || "15.15.15"),
@@ -234,6 +252,79 @@ function normalizeKnxConfig(nextConfig = {}) {
}; };
} }
function createUdpSocket(target, label) {
const socket = dgram.createSocket("udp4");
socket.on("error", (error) => {
emitStatus(target, `${label} Socket Fehler: ${error.message}`);
});
return socket;
}
function closeUdpSocket(target, key, signatureKey) {
if (target[key]) {
try {
target[key].close();
} catch (_error) {
// Ignore close races while reconfiguring sockets.
}
}
target[key] = null;
target[signatureKey] = "";
}
async function ensureUdpSocket(target, key, signatureKey, label, config, options = {}) {
const signature = `${config.localAddress || ""}|${options.broadcast ? "1" : "0"}`;
if (target[key] && target[signatureKey] === signature) {
return target[key];
}
closeUdpSocket(target, key, signatureKey);
const socket = createUdpSocket(target, label);
target[key] = socket;
target[signatureKey] = signature;
if (options.broadcast) {
socket.setBroadcast(true);
}
if (!config.localAddress) {
return socket;
}
await new Promise((resolve, reject) => {
let settled = false;
const handleError = (error) => {
if (settled) {
return;
}
settled = true;
socket.off("listening", handleListening);
reject(error);
};
const handleListening = () => {
if (settled) {
return;
}
settled = true;
socket.off("error", handleError);
resolve(true);
};
socket.once("error", handleError);
socket.once("listening", handleListening);
try {
socket.bind(0, config.localAddress);
} catch (error) {
socket.off("error", handleError);
socket.off("listening", handleListening);
reject(error);
}
});
emitStatus(target, "");
return socket;
}
function closeArtNetListener() { function closeArtNetListener() {
if (artnet.listenSocket) { if (artnet.listenSocket) {
try { try {
@@ -273,10 +364,7 @@ function ensureArtNetListener() {
return; return;
} }
artnet.listenSocket = dgram.createSocket("udp4"); artnet.listenSocket = createUdpSocket(artnet, "Art-Net");
artnet.listenSocket.on("error", (error) => {
emitStatus(artnet, `Art-Net Listen Fehler: ${error.message}`);
});
artnet.listenSocket.on("listening", () => { artnet.listenSocket.on("listening", () => {
emitStatus(artnet, ""); emitStatus(artnet, "");
}); });
@@ -289,7 +377,11 @@ function ensureArtNetListener() {
}); });
try { try {
if (artnet.config.localAddress) {
artnet.listenSocket.bind(artnet.config.timecodeListenPort, artnet.config.localAddress);
} else {
artnet.listenSocket.bind(artnet.config.timecodeListenPort); artnet.listenSocket.bind(artnet.config.timecodeListenPort);
}
} catch (error) { } catch (error) {
emitStatus(artnet, `Art-Net Listen Fehler: ${error.message}`); emitStatus(artnet, `Art-Net Listen Fehler: ${error.message}`);
} }
@@ -297,32 +389,40 @@ function ensureArtNetListener() {
function configureArtNet(nextConfig) { function configureArtNet(nextConfig) {
const previousPort = artnet.config.timecodeListenPort; const previousPort = artnet.config.timecodeListenPort;
const previousLocalAddress = artnet.config.localAddress;
artnet.config = normalizeArtNetConfig(nextConfig); artnet.config = normalizeArtNetConfig(nextConfig);
try {
artnet.sendSocket.setBroadcast(artnet.config.broadcast);
} catch (error) {
emitStatus(artnet, `Art-Net Konfig Fehler: ${error.message}`);
}
if (!artnet.config.enabled) { if (!artnet.config.enabled) {
closeArtNetListener(); closeArtNetListener();
closeUdpSocket(artnet, "sendSocket", "sendSocketSignature");
emitStatus(artnet, ""); emitStatus(artnet, "");
return artnet.config; return artnet.config;
} }
if (previousPort !== artnet.config.timecodeListenPort) { if (
previousPort !== artnet.config.timecodeListenPort
|| previousLocalAddress !== artnet.config.localAddress
) {
closeArtNetListener(); closeArtNetListener();
} }
if (
artnet.sendSocket
&& (
previousLocalAddress !== artnet.config.localAddress
|| Boolean(artnet.config.broadcast) !== artnet.sendSocketSignature.endsWith("|1")
)
) {
closeUdpSocket(artnet, "sendSocket", "sendSocketSignature");
}
if (artnet.listeners.size > 0) { if (artnet.listeners.size > 0) {
ensureArtNetListener(); ensureArtNetListener();
} }
return artnet.config; return artnet.config;
} }
function sendArtNetDmx(payload) { async function sendArtNetDmx(payload) {
if (!artnet.config.enabled) { if (!artnet.config.enabled) {
return Promise.resolve(false); return false;
} }
const universe = Math.max(0, Math.min(32767, Number(payload.universe) || 0)); const universe = Math.max(0, Math.min(32767, Number(payload.universe) || 0));
@@ -344,8 +444,17 @@ function sendArtNetDmx(payload) {
} }
artnet.sequence = artnet.sequence >= 255 ? 1 : artnet.sequence + 1; artnet.sequence = artnet.sequence >= 255 ? 1 : artnet.sequence + 1;
const socket = await ensureUdpSocket(
artnet,
"sendSocket",
"sendSocketSignature",
"Art-Net",
artnet.config,
{ broadcast: artnet.config.broadcast }
);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
artnet.sendSocket.send(packet, 0, packet.length, artnet.config.port, artnet.config.host, (error) => { socket.send(packet, 0, packet.length, artnet.config.port, artnet.config.host, (error) => {
if (error) { if (error) {
emitStatus(artnet, `Art-Net Send Fehler: ${error.message}`); emitStatus(artnet, `Art-Net Send Fehler: ${error.message}`);
reject(error); reject(error);
@@ -358,7 +467,11 @@ function sendArtNetDmx(payload) {
} }
function configureOsc(nextConfig) { function configureOsc(nextConfig) {
const previousLocalAddress = osc.config.localAddress;
osc.config = normalizeOscConfig(nextConfig); osc.config = normalizeOscConfig(nextConfig);
if (previousLocalAddress !== osc.config.localAddress) {
closeUdpSocket(osc, "socket", "socketSignature");
}
emitStatus(osc, ""); emitStatus(osc, "");
return osc.config; return osc.config;
} }
@@ -383,9 +496,9 @@ function encodeOscArgument(value, argType) {
return { tag: "f", data }; return { tag: "f", data };
} }
function sendOsc(payload) { async function sendOsc(payload) {
if (!osc.config.enabled) { if (!osc.config.enabled) {
return Promise.resolve(false); return false;
} }
const address = String(payload.address || "/time4midi"); const address = String(payload.address || "/time4midi");
const normalizedAddress = address.startsWith("/") ? address : `/${address}`; const normalizedAddress = address.startsWith("/") ? address : `/${address}`;
@@ -395,8 +508,9 @@ function sendOsc(payload) {
encodeOscString(`,${argument.tag}`), encodeOscString(`,${argument.tag}`),
argument.data argument.data
]); ]);
const socket = await ensureUdpSocket(osc, "socket", "socketSignature", "OSC", osc.config);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
osc.socket.send(packet, 0, packet.length, osc.config.port, osc.config.host, (error) => { socket.send(packet, 0, packet.length, osc.config.port, osc.config.host, (error) => {
if (error) { if (error) {
emitStatus(osc, `OSC Send Fehler: ${error.message}`); emitStatus(osc, `OSC Send Fehler: ${error.message}`);
reject(error); reject(error);
@@ -409,7 +523,11 @@ function sendOsc(payload) {
} }
function configureSacn(nextConfig) { function configureSacn(nextConfig) {
const previousLocalAddress = sacn.config.localAddress;
sacn.config = normalizeSacnConfig(nextConfig); sacn.config = normalizeSacnConfig(nextConfig);
if (previousLocalAddress !== sacn.config.localAddress) {
closeUdpSocket(sacn, "socket", "socketSignature");
}
emitStatus(sacn, ""); emitStatus(sacn, "");
return sacn.config; return sacn.config;
} }
@@ -437,9 +555,9 @@ function writeFixedString(buffer, offset, size, value) {
Buffer.from(String(value || ""), "utf8").copy(buffer, offset, 0, size); Buffer.from(String(value || ""), "utf8").copy(buffer, offset, 0, size);
} }
function sendSacnDmx(payload) { async function sendSacnDmx(payload) {
if (!sacn.config.enabled) { if (!sacn.config.enabled) {
return Promise.resolve(false); return false;
} }
const universe = clampSacnUniverse(payload.universe); const universe = clampSacnUniverse(payload.universe);
@@ -480,9 +598,10 @@ function sendSacnDmx(payload) {
sacn.sequence = sacn.sequence >= 255 ? 1 : sacn.sequence + 1; sacn.sequence = sacn.sequence >= 255 ? 1 : sacn.sequence + 1;
const host = sacnDestination(universe); const host = sacnDestination(universe);
const socket = await ensureUdpSocket(sacn, "socket", "socketSignature", "sACN", sacn.config);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
sacn.socket.send(packet, 0, packet.length, sacn.config.port, host, (error) => { socket.send(packet, 0, packet.length, sacn.config.port, host, (error) => {
if (error) { if (error) {
emitStatus(sacn, `sACN Send Fehler: ${error.message}`); emitStatus(sacn, `sACN Send Fehler: ${error.message}`);
reject(error); reject(error);
@@ -520,6 +639,7 @@ function configureMqtt(nextConfig) {
if (mqttState.config.clientId) options.clientId = mqttState.config.clientId; if (mqttState.config.clientId) options.clientId = mqttState.config.clientId;
if (mqttState.config.username) options.username = mqttState.config.username; if (mqttState.config.username) options.username = mqttState.config.username;
if (mqttState.config.password) options.password = mqttState.config.password; if (mqttState.config.password) options.password = mqttState.config.password;
if (mqttState.config.localAddress) options.localAddress = mqttState.config.localAddress;
try { try {
mqttState.client = mqtt.connect(mqttState.config.brokerUrl, options); mqttState.client = mqtt.connect(mqttState.config.brokerUrl, options);
@@ -656,6 +776,28 @@ async function listSerialPorts() {
})); }));
} }
async function listNetworkInterfaces() {
const interfaces = os.networkInterfaces();
const items = [];
Object.entries(interfaces).forEach(([name, entries]) => {
(entries || []).forEach((entry) => {
const family = typeof entry.family === "string" ? entry.family : String(entry.family);
if (family !== "IPv4" || entry.internal || !entry.address) {
return;
}
items.push({
id: entry.address,
name: `${name} - ${entry.address}`
});
});
});
items.sort((left, right) => left.name.localeCompare(right.name));
return items;
}
async function disconnectModbusClient(state) { async function disconnectModbusClient(state) {
if (state.client?.isOpen) { if (state.client?.isOpen) {
try { try {
@@ -693,7 +835,8 @@ async function ensureModbusTcpConnected() {
await disconnectModbusClient(modbusTcpState); await disconnectModbusClient(modbusTcpState);
await modbusTcpState.client.connectTCP(modbusTcpState.config.host, { await modbusTcpState.client.connectTCP(modbusTcpState.config.host, {
port: modbusTcpState.config.port, port: modbusTcpState.config.port,
timeout: modbusTcpState.config.timeout timeout: modbusTcpState.config.timeout,
localAddress: modbusTcpState.config.localAddress || undefined
}); });
modbusTcpState.client.setTimeout(modbusTcpState.config.timeout); modbusTcpState.client.setTimeout(modbusTcpState.config.timeout);
modbusTcpState.connected = true; modbusTcpState.connected = true;
@@ -806,6 +949,7 @@ async function ensureKnxConnection() {
const connection = knx.Connection({ const connection = knx.Connection({
ipAddr: knxState.config.ipAddr, ipAddr: knxState.config.ipAddr,
ipPort: knxState.config.ipPort, ipPort: knxState.config.ipPort,
interface: knxState.config.interface || undefined,
physAddr: knxState.config.physAddr, physAddr: knxState.config.physAddr,
forceTunneling: knxState.config.forceTunneling, forceTunneling: knxState.config.forceTunneling,
handlers: { handlers: {
@@ -871,18 +1015,6 @@ async function sendKnx(payload) {
}); });
} }
artnet.sendSocket.on("error", (error) => {
emitStatus(artnet, `Art-Net Socket Fehler: ${error.message}`);
});
osc.socket.on("error", (error) => {
emitStatus(osc, `OSC Socket Fehler: ${error.message}`);
});
sacn.socket.on("error", (error) => {
emitStatus(sacn, `sACN Socket Fehler: ${error.message}`);
});
configureMqtt(mqttState.config); configureMqtt(mqttState.config);
contextBridge.exposeInMainWorld("time4midi", { contextBridge.exposeInMainWorld("time4midi", {
@@ -890,6 +1022,17 @@ contextBridge.exposeInMainWorld("time4midi", {
save: (payload) => ipcRenderer.invoke("project:save", payload), save: (payload) => ipcRenderer.invoke("project:save", payload),
load: () => ipcRenderer.invoke("project:load") load: () => ipcRenderer.invoke("project:load")
}, },
audio: {
pickFile: () => ipcRenderer.invoke("audio:pickFile"),
readFile: async (filePath) => {
const buffer = await fs.readFile(filePath);
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
},
fileUrl: (filePath) => pathToFileURL(filePath).href
},
network: {
listInterfaces: () => listNetworkInterfaces()
},
artnet: { artnet: {
configure: (config) => configureArtNet(config), configure: (config) => configureArtNet(config),
sendDmx: (payload) => sendArtNetDmx(payload), sendDmx: (payload) => sendArtNetDmx(payload),
+852 -39
View File
File diff suppressed because it is too large Load Diff
+156 -46
View File
@@ -11,12 +11,10 @@
<aside class="sidebar"> <aside class="sidebar">
<div class="panel brand-panel"> <div class="panel brand-panel">
<div class="brand-lockup"> <div class="brand-lockup">
<div class="logo-mark" aria-hidden="true"> <img class="brand-logo" src="../JHArtworks2023_wh.png" alt="J.H. Artworks Logo">
<span></span>
</div>
<div> <div>
<h1>Time4Midi</h1> <h1>Time4Midi</h1>
<div class="brand-credit">a J.H. Artworks Product</div> <div class="brand-credit">by J.H. Artworks</div>
</div> </div>
</div> </div>
</div> </div>
@@ -62,6 +60,7 @@
<button id="addKnxTrackButton" class="ghost">+ KNX</button> <button id="addKnxTrackButton" class="ghost">+ KNX</button>
<button id="addOscTrackButton" class="ghost">+ OSC</button> <button id="addOscTrackButton" class="ghost">+ OSC</button>
<button id="addMqttTrackButton" class="ghost">+ MQTT</button> <button id="addMqttTrackButton" class="ghost">+ MQTT</button>
<button id="addAudioTrackButton" class="ghost">+ Audio</button>
</div> </div>
</div> </div>
<div id="trackList" class="track-list"></div> <div id="trackList" class="track-list"></div>
@@ -88,6 +87,7 @@
<strong id="transportStatusLabel">Idle</strong> <strong id="transportStatusLabel">Idle</strong>
</div> </div>
<div class="transport-controls"> <div class="transport-controls">
<button id="openSettingsButton" class="ghost">Einstellungen</button>
<button id="playButton">Play</button> <button id="playButton">Play</button>
<button id="pauseButton" class="ghost">Pause</button> <button id="pauseButton" class="ghost">Pause</button>
<button id="stopButton" class="ghost">Stop</button> <button id="stopButton" class="ghost">Stop</button>
@@ -112,14 +112,37 @@
</div> </div>
</section> </section>
</main> </main>
</div>
<aside class="inspector" id="inspectorPane"> <div id="settingsModal" class="settings-modal" aria-hidden="true">
<button id="inspectorToggleButton" class="inspector-toggle ghost" title="Rechte Leiste ein-/ausklappen"> <div id="settingsBackdrop" class="settings-backdrop"></div>
> <section class="settings-dialog panel" role="dialog" aria-modal="true" aria-labelledby="settingsTitle">
</button> <div class="settings-dialog-header">
<div class="panel"> <div>
<div class="eyebrow">Setup</div>
<h2 id="settingsTitle">Einstellungen</h2>
</div>
<div class="panel-actions">
<button id="closeSettingsButton" class="ghost" type="button">Schliessen</button>
</div>
</div>
<div class="settings-tabbar" role="tablist" aria-label="Einstellungen">
<button class="settings-tab active" type="button" data-settings-tab="routing">Routing</button>
<button class="settings-tab" type="button" data-settings-tab="timeline">Timeline</button>
<button class="settings-tab" type="button" data-settings-tab="selection">Auswahl</button>
</div>
<div class="settings-content">
<div class="settings-tab-panel active" data-settings-panel="routing">
<div class="settings-stack">
<div class="info-card">
Adapter = lokale Netzwerkkarte bzw. lokale IP. Leer bedeutet automatische Auswahl durch das Betriebssystem.
</div>
<section class="settings-section">
<div class="panel-header"> <div class="panel-header">
<h2>Routing</h2> <h2>MIDI</h2>
</div> </div>
<label class="field"> <label class="field">
<span>MIDI Input</span> <span>MIDI Input</span>
@@ -129,14 +152,34 @@
<span>MIDI Output</span> <span>MIDI Output</span>
<select id="midiOutputSelect"></select> <select id="midiOutputSelect"></select>
</label> </label>
</section>
<section class="settings-section">
<div class="panel-header">
<h2>Audio</h2>
</div>
<label class="field"> <label class="field">
<span>Art-Net Ziel</span> <span>Audio Output</span>
<input id="artnetHostInput" type="text" placeholder="255.255.255.255"> <select id="audioOutputSelect"></select>
</label> </label>
</section>
<section class="settings-section">
<div class="panel-header">
<h2>Art-Net</h2>
</div>
<label class="field checkbox-row"> <label class="field checkbox-row">
<span>Art-Net aktiv</span> <span>Art-Net aktiv</span>
<input id="artnetEnabledInput" type="checkbox"> <input id="artnetEnabledInput" type="checkbox">
</label> </label>
<label class="field">
<span>Netzwerkadapter</span>
<select id="artnetAdapterSelect"></select>
</label>
<label class="field">
<span>Art-Net Ziel</span>
<input id="artnetHostInput" type="text" placeholder="255.255.255.255">
</label>
<div class="field-grid"> <div class="field-grid">
<label class="field"> <label class="field">
<span>Port</span> <span>Port</span>
@@ -157,19 +200,19 @@
<input id="artnetBroadcastInput" type="checkbox"> <input id="artnetBroadcastInput" type="checkbox">
</label> </label>
</div> </div>
<div class="field-grid"> </section>
<label class="field">
<span>OSC Host</span> <section class="settings-section">
<input id="oscHostInput" type="text" placeholder="127.0.0.1"> <div class="panel-header">
</label> <h2>sACN</h2>
<label class="field">
<span>OSC Port</span>
<input id="oscPortInput" type="number" min="1" max="65535">
</label>
</div> </div>
<label class="field checkbox-row"> <label class="field checkbox-row">
<span>OSC aktiv</span> <span>sACN aktiv</span>
<input id="oscEnabledInput" type="checkbox"> <input id="sacnEnabledInput" type="checkbox">
</label>
<label class="field">
<span>Netzwerkadapter</span>
<select id="sacnAdapterSelect"></select>
</label> </label>
<div class="field-grid"> <div class="field-grid">
<label class="field"> <label class="field">
@@ -181,10 +224,6 @@
<input id="sacnPortInput" type="number" min="1" max="65535"> <input id="sacnPortInput" type="number" min="1" max="65535">
</label> </label>
</div> </div>
<label class="field checkbox-row">
<span>sACN aktiv</span>
<input id="sacnEnabledInput" type="checkbox">
</label>
<div class="field-grid"> <div class="field-grid">
<label class="field"> <label class="field">
<span>sACN Source</span> <span>sACN Source</span>
@@ -195,14 +234,48 @@
<input id="sacnMulticastInput" type="checkbox"> <input id="sacnMulticastInput" type="checkbox">
</label> </label>
</div> </div>
<label class="field"> </section>
<span>MQTT Broker</span>
<input id="mqttBrokerInput" type="text" placeholder="mqtt://127.0.0.1:1883"> <section class="settings-section">
<div class="panel-header">
<h2>OSC</h2>
</div>
<label class="field checkbox-row">
<span>OSC aktiv</span>
<input id="oscEnabledInput" type="checkbox">
</label> </label>
<label class="field">
<span>Netzwerkadapter</span>
<select id="oscAdapterSelect"></select>
</label>
<div class="field-grid">
<label class="field">
<span>OSC Host</span>
<input id="oscHostInput" type="text" placeholder="127.0.0.1">
</label>
<label class="field">
<span>OSC Port</span>
<input id="oscPortInput" type="number" min="1" max="65535">
</label>
</div>
</section>
<section class="settings-section">
<div class="panel-header">
<h2>MQTT</h2>
</div>
<label class="field checkbox-row"> <label class="field checkbox-row">
<span>MQTT aktiv</span> <span>MQTT aktiv</span>
<input id="mqttEnabledInput" type="checkbox"> <input id="mqttEnabledInput" type="checkbox">
</label> </label>
<label class="field">
<span>Netzwerkadapter</span>
<select id="mqttAdapterSelect"></select>
</label>
<label class="field">
<span>MQTT Broker</span>
<input id="mqttBrokerInput" type="text" placeholder="mqtt://127.0.0.1:1883">
</label>
<div class="field-grid"> <div class="field-grid">
<label class="field"> <label class="field">
<span>MQTT User</span> <span>MQTT User</span>
@@ -217,6 +290,16 @@
<span>MQTT Client ID</span> <span>MQTT Client ID</span>
<input id="mqttClientIdInput" type="text" placeholder="time4midi-desktop"> <input id="mqttClientIdInput" type="text" placeholder="time4midi-desktop">
</label> </label>
</section>
<section class="settings-section">
<div class="panel-header">
<h2>Serial</h2>
</div>
<label class="field checkbox-row">
<span>Serial aktiv</span>
<input id="serialEnabledInput" type="checkbox">
</label>
<div class="field-grid"> <div class="field-grid">
<label class="field"> <label class="field">
<span>Serial Port</span> <span>Serial Port</span>
@@ -244,9 +327,19 @@
</select> </select>
</label> </label>
</div> </div>
</section>
<section class="settings-section">
<div class="panel-header">
<h2>Modbus TCP</h2>
</div>
<label class="field checkbox-row"> <label class="field checkbox-row">
<span>Serial aktiv</span> <span>Modbus TCP aktiv</span>
<input id="serialEnabledInput" type="checkbox"> <input id="modbusTcpEnabledInput" type="checkbox">
</label>
<label class="field">
<span>Netzwerkadapter</span>
<select id="modbusTcpAdapterSelect"></select>
</label> </label>
<div class="field-grid"> <div class="field-grid">
<label class="field"> <label class="field">
@@ -258,9 +351,15 @@
<input id="modbusTcpPortInput" type="number" min="1" max="65535"> <input id="modbusTcpPortInput" type="number" min="1" max="65535">
</label> </label>
</div> </div>
</section>
<section class="settings-section">
<div class="panel-header">
<h2>Modbus RTU</h2>
</div>
<label class="field checkbox-row"> <label class="field checkbox-row">
<span>Modbus TCP aktiv</span> <span>Modbus RTU aktiv</span>
<input id="modbusTcpEnabledInput" type="checkbox"> <input id="modbusRtuEnabledInput" type="checkbox">
</label> </label>
<div class="field-grid"> <div class="field-grid">
<label class="field"> <label class="field">
@@ -272,7 +371,6 @@
<input id="modbusRtuBaudInput" type="number" min="300" max="921600"> <input id="modbusRtuBaudInput" type="number" min="300" max="921600">
</label> </label>
</div> </div>
<div class="field-grid">
<label class="field"> <label class="field">
<span>Parity</span> <span>Parity</span>
<select id="modbusRtuParitySelect"> <select id="modbusRtuParitySelect">
@@ -281,11 +379,20 @@
<option value="odd">Odd</option> <option value="odd">Odd</option>
</select> </select>
</label> </label>
<label class="field checkbox-row"> </section>
<span>Modbus RTU aktiv</span>
<input id="modbusRtuEnabledInput" type="checkbox"> <section class="settings-section">
</label> <div class="panel-header">
<h2>KNX</h2>
</div> </div>
<label class="field checkbox-row">
<span>KNX aktiv</span>
<input id="knxEnabledInput" type="checkbox">
</label>
<label class="field">
<span>Netzwerkadapter</span>
<select id="knxAdapterSelect"></select>
</label>
<div class="field-grid"> <div class="field-grid">
<label class="field"> <label class="field">
<span>KNX IP</span> <span>KNX IP</span>
@@ -306,26 +413,29 @@
<input id="knxTunnelingInput" type="checkbox"> <input id="knxTunnelingInput" type="checkbox">
</label> </label>
</div> </div>
<label class="field checkbox-row"> </section>
<span>KNX aktiv</span> </div>
<input id="knxEnabledInput" type="checkbox">
</label>
</div> </div>
<div class="panel"> <div class="settings-tab-panel" data-settings-panel="timeline">
<section class="settings-section">
<div class="panel-header"> <div class="panel-header">
<h2>Timeline</h2> <h2>Timeline</h2>
</div> </div>
<div id="timelineInspector"></div> <div id="timelineInspector"></div>
</section>
</div> </div>
<div class="panel"> <div class="settings-tab-panel" data-settings-panel="selection">
<section class="settings-section">
<div class="panel-header"> <div class="panel-header">
<h2>Auswahl</h2> <h2>Auswahl</h2>
</div> </div>
<div id="selectionInspector"></div> <div id="selectionInspector"></div>
</section>
</div> </div>
</aside> </div>
</section>
</div> </div>
<script src="./app.js"></script> <script src="./app.js"></script>
+224 -139
View File
@@ -7,13 +7,14 @@
--panel-border: rgba(255, 255, 255, 0.08); --panel-border: rgba(255, 255, 255, 0.08);
--text: #ecf2ff; --text: #ecf2ff;
--muted: #a4b2cc; --muted: #a4b2cc;
--accent: #6ef2c6; --accent: #6ec2f2;
--accent-strong: #17c98d; --accent-strong: #1782c9;
--danger: #ff7e87; --danger: #ff7e87;
--grid: rgba(255, 255, 255, 0.08); --grid: rgba(255, 255, 255, 0.08);
--midi: #61c8ff; --midi: #61c8ff;
--artnet: #ffb35c; --artnet: #ffb35c;
--shadow: 0 20px 60px rgba(0, 0, 0, 0.35); --shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
font-size: 14px;
} }
* { * {
@@ -25,9 +26,9 @@ body {
margin: 0; margin: 0;
min-height: 100%; min-height: 100%;
background: background:
radial-gradient(circle at top left, rgba(110, 242, 198, 0.14), transparent 32%), radial-gradient(circle at top left, rgba(110, 194, 242, 0.18), transparent 34%),
radial-gradient(circle at top right, rgba(97, 200, 255, 0.14), transparent 28%), radial-gradient(circle at top right, rgba(23, 130, 201, 0.16), transparent 28%),
linear-gradient(180deg, #0a0d13 0%, #111722 100%); linear-gradient(180deg, #09111b 0%, #101a28 100%);
color: var(--text); color: var(--text);
font-family: "Trebuchet MS", "Segoe UI Variable Text", "Aptos", sans-serif; font-family: "Trebuchet MS", "Segoe UI Variable Text", "Aptos", sans-serif;
} }
@@ -44,17 +45,18 @@ select {
button { button {
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px; border-radius: 10px;
background: linear-gradient(180deg, rgba(110, 242, 198, 0.2), rgba(23, 201, 141, 0.15)); background: linear-gradient(180deg, rgba(110, 194, 242, 0.2), rgba(23, 130, 201, 0.15));
color: var(--text); color: var(--text);
padding: 0.65rem 0.95rem; padding: 0.5rem 0.75rem;
cursor: pointer; cursor: pointer;
transition: transform 120ms ease, border-color 120ms ease, background 120ms ease; transition: transform 120ms ease, border-color 120ms ease, background 120ms ease;
font-size: 0.92rem;
} }
button:hover { button:hover {
transform: translateY(-1px); transform: translateY(-1px);
border-color: rgba(110, 242, 198, 0.45); border-color: rgba(110, 222, 242, 0.45);
} }
button.ghost { button.ghost {
@@ -65,68 +67,35 @@ input,
select { select {
width: 100%; width: 100%;
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px; border-radius: 10px;
padding: 0.7rem 0.8rem; padding: 0.56rem 0.7rem;
background: rgba(0, 0, 0, 0.22); background: rgba(0, 0, 0, 0.22);
color: var(--text); color: var(--text);
font-size: 0.92rem;
} }
.app-shell { .app-shell {
display: grid; display: grid;
grid-template-columns: 332px minmax(0, 1fr) 292px; grid-template-columns: 286px minmax(0, 1fr);
gap: 18px; gap: 12px;
min-height: 100vh; min-height: 100vh;
padding: 18px; padding: 12px;
}
.app-shell.inspector-collapsed {
grid-template-columns: 332px minmax(0, 1fr) 54px;
} }
.sidebar, .sidebar,
.workspace, .workspace {
.inspector {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 18px; gap: 12px;
} }
.panel { .panel {
background: var(--panel); background: var(--panel);
border: 1px solid var(--panel-border); border: 1px solid var(--panel-border);
border-radius: 24px; border-radius: 18px;
box-shadow: var(--shadow); box-shadow: var(--shadow);
backdrop-filter: blur(18px); backdrop-filter: blur(18px);
padding: 18px; padding: 14px;
}
.inspector {
position: relative;
gap: 12px;
min-width: 0;
overflow-y: auto;
padding-right: 2px;
}
.inspector .panel {
padding: 12px;
border-radius: 20px;
}
.inspector-toggle {
align-self: flex-end;
width: 40px;
height: 40px;
padding: 0;
border-radius: 12px;
}
.app-shell.inspector-collapsed .inspector .panel {
display: none;
}
.app-shell.inspector-collapsed .inspector {
align-items: center;
} }
.brand-panel { .brand-panel {
@@ -140,41 +109,12 @@ select {
gap: 14px; gap: 14px;
} }
.logo-mark { .brand-logo {
width: 64px; width: 96px;
height: 64px; max-width: 36%;
border-radius: 18px; height: auto;
position: relative; object-fit: contain;
background: filter: drop-shadow(0 10px 24px rgba(0, 0, 0, 0.35));
linear-gradient(160deg, rgba(110, 242, 198, 0.3), rgba(97, 200, 255, 0.18)),
rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.12);
}
.logo-mark::before,
.logo-mark::after,
.logo-mark span {
content: "";
position: absolute;
bottom: 12px;
width: 10px;
border-radius: 999px;
background: linear-gradient(180deg, #6ef2c6, #61c8ff);
}
.logo-mark::before {
left: 16px;
height: 22px;
}
.logo-mark span {
left: 28px;
height: 34px;
}
.logo-mark::after {
left: 40px;
height: 46px;
} }
.brand-panel::after { .brand-panel::after {
@@ -183,14 +123,14 @@ select {
inset: auto -80px -100px auto; inset: auto -80px -100px auto;
width: 220px; width: 220px;
height: 220px; height: 220px;
background: radial-gradient(circle, rgba(110, 242, 198, 0.24), transparent 68%); background: radial-gradient(circle, rgba(110, 194, 242, 0.24), transparent 68%);
pointer-events: none; pointer-events: none;
} }
.eyebrow { .eyebrow {
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.18em; letter-spacing: 0.18em;
font-size: 0.72rem; font-size: 0.64rem;
color: var(--accent); color: var(--accent);
} }
@@ -200,25 +140,26 @@ h2 {
} }
h1 { h1 {
font-size: 1.6rem; font-size: 1.24rem;
line-height: 1.15; line-height: 1.15;
} }
.brand-credit { .brand-credit {
margin-top: 0.35rem; margin-top: 0.2rem;
color: var(--muted); color: var(--muted);
font-size: 0.72rem; font-size: 0.62rem;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
} }
h2 { h2 {
font-size: 1.02rem; font-size: 0.9rem;
} }
p { p {
color: var(--muted); color: var(--muted);
line-height: 1.5; line-height: 1.4;
font-size: 0.9rem;
} }
.panel-header, .panel-header,
@@ -227,32 +168,32 @@ p {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 12px; gap: 8px;
} }
.panel-actions, .panel-actions,
.transport-controls, .transport-controls,
.editor-hints { .editor-hints {
display: flex; display: flex;
gap: 8px; gap: 6px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.editor-hints span, .editor-hints span,
.meta-row span, .meta-row span,
.transport-label { .transport-label {
font-size: 0.78rem; font-size: 0.7rem;
color: var(--muted); color: var(--muted);
} }
.field, .field,
.field-grid { .field-grid {
display: grid; display: grid;
gap: 0.45rem; gap: 0.32rem;
} }
.field { .field {
margin-top: 12px; margin-top: 8px;
} }
.checkbox-row { .checkbox-row {
@@ -261,18 +202,18 @@ p {
} }
.checkbox-row input { .checkbox-row input {
width: 20px; width: 16px;
height: 20px; height: 16px;
} }
.field span { .field span {
font-size: 0.82rem; font-size: 0.72rem;
color: var(--muted); color: var(--muted);
} }
.field-grid { .field-grid {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 12px; gap: 8px;
} }
.checkbox { .checkbox {
@@ -280,14 +221,14 @@ p {
} }
.checkbox input { .checkbox input {
width: 20px; width: 16px;
height: 20px; height: 16px;
} }
.meta-row { .meta-row {
margin-top: 12px; margin-top: 8px;
display: grid; display: grid;
gap: 0.3rem; gap: 0.18rem;
} }
.path-label { .path-label {
@@ -303,16 +244,16 @@ p {
#timelineInspector, #timelineInspector,
#selectionInspector { #selectionInspector {
display: grid; display: grid;
gap: 10px; gap: 8px;
margin-top: 12px; margin-top: 8px;
} }
.timeline-card, .timeline-card,
.track-card, .track-card,
.info-card { .info-card {
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 18px; border-radius: 14px;
padding: 12px 14px; padding: 10px 12px;
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
} }
@@ -322,16 +263,28 @@ p {
.timeline-card.active, .timeline-card.active,
.track-card.active { .track-card.active {
border-color: rgba(110, 242, 198, 0.52); border-color: rgba(110, 194, 242, 0.52);
background: rgba(110, 242, 198, 0.09); background: rgba(110, 194, 242, 0.12);
}
.track-card.drag-source {
opacity: 0.62;
}
.track-card.drag-before {
box-shadow: inset 0 3px 0 rgba(110, 194, 242, 0.9);
}
.track-card.drag-after {
box-shadow: inset 0 -3px 0 rgba(110, 194, 242, 0.9);
} }
.track-pill { .track-pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
border-radius: 999px; border-radius: 999px;
padding: 0.2rem 0.55rem; padding: 0.14rem 0.44rem;
font-size: 0.72rem; font-size: 0.62rem;
font-weight: 700; font-weight: 700;
color: #081118; color: #081118;
} }
@@ -345,7 +298,7 @@ p {
} }
.track-pill.midicc { .track-pill.midicc {
background: #8fd86f; background: #78cfff;
} }
.track-pill.osc { .track-pill.osc {
@@ -356,12 +309,16 @@ p {
background: #9ca8ff; background: #9ca8ff;
} }
.track-pill.audio {
background: #8ee8c8;
}
.track-pill.serial { .track-pill.serial {
background: #ff9e6b; background: #ff9e6b;
} }
.track-pill.modbus { .track-pill.modbus {
background: #63e0d3; background: #6fc2de;
} }
.track-pill.knx { .track-pill.knx {
@@ -374,7 +331,7 @@ p {
.track-card-top { .track-card-top {
display: flex; display: flex;
gap: 10px; gap: 8px;
align-items: flex-start; align-items: flex-start;
} }
@@ -384,14 +341,38 @@ p {
.track-card-actions { .track-card-actions {
display: flex; display: flex;
gap: 6px; gap: 4px;
} }
.track-action { .track-action {
min-width: 34px; min-width: 28px;
height: 34px; height: 28px;
padding: 0; padding: 0;
border-radius: 10px; border-radius: 8px;
}
.track-grip {
position: relative;
cursor: grab;
}
.track-grip:active {
cursor: grabbing;
}
.track-grip::before {
content: "";
position: absolute;
top: 8px;
left: 9px;
width: 3px;
height: 3px;
border-radius: 999px;
background: rgba(236, 242, 255, 0.72);
box-shadow:
0 6px 0 rgba(236, 242, 255, 0.72),
6px 0 0 rgba(236, 242, 255, 0.72),
6px 6px 0 rgba(236, 242, 255, 0.72);
} }
.workspace { .workspace {
@@ -399,7 +380,7 @@ p {
} }
.transport-bar { .transport-bar {
padding: 16px 18px; padding: 12px 14px;
} }
.transport-main { .transport-main {
@@ -408,11 +389,11 @@ p {
.transport-block { .transport-block {
display: grid; display: grid;
gap: 4px; gap: 2px;
} }
#timecodeLabel { #timecodeLabel {
font-size: 1.55rem; font-size: 1.2rem;
letter-spacing: 0.08em; letter-spacing: 0.08em;
} }
@@ -424,10 +405,10 @@ p {
} }
.canvas-frame { .canvas-frame {
margin-top: 14px; margin-top: 10px;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
border-radius: 20px; border-radius: 16px;
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent), linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent),
rgba(5, 8, 12, 0.62); rgba(5, 8, 12, 0.62);
@@ -443,31 +424,122 @@ p {
.empty-state { .empty-state {
color: var(--muted); color: var(--muted);
font-size: 0.92rem; font-size: 0.82rem;
line-height: 1.5; line-height: 1.4;
} }
.inspector-grid { .settings-modal {
position: fixed;
inset: 0;
display: grid;
place-items: center;
opacity: 0;
pointer-events: none;
transition: opacity 180ms ease;
z-index: 40;
}
.settings-modal.open {
opacity: 1;
pointer-events: auto;
}
.settings-backdrop {
position: absolute;
inset: 0;
background: rgba(5, 9, 16, 0.72);
backdrop-filter: blur(10px);
}
.settings-dialog {
position: relative;
width: min(1080px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
display: grid;
gap: 12px;
padding: 16px;
overflow: hidden;
border: 1px solid rgba(110, 194, 242, 0.18);
}
.settings-dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.settings-tabbar {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.settings-tab {
min-width: 92px;
background: rgba(255, 255, 255, 0.04);
}
.settings-tab.active {
border-color: rgba(110, 194, 242, 0.48);
background: linear-gradient(180deg, rgba(110, 194, 242, 0.22), rgba(23, 130, 201, 0.18));
}
.settings-content {
min-height: 0;
overflow: auto;
padding-right: 4px;
}
.settings-tab-panel {
display: none;
}
.settings-tab-panel.active {
display: block;
}
.settings-stack {
display: grid; display: grid;
gap: 10px; gap: 10px;
} }
.settings-section {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: 12px;
background: rgba(255, 255, 255, 0.03);
}
.info-card {
border: 1px solid rgba(110, 194, 242, 0.14);
border-radius: 14px;
padding: 10px 12px;
background: linear-gradient(180deg, rgba(110, 194, 242, 0.08), rgba(255, 255, 255, 0.02));
color: var(--muted);
line-height: 1.4;
font-size: 0.82rem;
}
.info-card strong {
color: var(--text);
}
.inspector-grid {
display: grid;
gap: 8px;
}
.section-label { .section-label {
color: var(--muted); color: var(--muted);
font-size: 0.8rem; font-size: 0.68rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.14em; letter-spacing: 0.14em;
} }
@media (max-width: 1320px) { @media (max-width: 1320px) {
.app-shell { .app-shell {
grid-template-columns: 320px minmax(0, 1fr); grid-template-columns: 270px minmax(0, 1fr);
}
.inspector {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
} }
} }
@@ -476,7 +548,20 @@ p {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.inspector { .transport-main,
.editor-header,
.settings-dialog-header {
align-items: flex-start;
flex-direction: column;
}
.field-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.settings-dialog {
width: calc(100vw - 20px);
max-height: calc(100vh - 20px);
padding: 14px;
}
} }