Time for React! #11

Merged
steino merged 1 commits from react into master 2023-11-02 20:10:52 +00:00
13 changed files with 991 additions and 87 deletions

View File

@ -1,6 +1,7 @@
package main package main
import ( import (
"encoding/json"
"fmt" "fmt"
"io" "io"
"log" "log"
@ -50,6 +51,8 @@ func init() {
app.router = httprouter.New() app.router = httprouter.New()
app.router.GET("/", auth(app.Index)) app.router.GET("/", auth(app.Index))
app.router.GET("/playlists", auth(app.Web_Playlists))
app.router.GET("/ambiance", auth(app.Web_Ambiance))
app.router.GET("/play/:playlist", auth(app.Play)) app.router.GET("/play/:playlist", auth(app.Play))
app.router.GET("/reset", auth(app.Reset)) app.router.GET("/reset", auth(app.Reset))
app.router.GET("/public/*js", auth(app.ServeFiles)) app.router.GET("/public/*js", auth(app.ServeFiles))
@ -232,6 +235,30 @@ func (app App) Index(w http.ResponseWriter, r *http.Request, _ httprouter.Params
} }
} }
func (app App) Web_Playlists(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
playlists, err := app.GetPlaylists()
if err != nil {
http.Error(w, "Unable to get playlists. "+err.Error(), http.StatusInternalServerError)
}
err = json.NewEncoder(w).Encode(playlists)
if err != nil {
http.Error(w, "Unable to get playlists. "+err.Error(), http.StatusInternalServerError)
}
}
func (app App) Web_Ambiance(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
ambiance, err := GetAmbiances()
if err != nil {
http.Error(w, "Unable to get ambiance. "+err.Error(), http.StatusInternalServerError)
}
err = json.NewEncoder(w).Encode(ambiance)
if err != nil {
http.Error(w, "Unable to get playlists. "+err.Error(), http.StatusInternalServerError)
}
}
func (app *App) Play(w http.ResponseWriter, r *http.Request, p httprouter.Params) { func (app *App) Play(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
plname := p.ByName("playlist") plname := p.ByName("playlist")

188
src/app/ambiance.tsx Normal file
View File

@ -0,0 +1,188 @@
import React, { useEffect, useState } from "react";
import CSS from "csstype";
import { on } from "./events";
import ws from "./ws";
import SortableList, { SortableItem } from "react-easy-sort";
import { arrayMoveImmutable } from "array-move";
export default function Ambiance() {
interface Ambiance {
Id: string;
Title: string;
}
const [ambiance, setAmbiance] = useState<Ambiance[]>([]);
const [active, setActive] = useState("");
const [title, setTitle] = useState("");
const [url, setUrl] = useState("");
const [percent, setPercent] = useState(0);
const [running, setRunning] = useState(false);
const onSortEnd = (oldIndex: number, newIndex: number) => {
setAmbiance((array) => arrayMoveImmutable(array, oldIndex, newIndex));
};
const activeStyle: CSS.Properties = {
backgroundColor: "burlywood",
};
const getOrder = (name: string) => {
var order = localStorage.getItem("dndmusicbot-" + name);
return order ? order.split("|") : [];
};
const setOrder = (name: string) => {
var order = ambiance.map((a) => a.Id);
localStorage.setItem("dndmusicbot-" + name, order.join("|"));
};
const fetchAmbiance = () => {
const order = getOrder("ambiance");
fetch("/ambiance")
.then((response) => {
return response.json();
})
.then((data) => {
data.sort(
(a: Ambiance, b: Ambiance) =>
order.indexOf(a.Id) - order.indexOf(b.Id)
);
setAmbiance(data);
});
};
useEffect(() => {
fetchAmbiance();
}, []);
useEffect(() => {
setOrder("ambiance");
}, [ambiance]);
const Play = (e: any) => {
ws.send(
JSON.stringify({
event: "ambiance_play",
payload: e.target.dataset.id,
})
);
};
const Stop = () => {
ws.send(
JSON.stringify({
event: "ambiance_stop",
})
);
};
const AddAmbiance = () => {
if (title == "" || url == "") {
return;
}
ws.send(
JSON.stringify({
event: "ambiance_add",
payload: {
title: title,
url: url,
},
})
);
};
on("dnd:ambiance_add", () => fetchAmbiance());
on("dnd:ambiance_add_start", () => setRunning(true));
on("dnd:ambiance_add_finish", () => {
setRunning(false);
setPercent(0);
setTitle("");
setUrl("");
});
on("dnd:ambiance_play", (e: any) => setActive(e.detail.id));
on("dnd:ambiance_stop", () => setActive(""));
on("dnd:ambiance_encode_finish", () => setPercent(0));
on("dnd:ambiance_encode_progress", (e: any) => {
setRunning(true);
const p = parseInt(e.detail.percent, 10);
if (!Number.isNaN(p)) {
setPercent(p);
}
});
return (
<>
<h2 className="bot">Ambiance</h2>
<section>
<SortableList
onSortEnd={onSortEnd}
className="item-container"
draggedItemClassName="dragged"
>
{ambiance.map((item) => {
return (
<SortableItem key={item.Id}>
<div
className="item drag"
data-id={item.Id}
style={item.Id == active ? activeStyle : {}}
onClick={Play}
>
{item.Title}
</div>
</SortableItem>
);
})}
<div className="item locked stop" data-id="reset" onClick={Stop}>
Stop
</div>
</SortableList>
</section>
<section>
<div id="progressambiance" className="input-container">
<progress
max="100"
value={percent}
style={{ width: "100%" }}
className={running ? "" : "u-hidden"}
>
{percent}%
</progress>
</div>
</section>
<section className={running ? "u-hidden" : ""}>
<div id="inputambiance" className="input-container">
<input
className="u-full-width"
name="title"
type="text"
value={title}
placeholder="Enter name.."
onChange={(e) => setTitle(e.target.value)}
disabled={running}
/>
<input
className="u-full-width"
name="url"
type="text"
value={url}
placeholder="Enter url..."
onChange={(e) => setUrl(e.target.value)}
disabled={running}
/>
<input
id="addambiance"
name="submit"
value="Add"
type="submit"
onClick={AddAmbiance}
disabled={running}
/>
</div>
</section>
</>
);
}

98
src/app/controls.tsx Normal file
View File

@ -0,0 +1,98 @@
import React, { useState } from "react";
import CSS from "csstype";
import { on } from "./events";
import ws from "./ws";
export default function Controls() {
const [channel, setChannel] = useState("");
const [title, setTitle] = useState("");
const [pos, setPos] = useState(0);
const [len, setLen] = useState(0);
const [song, setSong] = useState("");
const [pause, setPause] = useState(true);
const linkStyle: CSS.Properties = {
textDecoration: "none",
};
const Next = () => {
ws.send(
JSON.stringify({
event: "next",
})
);
};
const Prev = () => {
ws.send(
JSON.stringify({
event: "next",
})
);
};
const msToTime = (duration: number) => {
var milliseconds = (duration % 1000) / 100,
seconds = Math.floor((duration / 1000) % 60),
minutes = Math.floor((duration / (1000 * 60)) % 60),
minutes = minutes < 10 ? 0 + minutes : minutes;
seconds = seconds < 10 ? 0 + seconds : seconds;
return (
minutes.toString().padStart(2, "0") +
":" +
seconds.toString().padStart(2, "0")
);
};
on("dnd:song_info", (e: any) => {
setChannel(e.detail.channel);
setTitle(e.detail.current);
setPos(e.detail.position);
setLen(e.detail.len);
setSong("https://youtu.be/" + e.detail.song);
setPause(e.detail.pause);
});
on("dnd:song_position", (e: any) => {
setPos(e.detail.position);
setLen(e.detail.len);
});
return (
<section>
<div id="info">
<a
href={song}
id="link"
style={linkStyle}
className={pause ? "u-hidden" : ""}
>
<span id="channel">{channel}</span>
<span> - </span>
<span id="title">{title}</span>
</a>
<div className={pause ? "controls u-hidden" : "controls"}>
<input
id="prev"
name="prev"
type="button"
value="prev"
onClick={Prev}
/>
<span id="time">
{msToTime(pos)} / {msToTime(len)}
</span>
<input
id="next"
name="next"
type="button"
value="next"
onClick={Next}
/>
</div>
</div>
</section>
);
}

23
src/app/events.tsx Normal file
View File

@ -0,0 +1,23 @@
function on(eventType: any, listener: { (event: any): void; (this: Document, ev: any): any; }) {
document.addEventListener(eventType, listener);
}
function off(eventType: any, listener: { (event: any): void; (this: Document, ev: any): any; }) {
document.removeEventListener(eventType, listener);
}
function once(eventType: any, listener: (arg0: any) => void) {
on(eventType, handleEventOnce);
function handleEventOnce(event: any) {
listener(event);
off(eventType, handleEventOnce);
}
}
function trigger(eventType: string, data: any) {
const event = new CustomEvent(eventType, { detail: data });
document.dispatchEvent(event);
}
export { on, once, off, trigger };

View File

@ -0,0 +1,17 @@
import { useState, useEffect } from "react";
export const useDebounce = (value: any, milliSeconds: number) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, milliSeconds);
return () => {
clearTimeout(handler);
};
}, [value, milliSeconds]);
return debouncedValue;
};

26
src/app/modal.tsx Normal file
View File

@ -0,0 +1,26 @@
import React, { useState } from "react";
import ws from "./ws";
export default function Modal() {
const [waiting, setWaiting] = useState(true);
ws.onopen = () => {
console.log("false");
setWaiting(false);
};
ws.onclose = () => {
console.log("true");
setWaiting(true);
};
return (
<>
<div id="waiting" className={waiting ? "modal" : "modal u-hidden"}>
<div className="modal-content">
<p>Waiting for bot..</p>
</div>
</div>
</>
);
}

163
src/app/playlist.tsx Normal file
View File

@ -0,0 +1,163 @@
import React, { useEffect, useState } from "react";
import CSS from "csstype";
import { on } from "./events";
import ws from "./ws";
import SortableList, { SortableItem } from "react-easy-sort";
import { arrayMoveImmutable } from "array-move";
export default function Playlists() {
interface Playlist {
Id: string;
Title: string;
}
const [playlists, setPlaylists] = useState<Playlist[]>([]);
const [active, setActive] = useState("");
const [title, setTitle] = useState("");
const [url, setUrl] = useState("");
const [output, setOutput] = useState("");
const onSortEnd = (oldIndex: number, newIndex: number) => {
setPlaylists((array) => arrayMoveImmutable(array, oldIndex, newIndex));
};
const activeStyle: CSS.Properties = {
backgroundColor: "burlywood",
};
const getOrder = (name: string) => {
var order = localStorage.getItem("dndmusicbot-" + name);
return order ? order.split("|") : [];
};
const setOrder = (name: string) => {
var order = playlists.map((a) => a.Id);
localStorage.setItem("dndmusicbot-" + name, order.join("|"));
};
const fetchPlaylists = () => {
const order = getOrder("playlist");
fetch("/playlists")
.then((response) => {
return response.json();
})
.then((data) => {
data.sort(
(a: Playlist, b: Playlist) =>
order.indexOf(a.Id) - order.indexOf(b.Id)
);
setPlaylists(data);
});
};
useEffect(() => {
fetchPlaylists();
}, []);
useEffect(() => {
setOrder("playlist");
}, [playlists]);
const Play = (e: any) => {
ws.send(
JSON.stringify({
event: "load_playlist",
payload: e.target.dataset.id,
})
);
};
const Stop = () => {
ws.send(
JSON.stringify({
event: "stop",
})
);
};
const AddPlaylist = () => {
if (title == "" || url == "") {
setOutput("Title or Url is empty!");
return;
}
ws.send(
JSON.stringify({
event: "add_playlist",
payload: {
title: title,
url: url,
},
})
);
};
on("dnd:song_info", (e: any) => {
setActive(e.detail.playlist);
});
on("dnd:new_playlist", (e: any) => {
fetchPlaylists();
setOutput("New playlist was added: " + e.details.title);
});
return (
<>
<h2 className="bot">Playlists</h2>
<section>
<SortableList
onSortEnd={onSortEnd}
className="item-container"
draggedItemClassName="dragged"
>
{playlists.map((playlist) => {
return (
<SortableItem key={playlist.Id}>
<div
onClick={Play}
className="item"
data-id={playlist.Id}
style={playlist.Id == active ? activeStyle : {}}
>
{playlist.Title}
</div>
</SortableItem>
);
})}
<div className="item locked stop" onClick={Stop} data-id="reset">
Stop
</div>
</SortableList>
</section>
<section>
<div id="inputplaylist" className="input-container">
<input
className="u-full-width"
name="title"
type="text"
placeholder="Enter name.."
onChange={(e) => setTitle(e.target.value)}
/>
<input
className="u-full-width"
name="url"
type="text"
placeholder="https://youtube.com/playlist?list=..."
onChange={(e) => setUrl(e.target.value)}
/>
<input
id="addplaylist"
name="submit"
value="Add"
type="submit"
onClick={AddPlaylist}
/>
</div>
</section>
<section>
<p>{output}</p>
</section>
</>
);
}

30
src/app/root.tsx Normal file
View File

@ -0,0 +1,30 @@
import React, { useState } from "react";
import { createRoot } from "react-dom/client";
import { trigger, on } from "./events";
import Playlists from "./playlist";
import ws from "./ws";
import Controls from "./controls";
import Ambiance from "./ambiance";
import Volumes from "./volume";
import Modal from "./modal";
function Content() {
ws.onmessage = (e) => {
const data = JSON.parse(e.data);
trigger("dnd:" + data.event, data.payload);
};
return (
<>
<Playlists />
<Controls />
<Ambiance />
<Volumes />
<Modal />
</>
);
}
const domNode = document.getElementById("content");
const root = createRoot(domNode!);
root.render(<Content />);

111
src/app/volume.tsx Normal file
View File

@ -0,0 +1,111 @@
import React, { useState } from "react";
import { on } from "./events";
import ws from "./ws";
import { useEffect, useRef } from "react";
import { useDebounce } from "./hooks/useDebounce";
export default function Volumes() {
const [playlist, setPlaylist] = useState(0);
const [ambiance, setAmbiance] = useState(0);
const plfirstUpdate = useRef(true);
const ambfirstUpdate = useRef(true);
const plsend = useDebounce(playlist, 100);
const ambsend = useDebounce(ambiance, 100);
on("dnd:volume", (e: any) => {
setPlaylist(e.detail.playlist);
setAmbiance(e.detail.ambiance);
});
useEffect(() => {
if (plfirstUpdate.current) {
plfirstUpdate.current = false;
return;
}
ws.send(
JSON.stringify({
event: "vol_set",
payload: {
type: "playlist",
vol: plsend.toString(),
},
})
);
}, [plsend]);
useEffect(() => {
if (ambfirstUpdate.current) {
ambfirstUpdate.current = false;
return;
}
ws.send(
JSON.stringify({
event: "vol_set",
payload: {
type: "ambiance",
vol: ambsend.toString(),
},
})
);
}, [ambsend]);
const PlaylistVol = (e: any) => {
setPlaylist(parseFloat(e.target.value));
};
const AmbianceVol = (e: any) => {
setAmbiance(parseFloat(e.target.value));
};
return (
<>
<section>
<div id="volume_playlist" className="input-container">
<label htmlFor="playlist-volume">Playlist</label>
<input
type="range"
id="playlist-volume"
min="-10"
max="4"
step="0.1"
value={playlist}
onChange={PlaylistVol}
/>
<input
id="playlist-volume-number"
type="number"
min="-10"
max="4"
step="0.1"
style={{ width: "50px" }}
value={playlist}
onChange={PlaylistVol}
/>
</div>
<div id="volume_ambiance" className="input-container">
<label htmlFor="ambiance-volume">Ambiance</label>
<input
type="range"
id="ambiance-volume"
min="-10"
max="4"
step="0.1"
value={ambiance}
onChange={AmbianceVol}
/>
<input
id="ambiance-volume-number"
type="number"
min="-10"
max="4"
step="0.1"
style={{ width: "50px" }}
value={ambiance}
onChange={AmbianceVol}
/>
</div>
</section>
</>
);
}

9
src/app/ws.tsx Normal file
View File

@ -0,0 +1,9 @@
import ReconnectingWebSocket from "reconnecting-websocket";
const ws = new ReconnectingWebSocket(
(window.location.protocol === "https:" ? "wss://" : "ws://") +
window.location.host +
"/ws"
);
export default ws;

View File

@ -1,9 +1,20 @@
{ {
"scripts": { "scripts": {
"build": "esbuild script.js --bundle --outdir=/public/" "build": "esbuild app/root.tsx --bundle --outfile=../public_test/script.js"
}, },
"dependencies": { "dependencies": {
"@types/react": "^18.2.33",
"@types/react-dom": "^18.2.14",
"array-move": "^4.0.0",
"esbuild": "^0.15.14", "esbuild": "^0.15.14",
"react": "^18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-debounce-input": "^3.3.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.2.0",
"react-easy-sort": "^1.6.0",
"react-sortable-hoc": "^2.0.0",
"reconnecting-websocket": "^4.4.0", "reconnecting-websocket": "^4.4.0",
"sortablejs": "^1.15.0" "sortablejs": "^1.15.0"
} }

View File

@ -2,6 +2,13 @@
# yarn lockfile v1 # yarn lockfile v1
"@babel/runtime@^7.15.4", "@babel/runtime@^7.2.0", "@babel/runtime@^7.9.2":
version "7.23.2"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.2.tgz#062b0ac103261d68a966c4c7baf2ae3e62ec3885"
integrity sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==
dependencies:
regenerator-runtime "^0.14.0"
"@esbuild/android-arm@0.15.14": "@esbuild/android-arm@0.15.14":
version "0.15.14" version "0.15.14"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.15.14.tgz#5d0027f920eeeac313c01fd6ecb8af50c306a466" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.15.14.tgz#5d0027f920eeeac313c01fd6ecb8af50c306a466"
@ -12,6 +19,96 @@
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.15.14.tgz#1221684955c44385f8af34f7240088b7dc08d19d" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.15.14.tgz#1221684955c44385f8af34f7240088b7dc08d19d"
integrity sha512-eQi9rosGNVQFJyJWV0HCA5WZae/qWIQME7s8/j8DMvnylfBv62Pbu+zJ2eUDqNf2O4u3WB+OEXyfkpBoe194sg== integrity sha512-eQi9rosGNVQFJyJWV0HCA5WZae/qWIQME7s8/j8DMvnylfBv62Pbu+zJ2eUDqNf2O4u3WB+OEXyfkpBoe194sg==
"@react-dnd/asap@^5.0.1":
version "5.0.2"
resolved "https://registry.yarnpkg.com/@react-dnd/asap/-/asap-5.0.2.tgz#1f81f124c1cd6f39511c11a881cfb0f715343488"
integrity sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==
"@react-dnd/invariant@^4.0.1":
version "4.0.2"
resolved "https://registry.yarnpkg.com/@react-dnd/invariant/-/invariant-4.0.2.tgz#b92edffca10a26466643349fac7cdfb8799769df"
integrity sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==
"@react-dnd/shallowequal@^4.0.1":
version "4.0.2"
resolved "https://registry.yarnpkg.com/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz#d1b4befa423f692fa4abf1c79209702e7d8ae4b4"
integrity sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==
"@types/hoist-non-react-statics@^3.3.0":
version "3.3.4"
resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.4.tgz#cc477ce0283bb9d19ea0cbfa2941fe2c8493a1be"
integrity sha512-ZchYkbieA+7tnxwX/SCBySx9WwvWR8TaP5tb2jRAzwvLb/rWchGw3v0w3pqUbUvj0GCwW2Xz/AVPSk6kUGctXQ==
dependencies:
"@types/react" "*"
hoist-non-react-statics "^3.3.0"
"@types/prop-types@*":
version "15.7.9"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.9.tgz#b6f785caa7ea1fe4414d9df42ee0ab67f23d8a6d"
integrity sha512-n1yyPsugYNSmHgxDFjicaI2+gCNjsBck8UX9kuofAKlc0h1bL+20oSF72KeNaW2DUlesbEVCFgyV2dPGTiY42g==
"@types/react-dom@^18.2.14":
version "18.2.14"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.14.tgz#c01ba40e5bb57fc1dc41569bb3ccdb19eab1c539"
integrity sha512-V835xgdSVmyQmI1KLV2BEIUgqEuinxp9O4G6g3FqO/SqLac049E53aysv0oEFD2kHfejeKU+ZqL2bcFWj9gLAQ==
dependencies:
"@types/react" "*"
"@types/react-redux@^7.1.20":
version "7.1.28"
resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.28.tgz#30a44303c7daceb6ede9cfb4aaf72e64f1dde4de"
integrity sha512-EQr7cChVzVUuqbA+J8ArWK1H0hLAHKOs21SIMrskKZ3nHNeE+LFYA+IsoZGhVOT8Ktjn3M20v4rnZKN3fLbypw==
dependencies:
"@types/hoist-non-react-statics" "^3.3.0"
"@types/react" "*"
hoist-non-react-statics "^3.3.0"
redux "^4.0.0"
"@types/react@*", "@types/react@^18.2.33":
version "18.2.33"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.33.tgz#055356243dc4350a9ee6c6a2c07c5cae12e38877"
integrity sha512-v+I7S+hu3PIBoVkKGpSYYpiBT1ijqEzWpzQD62/jm4K74hPpSP7FF9BnKG6+fg2+62weJYkkBWDJlZt5JO/9hg==
dependencies:
"@types/prop-types" "*"
"@types/scheduler" "*"
csstype "^3.0.2"
"@types/scheduler@*":
version "0.16.5"
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.5.tgz#4751153abbf8d6199babb345a52e1eb4167d64af"
integrity sha512-s/FPdYRmZR8SjLWGMCuax7r3qCWQw9QKHzXVukAuuIJkXkDRwp+Pu5LMIVFi0Fxbav35WURicYr8u1QsoybnQw==
array-move@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/array-move/-/array-move-3.0.1.tgz#179645cc0987b65953a4fc06b6df9045e4ba9618"
integrity sha512-H3Of6NIn2nNU1gsVDqDnYKY/LCdWvCMMOWifNGhKcVQgiZ6nOek39aESOvro6zmueP07exSl93YLvkN4fZOkSg==
array-move@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/array-move/-/array-move-4.0.0.tgz#2c3730f056cc926f62a59769a5a8cda2fb6d8c55"
integrity sha512-+RY54S8OuVvg94THpneQvFRmqWdAHeqtMzgMW6JNurHxe8rsS07cHQdfGkXnTUXiBcyZ0j3SiDIxxj0RPiqCkQ==
css-box-model@^1.2.0:
version "1.2.1"
resolved "https://registry.yarnpkg.com/css-box-model/-/css-box-model-1.2.1.tgz#59951d3b81fd6b2074a62d49444415b0d2b4d7c1"
integrity sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==
dependencies:
tiny-invariant "^1.0.6"
csstype@^3.0.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b"
integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==
dnd-core@^16.0.1:
version "16.0.1"
resolved "https://registry.yarnpkg.com/dnd-core/-/dnd-core-16.0.1.tgz#a1c213ed08961f6bd1959a28bb76f1a868360d19"
integrity sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==
dependencies:
"@react-dnd/asap" "^5.0.1"
"@react-dnd/invariant" "^4.0.1"
redux "^4.2.0"
esbuild-android-64@0.15.14: esbuild-android-64@0.15.14:
version "0.15.14" version "0.15.14"
resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.15.14.tgz#114e55b0d58fb7b45d7fa3d93516bd13fc8869cc" resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.15.14.tgz#114e55b0d58fb7b45d7fa3d93516bd13fc8869cc"
@ -140,12 +237,199 @@ esbuild@^0.15.14:
esbuild-windows-64 "0.15.14" esbuild-windows-64 "0.15.14"
esbuild-windows-arm64 "0.15.14" esbuild-windows-arm64 "0.15.14"
fast-deep-equal@^3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
dependencies:
react-is "^16.7.0"
invariant@^2.2.4:
version "2.2.4"
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
dependencies:
loose-envify "^1.0.0"
"js-tokens@^3.0.0 || ^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
lodash.debounce@^4:
version "4.0.8"
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==
loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
dependencies:
js-tokens "^3.0.0 || ^4.0.0"
memoize-one@^5.1.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==
object-assign@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
prop-types@^15.5.7, prop-types@^15.7.2, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
dependencies:
loose-envify "^1.4.0"
object-assign "^4.1.1"
react-is "^16.13.1"
raf-schd@^4.0.2:
version "4.0.3"
resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-4.0.3.tgz#5d6c34ef46f8b2a0e880a8fcdb743efc5bfdbc1a"
integrity sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==
react-beautiful-dnd@^13.1.1:
version "13.1.1"
resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz#b0f3087a5840920abf8bb2325f1ffa46d8c4d0a2"
integrity sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ==
dependencies:
"@babel/runtime" "^7.9.2"
css-box-model "^1.2.0"
memoize-one "^5.1.1"
raf-schd "^4.0.2"
react-redux "^7.2.0"
redux "^4.0.4"
use-memo-one "^1.1.1"
react-debounce-input@^3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/react-debounce-input/-/react-debounce-input-3.3.0.tgz#85e3ebcaa41f2016e50613134a1ec9fe3cdb422e"
integrity sha512-VEqkvs8JvY/IIZvh71Z0TC+mdbxERvYF33RcebnodlsUZ8RSgyKe2VWaHXv4+/8aoOgXLxWrdsYs2hDhcwbUgA==
dependencies:
lodash.debounce "^4"
prop-types "^15.8.1"
react-dnd-html5-backend@^16.0.1:
version "16.0.1"
resolved "https://registry.yarnpkg.com/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz#87faef15845d512a23b3c08d29ecfd34871688b6"
integrity sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==
dependencies:
dnd-core "^16.0.1"
react-dnd@^16.0.1:
version "16.0.1"
resolved "https://registry.yarnpkg.com/react-dnd/-/react-dnd-16.0.1.tgz#2442a3ec67892c60d40a1559eef45498ba26fa37"
integrity sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==
dependencies:
"@react-dnd/invariant" "^4.0.1"
"@react-dnd/shallowequal" "^4.0.1"
dnd-core "^16.0.1"
fast-deep-equal "^3.1.3"
hoist-non-react-statics "^3.3.2"
react-dom@^18.2.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"
integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==
dependencies:
loose-envify "^1.1.0"
scheduler "^0.23.0"
react-easy-sort@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/react-easy-sort/-/react-easy-sort-1.6.0.tgz#b40cce827913f0640c1b2e5438dd4d007e26db32"
integrity sha512-zd9Nn90wVlZPEwJrpqElN87sf9GZnFR1StfjgNQVbSpR5QTSzCHjEYK6REuwq49Ip+76KOMSln9tg/ST2KLelg==
dependencies:
array-move "^3.0.1"
tslib "2.0.1"
react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
react-is@^17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
react-redux@^7.2.0:
version "7.2.9"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.9.tgz#09488fbb9416a4efe3735b7235055442b042481d"
integrity sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==
dependencies:
"@babel/runtime" "^7.15.4"
"@types/react-redux" "^7.1.20"
hoist-non-react-statics "^3.3.2"
loose-envify "^1.4.0"
prop-types "^15.7.2"
react-is "^17.0.2"
react-sortable-hoc@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/react-sortable-hoc/-/react-sortable-hoc-2.0.0.tgz#f6780d8aa4b922a21f3e754af542f032677078b7"
integrity sha512-JZUw7hBsAHXK7PTyErJyI7SopSBFRcFHDjWW5SWjcugY0i6iH7f+eJkY8cJmGMlZ1C9xz1J3Vjz0plFpavVeRg==
dependencies:
"@babel/runtime" "^7.2.0"
invariant "^2.2.4"
prop-types "^15.5.7"
react@^18.2.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==
dependencies:
loose-envify "^1.1.0"
reconnecting-websocket@^4.4.0: reconnecting-websocket@^4.4.0:
version "4.4.0" version "4.4.0"
resolved "https://registry.yarnpkg.com/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz#3b0e5b96ef119e78a03135865b8bb0af1b948783" resolved "https://registry.yarnpkg.com/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz#3b0e5b96ef119e78a03135865b8bb0af1b948783"
integrity sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng== integrity sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng==
redux@^4.0.0, redux@^4.0.4, redux@^4.2.0:
version "4.2.1"
resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.1.tgz#c08f4306826c49b5e9dc901dee0452ea8fce6197"
integrity sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==
dependencies:
"@babel/runtime" "^7.9.2"
regenerator-runtime@^0.14.0:
version "0.14.0"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz#5e19d68eb12d486f797e15a3c6a918f7cec5eb45"
integrity sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==
scheduler@^0.23.0:
version "0.23.0"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe"
integrity sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==
dependencies:
loose-envify "^1.1.0"
sortablejs@^1.15.0: sortablejs@^1.15.0:
version "1.15.0" version "1.15.0"
resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.15.0.tgz#53230b8aa3502bb77a29e2005808ffdb4a5f7e2a" resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.15.0.tgz#53230b8aa3502bb77a29e2005808ffdb4a5f7e2a"
integrity sha512-bv9qgVMjUMf89wAvM6AxVvS/4MX3sPeN0+agqShejLU5z5GX4C75ow1O2e5k4L6XItUyAK3gH6AxSbXrOM5e8w== integrity sha512-bv9qgVMjUMf89wAvM6AxVvS/4MX3sPeN0+agqShejLU5z5GX4C75ow1O2e5k4L6XItUyAK3gH6AxSbXrOM5e8w==
tiny-invariant@^1.0.6:
version "1.3.1"
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.1.tgz#8560808c916ef02ecfd55e66090df23a4b7aa642"
integrity sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==
tslib@2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.1.tgz#410eb0d113e5b6356490eec749603725b021b43e"
integrity sha512-SgIkNheinmEBgx1IUNirK0TUD4X9yjjBRTqqjggWCU3pUEqIk3/Uwl3yRixYKT6WjQuGiwDv4NomL3wqRCj+CQ==
use-memo-one@^1.1.1:
version "1.1.3"
resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.3.tgz#2fd2e43a2169eabc7496960ace8c79efef975e99"
integrity sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==

View File

@ -10,90 +10,7 @@
<link rel="icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QA/wD/AP+gvaeTAAACNklEQVQ4jaWSy0vUcRTFP/f7G4cUUqzAZtQsgzYWiDMjjYgotbOtYLSIFlKBix7YH9DWXQaRFRREi8hN9MCFPZDUzB4IUkRo5Oho1CLTcEbne1o0UWCI0FndC+cezr33wH/C/m5mkvFOw04JIsBrmXVVDI0OryfgfhepZH0bWA/wFaMLqDXp/nQyuWVDAhi1wIz36k5n/BXgMVAamI+vJxD6o7TS61Uw7QLbEXH+OGIOQzmtTG7IgVfBPjOdE6oXdtrQd8mOVQ6/+vCbM9XcvGmipia8xsFsLLZN6DBQZWIRKJPZISd5gLFYrCBaYF3KLB0IFxdVzyQTZYZukNXZEEA2CDaH8SUS5zHbBVZo+AE5OzrXEL+eyxHxZo2IyzL/FqywcnhsFCCktrYgnZrqWc35nsCCqHM2mJMWkXuD+RVPUIvzRxB3K0Ze3F5zg8/pj1XCEkEQ9OHoZnH5uaFAYfXjrcmhUkPvyit39v7zC9lcKO1Y3QoEwOD28fEl4CRAqiFxJyddAHuUnpwsnW9MFIdW3JeMW41Ghl++N/AOMuX5YTCqlU/nbCxWZLDXQRfQRNiNr+Z0L+OUFHZwNhm/CRBaWMh8Ki0u+iYoQeyZ3Z+4lcKeytSBqPOyZ2a+X1grWESoE6wFWAZwNRMTWaEzBk/yLtrNdAmow7gaHRm96HEpYCC/divGjGEdv+h5qLk5lM4utUvWAvxw8g8iI2MPAeYbE9VeFMrbNZl2GzoRHRrrWy+hG8ZPg13xb+XS+CIAAAAASUVORK5CYII="> <link rel="icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QA/wD/AP+gvaeTAAACNklEQVQ4jaWSy0vUcRTFP/f7G4cUUqzAZtQsgzYWiDMjjYgotbOtYLSIFlKBix7YH9DWXQaRFRREi8hN9MCFPZDUzB4IUkRo5Oho1CLTcEbne1o0UWCI0FndC+cezr33wH/C/m5mkvFOw04JIsBrmXVVDI0OryfgfhepZH0bWA/wFaMLqDXp/nQyuWVDAhi1wIz36k5n/BXgMVAamI+vJxD6o7TS61Uw7QLbEXH+OGIOQzmtTG7IgVfBPjOdE6oXdtrQd8mOVQ6/+vCbM9XcvGmipia8xsFsLLZN6DBQZWIRKJPZISd5gLFYrCBaYF3KLB0IFxdVzyQTZYZukNXZEEA2CDaH8SUS5zHbBVZo+AE5OzrXEL+eyxHxZo2IyzL/FqywcnhsFCCktrYgnZrqWc35nsCCqHM2mJMWkXuD+RVPUIvzRxB3K0Ze3F5zg8/pj1XCEkEQ9OHoZnH5uaFAYfXjrcmhUkPvyit39v7zC9lcKO1Y3QoEwOD28fEl4CRAqiFxJyddAHuUnpwsnW9MFIdW3JeMW41Ghl++N/AOMuX5YTCqlU/nbCxWZLDXQRfQRNiNr+Z0L+OUFHZwNhm/CRBaWMh8Ki0u+iYoQeyZ3Z+4lcKeytSBqPOyZ2a+X1grWESoE6wFWAZwNRMTWaEzBk/yLtrNdAmow7gaHRm96HEpYCC/divGjGEdv+h5qLk5lM4utUvWAvxw8g8iI2MPAeYbE9VeFMrbNZl2GzoRHRrrWy+hG8ZPg13xb+XS+CIAAAAASUVORK5CYII=">
</head> </head>
<body> <body>
<div class="container"> <div id="content" class="container"></div>
<h2 class="bot">Playlists</h2>
<section>
<div id="items" class="item-container">
{{ range .Playlists }}
<div class="item" data-id="{{ .Id }}">{{ .Title }}</div>
{{ end}}
<div class="item locked stop" data-id="reset">Stop</div>
</div>
</section>
<section>
<div id="inputplaylist" class="input-container">
<input class="u-full-width" name="title" type="text" placeholder="Enter name..">
<input class="u-full-width" name="url" type="text" placeholder="https://youtube.com/playlist?list=...">
<input id="addplaylist" name="submit" value="Add" type="submit">
</div>
</section>
<section>
<p id="output"></p>
<div id="info">
<a id="link" style="text-decoration: none;">
<span id="channel"></span>
<span> - </span>
<span id="title"></span>
</a>
<div class="controls">
<input id="prev" name="prev" type="button" value="prev">
<span id="time"></span>
<input id="next" name="next" type="button" value="next">
</div>
</div>
</section>
<h2 class="bot">Ambiance</h2>
<section>
<div id="ambiance" class="item-container">
{{ range .Ambiance }}
<div class="item drag" data-id="{{ .Id }}">{{ .Title }}</div>
{{ end}}
<div class="item locked stop" data-id="reset">Stop</div>
</div>
</section>
<section>
<div id="progressambiance" class="input-container">
<progress max="100" value="0" style="display:none">0%</progress>
</div>
</section>
<section>
<div id="inputambiance" class="input-container">
<input class="u-full-width" name="title" type="text" placeholder="Enter name..">
<input class="u-full-width" name="url" type="text" placeholder="Enter url...">
<input id="addambiance" name="submit" value="Add" type="submit">
</div>
</section>
<section>
<div id="volume_playlist" class="input-container">
<label for="playlist-volume">Playlist</label>
<input type="range" id="playlist-volume" min="-10" max="4" step="0.1">
<input id="playlist-volume-number" type="number" min="-10" max="4" step="0.1" style="width:50px" />
</div>
<div id="volume_ambiance" class="input-container">
<label for="ambiance-volume">Ambiance</label>
<input type="range" id="ambiance-volume" min="-10" max="4" step="0.1">
<input id="ambiance-volume-number" type="number" min="-10" max="4" step="0.1" style="width:50px" />
</div>
</section>
<!-- The Modal -->
<div id="waiting" class="modal">
<!-- Modal content -->
<div class="modal-content">
<p>Waiting for bot..</p>
</div>
</div>
</div>
<script src="/public/script.js"></script> <script src="/public/script.js"></script>
</body> </body>
</html> </html>