removed idea folder

This commit is contained in:
2025-01-12 22:25:24 +01:00
parent e8442ffde4
commit 79bdb7163f
27 changed files with 2844 additions and 273 deletions
+10
View File
@@ -0,0 +1,10 @@
{
"extends": ["next/core-web-vitals", "eslint:recommended"],
"rules": {
"react/react-in-jsx-scope": "off", // Next.js doesn't require React in scope.
"no-console": "warn", // Allow console.logs but with a warning.
"semi": ["warn", "always"], // Enforce semicolons with a warning.
"quotes": ["warn", "single"], // Prefer single quotes with a warning.
"@next/next/no-html-link-for-pages": "off" // Disable Next.js-specific rule.
}
}
+1
View File
@@ -1,4 +1,5 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
.idea/
# dependencies
/node_modules
-8
View File
@@ -1,8 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
-12
View File
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/jam-session-web.iml" filepath="$PROJECT_DIR$/.idea/jam-session-web.iml" />
</modules>
</component>
</project>
+38
View File
@@ -0,0 +1,38 @@
# client/Dockerfile
# 1. Use an official Node.js image as the base
FROM node:19-alpine AS builder
# 2. Create and set the working directory
WORKDIR /app
# 3. Copy package manifests and install dependencies
COPY package*.json ./
RUN npm install
# 4. Copy your Next.js source code
COPY . .
# 5. Build the Next.js app
RUN npm run build
# 6. Use a lightweight webserver for the final image (or continue with Node SSR)
# Option A: If you use Next.js "standalone" + "output: 'standalone'" in next.config.js:
# https://nextjs.org/docs/advanced-features/output-file-tracing
# Option B: Run Next.js in Node as usual.
# We'll do SSR with Node below.
FROM node:19-alpine AS runner
WORKDIR /app
# Copy the build output from the builder
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/config.json ./
EXPOSE 3000
# For SSR, we run Next.js in Node:
CMD ["npm", "run", "start"]
+10
View File
@@ -0,0 +1,10 @@
FROM node:19-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 8080
CMD ["node", "./src/OSCWebServer/server.js"]
+69
View File
@@ -0,0 +1,69 @@
{
"polling_interval": 500,
"inputs": [
{
"id": "mic",
"name": "Microphone",
"channels": [
1
]
},
{
"id": "piano",
"name": "Piano",
"channels": [
9
]
},
{
"id": "maschine",
"name": "Maschine",
"channels": [
11
]
},
{
"id": "synth",
"name": "Synthesizer",
"channels": [
13
]
},
{
"id": "loopstation",
"name": "Loopstation",
"channels": [
15
]
},
{
"id": "pc",
"name": "PC",
"channels": [
25
]
}
],
"bus_controls": [
{
"id": "maschine_input_bus",
"name": "Maschine Input",
"bus": 1
},
{
"id": "loopstation_input_1",
"name": "Loopstation Input 1",
"bus": 3
},
{
"id": "monitor_mix_1",
"name": "Monitor Mix 1",
"bus": 9
},
{
"id": "monitor_mix_2",
"name": "Monitor Mix 2",
"bus": 11
}
]
}
+11 -1
View File
@@ -10,7 +10,17 @@ const compat = new FlatCompat({
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
...compat.config({
extends: [
"next/core-web-vitals",
"next/typescript"
],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'react-hooks/exhaustive-deps': 'off'
},
}
),
];
export default eslintConfig;
+1
View File
@@ -4,4 +4,5 @@ const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+1935 -118
View File
File diff suppressed because it is too large Load Diff
+20 -5
View File
@@ -2,26 +2,41 @@
"name": "jam-session-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"start:websocket": "node ./src/OSCWebServer/server.js",
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-slider": "^1.2.2",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.2",
"@shadcn/ui": "^0.0.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^16.4.7",
"lodash": "^4.17.21",
"lucide-react": "^0.471.0",
"next": "15.1.4",
"osc": "2.4.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"next": "15.1.4"
"react-icons": "^5.4.0",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"typescript": "^5",
"@eslint/eslintrc": "^3",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"eslint": "^9",
"eslint-config-next": "15.1.4",
"@eslint/eslintrc": "^3"
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}
+92
View File
@@ -0,0 +1,92 @@
import dotenv from 'dotenv';
dotenv.config();
import osc from "osc";
import WebSocket from "ws";
const x32IP = process.env.X32_IP;
const x32Port = Number(process.env.X32_PORT);
const OSC_WEB_SOCKET_PORT = 8080;
// OSC Setup
const udpPort = new osc.UDPPort({
localAddress: "0.0.0.0",
localPort: 57122,
remoteAddress: x32IP,
remotePort: x32Port,
});
console.log(`Connecting to X32 at ${x32IP}:${x32Port}`)
udpPort.open();
udpPort.on("ready", () => {
console.log("OSC connection is ready!");
});
// WebSocket Server
console.log(`WebSocket server listening on ws://localhost:${OSC_WEB_SOCKET_PORT}`);
const wss = new WebSocket.Server({port: OSC_WEB_SOCKET_PORT});
wss.on("connection", (ws) => {
console.log("WebSocket client connected!");
// Handle messages from the frontend
ws.on("message", (message) => {
try {
const {action, path, value} = JSON.parse(message.toString());
if (action === "getValue" && path) {
// Query OSC for the requested value
const fetchValue = async () => {
const result = await new Promise((resolve) => {
const handleMessage = (oscMessage) => {
if (
oscMessage.address === path &&
oscMessage.args.length > 0
) {
udpPort.removeListener("message", handleMessage);
resolve(oscMessage.args[0]);
}
};
udpPort.on("message", handleMessage);
udpPort.send({address: path, args: []});
});
ws.send(
JSON.stringify({
action: "response",
path,
value: result,
})
);
};
fetchValue();
}
if (action === "sendValue" && path && value !== undefined) {
// Send OSC command
udpPort.send({address: path, args: [{type: "f", value}]});
ws.send(
JSON.stringify({
action: "acknowledge",
path,
value,
status: "success",
})
);
}
} catch (err) {
console.error("Error processing WebSocket message:", err);
ws.send(JSON.stringify({error: "Invalid message format"}));
}
});
ws.on("close", () => {
console.log("WebSocket client disconnected.");
});
});
+17
View File
@@ -0,0 +1,17 @@
import {NextResponse} from "next/server";
import fs from "fs";
import path from "path";
export async function GET() {
const configPath = process.env.CONFIG_PATH || "./config.json";
const absolutePath = path.resolve(configPath);
if (!fs.existsSync(absolutePath)) {
return NextResponse.json({error: "Config file not found"}, {status: 404});
}
const fileContent = fs.readFileSync(absolutePath, "utf-8");
const config = JSON.parse(fileContent);
return NextResponse.json(config);
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import {useEffect, useState} from "react";
import {fetchConfig} from "@/app/utils/fetchConfig";
import InputControl from "@/app/components/InputControl";
const BusControl = ({busControlConfig, mainConfig}) => {
const [appConfig, setAppConfig] = useState(null);
const busId = busControlConfig.id;
const busChannel = busControlConfig.bus
useEffect(() => {
if (mainConfig) {
setAppConfig(mainConfig);
} else {
fetchConfig().then(config => {
setAppConfig(config);
}
);
}
}, [mainConfig]);
return (
<div>
{!appConfig ? <p>Loading...</p> : (
<div className={"p-2 rounded-md border border-gray-250"}>
<div className={"mb-2 pb-3 border-b border-b-gray-250"}>
<InputControl
id={`bus-${busId}-main`}
name={`Main Level`}
basePath={`/bus/${busChannel.toString().padStart(2, "0")}/mix`}
appConfig={appConfig} faderControlSuffix={undefined} muteControlSuffix={undefined} />
</div>
<div className={"flex flex-col gap-3"}>
<h3>Inputs</h3>
{appConfig.inputs.map((input) => (
<InputControl
key={input.id}
id={`bus-${busId}-input-${input.id}`}
name={input.name}
faderControlSuffix={"level"}
basePath={`/ch/${input.channels[0].toString().padStart(2, "0")}/mix/${busChannel.toString().padStart(2, "0")}`}
appConfig={appConfig} muteControlSuffix={undefined} />
))}
</div>
</div>
)}
</div>
);
};
export default BusControl;
+85
View File
@@ -0,0 +1,85 @@
import React, {useEffect, useMemo, useState} from "react";
import {fetchConfig} from "@/app/utils/fetchConfig";
import BusControl from "@/app/components/BusControl";
import {Config} from "@/app/types/config";
const BusControlTabs = () => {
const [busControlConfigs, setBusControlConfigs] = useState([]);
const [activeTab, setActiveTab] = useState(null);
useEffect(() => {
fetchConfig().then((config: Config) => {
if (config) {
setBusControlConfigs(config.bus_controls);
// create tab components from bus control configs
const components = config.bus_controls.map((busControl) => {
return {
id: busControl.id,
label: busControl.name,
content: <BusControl busControlConfig={busControl} mainConfig={config}/>
};
});
// setTabComponents(components);
// set the active tab to the first tab
if (components.length > 0) {
setActiveTab(components[0].id);
}
}
}
);
}, []);
const tabComponents = useMemo(() => {
return busControlConfigs.map((busControl) => ({
id: busControl.id,
label: busControl.name,
content: (
<BusControl
key={busControl.id}
busControlConfig={busControl} mainConfig={undefined}
/>
),
}));
}, [busControlConfigs]);
if (!tabComponents.length) {
return <p>Loading...</p>;
}
return (
<div>
{/* Tabs Header */}
<div className="flex border-b mb-4">
{tabComponents.map((tab) => (
<button
key={tab.id}
className={`px-4 py-2 ${
activeTab === tab.id
? "border-b-2 border-blue-500 text-blue-500"
: "text-gray-500"
}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
{/* Tabs Content */}
<div>
{tabComponents.map((tab) => (
<div
key={tab.id}
style={{display: activeTab === tab.id ? "block" : "none"}}
>
{tab.content}
</div>
))}
</div>
</div>
);
};
export default BusControlTabs;
+64
View File
@@ -0,0 +1,64 @@
"use client";
import {useState, useEffect} from "react";
import {Slider} from "@/components/ui/slider"
import {fetchValue, sendValue} from "@/app/lib/x32CommunicationManager";
interface FaderProps {
id: string;
path: string;
pollingInterval: number;
}
const Fader: React.FC<FaderProps> = ({id, path, pollingInterval}) => {
const [faderValue, setFaderValue] = useState(0); // Values for each path
useEffect(() => {
const updateCurrentFaderValue = async () => {
fetchValue(path).then((value) => {
if (value !== faderValue) {
setFaderValue(value);
}
}
);
}
// Initial fetch and polling
updateCurrentFaderValue();
// add random interval to avoid all clients polling at the same time
const randomInterval = Math.random() * pollingInterval;
setTimeout(() => null, randomInterval);
const interval = setInterval(() => {
updateCurrentFaderValue();
}, pollingInterval);
return () => clearInterval(interval); // Cleanup interval
}, [path, pollingInterval]);
const handleFaderChange = async (path: string, newValue: number) => {
setFaderValue(newValue)
sendValue(path, newValue); // Send new value
};
return (
<Slider
id={`${id}-${path}-fader`}
min={0}
max={1}
step={0.01}
value={faderValue ? [faderValue,] : [0,]}
onValueChange={(newValue) => handleFaderChange(path, newValue[0])}
style={{
background: `linear-gradient(to right, #4ade80 75%, #ef4444 75%)`,
}}
/>
);
};
export default Fader;
+45
View File
@@ -0,0 +1,45 @@
import Fader from "@/app/components/Fader";
import MuteButton from "@/app/components/MuteButton";
const InputControl = ({
id,
name,
basePath,
faderControlSuffix,
muteControlSuffix,
appConfig
}) => {
if (faderControlSuffix === undefined) {
faderControlSuffix = "fader";
}
if (muteControlSuffix === undefined) {
muteControlSuffix = "on";
}
const faderControlPath = `${basePath}/${faderControlSuffix}`;
const muteControlPath = `${basePath}/${muteControlSuffix}`;
return (
<div className={"flex gap-5 w-auto"}>
<div className={"min-w-24"}>
<MuteButton
key={`inputControl-${id}-mute`}
id={`inputControl-${id}-mute`}
label={name}
path={muteControlPath}
pollingInterval={appConfig.polling_interval}
/>
</div>
<Fader
key={`inputControl-${id}`}
id={`inputControl-${id}`}
path={faderControlPath}
pollingInterval={appConfig.polling_interval}
/>
</div>
);
}
export default InputControl;
+42
View File
@@ -0,0 +1,42 @@
import {useEffect, useState} from "react";
import {fetchConfig} from "@/app/utils/fetchConfig";
import InputControl from "@/app/components/InputControl";
const MainMix = () => {
const [appConfig, setAppConfig] = useState(null);
useEffect(() => {
fetchConfig().then(config => {
setAppConfig(config);
});
}, []);
if (!appConfig) {
return <p> Loading...</p>;
}
return (
<div className={"p-2 rounded-md border border-gray-250"}>
<div className={"mb-3 pb-3 border-b border-b-gray-250"}>
<InputControl id={"mainMix"} name={"Main Level"} basePath={"/main/st/mix"} appConfig={appConfig}
faderControlSuffix={undefined} muteControlSuffix={undefined}/>
</div>
<div className={"flex flex-col gap-5"}>
{appConfig.inputs.map((input) => (
<InputControl
key={input.id}
id={input.id}
name={input.name}
basePath={`/ch/${input.channels[0].toString().padStart(2, "0")}/mix`}
appConfig={appConfig} faderControlSuffix={undefined} muteControlSuffix={undefined} />
))
}
</div>
</div>
);
}
export default MainMix;
+57
View File
@@ -0,0 +1,57 @@
import {useEffect, useState} from "react";
import {Button} from "@/components/ui/button";
import {fetchValue, sendValue} from "@/app/lib/x32CommunicationManager";
const MuteButton = ({id, label, path, pollingInterval}) => {
const [isMuted, setIsMuted] = useState(false);
useEffect(() => {
const updateCurrentMuteValue = async () => {
fetchValue(path).then((value) => {
if (value !== isMuted) {
setIsMuted(value);
}
}
);
}
// Initial fetch and polling
updateCurrentMuteValue();
// add random interval to avoid all clients polling at the same time
const randomInterval = Math.random() * pollingInterval;
setTimeout(() => null, randomInterval);
const interval = setInterval(() => {
updateCurrentMuteValue();
}, pollingInterval);
return () => clearInterval(interval); // Cleanup interval
}, [path, pollingInterval]);
const toggleMute = () => {
const newIsMuted = !isMuted;
// disable or enable the input for each channel
sendValue(
path,
newIsMuted ? 1 : 0,
);
setIsMuted(newIsMuted);
}
return (
<Button key={`${id}-${path}-mute`}
className={`w-24 ${isMuted ? 'bg-green-500' : 'bg-red-500'}`}
onClick={toggleMute}>
{label}
</Button>
)
}
export default MuteButton;
+65 -14
View File
@@ -2,20 +2,71 @@
@tailwind components;
@tailwind utilities;
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
+77
View File
@@ -0,0 +1,77 @@
import dotenv from "dotenv";
dotenv.config();
// A global manager (no React hooks)
let ws: WebSocket | null = null;
let isConnected = false;
// Your choice: store responses in memory, or just call callbacks
let messageCallbacks: Array<(msg: string) => void> = [];
export function initConnection(wsUrl: string) {
if (ws) return; // Already connected or connecting
console.log("Connecting to WebSocket:", wsUrl);
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log("Global WebSocket connected!");
isConnected = true;
};
ws.onclose = () => {
console.log("Global WebSocket disconnected.");
isConnected = false;
ws = null; // So we can reconnect if needed
};
ws.onerror = (err) => {
console.error("Global WebSocket error:", err);
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
// Notify any registered callbacks
messageCallbacks.forEach((cb) => cb(message));
} catch (err) {
console.error("Error parsing WebSocket message:", err);
}
};
}
export function registerMessageCallback(cb: (msg: any) => void) {
messageCallbacks.push(cb);
}
export function removeMessageCallback(cb: (response: any) => void) {
messageCallbacks = messageCallbacks.filter((f) => f !== cb);
}
// Send a message to the X32
export function sendMessage(msg: any) {
if (ws && isConnected) {
ws.send(JSON.stringify(msg));
} else {
console.error("WebSocket not connected. Message not sent:", msg);
}
}
export function fetchValue(path: string): Promise<any> {
return new Promise((resolve) => {
sendMessage({action: "getValue", path});
// We'll listen for the matching response just once
const callback = (response: any) => {
if (response.action === "response" && response.path === path) {
removeMessageCallback(callback);
resolve(response.value);
}
};
registerMessageCallback(callback);
});
}
export function sendValue(path: string, value: any) {
sendMessage({action: "sendValue", path, value});
}
+75 -94
View File
@@ -1,101 +1,82 @@
import Image from "next/image";
"use client";
import {useEffect, useMemo, useState} from "react";
import {fetchConfig} from "@/app/utils/fetchConfig";
import BusControlTabs from "@/app/components/BusControlTabs";
import MainMix from "@/app/components/MainMix";
// initialize global websocket connection
import {initConnection} from "@/app/lib/x32CommunicationManager";
const Page = () => {
const [appConfig, setAppConfig] = useState(null);
const [activeTab, setActiveTab] = useState("mainMix");
useEffect(() => {
initConnection(`ws://${window.location.hostname}:8080`);
fetchConfig().then(config => {
setAppConfig(config);
});
}, []);
const tabComponents = useMemo(() => {
return [
{
id: "mainMix",
label: "Main Mix",
content: (
<MainMix/>
),
},
{
id: "busControl",
label: "Bus Control",
content: (
<BusControlTabs/>
),
}
]
}
, []);
if (!appConfig) {
return <p>Error fetching config</p>;
}
export default function Home() {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
src/app/page.tsx
</code>
.
</li>
<li>Save and see your changes instantly.</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
<div>
<div className="p-4">
{/* Tabs Header */}
<div className="flex border-b mb-4">
{tabComponents.map((tab) => (
<button
key={tab.id}
className={`px-4 py-2 ${
activeTab === tab.id
? "border-b-2 border-blue-500 text-blue-500"
: "text-gray-500"
}`}
onClick={() => setActiveTab(tab.id)}
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
{tab.label}
</button>
))}
</div>
</main>
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
{/* Tabs Content */}
<div>
{tabComponents.map((tab) => (
<div
key={tab.id}
style={{display: activeTab === tab.id ? "block" : "none"}}
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
{tab.content}
</div>
))}
</div>
</div>
</div>
);
}
};
export default Page;
+5
View File
@@ -0,0 +1,5 @@
export interface Config {
polling_interval: number
inputs: any
bus_controls: any
}
+14
View File
@@ -0,0 +1,14 @@
export const fetchConfig = async <T>(): Promise<T | null> => {
try {
const response = await fetch("/api/config");
if (!response.ok) {
console.error(`Failed to fetch config:`, response.statusText);
return null;
}
return await response.json();
} catch (error) {
console.error(`Error fetching config from:`, error);
return null;
}
};
+47 -3
View File
@@ -1,6 +1,7 @@
import type { Config } from "tailwindcss";
export default {
darkMode: ["class"],
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
@@ -9,10 +10,53 @@ export default {
theme: {
extend: {
colors: {
background: "var(--background)",
foreground: "var(--foreground)",
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
plugins: [],
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
}
}
},
plugins: [require("tailwindcss-animate")],
} satisfies Config;
+2 -1
View File
@@ -4,7 +4,8 @@
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"strict": false,
"noImplicitAny": false,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",