Initial interface

This commit is contained in:
nemunaire 2023-11-13 13:18:43 +01:00
parent 5d0a210e6d
commit e2013351d1
23 changed files with 3237 additions and 0 deletions

13
ui/.eslintignore Normal file
View File

@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

14
ui/.eslintrc.cjs Normal file
View File

@ -0,0 +1,14 @@
module.exports = {
root: true,
extends: ['eslint:recommended', 'plugin:svelte/recommended', 'prettier'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
extraFileExtensions: ['.svelte']
},
env: {
browser: true,
es2017: true,
node: true
}
};

10
ui/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
ui/.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict=true

13
ui/.prettierignore Normal file
View File

@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

9
ui/.prettierrc Normal file
View File

@ -0,0 +1,9 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"pluginSearchDirs": ["."],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

32
ui/assets-dev.go Normal file
View File

@ -0,0 +1,32 @@
//go:build dev
// +build dev
package ui
import (
"flag"
"net/http"
"os"
"path/filepath"
)
var (
Assets http.FileSystem
StaticDir string = "ui/"
)
func init() {
flag.StringVar(&StaticDir, "static", StaticDir, "Directory containing static files")
}
func sanitizeStaticOptions() error {
StaticDir, _ = filepath.Abs(StaticDir)
if _, err := os.Stat(StaticDir); os.IsNotExist(err) {
StaticDir, _ = filepath.Abs(filepath.Join(filepath.Dir(os.Args[0]), "ui"))
if _, err := os.Stat(StaticDir); os.IsNotExist(err) {
return err
}
}
Assets = http.Dir(StaticDir)
return nil
}

28
ui/assets.go Normal file
View File

@ -0,0 +1,28 @@
//go:build !dev
// +build !dev
package ui
import (
"embed"
"io/fs"
"log"
"net/http"
)
//go:embed all:build
var _assets embed.FS
var Assets http.FileSystem
func init() {
sub, err := fs.Sub(_assets, "build")
if err != nil {
log.Fatal("Unable to cd to ui/build directory:", err)
}
Assets = http.FS(sub)
}
func sanitizeStaticOptions() error {
return nil
}

2556
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
ui/package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "ui",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write ."
},
"devDependencies": {
"@sveltejs/adapter-auto": "^2.0.0",
"@sveltejs/kit": "^1.20.4",
"eslint": "^8.28.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-svelte": "^2.30.0",
"prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.10.1",
"svelte": "^4.0.5",
"vite": "^4.4.2"
},
"type": "module",
"dependencies": {
"@sveltejs/adapter-static": "^2.0.3",
"bootstrap": "^5.3.2",
"bootstrap-icons": "^1.11.1",
"sass": "^1.69.5"
}
}

71
ui/routes.go Normal file
View File

@ -0,0 +1,71 @@
package ui
import (
"io"
"net/http"
"net/url"
"path"
"github.com/gin-gonic/gin"
"git.nemunai.re/nemunaire/hathoris/config"
)
func serveOrReverse(forced_url string, cfg *config.Config) gin.HandlerFunc {
if cfg.DevProxy != "" {
// Forward to the Vue dev proxy
return func(c *gin.Context) {
if u, err := url.Parse(cfg.DevProxy); err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
} else {
if forced_url != "" {
u.Path = path.Join(u.Path, forced_url)
} else {
u.Path = path.Join(u.Path, c.Request.URL.Path)
}
if r, err := http.NewRequest(c.Request.Method, u.String(), c.Request.Body); err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
} else if resp, err := http.DefaultClient.Do(r); err != nil {
http.Error(c.Writer, err.Error(), http.StatusBadGateway)
} else {
defer resp.Body.Close()
for key := range resp.Header {
c.Writer.Header().Add(key, resp.Header.Get(key))
}
c.Writer.WriteHeader(resp.StatusCode)
io.Copy(c.Writer, resp.Body)
}
}
}
} else if forced_url != "" {
// Serve forced_url
return func(c *gin.Context) {
c.FileFromFS(forced_url, Assets)
}
} else {
// Serve requested file
return func(c *gin.Context) {
c.FileFromFS(c.Request.URL.Path, Assets)
}
}
}
func DeclareRoutes(router *gin.Engine, cfg *config.Config) {
if cfg.DevProxy != "" {
router.GET("/.svelte-kit/*_", serveOrReverse("", cfg))
router.GET("/node_modules/*_", serveOrReverse("", cfg))
router.GET("/@vite/*_", serveOrReverse("", cfg))
router.GET("/@id/*_", serveOrReverse("", cfg))
router.GET("/@fs/*_", serveOrReverse("", cfg))
router.GET("/src/*_", serveOrReverse("", cfg))
}
router.GET("/", serveOrReverse("", cfg))
router.GET("/_app/*_", serveOrReverse("", cfg))
router.GET("/img/*_", serveOrReverse("", cfg))
router.GET("/favicon.ico", serveOrReverse("", cfg))
}

17
ui/src/app.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="fr" class="d-flex flex-column mh-100 h-100">
<head>
<meta charset="utf-8" />
<meta name="description" content="" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#ffffff"/>
<meta name="author" content="nemucorp">
<meta name="robots" content="none">
<base href="/">
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" class="flex-fill d-flex flex-column">
<noscript>Si la page ne charge pas, essayez <a href="/nojs.html">la version sans JavaScript</a>.</noscript>
<div class="flex-fill d-flex flex-column justify-content-between" style="min-height: 100%">%sveltekit.body%</div>
</body>
</html>

75
ui/src/hathoris.scss Normal file
View File

@ -0,0 +1,75 @@
// Your variable overrides can go here, e.g.:
// $h1-font-size: 3rem;
//$primary: #ff485a;
//$secondary: #ff7b88;
$blue: #2a9fd6;
$indigo: #6610f2;
$purple: #6f42c1;
$pink: #e83e8c;
$red: #c00;
$orange: #fd7e14;
$yellow: #f80;
$green: #77b300;
$teal: #20c997;
$cyan: #93c;
$primary: $purple;
$success: $green;
$info: $cyan;
$warning: $yellow;
$danger: $red;
$min-contrast-ratio: 2.25;
$enable-shadows: true;
$enable-gradients: true;
$enable-responsive-font-sizes: true;
$link-color: $primary;
$navbar-padding-y: 0;
$nav-link-padding-y: 0.2rem;
@import "bootstrap/scss/bootstrap";
a.btn, button.btn {
text-decoration: none !important;
}
.fixed-bottom .nav-item a {
border-top: 1px solid $pink;
}
.fixed-bottom .nav-item a.active {
background: white;
color: $pink;
}
.marquee {
height: 1.5em;
overflow: hidden;
position: relative;
}
.marquee p {
position: absolute;
width: 100%;
height: 100%;
margin: 0;
text-align: center;
transform: translateX(100%);
animation: scroll-left 10s linear infinite;
}
@include media-breakpoint-up(md) {
.marquee p {
animation: scroll-left 25s linear infinite;
}
}
@include media-breakpoint-up(xl) {
.marquee p {
animation: scroll-left 45s linear infinite;
}
}
@keyframes scroll-left {
0% { transform: translateX(100%); }
100% { transform: translateX(-100%); }
}

View File

@ -0,0 +1,110 @@
<script>
function refreshMixers() {
const mxrs = fetch('api/mixer', {headers: {'Accept': 'application/json'}}).then((res) => res.json());
mxrs.then((m) => {
mixers = m;
altering_mixer = null;
})
}
export let showReadOnly = false;
export let advanced = false;
let mixers = null;
refreshMixers();
setInterval(refreshMixers, 5000);
let altering_mixer = null;
async function alterMixer(mixer, values) {
if (altering_mixer) altering_mixer.abort();
altering_mixer = setTimeout(() => {
fetch(`api/mixer/${mixer.NumID}/values`, {headers: {'Accept': 'application/json'}, method: 'POST', body: JSON.stringify(values ? values : (advanced ? mixer.values : [mixer.values[0]]))}).then(refreshMixers);
altering_mixer = null;
}, 450);
}
</script>
{#if !mixers}
<div class="card-body d-flex flex-fill justify-content-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
{:else}
<ul class="list-group list-group-flush">
{#each mixers as mixer (mixer.NumID)}
{#if showReadOnly || mixer.RW}
<li class="list-group-item py-3">
{#if mixer.items}
<label for={mixer.Name + '0'} class="form-label">{mixer.Name}</label>
{#if mixer.values}
{#each mixer.values as cur, idx}
<select
class="form-select"
disabled={!mixer.RW}
id={mixer.Name + idx}
bind:value={cur}
on:change={() => alterMixer(mixer)}
>
{#each mixer.items as opt, idx}
<option value={idx}>{opt}</option>
{/each}
</select>
{/each}
{/if}
{:else if mixer.Type === "INTEGER"}
<label for={mixer.Name + '0'} class="form-label">{mixer.Name}</label>
{#if mixer.values}
<div class="badge bg-primary float-end">
{mixer.DBScale ? ((mixer.DBScale.Min + mixer.DBScale.Step * mixer.values[0]) + ' dB') : mixer.values[0]}
</div>
{#each mixer.values as cur, idx}
{#if advanced || idx === 0}
<input
type="range"
class="form-range"
disabled={!mixer.RW}
id={mixer.Name + idx}
min={mixer.Min}
max={mixer.Max}
step={mixer.Step}
title={mixer.DBScale ? ((mixer.DBScale.Min + mixer.DBScale.Step * cur) + ' dB') : cur}
bind:value={cur}
on:change={() => alterMixer(mixer)}
>
{/if}
{/each}
{/if}
{:else if mixer.Type === "BOOLEAN"}
{#if mixer.RW}
<div class="btn-group" role="group" aria-label="Basic example">
<button
class="btn"
class:btn-secondary={!mixer.values.reduce((a,b) => a || b)}
class:btn-primary={mixer.values.reduce((a,b) => a || b)}
on:click={() => alterMixer(mixer, [!mixer.values.reduce((a,b) => a || b)])}
>
{mixer.Name}
</button>
{#if advanced && mixer.values.length > 1}
{#each mixer.values as cur, ichan}
<button
class="btn btn-sm"
class:btn-secondary={!cur}
class:btn-primary={cur}
on:click={() => alterMixer(mixer, mixer.values.map((v, i) => i == ichan ? !v : v))}
>
{ichan+1}
</button>
{/each}
{/if}
</div>
{:else}
<label class="form-label">{mixer.Name}</label>
{/if}
{/if}
</li>
{/if}
{/each}
</ul>
{/if}

View File

@ -0,0 +1,45 @@
<script>
import { sources } from '$lib/stores/sources';
let activating_source = null;
async function clickSource(src) {
activating_source = src.id;
if (src.enabled) {
await src.deactivate();
await sources.refresh();
} else {
await src.activate();
await sources.refresh();
}
activating_source = null;
}
</script>
{#if !$sources}
<div class="d-flex flex-fill justify-content-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
{:else}
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-6 row-cols-xl-auto justify-content-center g-2">
{#each Object.keys($sources) as source}
<div class="col d-flex flex-column justify-content-center align-items-center">
<button
class="btn btn-lg"
class:btn-primary={$sources[source].enabled}
class:btn-secondary={!$sources[source].enabled}
disabled={activating_source !== null}
on:click={() => clickSource($sources[source])}
>
{#if activating_source && activating_source === source}
<div class="spinner-grow spinner-grow-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
{/if}
{$sources[source].name}
</button>
</div>
{/each}
</div>
{/if}

1
ui/src/lib/index.js Normal file
View File

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

57
ui/src/lib/source.js Normal file
View File

@ -0,0 +1,57 @@
export class Source {
constructor(id, res) {
this.id = id;
if (res) {
this.update(res);
}
}
update({ name, enabled, active }) {
this.name = name;
this.enabled = enabled;
this.active = active;
}
async activate() {
await fetch(`api/sources/${this.id}/enable`, {headers: {'Accept': 'application/json'}, method: 'POST'});
}
async deactivate() {
await fetch(`api/sources/${this.id}/disable`, {headers: {'Accept': 'application/json'}, method: 'POST'});
}
async currently() {
const data = await fetch(`api/sources/${this.id}/currently`, {headers: {'Accept': 'application/json'}});
if (data.status == 200) {
return await data.json();
} else {
throw new Error((await res.json()).errmsg);
}
}
}
export async function getSources() {
const res = await fetch(`api/sources`, {headers: {'Accept': 'application/json'}})
if (res.status == 200) {
const data = await res.json();
if (data == null) {
return {}
} else {
Object.keys(data).forEach((k) => {
data[k] = new Source(k, data[k]);
});
return data;
}
} else {
throw new Error((await res.json()).errmsg);
}
}
export async function getSource(sid) {
const res = await fetch(`api/sources/${sid}`, {headers: {'Accept': 'application/json'}})
if (res.status == 200) {
return new Source(sid, await res.json());
} else {
throw new Error((await res.json()).errmsg);
}
}

View File

@ -0,0 +1,41 @@
import { derived, writable } from 'svelte/store';
import { getSources } from '$lib/source'
function createSourcesStore() {
const { subscribe, set, update } = writable(null);
return {
subscribe,
set: (v) => {
update((m) => v);
},
refresh: async () => {
const list = await getSources();
update((m) => list);
return list;
},
};
}
export const sources = createSourcesStore();
export const sourcesList = derived(
sources,
($sources) => {
if (!$sources) {
return [];
}
return Object.keys($sources).map((k) => $sources[k]);
},
);
export const activeSources = derived(
sourcesList,
($sourcesList) => {
return $sourcesList.filter((s) => s.active);
},
);

1
ui/src/routes/+layout.js Normal file
View File

@ -0,0 +1 @@
export const ssr = false;

View File

@ -0,0 +1,20 @@
<script>
import '../hathoris.scss'
import "bootstrap-icons/font/bootstrap-icons.css";
import { sources } from '$lib/stores/sources';
sources.refresh();
setInterval(sources.refresh, 5000);
const version = fetch('api/version', {headers: {'Accept': 'application/json'}}).then((res) => res.json())
</script>
<svelte:head>
<title>Hathoris</title>
</svelte:head>
<div class="flex-fill d-flex flex-column bg-light">
<div class="container-fluid flex-fill d-flex flex-column justify-content-center">
<slot></slot>
</div>
</div>

View File

@ -0,0 +1,75 @@
<script>
import Mixer from '$lib/components/Mixer.svelte';
import SourceSelection from '$lib/components/SourceSelection.svelte';
import { activeSources } from '$lib/stores/sources';
let mixerAdvanced = false;
</script>
<div class="my-2">
<SourceSelection />
</div>
<div class="container">
{#if $activeSources.length === 0}
<div class="text-muted text-center mt-1 mb-1">
Aucune source active pour l'instant.
</div>
{:else}
<marquee>
{#each $activeSources as source}
<div class="d-inline-block me-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>{source.name}&nbsp;:</strong>
{#await source.currently()}
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
{:then title}
{title}
{:catch error}
activée
{/await}
</div>
</div>
</div>
{/each}
</marquee>
{/if}
<div class="row">
<div class="col">
<div class="card my-2">
<h4 class="card-header">
<div class="d-flex justify-content-between">
<div>
<i class="bi bi-sliders"></i>
Mixer
</div>
<button
class="btn btn-sm"
class:btn-info={mixerAdvanced}
class:btn-secondary={!mixerAdvanced}
on:click={() => { mixerAdvanced = !mixerAdvanced; }}
>
<i class="bi bi-alt"></i>
</button>
</div>
</h4>
<Mixer advanced={mixerAdvanced} />
</div>
</div>
<div class="col">
<div class="card my-2">
<h4 class="card-header">
<i class="bi bi-speaker"></i>
Sources
</h4>
<div class="card-body">
</div>
</div>
</div>
</div>
</div>

12
ui/svelte.config.js Normal file
View File

@ -0,0 +1,12 @@
import adapter from '@sveltejs/adapter-static';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter({
fallback: 'index.html'
}),
}
};
export default config;

6
ui/vite.config.js Normal file
View File

@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});