diff --git a/api/routes.go b/api/routes.go index 7473389..1677bb1 100644 --- a/api/routes.go +++ b/api/routes.go @@ -4,12 +4,18 @@ import ( "github.com/gin-gonic/gin" "git.nemunai.re/nemunaire/hathoris/config" + "git.nemunai.re/nemunaire/hathoris/settings" ) -func DeclareRoutes(router *gin.Engine, cfg *config.Config) { +type SettingsGetter func() *settings.Settings + +type SettingsReloader func() error + +func DeclareRoutes(router *gin.Engine, cfg *config.Config, getsettings SettingsGetter, reloadsettings SettingsReloader) { apiRoutes := router.Group("/api") declareInputsRoutes(cfg, apiRoutes) + declareSettingsRoutes(cfg, getsettings, reloadsettings, apiRoutes) declareSourcesRoutes(cfg, apiRoutes) declareVolumeRoutes(cfg, apiRoutes) } diff --git a/api/settings.go b/api/settings.go new file mode 100644 index 0000000..4fc5abe --- /dev/null +++ b/api/settings.go @@ -0,0 +1,64 @@ +package api + +import ( + "fmt" + "log" + "net/http" + + "github.com/gin-gonic/gin" + + "git.nemunai.re/nemunaire/hathoris/config" + "git.nemunai.re/nemunaire/hathoris/settings" + "git.nemunai.re/nemunaire/hathoris/sources" +) + +type loadableSourceExposed struct { + Description string `json:"description"` + Fields []*sources.SourceField `json:"fields"` +} + +func declareSettingsRoutes(cfg *config.Config, getsettings SettingsGetter, reloadsettings SettingsReloader, router *gin.RouterGroup) { + router.GET("/settings", func(c *gin.Context) { + c.JSON(http.StatusOK, getsettings()) + }) + + router.POST("/settings", func(c *gin.Context) { + var params settings.Settings + + // Parse settings + err := c.ShouldBindJSON(¶ms) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Sprintf("Something is wrong in received settings: %s", err.Error())}) + return + } + + // Reload current settings + *getsettings() = params + + // Save settings + getsettings().Save(cfg.SettingsPath) + + err = reloadsettings() + if err != nil { + log.Println("Unable to reload settings:", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to reload settings: %s", err.Error())}) + return + } + + c.JSON(http.StatusOK, getsettings()) + }) + + router.GET("/settings/loadable_sources", func(c *gin.Context) { + ret := map[string]loadableSourceExposed{} + + for k, ls := range sources.LoadableSources { + ret[k] = loadableSourceExposed{ + Description: ls.Description, + Fields: sources.GenFields(ls.SourceDefinition), + } + } + + c.JSON(http.StatusOK, ret) + }) + +} diff --git a/app.go b/app.go index c0d2d61..5474672 100644 --- a/app.go +++ b/app.go @@ -49,7 +49,9 @@ func NewApp(cfg *config.Config) *App { // Register routes ui.DeclareRoutes(router, cfg) - api.DeclareRoutes(router, cfg) + api.DeclareRoutes(router, cfg, func() *settings.Settings { + return app.settings + }, app.loadCustomSources) router.GET("/api/version", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"version": Version}) @@ -82,6 +84,21 @@ func (app *App) loadCustomSources() error { } } + // Delete old custom sources, no more in config + for id1 := range sources.SoundSources { + found := false + for id2, csrc := range app.settings.CustomSources { + if id1 == fmt.Sprintf("%s-%d", csrc.Source, id2) { + found = true + break + } + } + + if !found { + delete(sources.SoundSources, id1) + } + } + return nil } diff --git a/sources/fields.go b/sources/fields.go new file mode 100644 index 0000000..69a8cfc --- /dev/null +++ b/sources/fields.go @@ -0,0 +1,53 @@ +package sources + +import ( + "reflect" + "strings" +) + +type SourceField struct { + Id string `json:"id"` + Type string `json:"type"` + Label string `json:"label,omitempty"` + Placeholder string `json:"placeholder,omitempty"` + Default interface{} `json:"default,omitempty"` + Required bool `json:"required,omitempty"` + Description string `json:"description,omitempty"` +} + +func GenFields(data interface{}) (fields []*SourceField) { + if data != nil { + dataMeta := reflect.Indirect(reflect.ValueOf(data)).Type() + + for i := 0; i < dataMeta.NumField(); i += 1 { + field := dataMeta.Field(i) + if field.IsExported() { + fields = append(fields, GenField(field)) + } + } + } + return +} + +func GenField(field reflect.StructField) (f *SourceField) { + f = &SourceField{ + Id: field.Name, + Type: field.Type.String(), + Label: field.Tag.Get("label"), + Placeholder: field.Tag.Get("placeholder"), + Default: field.Tag.Get("default"), + Description: field.Tag.Get("description"), + } + + jsonTag := field.Tag.Get("json") + jsonTuples := strings.Split(jsonTag, ",") + if len(jsonTuples) > 0 && len(jsonTuples[0]) > 0 { + f.Id = jsonTuples[0] + } + + if f.Label == "" { + f.Label = field.Name + } + + return +} diff --git a/ui/routes.go b/ui/routes.go index ae7bc9f..b04b498 100644 --- a/ui/routes.go +++ b/ui/routes.go @@ -64,6 +64,7 @@ func DeclareRoutes(router *gin.Engine, cfg *config.Config) { } router.GET("/", serveOrReverse("", cfg)) + router.GET("/settings", serveOrReverse("", cfg)) router.GET("/_app/*_", serveOrReverse("", cfg)) router.GET("/img/*_", serveOrReverse("", cfg)) diff --git a/ui/src/lib/components/SettingInput.svelte b/ui/src/lib/components/SettingInput.svelte new file mode 100644 index 0000000..78d7041 --- /dev/null +++ b/ui/src/lib/components/SettingInput.svelte @@ -0,0 +1,58 @@ + + +{#if field.type.startsWith("[]")} + {#each value as val, k} + {#if k !== 0}
{/if} + dispatch('change', e)} + on:input={(e) => dispatch('input', e)} + /> + {/each} + +{:else} + dispatch('change', e)} + on:input={(e) => dispatch('input', e)} + /> +{/if} diff --git a/ui/src/lib/components/SettingInputBasic.svelte b/ui/src/lib/components/SettingInputBasic.svelte new file mode 100644 index 0000000..4c0a002 --- /dev/null +++ b/ui/src/lib/components/SettingInputBasic.svelte @@ -0,0 +1,31 @@ + + +{#if field.type.endsWith('int')} + dispatch('change', e)} + on:input={(e) => dispatch('input', e)} + > +{:else} + dispatch('change', e)} + on:input={(e) => dispatch('input', e)} + > +{/if} diff --git a/ui/src/lib/components/SettingsButton.svelte b/ui/src/lib/components/SettingsButton.svelte new file mode 100644 index 0000000..eacf3f6 --- /dev/null +++ b/ui/src/lib/components/SettingsButton.svelte @@ -0,0 +1,20 @@ + + + diff --git a/ui/src/lib/custom_source.js b/ui/src/lib/custom_source.js new file mode 100644 index 0000000..737bd8c --- /dev/null +++ b/ui/src/lib/custom_source.js @@ -0,0 +1,26 @@ +export class CustomSource { + constructor(res) { + if (res) { + this.update(res); + } + } + + update({ src, kv }) { + this.src = src; + this.kv = kv; + } +} + +export async function retrieveLoadableSources() { + const res = await fetch(`api/settings/loadable_sources`, {headers: {'Accept': 'application/json'}}) + if (res.status == 200) { + const data = await res.json(); + if (data == null) { + return {} + } else { + return data; + } + } else { + throw new Error((await res.json()).errmsg); + } +} diff --git a/ui/src/lib/settings.js b/ui/src/lib/settings.js new file mode 100644 index 0000000..c562f88 --- /dev/null +++ b/ui/src/lib/settings.js @@ -0,0 +1,54 @@ +import { CustomSource } from '$lib/custom_source.js'; + +export class Settings { + constructor(res) { + if (res) { + this.update(res); + } + } + + update({ custom_sources }) { + if (custom_sources) { + this.custom_sources = custom_sources.map((e) => new CustomSource(e)); + } else { + this.custom_sources = []; + } + } + + async save(avoidStructUpdate) { + const res = await fetch('api/settings', { + headers: {'Accept': 'application/json'}, + method: 'POST', + body: JSON.stringify(this), + }); + if (res.status == 200) { + const data = await res.json(); + if (!avoidStructUpdate) { + if (data == null) { + this.update({}); + } else { + this.update(data); + } + return this; + } else { + return new Settings(data); + } + } else { + throw new Error((await res.json()).errmsg); + } + } +} + +export async function getSettings() { + const res = await fetch(`api/settings`, {headers: {'Accept': 'application/json'}}) + if (res.status == 200) { + const data = await res.json(); + if (data == null) { + return {} + } else { + return new Settings(data); + } + } else { + throw new Error((await res.json()).errmsg); + } +} diff --git a/ui/src/lib/stores/loadable-sources.js b/ui/src/lib/stores/loadable-sources.js new file mode 100644 index 0000000..a883b97 --- /dev/null +++ b/ui/src/lib/stores/loadable-sources.js @@ -0,0 +1,24 @@ +import { derived, writable } from 'svelte/store'; + +import { retrieveLoadableSources } from '$lib/custom_source' + +function createLoadableSourcesStore() { + const { subscribe, set, update } = writable(null); + + return { + subscribe, + + set: (v) => { + update((m) => v); + }, + + refresh: async () => { + const map = await retrieveLoadableSources(); + update((m) => map); + return map; + }, + }; + +} + +export const loadablesSources = createLoadableSourcesStore(); diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte index fda31dd..bcdfca7 100644 --- a/ui/src/routes/+layout.svelte +++ b/ui/src/routes/+layout.svelte @@ -10,6 +10,7 @@ inputs.refresh(); setInterval(inputs.refresh, 4500); + import SettingsButton from '$lib/components/SettingsButton.svelte'; import SourceSelection from '$lib/components/SourceSelection.svelte'; const version = fetch('api/version', {headers: {'Accept': 'application/json'}}).then((res) => res.json()) @@ -21,6 +22,10 @@
+
+ +
+
diff --git a/ui/src/routes/settings/+page.js b/ui/src/routes/settings/+page.js new file mode 100644 index 0000000..252e476 --- /dev/null +++ b/ui/src/routes/settings/+page.js @@ -0,0 +1,24 @@ +import { error } from '@sveltejs/kit'; +import { getSettings } from '$lib/settings'; +import { loadablesSources } from '$lib/stores/loadable-sources.js'; + +export const load = async({ parent, fetch }) => { + const data = await parent(); + + await loadablesSources.refresh(); + + let settings; + try { + settings = await getSettings(); + } catch (err) { + throw error(err.status, err.statusText); + } + + const version = fetch('/api/version').then((res) => res.json()); + + return { + ...data, + settings, + version, + }; +} diff --git a/ui/src/routes/settings/+page.svelte b/ui/src/routes/settings/+page.svelte new file mode 100644 index 0000000..9db3f0d --- /dev/null +++ b/ui/src/routes/settings/+page.svelte @@ -0,0 +1,153 @@ + + +
+
+

+
+
+ + General Settings +
+
+

+
+ Version: + {#await data.version then version} + {version.version} + {/await} +
+
+ +
+

+
+
+ + Custom Sources +
+ +
+

+
+ {#each data.settings.custom_sources as source, isrc} +
+
+
+
source.edit_name = true} + > + {#if source.kv && source.kv.name !== undefined} + {#if !source.edit_name} + {source.kv.name} + {:else} + + {/if} + {:else} + {source.src} #{isrc} + {/if} +
+ {#if source.src} + {source.src} + {/if} +
+ +
+ {#if source.src && source.kv} + {#if $loadablesSources[source.src].fields} + {#each $loadablesSources[source.src].fields.filter((e) => e.id !== "name") as field (field.id)} +
+ + +
+ {/each} + {:else} + {#each Object.keys(source.kv).filter((e) => e !== "name") as k} +
+ + +
+ {/each} + {/if} + {:else} + Choose a new custom source: +
+ {#if $loadablesSources} + {#each Object.keys($loadablesSources) as kls} + + {/each} + {/if} +
+ {/if} +
+ {/each} +
+
+