1
0
mirror of https://github.com/hrfee/jfa-go.git synced 2024-11-09 20:00:12 +00:00

Compare commits

..

3 Commits

Author SHA1 Message Date
563888c5e7
add user profiles menu
also fixed a bug where invites wouldn't load after deleting a profile.
2021-01-04 01:02:47 +00:00
29fafba035
remove custom css and bootstrap related options
i may reimplement custom css later as an additive version where their
file is loaded on top of a17t.css later, but for now its not a priority.
2021-01-03 21:13:37 +00:00
75d12b4a5c
fully implements settings load/save, add ombi user defaults
all that remains is the User profiles menu.
2021-01-03 20:58:44 +00:00
13 changed files with 485 additions and 83 deletions

11
api.go
View File

@ -667,6 +667,9 @@ func (app *appContext) DeleteProfile(gc *gin.Context) {
gc.BindJSON(&req) gc.BindJSON(&req)
name := req.Name name := req.Name
if _, ok := app.storage.profiles[name]; ok { if _, ok := app.storage.profiles[name]; ok {
if app.storage.defaultProfile == name {
app.storage.defaultProfile = ""
}
delete(app.storage.profiles, name) delete(app.storage.profiles, name)
} }
app.storage.storeProfiles() app.storage.storeProfiles()
@ -1097,15 +1100,12 @@ func (app *appContext) GetConfig(gc *gin.Context) {
} }
} }
s := resp.Sections["ui"].Settings["language"] s := resp.Sections["ui"].Settings["language"]
s.Options = app.lang.langOptions
s.Value = app.lang.langOptions[app.lang.chosenIndex]
resp.Sections["ui"].Settings["language"] = s
for sectName, section := range resp.Sections { for sectName, section := range resp.Sections {
for settingName, setting := range section.Settings { for settingName, setting := range section.Settings {
val := app.config.Section(sectName).Key(settingName) val := app.config.Section(sectName).Key(settingName)
s := resp.Sections[sectName].Settings[settingName] s := resp.Sections[sectName].Settings[settingName]
switch setting.Type { switch setting.Type {
case "text", "email", "select": case "text", "email", "select", "password":
s.Value = val.MustString("") s.Value = val.MustString("")
case "number": case "number":
s.Value = val.MustInt(0) s.Value = val.MustInt(0)
@ -1115,6 +1115,9 @@ func (app *appContext) GetConfig(gc *gin.Context) {
resp.Sections[sectName].Settings[settingName] = s resp.Sections[sectName].Settings[settingName] = s
} }
} }
s.Options = app.lang.langOptions
s.Value = app.lang.langOptions[app.lang.chosenIndex]
resp.Sections["ui"].Settings["language"] = s
gc.JSON(200, resp) gc.JSON(200, resp)
} }

View File

@ -81,9 +81,8 @@
"requires_restart": true, "requires_restart": true,
"type": "select", "type": "select",
"options": [ "options": [
"Bootstrap (Light)",
"Jellyfin (Dark)", "Jellyfin (Dark)",
"Custom CSS" "Default (Light)"
], ],
"value": "Jellyfin (Dark)", "value": "Jellyfin (Dark)",
"description": "Default appearance for all users." "description": "Default appearance for all users."
@ -179,14 +178,6 @@
"value": "Your account has been created. Click below to continue to Jellyfin.", "value": "Your account has been created. Click below to continue to Jellyfin.",
"description": "Displayed when a user creates an account" "description": "Displayed when a user creates an account"
}, },
"bs5": {
"name": "Use Bootstrap 5",
"required": false,
"requires_restart": true,
"type": "bool",
"value": false,
"description": "Use the Bootstrap 5 Alpha. Looks better and removes the need for jQuery, so the page should load faster."
},
"url_base": { "url_base": {
"name": "URL Base", "name": "URL Base",
"required": false, "required": false,
@ -685,14 +676,6 @@
"value": "", "value": "",
"description": "Location of stored user profiles (encompasses template and configuration and displayprefs) (json)" "description": "Location of stored user profiles (encompasses template and configuration and displayprefs) (json)"
}, },
"custom_css": {
"name": "Custom CSS",
"required": false,
"requires_restart": true,
"type": "text",
"value": "",
"description": "Location of custom bootstrap CSS."
},
"html_templates": { "html_templates": {
"name": "Custom HTML Template Directory", "name": "Custom HTML Template Directory",
"required": false, "required": false,

View File

@ -68,6 +68,10 @@
margin-left: 1rem; margin-left: 1rem;
} }
.ml-half {
margin-left: 0.5rem;
}
.mr-1 { .mr-1 {
margin-right: 1rem; margin-right: 1rem;
} }
@ -96,6 +100,10 @@
align-items: top; align-items: top;
} }
.flex {
display: flex;
}
.flex-expand { .flex-expand {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -208,6 +216,12 @@ sup.\~critical, .text-critical {
width: auto; width: auto;
} }
.ellipsis {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.no-pad { .no-pad {
padding: 0px 0px 0px 0px; padding: 0px 0px 0px 0px;
} }

View File

@ -40,12 +40,22 @@
width: 30%; width: 30%;
} }
.modal-content.wide {
width: 60%;
}
.modal-shown .modal-content { .modal-shown .modal-content {
animation: modal-content-show 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94); animation: modal-content-show 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
} }
@media screen and (max-width: 1000px) {
.modal-content.wide {
width: 75%;
}
}
@media screen and (max-width: 400px) { @media screen and (max-width: 400px) {
.modal-content { .modal-content, .modal-content.wide {
width: 90%; width: 90%;
} }
} }

View File

@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="light-theme"> <html lang="en" class="{{ .cssClass }}">
<head> <head>
<link rel="stylesheet" type="text/css" href="css/base.css"> <link rel="stylesheet" type="text/css" href="css/base.css">
@ -101,17 +101,17 @@
</form> </form>
</div> </div>
<div id="modal-restart" class="modal"> <div id="modal-restart" class="modal">
<div class="modal-content card ~critical !normal"> <div class="modal-content card ~critical !low">
<span class="heading">Restart needed <span class="modal-close">&times;</span></span> <span class="heading">Restart needed <span class="modal-close">&times;</span></span>
<p class="content pb-1">A restart is needed to apply some settings you changed. Do it now or later?</p> <p class="content pb-1">A restart is needed to apply some settings you changed. Do it now or later?</p>
<div class="fr"> <div class="fr">
<span class="button ~info !normal">Apply, restart later</span> <span class="button ~info !normal" id="settings-apply-no-restart">Apply, restart later</span>
<span class="button ~critical !normal">Apply &amp; restart</span> <span class="button ~critical !normal" id="settings-apply-restart">Apply &amp; restart</span>
</div> </div>
</div> </div>
</div> </div>
<div id="modal-refresh" class="modal"> <div id="modal-refresh" class="modal">
<div class="modal-content card ~urge !normal"> <div class="modal-content card ~neutral !normal">
<span class="heading">Settings applied.</span> <span class="heading">Settings applied.</span>
<p class="content">Refresh the page in a few seconds.</p> <p class="content">Refresh the page in a few seconds.</p>
</div> </div>
@ -132,6 +132,51 @@
</label> </label>
</form> </form>
</div> </div>
<div id="modal-user-profiles" class="modal">
<div class="modal-content wide card">
<span class="heading">User profiles <span class="modal-close">&times;</span></span>
<p class="support lg">Profiles are applied to users when they create an account. A profile includes library access rights and homescreen layout.</p>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Default</th>
<th>From</th>
<th>Libraries</th>
<th><span class="button ~neutral !high" id="button-profile-create">Create</span></th>
</tr>
</thead>
<tbody id="table-profiles">
</tbody>
</table>
</div>
</div>
</div>
<div id="modal-add-profile" class="modal">
<form class="modal-content card" id="form-add-profile" href="">
<span class="heading">Add profile <span class="modal-close">&times;</span></span>
<p class="content">Create a Jellyfin user and configure it. Select it here, and when this profile is applied to an invite, new users will be created with its settings.</p>
<label>
<span class="supra">Profile Name </span>
<input type="text" class="field input ~neutral !high mt-half mb-1" placeholder="Name" id="add-profile-name">
<label>
<span class="supra">User</span>
<div class="select ~neutral !normal mt-half mb-1">
<select id="add-profile-user">
</select>
</div>
</label>
<label class="switch mb-1">
<input type="checkbox" id="add-profile-homescreen" checked>
<span>Store homescreen layout</span>
</label>
<label>
<input type="submit" class="unfocused">
<span class="button ~urge !normal full-width center supra submit">Create</span>
</label>
</form>
</div>
<div id="notification-box"></div> <div id="notification-box"></div>
<div class="page-container max-w-screen-lg px-6 py-4 mx-auto lg:mx-auto md:py-8"> <div class="page-container max-w-screen-lg px-6 py-4 mx-auto lg:mx-auto md:py-8">
<div class="mb-1"> <div class="mb-1">
@ -230,17 +275,18 @@
</div> </div>
</div> </div>
<div id="settingsTab" class="unfocused"> <div id="settingsTab" class="unfocused">
<div class="card ~neutral !low settings"> <div class="card ~neutral !low settings overflow">
<span class="heading">Settings</span> <span class="heading">Settings</span>
<div class="fr"> <div class="fr">
<span class="button ~neutral !normal" id="accounts-add-user">Save</span> <span class="button ~neutral !normal unfocused" id="settings-save">Save</span>
</div> </div>
<div class="row"> <div class="row">
<div class="card ~neutral !normal col" id="settings-sidebar"> <div class="card ~neutral !normal col" id="settings-sidebar">
<aside class="aside sm ~info mb-half">Note: <span class="badge ~critical">*</span> indicates a required field, <span class="badge ~critical">R</span> indicates changes require a restart.</aside> <aside class="aside sm ~info mb-half">Note: <span class="badge ~critical">*</span> indicates a required field, <span class="badge ~info">R</span> indicates changes require a restart.</aside>
<span class="button ~neutral !low settings-section-button mb-half" id="setting-about">About</span> <span class="button ~neutral !low settings-section-button mb-half" id="setting-about"><span class="flex">About <i class="ri-information-line ml-half"></i></span></span>
<span class="button ~neutral !low settings-section-button mb-half" id="setting-profiles"><span class="flex">User profiles <i class="ri-user-line ml-half"></i></span></span>
</div> </div>
<div class="card ~neutral !normal col" id="settings-panel"></div> <div class="card ~neutral !normal col overflow" id="settings-panel"></div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="light-theme"> <html lang="en" class="{{ .cssClass }}">
<head> <head>
<link rel="stylesheet" type="text/css" href="css/base.css"> <link rel="stylesheet" type="text/css" href="css/base.css">
{{ template "header.html" . }} {{ template "header.html" . }}

22
main.go
View File

@ -51,8 +51,7 @@ type appContext struct {
configBase settings configBase settings
dataPath string dataPath string
localPath string localPath string
cssFile string cssClass string
bsVersion int
jellyfinLogin bool jellyfinLogin bool
users []User users []User
invalidTokens []string invalidTokens []string
@ -362,14 +361,6 @@ func start(asDaemon, firstCall bool) {
app.debug.Printf("Loaded config file \"%s\"", app.configPath) app.debug.Printf("Loaded config file \"%s\"", app.configPath)
if app.config.Section("ui").Key("bs5").MustBool(false) {
app.cssFile = "bs5-jf.css"
app.bsVersion = 5
} else {
app.cssFile = "bs4-jf.css"
app.bsVersion = 4
}
app.debug.Println("Loading storage") app.debug.Println("Loading storage")
app.storage.invite_path = app.config.Section("files").Key("invites").String() app.storage.invite_path = app.config.Section("files").Key("invites").String()
@ -420,14 +411,15 @@ func start(asDaemon, firstCall bool) {
json.Unmarshal(configBase, &app.configBase) json.Unmarshal(configBase, &app.configBase)
themes := map[string]string{ themes := map[string]string{
"Jellyfin (Dark)": fmt.Sprintf("bs%d-jf.css", app.bsVersion), "Jellyfin (Dark)": "dark-theme",
"Bootstrap (Light)": fmt.Sprintf("bs%d.css", app.bsVersion), "Default (Light)": "light-theme",
"Custom CSS": "", }
if app.config.Section("ui").Key("theme").String() == "Bootstrap (Light)" {
app.config.Section("ui").Key("theme").SetValue("Default (Light)")
} }
if val, ok := themes[app.config.Section("ui").Key("theme").String()]; ok { if val, ok := themes[app.config.Section("ui").Key("theme").String()]; ok {
app.cssFile = val app.cssClass = val
} }
app.debug.Printf("Using css file \"%s\"", app.cssFile)
secret, err := generateSecret(16) secret, err := generateSecret(16)
if err != nil { if err != nil {
app.err.Fatal(err) app.err.Fatal(err)

View File

@ -4,6 +4,7 @@ import { Tabs } from "./modules/tabs.js";
import { inviteList, createInvite } from "./modules/invites.js"; import { inviteList, createInvite } from "./modules/invites.js";
import { accountsList } from "./modules/accounts.js"; import { accountsList } from "./modules/accounts.js";
import { settingsList } from "./modules/settings.js"; import { settingsList } from "./modules/settings.js";
import { ProfileEditor } from "./modules/profiles.js";
import { _post, notificationBox, whichAnimationEvent, toggleLoader } from "./modules/common.js"; import { _post, notificationBox, whichAnimationEvent, toggleLoader } from "./modules/common.js";
loadTheme(); loadTheme();
@ -36,6 +37,10 @@ window.availableProfiles = window.availableProfiles || [];
window.modals.ombiDefaults = new Modal(document.getElementById('modal-ombi-defaults')); window.modals.ombiDefaults = new Modal(document.getElementById('modal-ombi-defaults'));
document.getElementById('form-ombi-defaults').addEventListener('submit', window.modals.ombiDefaults.close); document.getElementById('form-ombi-defaults').addEventListener('submit', window.modals.ombiDefaults.close);
window.modals.profiles = new Modal(document.getElementById("modal-user-profiles"));
window.modals.addProfile = new Modal(document.getElementById("modal-add-profile"));
})(); })();
var inviteCreator = new createInvite(); var inviteCreator = new createInvite();
@ -45,6 +50,8 @@ window.invites = new inviteList();
var settings = new settingsList(); var settings = new settingsList();
var profiles = new ProfileEditor();
window.notifications = new notificationBox(document.getElementById('notification-box') as HTMLDivElement, 5); window.notifications = new notificationBox(document.getElementById('notification-box') as HTMLDivElement, 5);
/*const modifySettingsSource = function () { /*const modifySettingsSource = function () {

204
ts/modules/profiles.ts Normal file
View File

@ -0,0 +1,204 @@
import { _get, _post, _delete, toggleLoader } from "../modules/common.js";
interface Profile {
admin: boolean;
libraries: string;
fromUser: string;
}
class profile implements Profile {
private _row: HTMLTableRowElement;
private _name: HTMLElement;
private _adminChip: HTMLSpanElement;
private _libraries: HTMLTableDataCellElement;
private _fromUser: HTMLTableDataCellElement;
private _defaultRadio: HTMLInputElement;
get name(): string { return this._name.textContent; }
set name(v: string) { this._name.textContent = v; }
get admin(): boolean { return this._adminChip.classList.contains("chip"); }
set admin(state: boolean) {
if (state) {
this._adminChip.classList.add("chip", "~info", "ml-half");
this._adminChip.textContent = "Admin";
} else {
this._adminChip.classList.remove("chip", "~info", "ml-half");
this._adminChip.textContent = "";
}
}
get libraries(): string { return this._libraries.textContent; }
set libraries(v: string) { this._libraries.textContent = v; }
get fromUser(): string { return this._fromUser.textContent; }
set fromUser(v: string) { this._fromUser.textContent = v; }
get default(): boolean { return this._defaultRadio.checked; }
set default(v: boolean) { this._defaultRadio.checked = v; }
constructor(name: string, p: Profile) {
this._row = document.createElement("tr") as HTMLTableRowElement;
this._row.innerHTML = `
<td><b class="profile-name"></b> <span class="profile-admin"></span></td>
<td><input type="radio" name="profile-default"></td>
<td class="profile-from ellipsis"></td>
<td class="profile-libraries"></td>
<td><span class="button ~critical !normal">Delete</span></td>
`;
this._name = this._row.querySelector("b.profile-name");
this._adminChip = this._row.querySelector("span.profile-admin") as HTMLSpanElement;
this._libraries = this._row.querySelector("td.profile-libraries") as HTMLTableDataCellElement;
this._fromUser = this._row.querySelector("td.profile-from") as HTMLTableDataCellElement;
this._defaultRadio = this._row.querySelector("input[type=radio]") as HTMLInputElement;
this._defaultRadio.onclick = () => document.dispatchEvent(new CustomEvent("profiles-default", { detail: this.name }));
(this._row.querySelector("span.button") as HTMLSpanElement).onclick = this.delete;
this.update(name, p);
}
update = (name: string, p: Profile) => {
this.name = name;
this.admin = p.admin;
this.fromUser = p.fromUser;
this.libraries = p.libraries;
}
remove = () => { document.dispatchEvent(new CustomEvent("profiles-delete", { detail: this._name })); this._row.remove(); }
delete = () => _delete("/profiles", { "name": this.name }, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status == 200 || req.status == 204) {
this.remove();
} else {
window.notifications.customError("profileDelete", `Failed to delete profile "${this.name}"`);
}
}
})
asElement = (): HTMLTableRowElement => { return this._row; }
}
interface profileResp {
default_profile: string;
profiles: { [name: string]: Profile };
}
export class ProfileEditor {
private _table = document.getElementById("table-profiles") as HTMLTableElement;
private _createButton = document.getElementById("button-profile-create") as HTMLSpanElement;
private _profiles: { [name: string]: profile } = {};
private _default: string;
private _createForm = document.getElementById("form-add-profile") as HTMLFormElement;
private _profileName = document.getElementById("add-profile-name") as HTMLInputElement;
private _userSelect = document.getElementById("add-profile-user") as HTMLSelectElement;
private _storeHomescreen = document.getElementById("add-profile-homescreen") as HTMLInputElement;
get empty(): boolean { return (Object.keys(this._table.children).length == 0) }
set empty(state: boolean) {
if (state) {
this._table.innerHTML = `<tr><td class="empty">None</td></tr>`
} else if (this._table.querySelector("td.empty")) {
this._table.textContent = ``;
}
}
get default(): string { return this._default; }
set default(v: string) {
this._default = v;
if (v != "") { this._profiles[v].default = true; }
for (let name in this._profiles) {
if (name != v) { this._profiles[name].default = false; }
}
}
load = () => _get("/profiles", null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status == 200) {
let resp = req.response as profileResp;
if (Object.keys(resp.profiles).length == 0) {
this.empty = true;
} else {
this.empty = false;
for (let name in resp.profiles) {
if (name in this._profiles) {
this._profiles[name].update(name, resp.profiles[name]);
} else {
this._profiles[name] = new profile(name, resp.profiles[name]);
this._table.appendChild(this._profiles[name].asElement());
}
}
}
this.default = resp.default_profile;
window.modals.profiles.show();
} else {
window.notifications.customError("profileEditor", "Failed to load profiles.");
}
}
})
constructor() {
(document.getElementById('setting-profiles') as HTMLSpanElement).onclick = this.load;
document.addEventListener("profiles-default", (event: CustomEvent) => {
const prevDefault = this.default;
const newDefault = event.detail;
_post("/profiles/default", { "name": newDefault }, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status == 200 || req.status == 204) {
this.default = newDefault;
} else {
this.default = prevDefault;
window.notifications.customError("profileDefault", "Failed to set default profile.");
}
}
});
});
document.addEventListener("profiles-delete", (event: CustomEvent) => {
delete this._profiles[event.detail];
this.load();
});
this._createButton.onclick = () => _get("/users", null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status == 200 || req.status == 204) {
let innerHTML = ``;
for (let user of req.response["users"]) {
innerHTML += `<option value="${user['id']}">${user['name']}</option>`;
}
this._userSelect.innerHTML = innerHTML;
this._storeHomescreen.checked = true;
window.modals.profiles.close();
window.modals.addProfile.show();
} else {
window.notifications.customError("loadUsers", "Failed to load users.");
}
}
});
this._createForm.onsubmit = (event: SubmitEvent) => {
event.preventDefault();
const button = this._createForm.querySelector("span.submit") as HTMLSpanElement;
toggleLoader(button);
let send = {
"homescreen": this._storeHomescreen.checked,
"id": this._userSelect.value,
"name": this._profileName.value
}
_post("/profiles", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
toggleLoader(button);
window.modals.addProfile.close();
if (req.status == 200 || req.status == 204) {
this.load();
window.notifications.customPositive("createProfile", "Success:", `created profile "${send['name']}"`);
} else {
window.notifications.customError("createProfile", `Failed to create profile "${send['name']}"`);
}
window.modals.profiles.show();
}
})
};
}
}

View File

@ -1,15 +1,9 @@
import { _get } from "../modules/common.js"; import { _get, _post, toggleLoader } from "../modules/common.js";
interface settingsBoolEvent extends Event { interface settingsBoolEvent extends Event {
detail: boolean; detail: boolean;
} }
interface Profile {
Admin: boolean;
LibraryAccess: string;
FromUser: string;
}
interface Meta { interface Meta {
name: string; name: string;
description: string; description: string;
@ -64,15 +58,15 @@ class DOMInput {
get requires_restart(): boolean { return this._restart.classList.contains("badge"); } get requires_restart(): boolean { return this._restart.classList.contains("badge"); }
set requires_restart(state: boolean) { set requires_restart(state: boolean) {
if (state) { if (state) {
this._restart.classList.add("badge", "~critical"); this._restart.classList.add("badge", "~info");
this._restart.textContent = "R"; this._restart.textContent = "R";
} else { } else {
this._restart.classList.remove("badge", "~critical"); this._restart.classList.remove("badge", "~info");
this._restart.textContent = ""; this._restart.textContent = "";
} }
} }
constructor(inputType: string, setting: Setting, section: string) { constructor(inputType: string, setting: Setting, section: string, name: string) {
this._container = document.createElement("div"); this._container = document.createElement("div");
this._container.classList.add("setting"); this._container.classList.add("setting");
this._container.innerHTML = ` this._container.innerHTML = `
@ -97,6 +91,12 @@ class DOMInput {
this._input.disabled = (event.detail !== state); this._input.disabled = (event.detail !== state);
}); });
} }
const onValueChange = () => {
const event = new CustomEvent(`settings-${section}-${name}`, { "detail": this.value })
document.dispatchEvent(event);
if (this.requires_restart) { document.dispatchEvent(new CustomEvent("settings-requires-restart")); }
};
this._input.onchange = onValueChange;
this.update(setting); this.update(setting);
} }
@ -118,7 +118,7 @@ interface SText extends Setting {
value: string; value: string;
} }
class DOMText extends DOMInput implements SText { class DOMText extends DOMInput implements SText {
constructor(setting: Setting, section: string) { super("text", setting, section); } constructor(setting: Setting, section: string, name: string) { super("text", setting, section, name); }
type: string = "text"; type: string = "text";
get value(): string { return this._input.value } get value(): string { return this._input.value }
set value(v: string) { this._input.value = v; } set value(v: string) { this._input.value = v; }
@ -128,7 +128,7 @@ interface SPassword extends Setting {
value: string; value: string;
} }
class DOMPassword extends DOMInput implements SPassword { class DOMPassword extends DOMInput implements SPassword {
constructor(setting: Setting, section: string) { super("password", setting, section); } constructor(setting: Setting, section: string, name: string) { super("password", setting, section, name); }
type: string = "password"; type: string = "password";
get value(): string { return this._input.value } get value(): string { return this._input.value }
set value(v: string) { this._input.value = v; } set value(v: string) { this._input.value = v; }
@ -138,7 +138,7 @@ interface SEmail extends Setting {
value: string; value: string;
} }
class DOMEmail extends DOMInput implements SEmail { class DOMEmail extends DOMInput implements SEmail {
constructor(setting: Setting, section: string) { super("email", setting, section); } constructor(setting: Setting, section: string, name: string) { super("email", setting, section, name); }
type: string = "email"; type: string = "email";
get value(): string { return this._input.value } get value(): string { return this._input.value }
set value(v: string) { this._input.value = v; } set value(v: string) { this._input.value = v; }
@ -148,7 +148,7 @@ interface SNumber extends Setting {
value: number; value: number;
} }
class DOMNumber extends DOMInput implements SNumber { class DOMNumber extends DOMInput implements SNumber {
constructor(setting: Setting, section: string) { super("number", setting, section); } constructor(setting: Setting, section: string, name: string) { super("number", setting, section, name); }
type: string = "number"; type: string = "number";
get value(): number { return +this._input.value; } get value(): number { return +this._input.value; }
set value(v: number) { this._input.value = ""+v; } set value(v: number) { this._input.value = ""+v; }
@ -193,10 +193,10 @@ class DOMBool implements SBool {
get requires_restart(): boolean { return this._restart.classList.contains("badge"); } get requires_restart(): boolean { return this._restart.classList.contains("badge"); }
set requires_restart(state: boolean) { set requires_restart(state: boolean) {
if (state) { if (state) {
this._restart.classList.add("badge", "~critical"); this._restart.classList.add("badge", "~info");
this._restart.textContent = "R"; this._restart.textContent = "R";
} else { } else {
this._restart.classList.remove("badge", "~critical"); this._restart.classList.remove("badge", "~info");
this._restart.textContent = ""; this._restart.textContent = "";
} }
} }
@ -220,10 +220,13 @@ class DOMBool implements SBool {
this._restart = this._container.querySelector("span.setting-restart") as HTMLSpanElement; this._restart = this._container.querySelector("span.setting-restart") as HTMLSpanElement;
this._input = this._container.querySelector("input[type=checkbox]") as HTMLInputElement; this._input = this._container.querySelector("input[type=checkbox]") as HTMLInputElement;
const onValueChange = () => { const onValueChange = () => {
const event = new CustomEvent(`settings-${section}-${name}`, { "detail": this._input.checked }) const event = new CustomEvent(`settings-${section}-${name}`, { "detail": this.value })
document.dispatchEvent(event); document.dispatchEvent(event);
}; };
this._input.onchange = onValueChange; this._input.onchange = () => {
onValueChange();
if (this.requires_restart) { document.dispatchEvent(new CustomEvent("settings-requires-restart")); }
};
document.addEventListener(`settings-loaded`, onValueChange); document.addEventListener(`settings-loaded`, onValueChange);
if (setting.depends_false || setting.depends_true) { if (setting.depends_false || setting.depends_true) {
@ -288,10 +291,10 @@ class DOMSelect implements SSelect {
get requires_restart(): boolean { return this._restart.classList.contains("badge"); } get requires_restart(): boolean { return this._restart.classList.contains("badge"); }
set requires_restart(state: boolean) { set requires_restart(state: boolean) {
if (state) { if (state) {
this._restart.classList.add("badge", "~critical"); this._restart.classList.add("badge", "~info");
this._restart.textContent = "R"; this._restart.textContent = "R";
} else { } else {
this._restart.classList.remove("badge", "~critical"); this._restart.classList.remove("badge", "~info");
this._restart.textContent = ""; this._restart.textContent = "";
} }
} }
@ -308,7 +311,7 @@ class DOMSelect implements SSelect {
this._select.innerHTML = innerHTML; this._select.innerHTML = innerHTML;
} }
constructor(setting: SSelect, section: string) { constructor(setting: SSelect, section: string, name: string) {
this._options = []; this._options = [];
this._container = document.createElement("div"); this._container = document.createElement("div");
this._container.classList.add("setting"); this._container.classList.add("setting");
@ -336,6 +339,12 @@ class DOMSelect implements SSelect {
this._input.disabled = (event.detail !== state); this._input.disabled = (event.detail !== state);
}); });
} }
const onValueChange = () => {
const event = new CustomEvent(`settings-${section}-${name}`, { "detail": this.value })
document.dispatchEvent(event);
if (this.requires_restart) { document.dispatchEvent(new CustomEvent("settings-requires-restart")); }
};
this._select.onchange = onValueChange;
this.update(setting); this.update(setting);
} }
update = (s: SSelect) => { update = (s: SSelect) => {
@ -360,15 +369,18 @@ class sectionPanel {
private _section: HTMLDivElement; private _section: HTMLDivElement;
private _settings: { [name: string]: Setting }; private _settings: { [name: string]: Setting };
private _sectionName: string; private _sectionName: string;
values: { [field: string]: string } = {};
constructor(s: Section, sectionName: string) { constructor(s: Section, sectionName: string) {
this._sectionName = sectionName; this._sectionName = sectionName;
this._settings = {}; this._settings = {};
this._section = document.createElement("div") as HTMLDivElement; this._section = document.createElement("div") as HTMLDivElement;
this._section.classList.add("settings-section", "unfocused"); this._section.classList.add("settings-section", "unfocused");
this._section.innerHTML = `<p class="support lg mb-half">${s.meta.description}</p>`; this._section.innerHTML = `
<span class="heading">${s.meta.name}</span>
<p class="support lg">${s.meta.description}</p>
`;
this.update(s); this.update(s);
} }
update = (s: Section) => { update = (s: Section) => {
for (let name of s.order) { for (let name of s.order) {
@ -378,24 +390,30 @@ class sectionPanel {
} else { } else {
switch (setting.type) { switch (setting.type) {
case "text": case "text":
setting = new DOMText(setting, this._sectionName); setting = new DOMText(setting, this._sectionName, name);
break; break;
case "password": case "password":
setting = new DOMPassword(setting, this._sectionName); setting = new DOMPassword(setting, this._sectionName, name);
break; break;
case "email": case "email":
setting = new DOMEmail(setting, this._sectionName); setting = new DOMEmail(setting, this._sectionName, name);
break; break;
case "number": case "number":
setting = new DOMNumber(setting, this._sectionName); setting = new DOMNumber(setting, this._sectionName, name);
break; break;
case "bool": case "bool":
setting = new DOMBool(setting as SBool, this._sectionName, name); setting = new DOMBool(setting as SBool, this._sectionName, name);
break; break;
case "select": case "select":
setting = new DOMSelect(setting as SSelect, this._sectionName); setting = new DOMSelect(setting as SSelect, this._sectionName, name);
break; break;
} }
this.values[name] = ""+setting.value;
document.addEventListener(`settings-${this._sectionName}-${name}`, (event: CustomEvent) => {
const oldValue = this.values[name];
this.values[name] = ""+event.detail;
document.dispatchEvent(new CustomEvent("settings-section-changed"));
});
this._section.appendChild(setting.asElement()); this._section.appendChild(setting.asElement());
this._settings[name] = setting; this._settings[name] = setting;
} }
@ -422,10 +440,15 @@ interface Settings {
} }
export class settingsList { export class settingsList {
private _saveButton = document.getElementById("settings-save") as HTMLSpanElement;
private _saveNoRestart = document.getElementById("settings-apply-no-restart") as HTMLSpanElement;
private _saveRestart = document.getElementById("settings-apply-restart") as HTMLSpanElement;
private _panel = document.getElementById("settings-panel") as HTMLDivElement; private _panel = document.getElementById("settings-panel") as HTMLDivElement;
private _sidebar = document.getElementById("settings-sidebar") as HTMLDivElement; private _sidebar = document.getElementById("settings-sidebar") as HTMLDivElement;
private _sections: { [name: string]: sectionPanel } private _sections: { [name: string]: sectionPanel }
private _buttons: { [name: string]: HTMLSpanElement } private _buttons: { [name: string]: HTMLSpanElement }
private _needsRestart: boolean = false;
addSection = (name: string, s: Section) => { addSection = (name: string, s: Section) => {
const section = new sectionPanel(s, name); const section = new sectionPanel(s, name);
@ -442,7 +465,6 @@ export class settingsList {
private _showPanel = (name: string) => { private _showPanel = (name: string) => {
for (let n in this._sections) { for (let n in this._sections) {
if (n == name) { if (n == name) {
console.log("found", n);
this._sections[name].visible = true; this._sections[name].visible = true;
this._buttons[name].classList.add("selected"); this._buttons[name].classList.add("selected");
} else { } else {
@ -452,9 +474,53 @@ export class settingsList {
} }
} }
private _save = () => {
let config = {};
for (let name in this._sections) {
config[name] = this._sections[name].values;
}
if (this._needsRestart) {
this._saveRestart.onclick = () => {
config["restart-program"] = true;
this._send(config, () => {
window.modals.settingsRestart.close();
window.modals.settingsRefresh.show();
});
};
this._saveNoRestart.onclick = () => {
config["restart-program"] = false;
this._send(config, window.modals.settingsRestart.close);
}
window.modals.settingsRestart.show();
} else {
this._send(config);
}
// console.log(config);
}
private _send = (config: Object, run?: () => void) => _post("/config", config, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status == 200 || req.status == 204) {
window.notifications.customPositive("settingsSaved", "Success:", "settings were saved.");
} else {
window.notifications.customError("settingsSaved", "Couldn't save settings.");
}
this.reload();
if (run) { run(); }
}
});
constructor() { constructor() {
this._sections = {}; this._sections = {};
this._buttons = {}; this._buttons = {};
document.addEventListener("settings-section-changed", () => this._saveButton.classList.remove("unfocused"));
this._saveButton.onclick = this._save;
document.addEventListener("settings-requires-restart", () => { this._needsRestart = true; });
if (window.ombiEnabled) {
let ombi = new ombiDefaults();
this._sidebar.appendChild(ombi.button());
}
} }
reload = () => _get("/config", null, (req: XMLHttpRequest) => { reload = () => _get("/config", null, (req: XMLHttpRequest) => {
@ -471,8 +537,78 @@ export class settingsList {
this.addSection(name, settings.sections[name]); this.addSection(name, settings.sections[name]);
} }
} }
this._showPanel(settings.order[0]);
this._needsRestart = false;
document.dispatchEvent(new CustomEvent("settings-loaded")); document.dispatchEvent(new CustomEvent("settings-loaded"));
this._saveButton.classList.add("unfocused");
} }
}) })
} }
interface ombiUser {
id: string;
name: string;
}
class ombiDefaults {
private _form: HTMLFormElement;
private _button: HTMLSpanElement;
private _select: HTMLSelectElement;
private _users: { [id: string]: string } = {};
constructor() {
this._button = document.createElement("span") as HTMLSpanElement;
this._button.classList.add("button", "~neutral", "!low", "settings-section-button", "mb-half");
this._button.innerHTML = `<span class="flex">Ombi user defaults <i class="ri-link-unlink-m ml-half"></i></span>`;
this._button.onclick = this.load;
this._form = document.getElementById("form-ombi-defaults") as HTMLFormElement;
this._form.onsubmit = this.send;
this._select = this._form.querySelector("select") as HTMLSelectElement;
}
button = (): HTMLSpanElement => { return this._button; }
send = () => {
const button = this._form.querySelector("span.submit") as HTMLSpanElement;
toggleLoader(button);
let resp = {} as ombiUser;
resp.id = this._select.value;
resp.name = this._users[resp.id];
_post("/ombi/defaults", resp, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
toggleLoader(button);
if (req.status == 200 || req.status == 204) {
window.notifications.customPositive("ombiDefaults", "Success:", "stored ombi defaults.");
} else {
window.notifications.customError("ombiDefaults", "Failed to store ombi defaults.");
}
window.modals.ombiDefaults.close();
}
});
}
load = () => {
toggleLoader(this._button);
_get("/ombi/users", null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status == 200 && "users" in req.response) {
const users = req.response["users"] as ombiUser[];
let innerHTML = "";
for (let user of users) {
this._users[user.id] = user.name;
innerHTML += `<option value="${user.id}">${user.name}</option>`;
}
this._select.innerHTML = innerHTML;
toggleLoader(this._button);
window.modals.ombiDefaults.show();
} else {
toggleLoader(this._button);
window.notifications.customError("ombiLoadError", "Failed to load ombi users.")
}
}
});
}
}

View File

@ -5,8 +5,12 @@ export function toggleTheme() {
} }
export function loadTheme() { export function loadTheme() {
if (localStorage.getItem('theme') == "dark") { const theme = localStorage.getItem("theme");
if (theme == "dark") {
document.documentElement.classList.add('dark-theme'); document.documentElement.classList.add('dark-theme');
document.documentElement.classList.remove('light-theme'); document.documentElement.classList.remove('light-theme');
} else if (theme == "light") {
document.documentElement.classList.add('light-theme');
document.documentElement.classList.remove('dark-theme');
} }
} }

View File

@ -60,6 +60,8 @@ declare interface Modals {
settingsRefresh: Modal; settingsRefresh: Modal;
ombiDefaults?: Modal; ombiDefaults?: Modal;
newAccountSuccess?: Modal; newAccountSuccess?: Modal;
profiles: Modal;
addProfile: Modal;
} }
interface Invite { interface Invite {

View File

@ -18,6 +18,7 @@ func (app *appContext) AdminPage(gc *gin.Context) {
ombiEnabled := app.config.Section("ombi").Key("enabled").MustBool(false) ombiEnabled := app.config.Section("ombi").Key("enabled").MustBool(false)
gcHTML(gc, http.StatusOK, "admin.html", gin.H{ gcHTML(gc, http.StatusOK, "admin.html", gin.H{
"urlBase": app.URLBase, "urlBase": app.URLBase,
"cssClass": app.cssClass,
"contactMessage": "", "contactMessage": "",
"email_enabled": emailEnabled, "email_enabled": emailEnabled,
"notifications": notificationsEnabled, "notifications": notificationsEnabled,
@ -39,6 +40,7 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
} }
gcHTML(gc, http.StatusOK, "form-loader.html", gin.H{ gcHTML(gc, http.StatusOK, "form-loader.html", gin.H{
"urlBase": app.URLBase, "urlBase": app.URLBase,
"cssClass": app.cssClass,
"contactMessage": app.config.Section("ui").Key("contact_message").String(), "contactMessage": app.config.Section("ui").Key("contact_message").String(),
"helpMessage": app.config.Section("ui").Key("help_message").String(), "helpMessage": app.config.Section("ui").Key("help_message").String(),
"successMessage": app.config.Section("ui").Key("success_message").String(), "successMessage": app.config.Section("ui").Key("success_message").String(),
@ -46,14 +48,13 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
"validate": app.config.Section("password_validation").Key("enabled").MustBool(false), "validate": app.config.Section("password_validation").Key("enabled").MustBool(false),
"requirements": app.validator.getCriteria(), "requirements": app.validator.getCriteria(),
"email": email, "email": email,
"bs5": app.config.Section("ui").Key("bs5").MustBool(false),
"username": !app.config.Section("email").Key("no_username").MustBool(false), "username": !app.config.Section("email").Key("no_username").MustBool(false),
"lang": app.storage.lang.Form["strings"], "lang": app.storage.lang.Form["strings"],
}) })
} else { } else {
gcHTML(gc, 404, "invalidCode.html", gin.H{ gcHTML(gc, 404, "invalidCode.html", gin.H{
"bs5": app.config.Section("ui").Key("bs5").MustBool(false), "bs5": app.config.Section("ui").Key("bs5").MustBool(false),
"cssFile": app.cssFile, "cssFile": app.cssClass,
"contactMessage": app.config.Section("ui").Key("contact_message").String(), "contactMessage": app.config.Section("ui").Key("contact_message").String(),
}) })
} }
@ -62,7 +63,7 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
func (app *appContext) NoRouteHandler(gc *gin.Context) { func (app *appContext) NoRouteHandler(gc *gin.Context) {
gcHTML(gc, 404, "404.html", gin.H{ gcHTML(gc, 404, "404.html", gin.H{
"bs5": app.config.Section("ui").Key("bs5").MustBool(false), "bs5": app.config.Section("ui").Key("bs5").MustBool(false),
"cssFile": app.cssFile, "cssFile": app.cssClass,
"contactMessage": app.config.Section("ui").Key("contact_message").String(), "contactMessage": app.config.Section("ui").Key("contact_message").String(),
}) })
} }