Merge a17t-redesign, kinda ts-ify setup.js

the web ui has been redesigned with the a17t toolkit, which imo looks a
lot better than bootstrap. This also brought a complete rework of the
web code, which now makes a lot more sense hopefully. the setup page is
still stuck with bootstrap, its not much of a priority but i'll rewrite
it eventually.
This commit is contained in:
Harvey Tindall 2021-01-05 18:16:23 +00:00
parent 69bf81b658
commit eb370d64df
Signed by: hrfee
GPG Key ID: BBC65952848FB1A2
89 changed files with 5753 additions and 5617 deletions

22
.gitignore vendored
View File

@ -1,26 +1,10 @@
node_modules/
passwordreset*.json
mail/*.html
scss/*.css*
scss/bs4/*.css*
scss/bs5/*.css*
data/static/*.css
data/static/*.js
data/static/*.js.map
data/static/ts/
data/static/modules/
!data/static/setup.js
data/config-base.json
data/config-default.ini
data/*.html
data/*.txt
data/docs/
dist/*
jfa-go
dist/
build/
pkg/
old/
data/
version.go
notes
docs/*
config-payload.json
!docs/go.mod

View File

@ -7,14 +7,20 @@ release:
before:
hooks:
- go mod download
- rm -rf data/web
- mkdir -p data
- cp -r static data/web
- cp -r css data/web/
- npm install
- cp node_modules/a17t/dist/a17t.css data/web/css/
- cp node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 data/web/css/
- cp -r html data/
- cp -r lang data/
- python3 config/fixconfig.py -i config/config-base.json -o data/config-base.json
- python3 config/generate_ini.py -i config/config-base.json -o data/config-default.ini
- python3 -m pip install libsass
- npm install
- python3 scss/compile.py
- python3 mail/generate.py
- python3 mail/generate.py -o data/
- python3 version.py {{.Version}} version.go
- bash -c 'npx esbuild ts/*.ts ts/modules/*.ts --outdir=data/static --minify'
- bash -c 'npx esbuild ts/*.ts ts/modules/*.ts --outdir=./data/web/js/ --minify'
- go get -u github.com/swaggo/swag/cmd/swag
- swag init -g main.go
builds:
@ -37,9 +43,8 @@ archives:
amd64: x86_64
files:
- data/*
- data/templates/*
- data/static/*
- data/static/modules/*
- data/**/*
- data/**/**/*
checksum:
name_template: 'checksums.txt'
snapshot:

View File

@ -7,7 +7,7 @@ RUN apt update -y \
&& (curl -sL https://deb.nodesource.com/setup_14.x | bash -) \
&& apt install nodejs \
&& (cd /opt/build; make all; make compress) \
&& sed -i 's#id="pwrJfPath" placeholder="Folder"#id="pwrJfPath" value="/jf" disabled#g' /opt/build/build/data/templates/setup.html
&& sed -i 's#id="pwrJfPath" placeholder="Folder"#id="pwrJfPath" value="/jf" disabled#g' /opt/build/build/data/html/setup.html
FROM golang:latest

View File

@ -1,33 +1,30 @@
npm:
$(info installing npm dependencies)
npm install
configuration:
$(info Fixing config-base)
python3 config/fixconfig.py -i config/config-base.json -o data/config-base.json
-mkdir -p build/data
python3 config/fixconfig.py -i config/config-base.json -o build/data/config-base.json
$(info Generating config-default.ini)
python3 config/generate_ini.py -i config/config-base.json -o data/config-default.ini
sass:
$(info Getting libsass)
python3 -m pip install libsass
$(info Getting node dependencies)
npm install
$(info Compiling sass)
python3 scss/compile.py
python3 config/generate_ini.py -i config/config-base.json -o build/data/config-default.ini
email:
$(info Generating email html)
python3 mail/generate.py
python3 mail/generate.py -o build/data/
typescript:
$(info Compiling typescript)
npx esbuild ts/*.ts ts/modules/*.ts --outdir=data/static --minify
-rm -r data/static/ts
-rm -r data/static/typings
-rm data/static/*.map
ts:
$(info compiling typescript)
-mkdir -p build/data/web/js
-npx esbuild ts/*.ts ts/modules/*.ts --outdir=./build/data/web/js/
ts-debug:
-npx tsc -p ts/ --sourceMap
-rm -r data/static/ts
-rm -r data/static/typings
cp -r ts data/static/
$(info compiling typescript w/ sourcemaps)
-mkdir -p build/data/web/js
-npx esbuild ts/*.ts ts/modules/*.ts --sourcemap --outdir=./build/data/web/js/
-rm -r build/data/web/js/ts
$(info copying typescript)
cp -r ts build/data/web/js
swagger:
go get github.com/swaggo/swag/cmd/swag
@ -47,11 +44,22 @@ compress:
upx --lzma build/jfa-go
copy:
$(info Copying data)
cp -r data build/
$(info copying css)
-mkdir -p build/data/web/css
cp -r css build/data/web/
cp node_modules/a17t/dist/a17t.css build/data/web/css/
cp -r node_modules/remixicon/fonts/remixicon.css node_modules/remixicon/fonts/remixicon.woff2 build/data/web/css/
$(info copying html)
cp -r html build/data/
$(info copying static data)
-mkdir -p build/data/web
cp -r static/* build/data/web/
$(info copying language files)
cp -r lang build/data/
install:
cp -r build $(DESTDIR)/jfa-go
all: configuration sass email version typescript swagger compile copy
debug: configuration sass email version ts-debug swagger compile copy
all: configuration npm email version ts swagger compile copy
debug: configuration npm email version ts-debug swagger compile copy

View File

@ -1,4 +1,4 @@
# ![jfa-go](data/static/banner.svg)
# ![jfa-go](images/banner.svg)
jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jellyfin) that provides invite-based account creation as well as other features that make one's instance much easier to manage.
@ -21,16 +21,15 @@ I chose to rewrite the python [jellyfin-accounts](https://github.com/hrfee/jelly
* 🌓 Customizable look
* Specify contact and help messages to appear in emails and pages
* Light and dark themes available
* Optionally provide custom CSS
## Interface
<p align="center">
<img src="https://github.com/hrfee/jfa-go/blob/main/images/demo.gif" width="100%"></img>
<img src="images/demo.gif" width="100%"></img>
</p>
<p align="center">
<img src="https://github.com/hrfee/jfa-go/blob/main/images/invites.png" width="48%" style="margin-left: 1.5%;" alt="Invites tab"></img>
<img src="https://github.com/hrfee/jfa-go/blob/main/images/accounts.png" width="48%" style="margin-right: 1.5%;" alt="Accounts tab"></img>
<img src="images/invites.png" width="48%" style="margin-left: 1.5%;" alt="Invites tab"></img>
<img src="images/accounts.png" width="48%" style="margin-right: 1.5%;" alt="Accounts tab"></img>
</p>
#### Install

38
README.md.old Normal file
View File

@ -0,0 +1,38 @@
This branch is for experimenting with [a17t](https://a17t.miles.land/) to replace bootstrap. Page structure is pretty much done (except setup.html), so i'm currently integrating this with the main app and existing web code.
#### todo
**general**
* [x] modal implementation
* [x] animations
* [x] utilities
* [x] CSS for light & dark
**admin**
* [x] invites tab
* [x] accounts tab
* [x] settings tab
* [x] modals
* [ ] integration with existing code
**invites**
* [x] page design
* [ ] integration with existing code
#### screenshots
##### dark
<p>
<img src="images/dark/invites.png" alt="invites" style="width: 32%; height: auto;">
<img src="images/dark/accounts.png" alt="accounts" style="width: 32%; height: auto;">
<img src="images/dark/settings.png" alt="settings" style="width: 32%; height: auto;">
<img src="images/dark/login-modal.png" alt="login modal" style="width: 32%; height: auto;">
<img src="images/dark/modify-settings.png" alt="modify user settings modal" style="width: 32%; height: auto;">
</p>
##### light
<p>
<img src="images/light/invites.png" alt="invites" style="width: 32%; height: auto;">
<img src="images/light/accounts.png" alt="accounts" style="width: 32%; height: auto;">
<img src="images/light/settings.png" alt="settings" style="width: 32%; height: auto;">
<img src="images/light/login-modal.png" alt="login modal" style="width: 32%; height: auto;">
<img src="images/light/modify-settings.png" alt="modify user settings modal" style="width: 32%; height: auto;">
</p>

62
api.go
View File

@ -667,6 +667,9 @@ func (app *appContext) DeleteProfile(gc *gin.Context) {
gc.BindJSON(&req)
name := req.Name
if _, ok := app.storage.profiles[name]; ok {
if app.storage.defaultProfile == name {
app.storage.defaultProfile = ""
}
delete(app.storage.profiles, name)
}
app.storage.storeProfiles()
@ -1072,13 +1075,14 @@ func (app *appContext) ApplySettings(gc *gin.Context) {
// @Summary Get jfa-go configuration.
// @Produce json
// @Success 200 {object} configDTO "Uses the same format as config-base.json"
// @Success 200 {object} settings "Uses the same format as config-base.json"
// @Router /config [get]
// @Security Bearer
// @tags Configuration
func (app *appContext) GetConfig(gc *gin.Context) {
app.info.Println("Config requested")
resp := map[string]interface{}{}
resp := app.configBase
// Load language options
langPath := filepath.Join(app.localPath, "lang", "form")
app.lang.langFiles, _ = ioutil.ReadDir(langPath)
app.lang.langOptions = make([]string, len(app.lang.langFiles))
@ -1095,37 +1099,25 @@ func (app *appContext) GetConfig(gc *gin.Context) {
app.lang.langOptions[i] = meta.(map[string]interface{})["name"].(string)
}
}
for section, settings := range app.configBase {
if section == "order" {
resp[section] = settings.([]interface{})
} else {
resp[section] = make(map[string]interface{})
for key, values := range settings.(map[string]interface{}) {
if key == "order" {
resp[section].(map[string]interface{})[key] = values.([]interface{})
} else {
resp[section].(map[string]interface{})[key] = values.(map[string]interface{})
if key != "meta" {
dataType := resp[section].(map[string]interface{})[key].(map[string]interface{})["type"].(string)
configKey := app.config.Section(section).Key(key)
if dataType == "number" {
if val, err := configKey.Int(); err == nil {
resp[section].(map[string]interface{})[key].(map[string]interface{})["value"] = val
}
} else if dataType == "bool" {
resp[section].(map[string]interface{})[key].(map[string]interface{})["value"] = configKey.MustBool(false)
} else if dataType == "select" && key == "language" {
resp[section].(map[string]interface{})[key].(map[string]interface{})["options"] = app.lang.langOptions
resp[section].(map[string]interface{})[key].(map[string]interface{})["value"] = app.lang.langOptions[app.lang.chosenIndex]
} else {
resp[section].(map[string]interface{})[key].(map[string]interface{})["value"] = configKey.String()
}
}
}
s := resp.Sections["ui"].Settings["language"]
for sectName, section := range resp.Sections {
for settingName, setting := range section.Settings {
val := app.config.Section(sectName).Key(settingName)
s := resp.Sections[sectName].Settings[settingName]
switch setting.Type {
case "text", "email", "select", "password":
s.Value = val.MustString("")
case "number":
s.Value = val.MustInt(0)
case "bool":
s.Value = val.MustBool(false)
}
resp.Sections[sectName].Settings[settingName] = s
}
}
// resp["jellyfin"].(map[string]interface{})["language"].(map[string]interface{})["options"].([]string)
s.Options = app.lang.langOptions
s.Value = app.lang.langOptions[app.lang.chosenIndex]
resp.Sections["ui"].Settings["language"] = s
gc.JSON(200, resp)
}
@ -1176,11 +1168,11 @@ func (app *appContext) ModifyConfig(gc *gin.Context) {
if _, ok := req["password_validation"]; ok {
app.debug.Println("Reinitializing validator")
validatorConf := ValidatorConf{
"characters": app.config.Section("password_validation").Key("min_length").MustInt(0),
"uppercase characters": app.config.Section("password_validation").Key("upper").MustInt(0),
"lowercase characters": app.config.Section("password_validation").Key("lower").MustInt(0),
"numbers": app.config.Section("password_validation").Key("number").MustInt(0),
"special characters": app.config.Section("password_validation").Key("special").MustInt(0),
"length": app.config.Section("password_validation").Key("min_length").MustInt(0),
"uppercase": app.config.Section("password_validation").Key("upper").MustInt(0),
"lowercase": app.config.Section("password_validation").Key("lower").MustInt(0),
"number": app.config.Section("password_validation").Key("number").MustInt(0),
"special": app.config.Section("password_validation").Key("special").MustInt(0),
}
if !app.config.Section("password_validation").Key("enabled").MustBool(false) {
for key := range validatorConf {

File diff suppressed because it is too large Load Diff

View File

@ -9,17 +9,17 @@ args = parser.parse_args()
with open(args.input, 'r') as f:
config = json.load(f)
newconfig = {"order": []}
newconfig = {"sections": {}, "order": []}
for sect in config:
for sect in config["sections"]:
newconfig["order"].append(sect)
newconfig[sect] = {}
newconfig[sect]["order"] = []
newconfig[sect]["meta"] = config[sect]["meta"]
for setting in config[sect]:
if setting != "meta":
newconfig[sect]["order"].append(setting)
newconfig[sect][setting] = config[sect][setting]
newconfig["sections"][sect] = {}
newconfig["sections"][sect]["order"] = []
newconfig["sections"][sect]["meta"] = config["sections"][sect]["meta"]
newconfig["sections"][sect]["settings"] = {}
for setting in config["sections"][sect]["settings"]:
newconfig["sections"][sect]["order"].append(setting)
newconfig["sections"][sect]["settings"][setting] = config["sections"][sect]["settings"][setting]
with open(args.output, 'w') as f:
f.write(json.dumps(newconfig, indent=4))

View File

@ -14,18 +14,19 @@ def generate_ini(base_file, ini_file):
ini = configparser.RawConfigParser(allow_no_value=True)
for section in config_base:
for section in config_base["sections"]:
ini.add_section(section)
for entry in config_base[section]:
if "description" in config_base[section][entry]:
ini.set(section, "; " + config_base[section][entry]["description"])
if entry != "meta":
value = config_base[section][entry]["value"]
if isinstance(value, bool):
value = str(value).lower()
else:
value = str(value)
ini.set(section, entry, value)
if "meta" in config_base["sections"][section]:
ini.set(section, "; " + config_base["sections"][section]["meta"]["description"])
for entry in config_base["sections"][section]["settings"]:
if "description" in config_base["sections"][section]["settings"][entry]:
ini.set(section, "; " + config_base["sections"][section]["settings"][entry]["description"])
value = config_base["sections"][section]["settings"][entry]["value"]
if isinstance(value, bool):
value = str(value).lower()
else:
value = str(value)
ini.set(section, entry, value)
with open(Path(ini_file), "w") as config_file:
ini.write(config_file)

372
css/base.css Normal file
View File

@ -0,0 +1,372 @@
@import "a17t.css";
@import "remixicon.css";
@import "modal.css";
@import "dark.css";
@import "tooltip.css";
@import "loader.css";
:root {
--border-width-default: 2px;
--border-width-2: 3px;
--border-width-4: 5px;
--border-width-8: 8px;
}
.light-theme {
--settings-section-button-filter: 90%;
}
.body {
background-color: #101010;
}
.page-container {
margin: 5% 20% 5% 20%;
}
@media (max-width: 1100px) {
.page-container {
margin: 2%;
}
}
@media screen and (max-width: 750px) {
:root {
font-size: 0.9rem;
}
.table-responsive table {
min-width: 660px;
}
}
.tab-button {
font-size: 2rem;
}
.mb-half {
margin-bottom: 0.5rem;
}
.mb-1 {
margin-bottom: 1rem;
}
.mb-2 {
margin-bottom: 2rem;
}
.mt-half {
margin-top: 0.5rem;
}
.mt-1 {
margin-top: 1rem;
}
.ml-1 {
margin-left: 1rem;
}
.ml-half {
margin-left: 0.5rem;
}
.mr-1 {
margin-right: 1rem;
}
.pb-1 {
padding-bottom: 1rem;
}
.pl-1 {
padding-left: 1rem;
}
.al {
text-align: left;
}
.ar {
text-align: right;
}
.inline-block {
display: inline-block;
}
.align-top {
align-items: top;
}
.flex {
display: flex;
}
.flex-expand {
display: flex;
justify-content: space-between;
}
.flex-row {
display: flex;
flex-direction: row;
}
.flex-row-group {
display: block;
flex-grow: 1;
}
.row {
display: flex;
flex-wrap: wrap;
}
.row.baseline {
align-items: baseline;
}
.col {
flex: 1;
margin: 0.5rem;
}
@media screen and (max-width: 400px) {
.row {
flex-direction: column;
}
.col {
flex: 45%;
}
}
.fr {
float: right;
}
.monospace {
background-color: inherit; /* so we can use a17t code blocks */
}
sup.\~critical, .text-critical {
color: var(--color-critical-normal-content);
}
.grey {
color: var(--color-neutral-500);
}
.aside.sm {
font-size: 0.8rem;
padding: 0.8rem;
}
.support.lg {
font-size: 1rem;
}
.badge.lg {
font-size: 1rem;
}
.inv-created-users strong,p {
padding-left: 0.5rem;
padding-bottom: 0.2rem;
}
.inv-created-users.empty strong,p {
padding: 0;
}
.inv {
overflow: visible;
}
.inv-table {
font-size: 0.8rem;
}
.inv-profilearea {
min-width: 20%;
}
.inv-profileselect {
min-width: 100%;
}
.inv-codearea {
max-width: 40%;
min-width: 10rem;
display: flex;
justify-content: center;
align-items: center;
}
.inv-empty .inv-codearea {
justify-content: start;
}
.invite-link {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
width: auto;
}
.ellipsis {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.no-pad {
padding: 0px 0px 0px 0px;
}
.elem-pad > * {
margin: var(--spacing-4, 1rem);
}
.icon.clickable {
padding: 0.5rem 0.6rem;
}
.input {
box-sizing: border-box; /* fixes weird length issue with inputs */
}
.button.lg {
height: 2.5rem;
}
.submit {
border: none;
outline: none; /* remove browser styling on submit buttons */
}
.full-width { /* full width inputs */
box-sizing: border-box; /* TODO: maybe remove if we figure out input thing? */
width: 100%;
}
.center {
justify-content: center;
}
.no-lp {
padding-left: 0px;
}
.block {
display: block;
}
.focused {
display: block;
}
.unfocused {
display: none;
}
.rotated {
transform: rotate(180deg);
-webkit-transition: all 0.3s cubic-bezier(0,.89,.27,.92);
-moz-transition: all 0.3s cubic-bezier(0,.89,.27,.92);
-o-transition: all 0.3s cubic-bezier(0,.89,.27,.92);
transition: all 0.3s cubic-bezier(0,.89,.27,.92);
}
.not-rotated {
transform: rotate(0deg);
-webkit-transition: all 0.3s cubic-bezier(0,.89,.27,.92);
-moz-transition: all 0.3s cubic-bezier(0,.89,.27,.92);
-o-transition: all 0.3s cubic-bezier(0,.89,.27,.92);
transition: all 0.3s cubic-bezier(0,.89,.27,.92);
}
.stealth-input {
font-size: 1rem;
padding-top: 0.1rem;
padding-bottom: 0.1rem;
margin-left: 0.5rem;
margin-right: 1rem;
max-width: 75%;
}
.stealth-input-hidden {
border-style: none;
--fallback-box-shadow: none;
--field-hover-box-shadow: none;
--field-focus-box-shadow: none;
padding-top: 0.1rem;
padding-bottom: 0.1rem;
}
.settings-section-button {
box-sizing: border-box;
width: 100%;
height: 2.5rem;
background-color: rgba(0,0,0,0);
}
.settings-section-button:hover, .settings-section-button:focus {
box-sizing: border-box;
width: 100%;
height: 2.5rem;
background-color: var(--color-neutral-normal-fill);
filter: brightness(var(--settings-section-button-filter)) !important;
}
.settings-section-button.selected {
background-color: var(--color-neutral-normal-fill);
--buton-filter-brightness: var(--settings-section-button-filter);
filter: brightness(var(--settings-section-button-filter)) !important;
}
.setting {
margin-bottom: 0.25rem;
}
.textarea {
resize: vertical;
}
.overflow {
overflow: visible;
}
.overflow-y {
overflow-y: visible;
}
select, textarea {
color: inherit;
border: 0 solid var(--color-neutral-300);
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
}
input {
color: inherit;
border: 0 solid var(--color-neutral-300);
}
p.top {
margin-top: 0px;
}
.table-responsive {
overflow-x: auto;
}
#notification-box {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: 16;
}

87
css/dark.css Normal file
View File

@ -0,0 +1,87 @@
.dark-theme {
--settings-section-button-filter: 110%;
--color-neutral-900: rgba(255, 255, 255, 0.87);
--color-neutral-800: rgba(255, 255, 255, 0.8);
--color-neutral-700: rgba(255, 255, 255, 0.73);
--color-neutral-600: rgba(255, 255, 255, 0.66);
--color-neutral-500: rgb(153, 153, 153);
--color-neutral-400: #383838;
--color-neutral-300: #303030;
--color-neutral-200: #292929;
--color-neutral-100: #242424;
--color-neutral-50: #202020;
--color-neutral-000: #101010;
--color-critical-900: #fef2f2;
--color-critical-800: #fee2e2;
--color-critical-700: #fecaca;
--color-critical-600: #fca5a5;
--color-critical-500: #f87171;
--color-critical-400: #ef4444;
--color-critical-300: #dc2626;
--color-critical-200: #b91c1c;
--color-critical-100: #991b1b;
--color-critical-50: #7f1d1d;
--color-critical-000: #441313;
--color-warning-900: #fffbeb;
--color-warning-800: #fef3c7;
--color-warning-700: #fde68a;
--color-warning-600: #fcd34d;
--color-warning-500: #fbbf24;
--color-warning-400: #f59e0b;
--color-warning-300: #d97706;
--color-warning-200: #b45309;
--color-warning-100: #92400e;
--color-warning-50: #783900;
--color-warning-000: #411e01;
--color-positive-900: #f0fdf4;
--color-positive-800: #dcfce7;
--color-positive-700: #bbf7d0;
--color-positive-600: #86efac;
--color-positive-500: #4ade80;
--color-positive-400: #22c55e;
--color-positive-300: #16a34a;
--color-positive-200: #15803d;
--color-positive-100: #166534;
--color-positive-50: #14532d;
--color-positive-000: #0f2e1b;
--color-urge-900: #e0ffff;
--color-urge-800: #c0fbff;
--color-urge-700: #a0f4ff;
--color-urge-600: #80e9ff;
--color-urge-500: #60dbfb;
--color-urge-400: #40cbf3;
--color-urge-300: #20b9e9;
--color-urge-200: #00a4dc; /* tab buttons */
--color-urge-100: #0054bc;
--color-urge-50: #00169a;
--color-urge-000: #050076;
--color-info-900: #f5f3ff;
--color-info-800: #ede9fe;
--color-info-700: #ddd6fe;
--color-info-600: #c4b5fd;
--color-info-500: #a78bfa;
--color-info-400: #8b5cf6;
--color-info-300: #7c3aed;
--color-info-200: #6d28d9;
--color-info-100: #5b21b6;
--color-info-50: #4c1d95;
--color-info-000: #240e44;
--color-neutral-normal-content: #ffffff;
}
.light-only {
display: none;
}
.dark-only {
display: initial;
}

40
css/loader.css Normal file
View File

@ -0,0 +1,40 @@
.loader {
height: auto;
color: rgba(0, 0, 0, 0);
}
.loader .dot {
--diameter: 0.5rem;
--radius: calc(var(--diameter) / 2);
--deviation: 20%;
height: var(--diameter);
width: var(--diameter);
background-color: var(--color-content);
border-radius: 50%;
position: absolute;
left: calc(50% - var(--radius));
animation: osc 1s cubic-bezier(.72,.16,.31,.97) infinite;
}
.loader.loader-sm .dot {
--deviation: 10%;
}
@keyframes osc {
25% {
left: calc(50% + var(--deviation) - var(--radius));
}
75% {
left: calc(50% - var(--deviation));
}
0%, 50%, 100% {
left: calc(50% - var(--radius));
}
/*
0%, 100% {
left: calc(50% - var(--deviation))
}
50% {
left: calc(50% + var(--deviation) - var(--radius));
}
*/
}

72
css/modal.css Normal file
View File

@ -0,0 +1,72 @@
.modal {
display: none;
position: fixed;
z-index: 12;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0,0,0,40%);
}
.modal-shown {
display: block;
}
@keyframes modal-hide {
from { opacity: 1; }
to { opacity: 0; }
}
.modal-hiding {
animation: modal-hide 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
@keyframes modal-content-show {
from {
opacity: 0;
top: -6rem;
}
to {
opacity: 1;
top: 0;
}
}
.modal-content {
position: relative;
margin: 10% auto;
width: 30%;
}
.modal-content.wide {
width: 60%;
}
.modal-shown .modal-content {
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) {
.modal-content, .modal-content.wide {
width: 90%;
}
}
.modal-close {
float: right;
color: #aaa;
font-weight: normal;
}
.modal-close:hover,
.modal-close:focus {
filter: brightness(60%);
}

36
css/tooltip.css Normal file
View File

@ -0,0 +1,36 @@
.tooltip {
position: relative;
display: inline-block;
}
.tooltip .content {
visibility: hidden;
max-width: 10rem;
min-width: 6rem;
background-color: rgba(0, 0, 0, 0.6);
color: #fff;
padding: 0.5rem;
border-radius: 6px;
overflow-wrap: break-word;
text-align: center;
position: absolute;
z-index: 1;
top: -1rem;
}
.tooltip.right .content {
left: 120%;
}
.tooltip.left .content {
right: 120%;
}
.tooltip .content.sm {
font-size: 0.8rem;
}
.tooltip:hover .content {
visibility: visible;
}

View File

@ -1,275 +0,0 @@
document.getElementById('page-1').scrollIntoView({
behavior: 'auto',
block: 'center',
inline: 'center' });
function checkAuthRadio() {
if (document.getElementById('manualAuthRadio').checked) {
document.getElementById('adminOnlyArea').style.display = 'none';
document.getElementById('manualAuthArea').style.display = '';
} else {
document.getElementById('manualAuthArea').style.display = 'none';
document.getElementById('adminOnlyArea').style.display = '';
};
};
var authRadios = ['manualAuthRadio', 'jfAuthRadio'];
for (var i = 0; i < authRadios.length; i++) {
document.getElementById(authRadios[i]).addEventListener('change', function() {
checkAuthRadio();
});
};
function checkEmailRadio() {
document.getElementById('emailNextButton').href = '#page-5';
document.getElementById('valBackButton').href = '#page-7';
if (document.getElementById('emailSMTPRadio').checked) {
document.getElementById('emailCommonArea').style.display = '';
document.getElementById('emailSMTPArea').style.display = '';
document.getElementById('emailMailgunArea').style.display = 'none';
document.getElementById('notificationsEnabled').checked = true;
} else if (document.getElementById('emailMailgunRadio').checked) {
document.getElementById('emailCommonArea').style.display = '';
document.getElementById('emailSMTPArea').style.display = 'none';
document.getElementById('emailMailgunArea').style.display = '';
document.getElementById('notificationsEnabled').checked = true;
} else if (document.getElementById('emailDisabledRadio').checked) {
document.getElementById('emailCommonArea').style.display = 'none';
document.getElementById('emailSMTPArea').style.display = 'none';
document.getElementById('emailMailgunArea').style.display = 'none';
document.getElementById('emailNextButton').href = '#page-8';
document.getElementById('valBackButton').href = '#page-4';
document.getElementById('notificationsEnabled').checked = false;
};
};
var emailRadios = ['emailDisabledRadio', 'emailSMTPRadio', 'emailMailgunRadio'];
for (var i = 0; i < emailRadios.length; i++) {
document.getElementById(emailRadios[i]).addEventListener('change', function() {
checkEmailRadio();
});
};
function checkSSL() {
var label = document.getElementById('emailSSL_TLSLabel');
if (document.getElementById('emailSSL_TLS').checked) {
label.textContent = 'Use SSL/TLS';
} else {
label.textContent = 'Use STARTTLS';
};
};
document.getElementById('emailSSL_TLS').addEventListener('change', function() {
checkSSL();
});
function checkPwrEnabled() {
if (document.getElementById('pwrEnabled').checked) {
document.getElementById('pwrArea').style.display = '';
} else {
document.getElementById('pwrArea').style.display = 'none';
};
};
var pwrEnabled = document.getElementById('pwrEnabled');
pwrEnabled.addEventListener('change', function() {
checkPwrEnabled();
});
function checkInvEnabled() {
if (document.getElementById('invEnabled').checked) {
document.getElementById('invArea').style.display = '';
} else {
document.getElementById('invArea').style.display = 'none';
};
};
document.getElementById('invEnabled').addEventListener('change', function() {
checkInvEnabled();
});
function checkValEnabled() {
if (document.getElementById('valEnabled').checked) {
document.getElementById('valArea').style.display = '';
} else {
document.getElementById('valArea').style.display = 'none';
};
};
document.getElementById('valEnabled').addEventListener('change', function() {
checkValEnabled();
});
checkValEnabled();
checkInvEnabled();
checkSSL();
checkAuthRadio();
checkEmailRadio();
checkPwrEnabled();
var jfValid = false
document.getElementById('jfTestButton').onclick = function() {
var testButton = document.getElementById('jfTestButton');
var nextButton = document.getElementById('jfNextButton');
var jfData = {};
jfData['jfHost'] = document.getElementById('jfHost').value;
jfData['jfUser'] = document.getElementById('jfUser').value;
jfData['jfPassword'] = document.getElementById('jfPassword').value;
let valid = true;
for (val in jfData) {
if (jfData[val] == "") {
valid = false;
}
}
if (!valid) {
if (!testButton.classList.contains('btn-danger')) {
testButton.classList.add('btn-danger');
testButton.textContent = 'Fill out fields above.';
setTimeout(function() {
if (testButton.classList.contains('btn-danger')) {
testButton.classList.remove('btn-danger');
testButton.textContent = 'Test';
}
}, 2000);
}
} else {
testButton.disabled = true;
testButton.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Testing...';
nextButton.classList.add('disabled');
nextButton.setAttribute('aria-disabled', 'true');
var req = new XMLHttpRequest();
req.open("POST", "/jellyfin/test", true);
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
req.responseType = 'json';
req.onreadystatechange = function() {
if (this.readyState == 4) {
testButton.disabled = false;
testButton.className = '';
if (this.response['success'] == true) {
testButton.classList.add('btn', 'btn-success');
testButton.textContent = 'Success';
nextButton.classList.remove('disabled');
nextButton.setAttribute('aria-disabled', 'false');
} else {
testButton.classList.add('btn', 'btn-danger');
testButton.textContent = 'Failed';
};
};
};
req.send(JSON.stringify(jfData));
}
};
document.getElementById('submitButton').onclick = function() {
var submitButton = document.getElementById('submitButton');
submitButton.disabled = true;
submitButton.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Submitting...';
var config = {};
config['jellyfin'] = {};
config['ui'] = {};
config['password_validation'] = {};
config['email'] = {};
config['password_resets'] = {};
config['invite_emails'] = {};
config['mailgun'] = {};
config['smtp'] = {};
config['notifications'] = {};
// Page 2: Auth
if (document.getElementById('jfAuthRadio').checked) {
config['ui']['jellyfin_login'] = 'true';
if (document.getElementById('jfAuthAdminOnly').checked) {
config['ui']['admin_only'] = 'true';
} else {
config['ui']['admin_only'] = 'false'
};
} else {
config['ui']['username'] = document.getElementById('manualAuthUsername').value;
config['ui']['password'] = document.getElementById('manualAuthPassword').value;
config['ui']['email'] = document.getElementById('manualAuthEmail').value;
};
// Page 3: Connect to jellyfin
config['jellyfin']['server'] = document.getElementById('jfHost').value;
let publicAddress = document.getElementById('jfPublicHost').value;
if (publicAddress != "") {
config['jellyfin']['public_server'] = publicAddress;
}
config['jellyfin']['username'] = document.getElementById('jfUser').value;
config['jellyfin']['password'] = document.getElementById('jfPassword').value;
// Page 4: Email (Page 5, 6, 7 are only used if this is enabled)
if (document.getElementById('emailDisabledRadio').checked) {
config['password_resets']['enabled'] = 'false';
config['invite_emails']['enabled'] = 'false';
config['notifications']['enabled'] = 'false';
} else {
if (document.getElementById('emailSMTPRadio').checked) {
if (document.getElementById('emailSSL_TLS').checked) {
config['smtp']['encryption'] = 'ssl_tls';
} else {
config['smtp']['encryption'] = 'starttls';
};
config['email']['method'] = 'smtp';
config['smtp']['server'] = document.getElementById('emailSMTPServer').value;
config['smtp']['port'] = document.getElementById('emailSMTPPort').value;
config['smtp']['password'] = document.getElementById('emailSMTPPassword').value;
config['email']['address'] = document.getElementById('emailSMTPAddress').value;
} else {
config['email']['method'] = 'mailgun';
config['mailgun']['api_url'] = document.getElementById('emailMailgunURL').value;
config['mailgun']['api_key'] = document.getElementById('emailMailgunKey').value;
config['email']['address'] = document.getElementById('emailMailgunAddress').value;
};
config['notifications']['enabled'] = document.getElementById('notificationsEnabled').checked.toString();
// Page 5: Email formatting
config['email']['from'] = document.getElementById('emailSender').value;
config['email']['date_format'] = document.getElementById('emailDateFormat').value;
if (document.getElementById('email24hTimeRadio').checked) {
config['email']['use_24h'] = 'true';
} else {
config['email']['use_24h'] = 'false';
};
config['email']['message'] = document.getElementById('emailMessage').value;
// Page 6: Password Resets
if (document.getElementById('pwrEnabled').checked) {
config['password_resets']['enabled'] = 'true';
config['password_resets']['watch_directory'] = document.getElementById('pwrJfPath').value;
config['password_resets']['subject'] = document.getElementById('pwrSubject').value;
} else {
config['password_resets']['enabled'] = 'false';
};
// Page 7: Invite Emails
if (document.getElementById('invEnabled').checked) {
config['invite_emails']['enabled'] = 'true';
config['invite_emails']['url_base'] = document.getElementById('invURLBase').value;
config['invite_emails']['subject'] = document.getElementById('invSubject').value;
} else {
config['invite_emails']['enabled'] = 'false';
};
};
// Page 8: Password Validation
if (document.getElementById('valEnabled').checked) {
config['password_validation']['enabled'] = 'true';
config['password_validation']['min_length'] = document.getElementById('valLength').value;
config['password_validation']['upper'] = document.getElementById('valUpper').value;
config['password_validation']['lower'] = document.getElementById('valLower').value;
config['password_validation']['number'] = document.getElementById('valNumber').value;
config['password_validation']['special'] = document.getElementById('valSpecial').value;
} else {
config['password_validation']['enabled'] = 'false';
};
// Page 9: Messages
config['ui']['contact_message'] = document.getElementById('msgContact').value;
config['ui']['help_message'] = document.getElementById('msgHelp').value;
config['ui']['success_message'] = document.getElementById('msgSuccess').value;
// Send it
config["restart-program"] = true;
var req = new XMLHttpRequest();
req.open("POST", "/config", true);
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
req.responseType = 'json';
req.onreadystatechange = function() {
if (this.readyState == 4) {
submitButton.disabled = false;
submitButton.className = '';
submitButton.classList.add('btn', 'btn-success');
submitButton.textContent = 'Success';
};
};
req.send(JSON.stringify(config));
};

View File

@ -1,21 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>404 - jfa-go</title>
<link rel="stylesheet" type="text/css" href="{{ .cssFile }}">
{{ template "header.html" . }}
<style>
.messageBox {
margin: 20%;
}
</style>
</head>
<body>
<div class="messageBox">
<h1>Page not found.</h1>
<p>
{{ .contactMessage }}
</p>
</div>
</body>
</html>

View File

@ -1,473 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<script>
// To grab theme preference
function getCookie(cname) {
let name = cname + "=";
let decodedCookie = decodeURIComponent(document.cookie);
let ca = decodedCookie.split(';');
for (let c of ca) {
while(c.charAt(0) == ' ') {
c = c.substring(1);
}
if (c.indexOf(name) == 0) {
return c.substring(name.length, c.length);
}
}
return "";
}
window.URLBase = "{{ .urlBase }}";
{{ if .bs5 }}
window.bsVersion = 5;
{{ else }}
window.bsVersion = 4;
{{ end }}
window.cssFile = "{{ .cssFile }}";
var css = document.createElement('link');
css.setAttribute('rel', 'stylesheet');
css.setAttribute('type', 'text/css');
var cssCookie = getCookie("css");
if (cssCookie.includes('bs' + bsVersion)) {
cssFile = cssCookie;
} else if (cssCookie.includes('bs')) {
if (cssCookie.includes('jf')) {
cssFile = 'bs' + bsVersion + '-jf.css';
} else {
cssFile = 'bs' + bsVersion + '.css';
}
document.cookie = 'css=' + cssFile;
}
css.setAttribute('href', cssFile);
document.head.appendChild(css);
</script>
{{ template "header.html" . }}
<title>Admin - jfa-go</title>
</head>
<body class="smooth-transition">
<div class="modal fade" id="login" role="dialog" aria-labelledby="login" aria-hidden="true" data-backdrop="static">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="loginTitle">Login</h5>
</div>
<div class="modal-body" id="formBody">
<form action="#" method="POST" id="loginForm">
<div class="form-group">
<label for="username">Username</label>
<input type="text" class="form-control" id="username" name="username" placeholder="Username" required>
<label for="password">Password</label>
<input type="password" class="form-control" id="password" name="password" placeholder="Password" required>
</div>
</form>
</div>
<div class="modal-footer">
<button type="submit" id="loginSubmit" class="btn btn-primary" form="loginForm">Login</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="users" role="dialog" aria-labelledby="users" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="usersTitle">Users</h5>
<button type="button" class="{{ if .bs5 }}btn-{{ end }}close" data-dismiss="modal" aria-label="Close">
{{ if not .bs5 }}
<span aria-hidden="true">&times;</span>
{{ end }}
</button>
</div>
<div class="modal-body">
<ul class="list-group list-group-flush" id="userList">
</ul>
</div>
<div class="modal-footer" id="userFooter">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="userDefaults" role="dialog" aria-labelledby="users" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="defaultsTitle"></h5>
<button type="button" class="{{ if .bs5 }}btn-{{ end }}close" data-dismiss="modal" aria-label="Close">
{{ if not .bs5 }}
<span aria-hidden="true">&times;</span>
{{ end }}
</button>
</div>
<div class="modal-body">
<p id="userDefaultsDescription"></p>
<div class="mb-3" id="defaultsSourceSection">
<label for="defaultsSource" class="form-label">Use settings from:</label>
<select class="form-select" id="defaultsSource" aria-label="User settings source">
<option value="profile" selected>Profile</option>
<option value="fromUser">Source from existing user</option>
</select>
</div>
<div class="mb-3 unfocused" id="profileSelectBox">
<label for="profileSelect" class="form-label">Profile</label>
<select class="form-select" id="profileSelect" aria-label="Profile to apply">
</select>
</div>
<div class="mb-3 unfocused" id="newProfileBox">
<label for="newProfileName" class="form-label">Name</label>
<input type="text" class="form-control" id="newProfileName" aria-describedby="Profile Name">
</div>
<div id="defaultUserRadiosBox">
<label for="defaultUserRadios" class="form-label">Get settings from</label>
<div id="defaultUserRadios"></div>
</div>
<div class="form-check" style="margin-top: 1rem;">
<input class="form-check-input" type="checkbox" value="" id="storeDefaultHomescreen" checked>
<label class="form-check-label" for="storeDefaultHomescreen" id="storeHomescreenLabel"></label>
</div>
</div>
<div class="modal-footer" id="defaultsFooter">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="storeDefaults">Submit</button>
</div>
</div>
</div>
</div>
{{ if .ombiEnabled }}
<div class="modal fade" id="ombiDefaults" role="dialog" aria-labelledby="Ombi Users" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="ombiTitle">Ombi user defaults</h5>
<button type="button" class="{{ if .bs5 }}btn-{{ end }}close" data-dismiss="modal" aria-label="Close">
{{ if not .bs5 }}
<span aria-hidden="true">&times;</span>
{{ end }}
</button>
</div>
<div class="modal-body">
<p>Create an Ombi user and configure it to your liking, then choose it from below to store the settings and permissions as a template for all new users.</p>
<div id="ombiUserRadios"></div>
</div>
<div class="modal-footer" id="ombiFooter">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="storeOmbiDefaults">Submit</button>
</div>
</div>
</div>
</div>
{{ end }}
<div class="modal fade" id="restartModal" role="dialog" aria-labelledby="Restart Warning" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Warning</h5>
</div>
<div class="modal-body">
<p>A restart is needed to apply some settings. Restart now, later, or cancel?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-dismiss="modal" id="restartModalCancel">Cancel</button>
<button type="button" class="btn btn-secondary" id="applyRestarts" data-dismiss="modal">Apply, Restart later</button>
<button type="button" class="btn btn-primary" id="applyAndRestart" data-dismiss="modal">Apply &amp; Restart</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="refreshModal" role="dialog" aria-labelledby="Refresh page notice" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Settings applied.</h5>
</div>
<div class="modal-body">
<p>Refresh the page in a few seconds.</p>
</div>
</div>
</div>
</div>
<div class="modal fade" id="aboutModal" role="dialog" aria-labelledby="About jfa-go" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">About</h5>
<button type="button" class="{{ if .bs5 }}btn-{{ end }}close" data-dismiss="modal" aria-label="Close">
{{ if not .bs5 }}
<span aria-hidden="true">&times;</span>
{{ end }}
</button>
</div>
<div class="modal-body">
<img src="banner.svg" alt="jfa-go banner">
<p><a href="https://github.com/hrfee/jfa-go"><i class="fa fa-github"></i> jfa-go</a></p>
<p>Version <i class="text-monospace">{{ .version }}</i></p>
<p>Commit <i class="text-monospace">{{ .commit }}</i></p>
<p><a href="https://github.com/hrfee/jfa-go/blob/main/LICENSE">Available under the MIT License.</a></p>
</div>
</div>
</div>
</div>
<div class="modal fade" id="deleteModal" role="dialog" aria-labelledby="Account deletion" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteModalTitle"></h5>
<button type="button" class="{{ if .bs5 }}btn-{{ end }}close" data-dismiss="modal" aria-label="Close">
{{ if not .bs5 }}
<span aria-hidden="true">&times;</span>
{{ end }}
</button>
</div>
<div class="modal-body">
<div class="form-check">
<input class="form-check-input" type="checkbox" value="" id="deleteModalNotify">
<label class="form-check-label" for="deleteModalNotify" id="deleteModalNotifyLabel">Notify users of account deletion</label>
</div>
<div class="mb-3 unfocused" id="deleteModalReasonBox">
<label for="deleteModalReason" class="form-label">Reason for deletion</label>
<textarea class="form-control" id="deleteModalReason" rows="2"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="deleteModalSend">Delete</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="newUserModal" role="dialog" aria-labelledby="Create new user" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Create a user</h5>
<button type="button" class="{{ if .bs5 }}btn-{{ end }}close" data-dismiss="modal" aria-label="Close">
{{ if not .bs5 }}
<span aria-hidden="true">&times;</span>
{{ end }}
</button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="newUserEmail" class="form-label">Email address</label>
<input type="email" class="form-control" id="newUserEmail" aria-describedby="Email address">
</div>
{{ if .username }}
<div class="mb-3">
<label for="newUserName" class="form-label">Username</label>
<input type="text" class="form-control" id="newUserName" aria-describedby="Username">
</div>
{{ end }}
<div class="mb-3">
<label for="newUserPassword" class="form-label">Password</label>
<input type="password" class="form-control" id="newUserPassword" aria-describedby="Password">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="newUserCreate">Create</button>
</div>
</div>
</div>
</div>
<div class="pageContainer">
<ul class="nav nav-pills" style="margin-bottom: 2rem;">
<li class="nav-item">
<h2><a id="invitesTabButton" class="nl nav-link active">Invites</a></h2>
</li>
<li class="nav-item">
<h2><a id="accountsTabButton" class="nl nav-link">Accounts</a></h2>
</li>
<li class="nav-item">
<h2><a id="settingsTabButton" class="nl nav-link">Settings</a></h2>
</li>
</ul>
<div class="btn-group" role="group" id="headerButtons">
<button type="button" class="btn btn-danger unfocused" id="logoutButton">
Logout <i class="fa fa-sign-out"></i>
</button>
</div>
<div id="invitesTab">
<div class="card mb-3 tabGroup">
<div class="card-header">Current Invites</div>
<ul class="list-group list-group-flush" id="invites">
</ul>
</div>
<div class="linkForm">
<div class="card mb-3">
<div class="card-header">Generate Invite</div>
<div class="card-body">
<form action="#" method="POST" id="inviteForm" class="container">
<div class="row align-items-start">
<div class="col-sm">
<div class="form-group">
<label for="days">Days</label>
<select class="form-control form-select" id="days" name="days">
</select>
</div>
<div class="form-group">
<label for="hours">Hours</label>
<select class="form-control form-select" id="hours" name="hours">
</select>
</div>
<div class="form-group">
<label for="minutes">Minutes</label>
<select class="form-control form-select" id="minutes" name="minutes">
</select>
</div>
</div>
<div class="col">
<div class="form-group">
<label for="multiUseCount">
Multiple uses
</label>
<div class="input-group">
<div class="input-group-text">
<input class="form-check-input" type="checkbox" onchange="document.getElementById('multiUseCount').disabled = !this.checked; document.getElementById('noUseLimit').disabled = !this.checked" aria-label="Checkbox to allow choice of invite usage limit" name="multiple-uses" id="multiUseEnabled">
</div>
<input type="number" class="form-control" name="remaining-uses" id="multiUseCount">
</div>
</div>
<div class="form-group form-check" style="margin-top: 1rem; margin-bottom: 1rem;">
<input class="form-check-input" type="checkbox" value="" name="no-limit" id="noUseLimit" onchange="document.getElementById('multiUseCount').disabled = this.checked; if (this.checked) { document.getElementById('noLimitWarning').style = 'display: block;' } else { document.getElementById('noLimitWarning').style = 'display: none;'; }">
<label class="form-check-label" for="noUseLimit">
No use limit
</label>
<div id="noLimitWarning" class="form-text" style="display: none;">Warning: Unlimited usage invites pose a risk if published online.</div>
</div>
<div class="form-group" style="margin-bottom: 1rem;">
<label for="inviteProfile">Account creation profile</label>
<select class="form-control form-select" id="inviteProfile" name="profile">
</select>
</div>
{{ if .email_enabled }}
<div class="form-group">
<label for="send_to_address">Send invite to address</label>
<div class="input-group">
<div class="input-group-text">
<input class="form-check-input" type="checkbox" onchange="document.getElementById('send_to_address').disabled = !this.checked;" aria-label="Checkbox to allow input of email address" id="send_to_address_enabled">
</div>
<input type="email" class="form-control" placeholder="example@example.com" id="send_to_address" disabled>
</div>
</div>
{{ end }}
</div>
</div>
<div class="row">
<div class="col">
<div class="form-group d-flex float-right">
<button type="submit" id="generateSubmit" class="btn btn-primary" style="margin-top: 1rem;">
Generate
</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<div id="accountsTab" class="unfocused">
<div class="card mb-3 tabGroup">
<div class="card-header d-flex" style="align-items: center;">
<div>Accounts</div>
<div class="ml-auto">
<button type="button" class="btn btn-secondary" id="accountsTabAddUser">Add User</button>
<button type="button" class="btn btn-primary unfocused" id="accountsTabSetDefaults">Modify Settings</button>
<button type="button" class="btn btn-danger unfocused" id="accountsTabDelete"></button>
</div>
</div>
<div class="card-body table-responsive">
<table class="table table-hover table-striped table-borderless">
<thead>
<tr>
{{ if .bs5 }}
<th scope="col"><input class="form-check-input" type="checkbox" value="" id="selectAll"></th>
{{ else }}
<th scope="col"><input type="checkbox" value="" id="selectAll"></th>
{{ end }}
<th scope="col">Username</th>
<th scope="col">Email Address</th>
<th scope="col">Last Active</th>
</tr>
</thead>
<tbody id="accountsList">
</tbody>
</table>
</div>
</div>
</div>
<div id="settingsTab" class="unfocused mb-3 tabGroup card">
<div class="card-header d-flex" style="align-items: center;">
<div>Settings</div>
<div class="ml-auto">
<button type="button" class="btn btn-primary" id="settingsSave">Save</button>
</div>
</div>
<div class="container card-body">
<div class="row">
<div class="col-sm">
<div class="" id="settingsLeft">
<ul class="list-group list-group-flush" style="margin-bottom: 1rem;">
<p>Note: <sup class="text-danger">*</sup> Indicates required field, <sup class="text-danger">R</sup> Indicates changes require a restart.</p>
<button type="button" class="list-group-item list-group-item-action static" id="openAbout">
About <i class="fa fa-info-circle settingIcon"></i>
</button>
<button type="button" class="list-group-item list-group-item-action" id="profiles_button">
User Profiles <i class="fa fa-user settingIcon"></i>
</button>
{{ if .ombiEnabled }}
<button type="button" class="list-group-item list-group-item-action static" id="openOmbiDefaults" onclick="window.openOmbiDefaults()">
Ombi User Defaults <i class="fa fa-chain-broken settingIcon"></i>
</button>
{{ end }}
</ul>
<div class="list-group list-group-flush" id="settingsSections">
</div>
</div>
</div>
<div class="col">
<div class="" id="settingsContent">
<div id="profiles" class="unfocused">
<div class="card card-body">
<p>Profiles are applied to users when they create an account. They include things like access rights and homescreen layout. You can create them here.</p>
<table class="table table-sm table-striped table-borderless">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Default</th>
<th scope="col">From</th>
<th scope="col">Admin?</th>
<th scope="col">Libraries</th>
<th scope="col"><button class="btn btn-outline-primary" onclick="createProfile()">Create</button></th>
</tr>
</thead>
<tbody id="profileList">
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="contactBox">
<p>{{ .contactMessage }}</p>
</div>
</div>
<script>
window.bs5 = {{ .bs5 }};
window.availableProfiles = [];
{{ if .notifications }}
window.notifications_enabled = true;
{{ else }}
window.notifications_enabled = false;
{{ end }}
</script>
<script src="admin.js" type="module"></script>
<script src="invites.js" type="module"></script>
{{ if .ombiEnabled }}
<script src="ombi.js" type="module"></script>
{{ end }}
</body>
</html>

View File

@ -1,106 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="stylesheet" type="text/css" href="{{ .cssFile }}">
{{ template "header.html" . }}
<style>
.pageContainer {
margin: 5% 20% 5% 20%;
}
@media (max-width: 1100px) {
.pageContainer {
margin: 2%;
}
}
.contactBox {
color: grey;
}
#container {
margin-top: 5%;
margin-bottom: 5%;
}
</style>
<title>{{ .lang.pageTitle }}</title>
</head>
<body>
<div class="modal fade" id="successBox" tabindex="-1" role="dialog" aria-labelledby="successBox" aria-hidden="true" data-backdrop="static">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="successHeader">{{ .lang.successHeader }}</h5>
</div>
<div class="modal-body" id="successBody">
<p>{{ .successMessage }}</p>
</div>
<div class="modal-footer">
<a href="{{ .jfLink }}" class="btn btn-primary">{{ .lang.successContinueButton }}</a>
</div>
</div>
</div>
</div>
<div class="pageContainer">
<h1>
{{ .lang.createAccountHeader }}
</h1>
<p>{{ .helpMessage }}</p>
<p class="contactBox">{{ .contactMessage }}</p>
<div class="container" id="container">
<div class="row" id="cardContainer">
<div class="col-sm">
<div class="card mb-3">
<div class="card-header">{{ .lang.accountDetails }}</div>
<div class="card-body">
<form action="#" method="POST" id="accountForm">
<div class="form-group">
<label for="inputEmail">{{ .lang.emailAddress }}</label>
<input type="email" class="form-control" id="{{ if .username }}inputEmail{{ else }}inputUsername{{ end }}" name="{{ if .username }}email{{ else }}username{{ end }}" placeholder="{{ .lang.emailAddress }}" value="{{ .email }}" required>
</div>
{{ if .username }}
<div class="form-group">
<label for="inputUsername">{{ .lang.username }}</label>
<input type="username" class="form-control" id="inputUsername" name="username" placeholder="{{ .lang.username }}" required>
</div>
{{ end }}
<div class="form-group">
<label for="inputPassword">{{ .lang.password }}</label>
<input type="password" class="form-control" id="inputPassword" name="password" placeholder="{{ .lang.password }}" required>
</div>
<div class="form-group">
<label for="inputPassword">{{ .lang.reEnterPassword }}</label>
<input type="password" class="form-control" id="reInputPassword" onkeyup="window.checkPassword()" placeholder="{{ .lang.password }}" required>
</div>
<div class="btn-group" role="group" aria-label="Button & Error" id="errorBox" style="margin-top: 1rem;">
<button type="submit" class="btn btn-outline-primary" id="submitButton">
<span id="createAccount">{{ .lang.createAccountButton }}</span>
</button>
</div>
</form>
</div>
</div>
</div>
{{ if .validate }}
<div class="col-sm" id="requirementBox">
<div class="card mb-3 requirementBox">
<div class="card-header">{{ .lang.passwordRequirementsHeader }}</div>
<div class="card-body">
<ul class="list-group">
{{ range $key, $value := .requirements }}
<li id="{{ $key }}" min="{{ $value }}" class="list-group-item list-group-item-danger">
<div></div>
</li>
{{ end }}
</ul>
</div>
</div>
</div>
{{ end }}
</div>
</div>
</div>
<script>
window.validationStrings = {{ .lang.validationStrings }};
</script>
{{ template "form-base" . }}
</body>
</html>

View File

@ -1,23 +0,0 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="Description" content="jfa-go, a better way to manage Jellyfin users.">
<link rel="apple-touch-icon" sizes="180x180" href="{{ .urlBase }}/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="{{ .urlBase }}/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="{{ .urlBase }}/favicon-16x16.png">
<link rel="manifest" href="{{ .urlBase }}/site.webmanifest">
<link rel="mask-icon" href="{{ .urlBase }}/safari-pinned-tab.svg" color="#5bbad5">
<meta name="msapplication-TileColor" content="#603cba">
<meta name="theme-color" content="#ffffff">
{{ if not .bs5 }}
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
{{ end }}
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.1/dist/umd/popper.min.js" integrity="sha384-9/reFTGAW83EW2RDu2S0VKaIzap3H66lZH81PoYlFhbGU+6BZp6G7niu735Sk7lN" crossorigin="anonymous"></script>
{{ if .bs5 }}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-alpha3/dist/js/bootstrap.min.js" integrity="sha384-t6I8D5dJmMXjCsRLhSzCltuhNZg6P10kE0m0nAncLUjH6GeYLhRU1zfLoW3QNQDF" crossorigin="anonymous"></script>
{{ else }}
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script>
{{ end }}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">

View File

@ -1,20 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Invalid Code - jfa-go</title>
<link rel="stylesheet" type="text/css" href="{{ .cssFile }}">
{{ template "header.html" . }}
<style>
.messageBox {
margin: 20%;
}
</style>
</head>
<body>
<div class="messageBox">
<h1>Invalid Code.</h1>
<p>The above code is either incorrect, or has expired.</p>
<p>{{ .contactMessage }}</p>
</div>
</body>
</html>

View File

@ -1,5 +0,0 @@
#!/usr/bin/bash
# set +e
# npx tsc -p ts/
# set -e
npx esbuild ts/* --outdir=data/static --minify

6
go.mod
View File

@ -19,6 +19,7 @@ require (
github.com/gin-gonic/gin v1.6.3
github.com/go-chi/chi v4.1.2+incompatible // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/spec v0.20.0 // indirect
github.com/go-playground/validator/v10 v10.4.1 // indirect
github.com/gofrs/uuid v3.3.0+incompatible // indirect
github.com/golang/protobuf v1.4.3
@ -45,10 +46,9 @@ require (
github.com/ugorji/go v1.2.0 // indirect
github.com/urfave/cli/v2 v2.3.0 // indirect
golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9 // indirect
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b // indirect
golang.org/x/sys v0.0.0-20201113233024-12cec1faf1ba // indirect
golang.org/x/net v0.0.0-20201224014010-6772e930b67b // indirect
golang.org/x/text v0.3.4 // indirect
golang.org/x/tools v0.0.0-20210104081019-d8d6ddbec6ee // indirect
google.golang.org/protobuf v1.25.0 // indirect
gopkg.in/ini.v1 v1.62.0
gopkg.in/yaml.v2 v2.3.0 // indirect
)

20
go.sum
View File

@ -15,6 +15,7 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSY
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgrijalva/jwt-go v1.0.2 h1:KPldsxuKGsS2FPWsNeg9ZO18aCrGKujPoWXn2yo+KQM=
@ -66,6 +67,8 @@ github.com/go-openapi/jsonreference v0.19.3 h1:5cxNfTy0UVC3X8JL5ymxzyoUZmo8iZb+j
github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8=
github.com/go-openapi/jsonreference v0.19.4 h1:3Vw+rh13uq2JFNxgnMTGE1rnoieU9FmyE1gvnyylsYg=
github.com/go-openapi/jsonreference v0.19.4/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg=
github.com/go-openapi/jsonreference v0.19.5 h1:1WJP/wi4OjB4iV8KVbH73rQaoialJrqv8gitZLxGLtM=
github.com/go-openapi/jsonreference v0.19.5/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg=
github.com/go-openapi/spec v0.19.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI=
github.com/go-openapi/spec v0.19.4 h1:ixzUSnHTd6hCemgtAJgluaTSGYpLNpJY4mA2DIkdOAo=
github.com/go-openapi/spec v0.19.4/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo=
@ -79,6 +82,8 @@ github.com/go-openapi/spec v0.19.13 h1:AcZVcWsrfW7LqyHKVbTZYpFF7jQcMxmAsWrw2p/b9
github.com/go-openapi/spec v0.19.13/go.mod h1:gwrgJS15eCUgjLpMjBJmbZezCsw88LmgeEip0M63doA=
github.com/go-openapi/spec v0.19.14 h1:r4fbYFo6N4ZelmSX8G6p+cv/hZRXzcuqQIADGT1iNKM=
github.com/go-openapi/spec v0.19.14/go.mod h1:gwrgJS15eCUgjLpMjBJmbZezCsw88LmgeEip0M63doA=
github.com/go-openapi/spec v0.20.0 h1:HGLc8AJ7ynOxwv0Lq4TsnwLsWMawHAYiJIFzbcML86I=
github.com/go-openapi/spec v0.20.0/go.mod h1:+81FIL1JwC5P3/Iuuozq3pPE9dXdIEGxFutcFKaVbmU=
github.com/go-openapi/swag v0.17.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg=
github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.5 h1:lTz6Ys4CmqqCQmZPBlbQENR1/GucA2bzYTE12Pw4tFY=
@ -89,6 +94,8 @@ github.com/go-openapi/swag v0.19.10 h1:A1SWXruroGP15P1sOiegIPbaKio+G9N5TwWTFaVPm
github.com/go-openapi/swag v0.19.10/go.mod h1:Uc0gKkdR+ojzsEpjh39QChyu92vPgIr72POcgHMAgSY=
github.com/go-openapi/swag v0.19.11 h1:RFTu/dlFySpyVvJDfp/7674JY4SDglYWKztbiIGFpmc=
github.com/go-openapi/swag v0.19.11/go.mod h1:Uc0gKkdR+ojzsEpjh39QChyu92vPgIr72POcgHMAgSY=
github.com/go-openapi/swag v0.19.12 h1:Bc0bnY2c3AoF7Gc+IMIAQQsD8fLHjHpc19wXvYuayQI=
github.com/go-openapi/swag v0.19.12/go.mod h1:eFdyEBkTdoAf/9RXBvj4cr1nH7GD8Kzo5HTt47gr72M=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM=
github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
@ -153,6 +160,7 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo v3.3.10+incompatible h1:pGRcYk231ExFAyoAjAfD85kQzRJCRI8bbnE7CX5OEgg=
github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s=
github.com/labstack/gommon v0.2.9 h1:heVeuAYtevIQVYkGj6A41dtfT91LrvFG220lavpWhrU=
@ -202,6 +210,7 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLD
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pdrum/swagger-automation v0.0.0-20190629163613-c8c7c80ba858 h1:lgbJiJQx8bXo+eM88AFdd0VxUvaTLzCBXpK+H9poJ+Y=
github.com/pdrum/swagger-automation v0.0.0-20190629163613-c8c7c80ba858/go.mod h1:y02HeaN0visd95W6cEX2NXDv5sCwyqfzucWTdDGEwYY=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
@ -314,6 +323,8 @@ golang.org/x/net v0.0.0-20201031054903-ff519b6c9102 h1:42cLlJJdEh+ySyeUUbEQ5bsTi
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b h1:uwuIcX0g4Yl1NC5XAz37xsr2lTtcqevgzYNVt49waME=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b h1:iFwSg7t5GZmB/Q5TjiEAsdoLDrdJRC1RiF2WhuV29Qw=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -344,6 +355,9 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f h1:+Nyd8tzPX9R7BWHguqsrbFdRx
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201113233024-12cec1faf1ba h1:xmhUJGQGbxlod18iJGqVEp9cHIPLl7QiX2aA3to708s=
golang.org/x/sys v0.0.0-20201113233024-12cec1faf1ba/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
@ -382,6 +396,8 @@ golang.org/x/tools v0.0.0-20201114224030-61ea331ec02b h1:Ych5r0Z6MLML1fgf5hTg9p5
golang.org/x/tools v0.0.0-20201114224030-61ea331ec02b/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201120155355-20be4ac4bd6e h1:t96dS3DO8DGjawSLJL/HIdz8CycAd2v07XxqB3UPTi0=
golang.org/x/tools v0.0.0-20201120155355-20be4ac4bd6e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210104081019-d8d6ddbec6ee h1:5xKxdl/RhlelmSPaxyVeq5PYSmJ4H14yeQT58qP1F6o=
golang.org/x/tools v0.0.0-20210104081019-d8d6ddbec6ee/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -407,6 +423,7 @@ google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
@ -426,6 +443,9 @@ gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

16
html/404.html Normal file
View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<head>
<link rel="stylesheet" type="text/css" href="css/base.css">
{{ template "header.html" . }}
<title>404 - jfa-go</title>
</head>
<body class="section">
<div class="page-container">
<h1 class="heading">Page not found.</h1>
<p class="content">
{{ .contactMessage }}
</p>
</div>
</body>
</html>

296
html/admin.html Normal file
View File

@ -0,0 +1,296 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<head>
<link rel="stylesheet" type="text/css" href="css/base.css">
<script>
window.URLBase = "{{ .urlBase }}";
window.notificationsEnabled = {{ .notifications }};
window.emailEnabled = {{ .email_enabled }};
window.ombiEnabled = {{ .ombiEnabled }};
window.usernamesEnabled = {{ .username }};
</script>
{{ template "header.html" . }}
<title>Admin - jfa-go</title>
</head>
<body class="max-w-full overflow-x-hidden section">
<div id="modal-login" class="modal">
<form class="modal-content card" id="form-login" href="">
<span class="heading">Login</span>
<input type="text" class="field input ~neutral !high mt-half mb-1" placeholder="username" id="login-user">
<input type="password" class="field input ~neutral !high mb-1" placeholder="password" id="login-password">
<label>
<input type="submit" class="unfocused">
<span class="button ~urge !normal full-width center supra submit">Login</span>
</label>
</form>
</div>
<div id="modal-add-user" class="modal">
<form class="modal-content card" id="form-add-user" href="">
<span class="heading">New User <span class="modal-close">&times;</span></span>
<input type="text" class="field input ~neutral !high mt-half mb-1" placeholder="username" id="add-user-user">
<input type="email" class="field input ~neutral !high mt-half mb-1" placeholder="email address">
<input type="password" class="field input ~neutral !high mb-1" placeholder="password" id="add-user-password">
<label>
<input type="submit" class="unfocused">
<span class="button ~urge !normal full-width center supra submit">Create</span>
</label>
</form>
</div>
<div id="modal-about" class="modal">
<div class="modal-content content card">
<span class="heading">About <span class="modal-close">&times;</span></span>
<img src="/banner.svg" class="mt-1" alt="jfa-go banner">
<p><i class="icon ri-github-fill"></i><a href="https://github.com/hrfee/jfa-go">jfa-go</a></p>
<p>Version <span class="code monospace">{{ .version }}</span></p>
<p>Commit <span class="code monospace">{{ .commit }}</span></p>
<p><a href="https://github.com/hrfee/jfa-go/blob/main/LICENSE">Available under the MIT License.</a></p>
</div>
</div>
<div id="modal-modify-user" class="modal">
<form class="modal-content card" id="form-modify-user" href="">
<span class="heading">Modify Settings for <span id="header-modify-user"></span> <span class="modal-close">&times;</span></span>
<p class="content">Apply settings from an existing profile, or source them directly from a user.</p>
<div class="flex-row mb-1">
<label class="flex-row-group mr-1">
<input type="radio" name="modify-user-source" class="unfocused" id="radio-use-profile" checked>
<span class="button ~neutral !high supra full-width center">Profile</span>
</label>
<label class="flex-row-group ml-1">
<input type="radio" name="modify-user-source" class="unfocused" id="radio-use-user">
<span class="button ~neutral !normal supra full-width center">User</span>
</label>
</div>
<div class="select ~neutral !normal mb-1">
<select id="modify-user-profiles">
<option>Friends</option>
<option>Family</option>
<option>Default</option>
</select>
</div>
<div class="select ~neutral !normal mb-1 unfocused">
<select id="modify-user-users">
<option>Person</option>
<option>Other person</option>
</select>
</div>
<label class="switch mb-1">
<input type="checkbox" id="modify-user-homescreen" checked>
<span>Apply homescreen layout</span>
</label>
<label>
<input type="submit" class="unfocused">
<span class="button ~urge !normal full-width center supra submit">Apply</span>
</label>
</form>
</div>
<div id="modal-delete-user" class="modal">
<form class="modal-content card" id="form-delete-user" href="">
<span class="heading">Delete <span id="header-delete-user"></span> <span class="modal-close">&times;</span></span>
<div class="content mt-half">
<label class="switch mb-1">
<input type="checkbox" id="delete-user-notify" checked>
<span>Send notification email</span>
</label>
<textarea id="textarea-delete-user" class="textarea full-width ~neutral !normal mb-1" placeholder="Your account has been deleted."></textarea>
<label>
<input type="submit" class="unfocused">
<span class="button ~critical !normal full-width center supra submit">Delete</span>
</label>
</div>
</form>
</div>
<div id="modal-restart" class="modal">
<div class="modal-content card ~critical !low">
<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>
<div class="fr">
<span class="button ~info !normal" id="settings-apply-no-restart">Apply, restart later</span>
<span class="button ~critical !normal" id="settings-apply-restart">Apply &amp; restart</span>
</div>
</div>
</div>
<div id="modal-refresh" class="modal">
<div class="modal-content card ~neutral !normal">
<span class="heading">Settings applied.</span>
<p class="content">Refresh the page in a few seconds.</p>
</div>
</div>
<div id="modal-ombi-defaults" class="modal">
<form class="modal-content card" id="form-ombi-defaults" href="">
<span class="heading">Ombi user defaults <span class="modal-close">&times;</span></span>
<p class="content">Create an Ombi user and configure it, then select it here. It's settings/permissions will be stored and applied to new ombi users created by jfa-go.</p>
<div class="select ~neutral !normal mb-1">
<select>
<option>Person</option>
<option>Other person</option>
</select>
</div>
<label>
<input type="submit" class="unfocused">
<span class="button ~urge !normal full-width center supra submit">Submit</span>
</label>
</form>
</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 class="page-container">
<div class="mb-1">
<header class="flex flex-wrap items-center justify-between">
<div class="text-neutral-700">
<span id="button-tab-invites" class="tab-button portal">Invites</span>
<span id="button-tab-accounts" class="tab-button portal">Accounts</span>
<span id="button-tab-settings" class="tab-button portal">Settings</span>
</div>
</header>
</div>
<div class="mb-1">
<div class="text-neutral-700">
<span class="button ~critical !normal mb-1 unfocused" id="logout-button">Logout</span>
<span id="button-theme" class="button ~neutral !normal mb-1">Theme</span>
</div>
</div>
<div id="tab-invites">
<div class="card ~neutral !low invites mb-1">
<span class="heading">Invites</span>
<div id="invites"></div>
</div>
<div class="card ~neutral !low">
<span class="heading">Create</span>
<div class="row" id="create-inv">
<div class="card ~neutral !normal col">
<label class="label supra" for="create-days">Days</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="create-days">
<option>0</option>
</select>
</div>
<label class="label supra" for="create-hours">Hours</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="create-hours">
<option>0</option>
</select>
</div>
<label class="label supra" for="create-minutes">Minutes</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="create-minutes">
<option>0</option>
</select>
</div>
</div>
<div class="card ~neutral !normal col">
<label class="label supra" for="create-uses">Number of uses</label>
<div class="flex-expand mb-1 mt-half">
<input type="number" min="0" id="create-uses" class="input ~neutral !normal mr-1" value=1>
<label for="create-inf-uses" class="button ~neutral !normal">
<span></span>
<input type="checkbox" class="unfocused" id="create-inf-uses" aria-label="Set uses to infinite">
</label>
</div>
<p class="support unfocused" id="create-inf-uses-warning"><span class="badge ~critical">Warning</span> invites with infinite uses can be used abusively.</p>
<label class="label supra">Profile</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="create-profile">
</select>
</div>
<div id="create-send-to-container">
<label class="label supra">Send to</label>
<div class="flex-expand mb-1 mt-half">
<input type="email" id="create-send-to" class="input ~neutral !normal mr-1" placeholder="example@example.com">
<label for="create-send-to-enabled" class="button ~neutral !normal">
<input type="checkbox" id="create-send-to-enabled" aria-label="Send to address enabled">
</label>
</div>
</div>
<span class="button ~urge !normal supra full-width center lg" id="create-submit">Create</span>
</div>
</div>
</div>
</div>
<div id="tab-accounts" class="unfocused">
<div class="card ~neutral !low accounts mb-1">
<span class="heading">Accounts</span>
<div class="fr">
<span class="button ~neutral !normal" id="accounts-add-user">Add User</span>
<span class="button ~urge !normal" id="accounts-modify-user">Modify Settings</span>
<span class="button ~critical !normal" id="accounts-delete-user">Delete User</span>
</div>
<div class="card ~neutral !normal accounts-header table-responsive mt-half">
<table class="table">
<thead>
<tr>
<th><input type="checkbox" value="" id="accounts-select-all"></th>
<th>Username</th>
<th>Email Address</th>
<th>Last Active</th>
</tr>
</thead>
<tbody id="accounts-list"></tbody>
</table>
</div>
</div>
</div>
<div id="tab-settings" class="unfocused">
<div class="card ~neutral !low settings overflow">
<span class="heading">Settings</span>
<div class="fr">
<span class="button ~neutral !normal unfocused" id="settings-save">Save</span>
</div>
<div class="row">
<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 ~info">R</span> indicates changes require a restart.</aside>
<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 class="card ~neutral !normal col overflow" id="settings-panel"></div>
</div>
</div>
</div>
</div>
<script src="js/admin.js" type="module"></script>
</body>
</html>

View File

@ -1,10 +1,9 @@
{{ define "form-base" }}
<script>
window.bs5 = {{ .bs5 }};
window.usernameEnabled = {{ .username }};
window.validationStrings = JSON.parse({{ .lang.validationStrings }});
window.invalidPassword = "{{ .lang.reEnterPasswordInvalid }}";
window.URLBase = "{{ .urlBase }}";
</script>
<script src="form.js" type="module"></script>
<script src="js/form.js" type="module"></script>
{{ end }}

67
html/form.html Normal file
View File

@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<head>
<link rel="stylesheet" type="text/css" href="css/base.css">
{{ template "header.html" . }}
<title>{{ .lang.pageTitle }}</title>
</head>
<body class="max-w-full overflow-x-hidden section">
<div id="modal-success" class="modal">
<div class="modal-content card">
<span class="heading mb-1">{{ .lang.successHeader }}</span>
<p class="content mb-1">{{ .successMessage }}</p>
<span class="button ~urge !normal full-width center supra submit" id="create-success-button">{{ .lang.successContinueButton }}</span>
</div>
</div>
<div id="notification-box"></div>
<div class="page-container">
<div class="card ~neutral !low">
<div class="row baseline">
<span class="col heading">{{ .lang.createAccountHeader }}</span>
<span class="col subheading"> {{ .helpMessage }}</span>
</div>
<div class="row">
<div class="col">
<form class="card ~neutral !normal" id="form-create" href="">
<label class="label supra">
{{ .lang.username }}
<input type="text" class="input ~neutral !high mt-half mb-1" placeholder="{{ .lang.username }}" id="create-username" aria-label="{{ .lang.username }}">
</label>
<label class="label supra" for="create-email">{{ .lang.emailAddress }}</label>
<input type="email" class="input ~neutral 1high mt-half mb-1" placeholder="{{ .lang.emailAddress }}" id="create-email" aria-label="{{ .lang.emailAddress }}" value="{{ .email }}">
<label class="label supra" for="create-password">{{ .lang.password }}</label>
<input type="password" class="input ~neutral 1high mt-half mb-1" placeholder="{{ .lang.password }}" id="create-password" aria-label="{{ .lang.password }}">
<label class="label supra" for="create-reenter-password">{{ .lang.reEnterPassword }}</label>
<input type="password" class="input ~neutral 1high mt-half mb-1" placeholder="{{ .lang.password }}" id="create-reenter-password" aria-label="{{ .lang.reEnterPassword }}">
<label>
<input type="submit" class="unfocused">
<span class="button ~urge !normal full-width center supra submit">{{ .lang.createAccountButton }}</span>
</label>
</form>
</div>
<div class="col">
<div class="card ~neutral !normal">
<span class="label supra" for="inv-uses">{{ .lang.passwordRequirementsHeader }}</span>
<ul>
{{ range $key, $value := .requirements }}
<li class="" id="requirement-{{ $key }}" min="{{ $value }}">
<span class="badge lg ~positive requirement-valid"></span> <span class="content requirement-content"></span>
</li>
{{ end }}
</ul>
</div>
<aside class="col aside sm ~info">{{ .contactMessage }}</aside>
</div>
</div>
</div>
</div>
<script>
window.validationStrings = {{ .lang.validationStrings }};
</script>
{{ template "form-base" . }}
</body>
</html>

11
html/header.html Normal file
View File

@ -0,0 +1,11 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="Description" content="jfa-go, a better way to manage Jellyfin users.">
<link rel="apple-touch-icon" sizes="180x180" href="{{ .urlBase }}/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="{{ .urlBase }}/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="{{ .urlBase }}/favicon-16x16.png">
<link rel="manifest" href="{{ .urlBase }}/site.webmanifest">
<link rel="mask-icon" href="{{ .urlBase }}/safari-pinned-tab.svg" color="#5bbad5">
<meta name="msapplication-TileColor" content="#603cba">
<meta name="theme-color" content="#ffffff">

17
html/invalidCode.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<head>
<link rel="stylesheet" type="text/css" href="css/base.css">
{{ template "header.html" . }}
<title>Invalid Code - jfa-go</title>
</head>
<body class="section">
<div class="page-container">
<h1 class="heading">Invalid invite code.</h1>
<p class="content">The code above was either incorrect, or has expired.</p>
<p class="content">
{{ .contactMessage }}
</p>
</div>
</body>
</html>

View File

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="bs5-jf.css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-giJF6kkoqNQ00vy+HMDP7azOuL0xtbfIcaT9wjKHr8RbDVddVHyTfAAsrekwKmP1" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.1/dist/umd/popper.min.js" integrity="sha384-9/reFTGAW83EW2RDu2S0VKaIzap3H66lZH81PoYlFhbGU+6BZp6G7niu735Sk7lN" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-alpha3/dist/js/bootstrap.min.js" integrity="sha384-t6I8D5dJmMXjCsRLhSzCltuhNZg6P10kE0m0nAncLUjH6GeYLhRU1zfLoW3QNQDF" crossorigin="anonymous"></script>
@ -369,6 +369,6 @@
</div>
</div>
</div>
<script src="setup.js"></script>
<script src="js/setup.js"></script>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

BIN
images/create.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

3
images/gengif.txt Normal file
View File

@ -0,0 +1,3 @@
Commands for making GIF:
ffmpeg -i demo.mkv -vf "palettegen" videoPalette.png
ffmpeg -i demo.mkv -i videoPalette.png -lavfi "fps=25 [x]; [x][1:v] paletteuse" -y demo.gif

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 71 KiB

View File

@ -1 +0,0 @@
../data/static/banner.svg

View File

@ -1,11 +0,0 @@
# Systemd service file for jfa-go. Install to ~/.config/systemd/user.
[Unit]
Description=A web app for managing users on Jellyfin
[Service]
# Modify this to the path to your executable, if necessary.
ExecStart=/opt/jfa-go/jfa-go
[Install]
WantedBy=default.target

View File

@ -1,8 +1,13 @@
import subprocess
import shutil
import os
import argparse
from pathlib import Path
parser = argparse.ArgumentParser()
parser.add_argument("-o", "--output", help="output directory for .html and .txt files")
args = parser.parse_args()
def runcmd(cmd):
if os.name == "nt":
@ -22,7 +27,8 @@ for mjml in [f for f in local_path.iterdir() if f.is_file() and "mjml" in f.suff
html = [f for f in local_path.iterdir() if f.is_file() and "html" in f.suffix]
output = local_path.parent / "data"
output = Path(args.output) # local_path.parent / "build" / "data"
output.mkdir(parents=True, exist_ok=True)
for f in html:
shutil.copy(str(f), str(output / f.name))

35
main.go
View File

@ -48,11 +48,10 @@ type appContext struct {
config *ini.File
configPath string
configBasePath string
configBase map[string]interface{}
configBase settings
dataPath string
localPath string
cssFile string
bsVersion int
cssClass string
jellyfinLogin bool
users []User
invalidTokens []string
@ -82,10 +81,10 @@ type Languages struct {
func (app *appContext) loadHTML(router *gin.Engine) {
customPath := app.config.Section("files").Key("html_templates").MustString("")
templatePath := filepath.Join(app.localPath, "templates")
templatePath := filepath.Join(app.localPath, "html")
htmlFiles, err := ioutil.ReadDir(templatePath)
if err != nil {
app.err.Fatalf("Couldn't access template directory: \"%s\"", filepath.Join(app.localPath, "templates"))
app.err.Fatalf("Couldn't access template directory: \"%s\"", templatePath)
return
}
loadFiles := make([]string, len(htmlFiles))
@ -362,14 +361,6 @@ func start(asDaemon, firstCall bool) {
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.storage.invite_path = app.config.Section("files").Key("invites").String()
@ -420,14 +411,15 @@ func start(asDaemon, firstCall bool) {
json.Unmarshal(configBase, &app.configBase)
themes := map[string]string{
"Jellyfin (Dark)": fmt.Sprintf("bs%d-jf.css", app.bsVersion),
"Bootstrap (Light)": fmt.Sprintf("bs%d.css", app.bsVersion),
"Custom CSS": "",
"Jellyfin (Dark)": "dark-theme",
"Default (Light)": "light-theme",
}
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 {
app.cssFile = val
app.cssClass = val
}
app.debug.Printf("Using css file \"%s\"", app.cssFile)
secret, err := generateSecret(16)
if err != nil {
app.err.Fatal(err)
@ -559,7 +551,7 @@ func start(asDaemon, firstCall bool) {
setGinLogger(router, debugMode)
router.Use(gin.Recovery())
router.Use(static.Serve("/", static.LocalFile(filepath.Join(app.localPath, "static"), false)))
router.Use(static.Serve("/", static.LocalFile(filepath.Join(app.localPath, "web"), false)))
app.loadHTML(router)
router.NoRoute(app.NoRouteHandler)
if debugMode {
@ -568,10 +560,13 @@ func start(asDaemon, firstCall bool) {
}
if !firstRun {
router.GET("/", app.AdminPage)
router.GET("/accounts", app.AdminPage)
router.GET("/settings", app.AdminPage)
router.GET("/token/login", app.getTokenLogin)
router.GET("/token/refresh", app.getTokenRefresh)
router.POST("/newUser", app.NewUser)
router.Use(static.Serve("/invite/", static.LocalFile(filepath.Join(app.localPath, "static"), false)))
router.Use(static.Serve("/invite/", static.LocalFile(filepath.Join(app.localPath, "web"), false)))
router.GET("/invite/:invCode", app.InviteProxy)
if *SWAGGER {
app.info.Print(aurora.Magenta("\n\nWARNING: Swagger should not be used on a public instance.\n\n"))

View File

@ -127,3 +127,32 @@ type errorListDTO map[string]map[string]string
type configDTO map[string]interface{}
// Below are for sending config
type meta struct {
Name string `json:"name"`
Description string `json:"description"`
}
type setting struct {
Name string `json:"name"`
Description string `json:"description"`
Required bool `json:"required"`
RequiresRestart bool `json:"requires_restart"`
Type string `json:"type"` // Type (string, number, bool, etc.)
Value interface{} `json:"value"`
Options []string `json:"options,omitempty"`
DependsTrue string `json:"depends_true,omitempty"` // If specified, this field is enabled when the specified bool setting is enabled.
DependsFalse string `json:"depends_false,omitempty"` // If specified, opposite behaviour of DependsTrue.
}
type section struct {
Meta meta `json:"meta"`
Order []string `json:"order"`
Settings map[string]setting `json:"settings"`
}
type settings struct {
Order []string `json:"order"`
Sections map[string]section `json:"sections"`
}

2808
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
{
"name": "jellyfin-accounts",
"name": "jfa-go",
"version": "1.0.0",
"description": "This is only used for grabbing scss build dependencies, and isn't a real package.",
"main": "index.js",
@ -8,24 +8,21 @@
},
"repository": {
"type": "git",
"url": "git+https://github.com/hrfee/jellyfin-accounts.git"
"url": "git+https://github.com/hrfee/jfa-go.git"
},
"author": "",
"license": "ISC",
"bugs": {
"url": "https://github.com/hrfee/jellyfin-accounts/issues"
"url": "https://github.com/hrfee/jfa-go/issues"
},
"homepage": "https://github.com/hrfee/jellyfin-accounts#readme",
"homepage": "https://github.com/hrfee/jfa-go#readme",
"dependencies": {
"@types/jquery": "^3.5.3",
"autoprefixer": "^9.8.5",
"bootstrap": "^5.0.0-alpha3",
"bootstrap4": "npm:bootstrap@^4.5.0",
"clean-css-cli": "^4.3.0",
"a17t": "^0.4.0",
"esbuild": "^0.7.8",
"lodash": "^4.17.19",
"mjml": "^4.6.3",
"postcss-cli": "^7.1.1",
"mjml": "^4.8.0",
"remixicon": "^2.5.0",
"typescript": "^4.0.3"
}
},
"devDependencies": {}
}

View File

@ -1,10 +0,0 @@
## SCSS
* `bs<4/5>-jf.scss` contains the source for the customizations to bootstrap. To customize the UI, you can make modifications to this file and then compile it.
**Note**: It is assumed that Bootstrap 5 is installed in `../../node_modules/bootstrap` relative to itself, and Bootstrap 4 in `../../node_modules/bootstrap4`.
* Compilation requires dev dependencies (`poetry update`), bootstrap and some extra npm packages.
* If you're buildings from source, you can simply run `poetry run task compile-css` before building to automatically get deps and compile CSS.
* If you are creating custom css, run `poetry run task get-npm-deps` to only install the necessary dependencies. Follow along with the commands `scss/compile.py` runs to build your css and then set `custom_css` in your config as the path to your minified css and change the `theme` option to `Custom CSS`.

View File

@ -1,126 +0,0 @@
.pageContainer {
margin: 5% 30% 5% 30%;
}
@media (max-width: 1900px) {
.pageContainer {
margin: 5% 20% 5% 20%;
}
}
@media (max-width: 1100px) {
.pageContainer {
margin: 2%;
}
}
h1 {
/*margin: 20%;*/
margin-bottom: 5%;
}
.tabGroup {
/*margin: 20%;*/
margin-bottom: 5%;
margin-top: 5%;
}
.linkForm {
/*margin: 20%;*/
margin-top: 5%;
margin-bottom: 5%;
}
.contactBox {
/*margin: 20%;*/
margin-top: 5%;
color: grey;
}
.circle {
/*margin-left: 1rem;
width: 1rem;
height: 1rem;
border-radius: 50%;
z-index: 5000;*/
-webkit-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
-moz-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
-o-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190); /* easeInCubic */
}
.smooth-transition {
-webkit-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
-moz-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
-o-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190); /* easeincubic */
}
.rotated {
transform: rotate(180deg);
-webkit-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
-moz-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
-o-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000); /* easeInOutQuart */
}
.not-rotated {
transform: rotate(0deg);
-webkit-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
-moz-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
-o-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000); /* easeInOutQuart */
}
.invite-link {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
width: auto;
}
.settingIcon {
margin-left: 0.2rem;
}
body.modal-open {
overflow: hidden;
}
@mixin white-text {
&, &:visited, &:hover, &:active {
font-style: inherit;
color: inherit;
font-size: inherit;
text-decoration: none;
font-variant: inherit;
font-weight: inherit;
line-height: inherit;
font-family: inherit;
}
}
%white-text {
@include white-text;
}
%link-unstyled {
@include white-text;
background-color: transparent;
margin-right: 0.5rem;
}
.text-button {
@extend %link-unstyled;
}
.text-button:hover {
@extend %link-unstyled;
}
.nl {
@extend %link-unstyled;
}
.nl:hover {
@extend %white-text;
}
.unfocused {
display: none;
}
.text-monospace {
font-family: monospace;
}

View File

@ -1,4 +0,0 @@
@import "../jf-pre.scss";
@import "../../node_modules/bootstrap4/scss/bootstrap";
@import "../jf-post.scss";
@import "../base.scss";

View File

@ -1,19 +0,0 @@
@import "../../node_modules/bootstrap4/scss/bootstrap";
.icon-button {
color: $text-muted;
}
.icon-button:hover {
color: inherit;
}
.icon-button:active {
color: $text-muted;
}
.nav-link:hover {
background-color: $list-group-hover-bg;
}
@import "../base.scss";

View File

@ -1,7 +0,0 @@
.btn-close {
filter: invert(80%);
}
@import "../jf-pre.scss";
@import "../../node_modules/bootstrap/scss/bootstrap";
@import "../jf-post.scss";
@import "../base.scss";

View File

@ -1,19 +0,0 @@
@import "../../node_modules/bootstrap/scss/bootstrap";
.icon-button {
color: $text-muted;
}
.icon-button:hover {
color: inherit;
}
.icon-button:active {
color: $text-muted;
}
.nav-link:hover {
background-color: $list-group-hover-bg;
}
@import "../base.scss";

View File

@ -1,49 +0,0 @@
#!/usr/bin/env python3
import sass
import subprocess
import shutil
import os
from pathlib import Path
def runcmd(cmd):
if os.name == "nt":
return subprocess.check_output(cmd, shell=True)
proc = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE)
return proc.communicate()
local_path = Path(__file__).resolve().parent
for bsv in [d for d in local_path.iterdir() if "bs" in d.name]:
scss = [(bsv / f"{bsv.name}-jf.scss"), (bsv / f"{bsv.name}.scss")]
css = [(bsv / f"{bsv.name}-jf.css"), (bsv / f"{bsv.name}.css")]
min_css = [
(bsv.parents[1] / "data" / "static" / f"{bsv.name}-jf.css"),
(bsv.parents[1] / "data" / "static" / f"{bsv.name}.css"),
]
for i in range(2):
with open(css[i], "w") as f:
f.write(
sass.compile(
filename=str(scss[i].resolve()),
output_style="expanded",
precision=6,
omit_source_map_url=True,
)
)
if css[i].exists():
print(f"{scss[i].name}: Compiled.")
# postcss only excepts forwards slashes? weird.
cssPath = str(css[i].resolve())
if os.name == "nt":
cssPath = cssPath.replace("\\", "/")
runcmd(f"npx postcss {cssPath} --replace --use autoprefixer")
print(f"{scss[i].name}: Prefixed.")
runcmd(
f"npx cleancss --level 1 --format breakWith=lf --output {str(min_css[i].resolve())} {str(css[i].resolve())}"
)
if min_css[i].exists():
print(
f"{scss[i].name}: Minified and copied to {str(min_css[i].resolve())}."
)

View File

@ -1,41 +0,0 @@
.btn-primary, .btn-outline-primary:hover, .btn-outline-primary:active {
color: $jf-text-bold;
}
.close {
color: $jf-text-secondary;
}
.close:hover, .close:active {
color: $jf-text-primary;
}
.icon-button {
color: $text-muted;
}
.icon-button:hover {
color: $jf-text-bold;
}
.icon-button:active {
color: $text-muted;
}
.text-bright {
color: $jf-text-bold;
}
.list-group-item-danger {
color: $jf-text-bold;
background-color: $danger;
}
.list-group-item-success {
color: $jf-text-bold;
background-color: $success;
}
.nav-link:hover {
background-color: $jf-blue-hover;
}

View File

@ -1,101 +0,0 @@
$jf-blue: rgb(0, 164, 220);
$jf-blue-hover: rgba(0, 164, 220, 0.2);
$jf-blue-focus: rgb(12, 176, 232);
$jf-blue-light: #4bb3dd;
$jf-red: rgb(204, 0, 0);
$jf-red-light: #e12026;
$jf-yellower: #ffc107;
$jf-yellow: #e1b222;
$jf-orange: #ff870f;
$jf-green: #6fbd45;
$jf-green-dark: #008040;
$jf-black: #101010; // 16 16 16
$jf-gray-90: #202020; // 32 32 32
$jf-gray-80: #242424; // jf-card 36 36 36
$jf-gray-70: #292929; // jf-input 41 41 41
$jf-gray-60: #303030; // jf-button 48 48 48
$jf-gray-50: #383838; // jf-button-focus 56 56 56
$jf-text-bold: rgba(255, 255, 255, 0.87);
$jf-text-primary: rgba(255, 255, 255, 0.8);
$jf-text-secondary: rgb(153, 153, 153);
$primary: $jf-blue;
$secondary: $jf-gray-50;
$success: $jf-green-dark;
$danger: $jf-red-light;
$light: $jf-text-primary;
$dark: $jf-gray-90;
$info: $jf-yellow;
$warning: $jf-yellower;
$enable-gradients: false;
$enable-shadows: false;
$enable-rounded: false;
$body-bg: $jf-black;
$body-color: $jf-text-primary;
$border-color: $jf-gray-60;
$component-active-color: $jf-text-bold;
$component-active-bg: $jf-blue-focus;
$text-muted: $jf-text-secondary;
$link-color: $jf-blue-focus;
$btn-link-disabled-color: $jf-text-secondary;
$input-bg: $jf-gray-90;
$input-color: $jf-text-primary;
$input-focus-bg: $jf-gray-60;
$input-focus-border-color: $jf-blue-focus;
$input-disabled-bg: $jf-gray-70;
input:disabled {
color: $text-muted;
}
$input-border-color: $jf-gray-60;
$input-placeholder-color: $text-muted;
$form-check-input-bg: $jf-gray-60;
$form-check-input-border: $jf-gray-50;
$form-check-input-checked-color: $jf-blue-focus;
$form-check-input-checked-bg-color: $jf-blue-hover;
$input-group-addon-bg: $input-bg;
$form-select-disabled-color: $jf-text-secondary;
$form-select-disabled-bg: $input-disabled-bg;
$form-select-indicator-color: $jf-gray-50;
$card-bg: $jf-gray-80;
$card-border-color: null;
$tooltip-color: $jf-text-bold;
$tooltip-bg: $jf-gray-50;
$modal-content-bg: $jf-gray-80;
$modal-content-border-color: $jf-gray-50;
$modal-header-border-color: null;
$modal-footer-border-color: null;
$list-group-bg: $card-bg;
$list-group-border-color: $jf-gray-50;
$list-group-hover-bg: $jf-blue-hover;
$list-group-active-bg: $jf-blue-focus;
$list-group-action-color: $jf-text-primary;
$list-group-action-hover-color: $jf-text-bold;
$list-group-action-active-color: $jf-text-bold;
$list-group-action-active-bg: $jf-blue-focus;
// idk why but i had to put these above and below the import
.list-group-item-danger {
color: $jf-text-bold;
background-color: $danger;
}
.list-group-item-success {
color: $jf-text-bold;
background-color: $success;
}
// @import "../../node_modules/bootstrap/scss/bootstrap";

View File

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

View File

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

283
static/banner.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 58 KiB

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

Before

Width:  |  Height:  |  Size: 768 B

After

Width:  |  Height:  |  Size: 768 B

View File

@ -1,196 +0,0 @@
import { checkCheckboxes, populateUsers, populateRadios, changeEmail, validateEmail } from "./modules/accounts.js";
import { _post, _get, _delete, rmAttr, addAttr, createEl } from "./modules/common.js";
import { populateProfiles } from "./modules/settings.js";
import { Focus, Unfocus, storeDefaults } from "./modules/admin.js";
interface aWindow extends Window {
changeEmail(icon: HTMLElement, id: string): void;
}
declare var window: aWindow;
window.changeEmail = changeEmail;
(<HTMLInputElement>document.getElementById('selectAll')).onclick = function (): void {
const checkboxes: NodeListOf<HTMLInputElement> = document.getElementById('accountsList').querySelectorAll('input[type=checkbox]');
for (let i = 0; i < checkboxes.length; i++) {
checkboxes[i].checked = (<HTMLInputElement>this).checked;
}
checkCheckboxes();
};
(<HTMLInputElement>document.getElementById('deleteModalNotify')).onclick = function (): void {
const textbox: HTMLElement = document.getElementById('deleteModalReasonBox');
if ((<HTMLInputElement>this).checked) {
Focus(textbox);
} else {
Unfocus(textbox);
}
};
(<HTMLButtonElement>document.getElementById('accountsTabDelete')).onclick = function (): void {
const deleteButton = this as HTMLButtonElement;
const checkboxes: NodeListOf<HTMLInputElement> = document.getElementById('accountsList').querySelectorAll('input[type=checkbox]:checked');
let selected: Array<string> = new Array(checkboxes.length);
for (let i = 0; i < checkboxes.length; i++) {
selected[i] = checkboxes[i].id.replace("select_", "");
}
let title = " user";
let msg = "Notify user";
if (selected.length > 1) {
title += "s";
msg += "s";
}
title = `Delete ${selected.length} ${title}`;
msg += " of account deletion";
document.getElementById('deleteModalTitle').textContent = title;
const dmNotify = document.getElementById('deleteModalNotify') as HTMLInputElement;
dmNotify.checked = false;
document.getElementById('deleteModalNotifyLabel').textContent = msg;
const dmReason = document.getElementById('deleteModalReason') as HTMLTextAreaElement;
dmReason.value = '';
Unfocus(document.getElementById('deleteModalReasonBox'));
const dmSend = document.getElementById('deleteModalSend') as HTMLButtonElement;
dmSend.textContent = 'Delete';
dmSend.onclick = function (): void {
const button = this as HTMLButtonElement;
const send = {
'users': selected,
'notify': dmNotify.checked,
'reason': dmReason.value
};
_delete("/users", send, function (): void {
if (this.readyState == 4) {
if (this.status == 500) {
if ("error" in this.reponse) {
button.textContent = 'Failed';
} else {
button.textContent = 'Partial fail (check console)';
console.log(this.response);
}
setTimeout((): void => {
Unfocus(deleteButton);
window.Modals.delete.hide();
}, 4000);
} else {
Unfocus(deleteButton);
window.Modals.delete.hide()
}
populateUsers();
checkCheckboxes();
}
});
};
window.Modals.delete.show();
};
(<HTMLInputElement>document.getElementById('selectAll')).checked = false;
(<HTMLButtonElement>document.getElementById('accountsTabSetDefaults')).onclick = function (): void {
const checkboxes: NodeListOf<HTMLInputElement> = document.getElementById('accountsList').querySelectorAll('input[type=checkbox]:checked');
let userIDs: Array<string> = new Array(checkboxes.length);
for (let i = 0; i < checkboxes.length; i++){
userIDs[i] = checkboxes[i].id.replace("select_", "");
}
if (userIDs.length == 0) {
return;
}
populateRadios();
let userString = 'user';
if (userIDs.length > 1) {
userString += "s";
}
populateProfiles(true);
const profileSelect = document.getElementById('profileSelect') as HTMLSelectElement;
profileSelect.textContent = '';
for (let i = 0; i < window.availableProfiles.length; i++) {
profileSelect.innerHTML += `
<option value="${window.availableProfiles[i]}" ${(i == 0) ? "selected" : ""}>${window.availableProfiles[i]}</option>
`;
}
document.getElementById('defaultsTitle').textContent = `Apply settings to ${userIDs.length} ${userString}`;
document.getElementById('userDefaultsDescription').textContent = `
Apply settings from an existing profile or source settings from a user.
`;
document.getElementById('storeHomescreenLabel').textContent = `Apply homescreen layout`;
Focus(document.getElementById('defaultsSourceSection'));
(<HTMLSelectElement>document.getElementById('defaultsSource')).value = 'profile';
Focus(document.getElementById('profileSelectBox'));
Unfocus(document.getElementById('defaultUserRadiosBox'));
Unfocus(document.getElementById('newProfileBox'));
document.getElementById('storeDefaults').onclick = (): void => storeDefaults(userIDs);
window.Modals.userDefaults.show();
};
(<HTMLSelectElement>document.getElementById('defaultsSource')).addEventListener('change', function (): void {
const radios = document.getElementById('defaultUserRadiosBox');
const profileBox = document.getElementById('profileSelectBox');
if (this.value == 'profile') {
Unfocus(radios);
Focus(profileBox);
} else {
Unfocus(profileBox);
Focus(radios);
}
});
(<HTMLButtonElement>document.getElementById('newUserCreate')).onclick = function (): void {
const button = this as HTMLButtonElement;
const ogText = button.textContent;
button.innerHTML = `
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Creating...
`;
const email: string = (<HTMLInputElement>document.getElementById('newUserEmail')).value;
var username: string = email;
if (document.getElementById('newUserName') != null) {
username = (<HTMLInputElement>document.getElementById('newUserName')).value;
}
const password: string = (<HTMLInputElement>document.getElementById('newUserPassword')).value;
if (!validateEmail(email) && email != "") {
return;
}
const send = {
'username': username,
'password': password,
'email': email
};
_post("/users", send, function (): void {
if (this.readyState == 4) {
rmAttr(button, 'btn-primary');
if (this.status == 200) {
addAttr(button, 'btn-success');
button.textContent = 'Success';
setTimeout((): void => {
rmAttr(button, 'btn-success');
addAttr(button, 'btn-primary');
button.textContent = ogText;
window.Modals.newUser.hide();
}, 1000);
populateUsers();
} else {
addAttr(button, 'btn-danger');
if ("error" in this.response) {
button.textContent = this.response["error"];
} else {
button.textContent = 'Failed';
}
setTimeout((): void => {
rmAttr(button, 'btn-danger');
addAttr(button, 'btn-primary');
button.textContent = ogText;
}, 2000);
populateUsers();
}
}
});
};
(<HTMLButtonElement>document.getElementById('accountsTabAddUser')).onclick = function (): void {
(<HTMLInputElement>document.getElementById('newUserEmail')).value = '';
(<HTMLInputElement>document.getElementById('newUserPassword')).value = '';
if (document.getElementById('newUserName') != null) {
(<HTMLInputElement>document.getElementById('newUserName')).value = '';
}
window.Modals.newUser.show();
};

View File

@ -1,124 +1,99 @@
import { serializeForm, rmAttr, addAttr, _get, _post, _delete } from "./modules/common.js";
import { Focus, Unfocus } from "./modules/admin.js";
import { toggleCSS } from "./modules/animation.js";
import { populateUsers, checkCheckboxes } from "./modules/accounts.js";
import { generateInvites, addOptions, checkDuration } from "./modules/invites.js";
import { showSetting, openSettings } from "./modules/settings.js";
import { BS4 } from "./modules/bs4.js";
import { BS5 } from "./modules/bs5.js";
import "./accounts.js";
import "./settings.js";
import { toggleTheme, loadTheme } from "./modules/theme.js";
import { Modal } from "./modules/modal.js";
import { Tabs } from "./modules/tabs.js";
import { inviteList, createInvite } from "./modules/invites.js";
import { accountsList } from "./modules/accounts.js";
import { settingsList } from "./modules/settings.js";
import { ProfileEditor } from "./modules/profiles.js";
import { _post, notificationBox, whichAnimationEvent, toggleLoader } from "./modules/common.js";
interface aWindow extends Window {
toClipboard(str: string): void;
}
loadTheme();
(document.getElementById('button-theme') as HTMLSpanElement).onclick = toggleTheme;
declare var window: aWindow;
window.animationEvent = whichAnimationEvent();
interface TabSwitcher {
els: Array<HTMLDivElement>;
tabButtons: Array<HTMLAnchorElement>;
focus: (el: number) => void;
invites: () => void;
accounts: () => void;
settings: () => void;
}
window.token = "";
const tabs: TabSwitcher = {
els: [document.getElementById('invitesTab') as HTMLDivElement, document.getElementById('accountsTab') as HTMLDivElement, document.getElementById('settingsTab') as HTMLDivElement],
tabButtons: [document.getElementById('invitesTabButton') as HTMLAnchorElement, document.getElementById('accountsTabButton') as HTMLAnchorElement, document.getElementById('settingsTabButton') as HTMLAnchorElement],
focus: (el: number): void => {
for (let i = 0; i < tabs.els.length; i++) {
if (i == el) {
Focus(tabs.els[i]);
addAttr(tabs.tabButtons[i], "active");
} else {
Unfocus(tabs.els[i]);
rmAttr(tabs.tabButtons[i], "active");
}
}
},
invites: (): void => tabs.focus(0),
accounts: (): void => {
populateUsers();
(document.getElementById('selectAll') as HTMLInputElement).checked = false;
checkCheckboxes();
tabs.focus(1);
},
settings: (): void => openSettings(document.getElementById('settingsSections'), document.getElementById('settingsContent'), (): void => {
window.BS.triggerTooltips();
showSetting("ui");
tabs.focus(2);
})
};
window.availableProfiles = window.availableProfiles || [];
window.bsVersion = window.bs5 ? 5 : 4
// load modals
(() => {
window.modals = {} as Modals;
if (window.bs5) {
window.BS = new BS5;
} else {
window.BS = new BS4;
window.BS.Compat();
}
window.modals.login = new Modal(document.getElementById('modal-login'), true);
window.Modals = {} as BSModals;
window.modals.addUser = new Modal(document.getElementById('modal-add-user'));
window.Modals.login = window.BS.newModal('login');
window.Modals.userDefaults = window.BS.newModal('userDefaults');
window.Modals.users = window.BS.newModal('users');
window.Modals.restart = window.BS.newModal('restartModal');
window.Modals.refresh = window.BS.newModal('refreshModal');
window.Modals.about = window.BS.newModal('aboutModal');
window.Modals.delete = window.BS.newModal('deleteModal');
window.Modals.newUser = window.BS.newModal('newUserModal');
window.modals.about = new Modal(document.getElementById('modal-about'));
(document.getElementById('setting-about') as HTMLSpanElement).onclick = window.modals.about.toggle;
tabs.tabButtons[0].onclick = tabs.invites;
tabs.tabButtons[1].onclick = tabs.accounts;
tabs.tabButtons[2].onclick = tabs.settings;
window.modals.modifyUser = new Modal(document.getElementById('modal-modify-user'));
tabs.invites();
window.modals.deleteUser = new Modal(document.getElementById('modal-delete-user'));
// Predefined colors for the theme button.
var buttonColor: string = "custom";
if (window.cssFile.includes("jf")) {
buttonColor = "rgb(255,255,255)";
} else if (window.cssFile == ("bs" + window.bsVersion + ".css")) {
buttonColor = "rgb(16,16,16)";
}
window.modals.settingsRestart = new Modal(document.getElementById('modal-restart'));
if (buttonColor != "custom") {
const switchButton = document.createElement('button') as HTMLButtonElement;
switchButton.classList.add('btn', 'btn-secondary');
switchButton.innerHTML = `
Theme
<i class="fa fa-circle circle" style="color: ${buttonColor}; margin-left: 0.4rem;" id="fakeButton"></i>
`;
switchButton.onclick = (): void => toggleCSS(document.getElementById('fakeButton'));
document.getElementById('headerButtons').appendChild(switchButton);
}
window.modals.settingsRefresh = new Modal(document.getElementById('modal-refresh'));
var availableProfiles: Array<string>;
window.modals.ombiDefaults = new Modal(document.getElementById('modal-ombi-defaults'));
document.getElementById('form-ombi-defaults').addEventListener('submit', window.modals.ombiDefaults.close);
window["token"] = "";
window.modals.profiles = new Modal(document.getElementById("modal-user-profiles"));
window.toClipboard = (str: string): void => {
const el = document.createElement('textarea') as HTMLTextAreaElement;
el.value = str;
el.readOnly = true;
el.style.position = "absolute";
el.style.left = "-9999px";
document.body.appendChild(el);
const selected = document.getSelection().rangeCount > 0 ? document.getSelection().getRangeAt(0) : false;
el.select();
document.execCommand("copy");
document.body.removeChild(el);
if (selected) {
document.getSelection().removeAllRanges();
document.getSelection().addRange(selected);
window.modals.addProfile = new Modal(document.getElementById("modal-add-profile"));
})();
var inviteCreator = new createInvite();
var accounts = new accountsList();
window.invites = new inviteList();
var settings = new settingsList();
var profiles = new ProfileEditor();
window.notifications = new notificationBox(document.getElementById('notification-box') as HTMLDivElement, 5);
/*const modifySettingsSource = function () {
const profile = document.getElementById('radio-use-profile') as HTMLInputElement;
const user = document.getElementById('radio-use-user') as HTMLInputElement;
const profileSelect = document.getElementById('modify-user-profiles') as HTMLDivElement;
const userSelect = document.getElementById('modify-user-users') as HTMLDivElement;
(user.nextElementSibling as HTMLSpanElement).classList.toggle('!normal');
(user.nextElementSibling as HTMLSpanElement).classList.toggle('!high');
(profile.nextElementSibling as HTMLSpanElement).classList.toggle('!normal');
(profile.nextElementSibling as HTMLSpanElement).classList.toggle('!high');
profileSelect.classList.toggle('unfocused');
userSelect.classList.toggle('unfocused');
}*/
// load tabs
window.tabs = new Tabs();
window.tabs.addTab("invites", null, window.invites.reload);
window.tabs.addTab("accounts", null, accounts.reload);
window.tabs.addTab("settings", null, settings.reload);
for (let tab of ["invites", "accounts", "settings"]) {
if (window.location.pathname == "/" + tab) {
window.tabs.switch(tab, true);
}
}
function login(username: string, password: string, modal: boolean, button?: HTMLButtonElement, run?: (arg0: number) => void): void {
if (window.location.pathname == "/") {
window.tabs.switch("invites", true);
}
document.addEventListener("tab-change", (event: CustomEvent) => {
let tab = "/" + event.detail;
if (tab == "/invites") {
if (window.location.pathname == "/") {
tab = "/";
} else { tab = "../"; }
}
window.history.replaceState("", "Admin - jfa-go", tab);
});
function login(username: string, password: string, run?: (state?: number) => void) {
const req = new XMLHttpRequest();
req.responseType = 'json';
let url = window.URLBase;
@ -135,77 +110,63 @@ function login(username: string, password: string, modal: boolean, button?: HTML
req.onreadystatechange = function (): void {
if (this.readyState == 4) {
if (this.status != 200) {
let errorMsg = this.response["error"];
let errorMsg = "Connection error.";
if (this.response) {
errorMsg = this.response["error"];
}
if (!errorMsg) {
errorMsg = "Unknown error";
}
if (modal) {
button.disabled = false;
button.textContent = errorMsg;
addAttr(button, "btn-danger");
rmAttr(button, "btn-primary");
setTimeout((): void => {
addAttr(button, "btn-primary");
rmAttr(button, "btn-danger");
button.textContent = "Login";
}, 4000);
if (!refresh) {
window.notifications.customError("loginError", errorMsg);
} else {
window.Modals.login.show();
window.modals.login.show();
}
} else {
const data = this.response;
window.token = data["token"];
generateInvites();
setInterval((): void => generateInvites(), 60 * 1000);
addOptions(30, document.getElementById('days') as HTMLSelectElement);
addOptions(24, document.getElementById('hours') as HTMLSelectElement);
const minutes = document.getElementById('minutes') as HTMLSelectElement;
addOptions(59, minutes);
minutes.value = "30";
checkDuration();
if (modal) {
window.Modals.login.hide();
window.modals.login.close();
setInterval(() => { window.invites.reload(); accounts.reload(); }, 30*1000);
const currentTab = window.tabs.current;
switch (currentTab) {
case "invites":
window.invites.reload();
break;
case "accounts":
accounts.reload();
break;
case "settings":
settings.reload();
break;
}
Focus(document.getElementById('logoutButton'));
}
if (run) {
run(+this.status);
document.getElementById("logout-button").classList.remove("unfocused");
}
if (run) { run(+this.status); }
}
};
req.send();
}
(document.getElementById('loginForm') as HTMLFormElement).onsubmit = function (): boolean {
window.token = "";
const details = serializeForm('loginForm');
const button = document.getElementById('loginSubmit') as HTMLButtonElement;
addAttr(button, "btn-primary");
rmAttr(button, "btn-danger");
button.disabled = true;
button.innerHTML = `
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>
Loading...`;
login(details["username"], details["password"], true, button);
return false;
(document.getElementById('form-login') as HTMLFormElement).onsubmit = (event: SubmitEvent) => {
event.preventDefault();
const button = (event.target as HTMLElement).querySelector(".submit") as HTMLSpanElement;
const username = (document.getElementById("login-user") as HTMLInputElement).value;
const password = (document.getElementById("login-password") as HTMLInputElement).value;
if (!username || !password) {
window.notifications.customError("loginError", "The username and/or password were left blank.");
return;
}
toggleLoader(button);
login(username, password, () => toggleLoader(button));
};
generateInvites(true);
login("", "");
login("", "", false, null, (status: number): void => {
if (!(status == 200 || status == 204)) {
window.Modals.login.show();
(document.getElementById('logout-button') as HTMLButtonElement).onclick = () => _post("/logout", null, (req: XMLHttpRequest): boolean => {
if (req.readyState == 4 && req.status == 200) {
window.token = "";
location.reload();
return false;
}
});
(document.getElementById('logoutButton') as HTMLButtonElement).onclick = function (): void {
_post("/logout", null, function (): boolean {
if (this.readyState == 4 && this.status == 200) {
window.token = "";
location.reload();
return false;
}
});
};

View File

@ -1,25 +1,29 @@
import { serializeForm, _post, _get, _delete, addAttr, rmAttr } from "./modules/common.js";
import { BS5 } from "./modules/bs5.js";
import { BS4 } from "./modules/bs4.js";
import { Modal } from "./modules/modal.js";
import { _post, toggleLoader } from "./modules/common.js";
interface formWindow extends Window {
usernameEnabled: boolean;
validationStrings: pwValStrings;
checkPassword(): void;
invalidPassword: string;
modal: Modal;
}
declare var window: formWindow;
interface pwValString {
singular: string;
plural: string;
}
interface pwValStrings {
length, uppercase, lowercase, number, special: pwValString;
length: pwValString;
uppercase: pwValString;
lowercase: pwValString;
number: pwValString;
special: pwValString;
[ type: string ]: pwValString;
}
window.modal = new Modal(document.getElementById("modal-success"));
declare var window: formWindow;
var defaultPwValStrings: pwValStrings = {
length: {
singular: "Must have at least {n} character",
@ -43,111 +47,132 @@ var defaultPwValStrings: pwValStrings = {
}
}
const toggleSpinner = (ogText?: string): string => {
const submitButton = document.getElementById('submitButton') as HTMLButtonElement;
if (document.getElementById('createAccountSpinner')) {
submitButton.innerHTML = ogText ? ogText : `<span>Create Account</span>`;
submitButton.disabled = false;
return "";
const form = document.getElementById("form-create") as HTMLFormElement;
const submitButton = form.querySelector("input[type=submit]") as HTMLInputElement;
const submitSpan = form.querySelector("span.submit") as HTMLSpanElement;
let usernameField = document.getElementById("create-username") as HTMLInputElement;
const emailField = document.getElementById("create-email") as HTMLInputElement;
if (!window.usernameEnabled) { usernameField.parentElement.remove(); usernameField = emailField; }
const passwordField = document.getElementById("create-password") as HTMLInputElement;
const rePasswordField = document.getElementById("create-reenter-password") as HTMLInputElement;
const checkPasswords = () => {
if (passwordField.value != rePasswordField.value) {
rePasswordField.setCustomValidity(window.invalidPassword);
submitButton.disabled = true;
submitSpan.setAttribute("disabled", "");
} else {
let ogText = submitButton.innerHTML;
submitButton.innerHTML = `
<span id="createAccountSpinner" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>Creating...
`;
return ogText;
rePasswordField.setCustomValidity("");
submitButton.disabled = false;
submitSpan.removeAttribute("disabled");
}
};
rePasswordField.addEventListener("keyup", checkPasswords);
passwordField.addEventListener("keyup", checkPasswords);
for (let key in window.validationStrings) {
if (window.validationStrings[key].singular == "" || !(window.validationStrings[key].plural.includes("{n}"))) {
window.validationStrings[key].singular = defaultPwValStrings[key].singular;
}
if (window.validationStrings[key].plural == "" || !(window.validationStrings[key].plural.includes("{n}"))) {
window.validationStrings[key].plural = defaultPwValStrings[key].plural;
}
let el = document.getElementById(key) as HTMLUListElement;
if (el) {
const min: number = +el.getAttribute("min");
let text = "";
if (min == 1) {
text = window.validationStrings[key].singular.replace("{n}", "1");
} else {
text = window.validationStrings[key].plural.replace("{n}", min.toString());
}
(document.getElementById(key).children[0] as HTMLDivElement).textContent = text;
}
interface respDTO {
[ type: string ]: boolean;
}
window.BS = window.bs5 ? new BS5 : new BS4;
var successBox: BSModal = window.BS.newModal('successBox');;
interface sendDTO {
code: string;
email: string;
username: string;
password: string;
}
var code = window.location.href.split('/').pop();
(document.getElementById('accountForm') as HTMLFormElement).addEventListener('submit', (event: any): boolean => {
const create = (event: SubmitEvent) => {
event.preventDefault();
const el = document.getElementById('errorMessage');
if (el) {
el.remove();
}
const ogText = toggleSpinner();
let send: Object = serializeForm('accountForm');
send["code"] = code;
if (!window.usernameEnabled) {
send["email"] = send["username"];
}
_post("/newUser", send, function (): void {
if (this.readyState == 4) {
toggleSpinner(ogText);
let data: Object = this.response;
const errorGiven = ("error" in data)
if (errorGiven || data["success"] === false) {
let errorMessage = "Unknown Error";
if (errorGiven && errorGiven != true) {
errorMessage = data["error"];
}
document.getElementById('errorBox').innerHTML += `
<button id="errorMessage" class="btn btn-outline-danger" disabled>${errorMessage}</button>
`;
toggleLoader(submitSpan);
let send: sendDTO = {
code: window.location.href.split('/').pop(),
username: usernameField.value,
email: emailField.value,
password: passwordField.value
};
_post("/newUser", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
let vals = JSON.parse(req.response) as respDTO;
let valid = true;
for (let type in vals) {
if (requirements[type]) { requirements[type].valid = vals[type]; }
if (!vals[type]) { valid = false; }
}
toggleLoader(submitSpan);
if (req.status == 200 && valid) {
window.modal.show();
} else {
let valid = true;
for (let key in data) {
if (data.hasOwnProperty(key)) {
const criterion = document.getElementById(key);
if (criterion) {
if (data[key] === false) {
valid = false;
addAttr(criterion, "list-group-item-danger");
rmAttr(criterion, "list-group-item-success");
} else {
addAttr(criterion, "list-group-item-success");
rmAttr(criterion, "list-group-item-danger");
}
}
}
}
if (valid) {
successBox.show();
}
submitSpan.classList.add("~critical");
submitSpan.classList.remove("~urge");
setTimeout(() => {
submitSpan.classList.add("~urge");
submitSpan.classList.remove("~critical");
}, 1000);
}
}
}, true);
return false;
});
});
};
window.checkPassword = (): void => {
const entry = document.getElementById('inputPassword') as HTMLInputElement;
if (entry.value != "") {
const reentry = document.getElementById('reInputPassword') as HTMLInputElement;
const identical = (entry.value == reentry.value);
const submitButton = document.getElementById('submitButton') as HTMLButtonElement;
if (identical) {
reentry.setCustomValidity('');
rmAttr(submitButton, "btn-outline-danger");
addAttr(submitButton, "btn-outline-primary");
form.onsubmit = create;
class Requirement {
private _name: string;
private _minCount: number;
private _content: HTMLSpanElement;
private _valid: HTMLSpanElement;
private _li: HTMLLIElement;
get valid(): boolean { return this._valid.classList.contains("~positive"); }
set valid(state: boolean) {
if (state) {
this._valid.classList.add("~positive");
this._valid.classList.remove("~critical");
this._valid.innerHTML = `<i class="icon ri-check-line" title="valid"></i>`;
} else {
reentry.setCustomValidity(window.invalidPassword);
addAttr(submitButton, "btn-outline-danger");
rmAttr(submitButton, "btn-outline-primary");
this._valid.classList.add("~critical");
this._valid.classList.remove("~positive");
this._valid.innerHTML = `<i class="icon ri-close-line" title="invalid"></i>`;
}
}
constructor(name: string, el: HTMLLIElement) {
this._name = name;
this._li = el;
this._content = this._li.querySelector("span.requirement-content") as HTMLSpanElement;
this._valid = this._li.querySelector("span.requirement-valid") as HTMLSpanElement;
this.valid = false;
this._minCount = +this._li.getAttribute("min");
let text = "";
if (this._minCount == 1) {
text = window.validationStrings[this._name].singular.replace("{n}", "1");
} else {
text = window.validationStrings[this._name].plural.replace("{n}", ""+this._minCount);
}
this._content.textContent = text;
}
}
const testStrings = (f: pwValString): boolean => {
const testString = (s: string): boolean => {
if (s == "" || !s.includes("{n}")) { return false; }
return true;
}
return testString(f.singular) && testString(f.plural);
}
var requirements: { [category: string]: Requirement} = {};
if (!window.validationStrings) {
window.validationStrings = defaultPwValStrings;
} else {
for (let category in window.validationStrings) {
if (!testStrings(window.validationStrings[category])) {
window.validationStrings[category] = defaultPwValStrings[category];
}
const el = document.getElementById("requirement-" + category);
if (el) {
requirements[category] = new Requirement(category, el as HTMLLIElement);
}
}
}

View File

@ -1,81 +0,0 @@
import { serializeForm, rmAttr, addAttr, _get, _post, _delete } from "./modules/common.js";
import { generateInvites, checkDuration } from "./modules/invites.js";
interface aWindow extends Window {
setProfile(el: HTMLElement): void;
}
declare var window: aWindow;
function fixCheckboxes(): void {
const send_to_address: Array<HTMLInputElement> = [document.getElementById('send_to_address') as HTMLInputElement, document.getElementById('send_to_address_enabled') as HTMLInputElement];
if (send_to_address[0] != null) {
send_to_address[0].disabled = !send_to_address[1].checked;
}
const multiUseEnabled = document.getElementById('multiUseEnabled') as HTMLInputElement;
const multiUseCount = document.getElementById('multiUseCount') as HTMLInputElement;
const noUseLimit = document.getElementById('noUseLimit') as HTMLInputElement;
multiUseCount.disabled = !multiUseEnabled.checked;
noUseLimit.checked = false;
noUseLimit.disabled = !multiUseEnabled.checked;
}
fixCheckboxes();
(document.getElementById('inviteForm') as HTMLFormElement).onsubmit = function (): boolean {
const button = document.getElementById('generateSubmit') as HTMLButtonElement;
button.disabled = true;
button.innerHTML = `
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>
Loading...`;
let send = serializeForm('inviteForm');
send["remaining-uses"] = +send["remaining-uses"];
if (!send['multiple-uses'] || send['no-limit']) {
delete send['remaining-uses'];
}
if (send["profile"] == "NoProfile") {
send["profile"] = "";
}
const sendToAddress: any = document.getElementById('send_to_address');
const sendToAddressEnabled: any = document.getElementById('send_to_address_enabled');
if (sendToAddress && sendToAddressEnabled) {
send['email'] = send['send_to_address'];
delete send['send_to_address'];
delete send['send_to_address_enabled'];
}
_post("/invites", send, function (): void {
if (this.readyState == 4) {
button.textContent = 'Generate';
button.disabled = false;
generateInvites();
}
});
return false;
};
window.BS.triggerTooltips();
window.setProfile= (select: HTMLSelectElement): void => {
if (!select.value) {
return;
}
let val = select.value;
if (select.value == "NoProfile") {
val = ""
}
const invite = select.id.replace("profile_", "");
const send = {
"invite": invite,
"profile": val
};
_post("/invites/profile", send, function (): void {
if (this.readyState == 4 && this.status != 200) {
generateInvites();
}
});
}
const nE: Array<string> = ["days", "hours", "minutes"];
for (const i in nE) {
document.getElementById(nE[i]).addEventListener("change", checkDuration);
}

View File

@ -1,163 +1,409 @@
import { _get, _post, _delete, createEl } from "../modules/common.js";
import { Focus, Unfocus } from "../modules/admin.js";
import { _get, _post, _delete, toggleLoader } from "../modules/common.js";
interface aWindow extends Window {
checkCheckboxes: () => void;
interface User {
id: string;
name: string;
email: string | undefined;
last_active: string;
admin: boolean;
}
declare var window: aWindow;
class user implements User {
private _row: HTMLTableRowElement;
private _check: HTMLInputElement;
private _username: HTMLSpanElement;
private _admin: HTMLSpanElement;
private _email: HTMLInputElement;
private _emailAddress: string;
private _emailEditButton: HTMLElement;
private _lastActive: HTMLTableDataCellElement;
id: string;
private _selected: boolean;
export const validateEmail = (email: string): boolean => /\S+@\S+\.\S+/.test(email);
get selected(): boolean { return this._selected; }
set selected(state: boolean) {
this._selected = state;
this._check.checked = state;
state ? document.dispatchEvent(this._checkEvent) : document.dispatchEvent(this._uncheckEvent);
}
export const checkCheckboxes = (): void => {
const defaultsButton = document.getElementById('accountsTabSetDefaults');
const deleteButton = document.getElementById('accountsTabDelete');
const checkboxes: NodeListOf<HTMLInputElement> = document.getElementById('accountsList').querySelectorAll('input[type=checkbox]:checked');
let checked = checkboxes.length;
if (checked == 0) {
Unfocus(defaultsButton);
Unfocus(deleteButton);
} else {
Focus(defaultsButton);
Focus(deleteButton);
if (checked == 1) {
deleteButton.textContent = 'Delete User';
get name(): string { return this._username.textContent; }
set name(value: string) { this._username.textContent = value; }
get admin(): boolean { return this._admin.classList.contains("chip"); }
set admin(state: boolean) {
if (state) {
this._admin.classList.add("chip", "~info", "ml-1");
this._admin.textContent = "Admin";
} else {
deleteButton.textContent = 'Delete Users';
this._admin.classList.remove("chip", "~info", "ml-1");
this._admin.textContent = ""
}
}
}
window.checkCheckboxes = checkCheckboxes;
get email(): string { return this._emailAddress; }
set email(value: string) { this._email.value = value; this._emailAddress = value; }
get last_active(): string { return this._lastActive.textContent; }
set last_active(value: string) { this._lastActive.textContent = value; }
export function changeEmail(icon: HTMLElement, id: string): void {
const iconContent = icon.outerHTML;
icon.setAttribute('class', '');
const entry = icon.nextElementSibling as HTMLInputElement;
const ogEmail = entry.value;
entry.readOnly = false;
entry.classList.remove('form-control-plaintext');
entry.classList.add('form-control');
if (ogEmail == "") {
entry.placeholder = 'Address';
private _checkEvent = new CustomEvent("accountCheckEvent");
private _uncheckEvent = new CustomEvent("accountUncheckEvent");
constructor(user: User) {
this._row = document.createElement("tr") as HTMLTableRowElement;
this._row.innerHTML = `
<td><input type="checkbox" value=""></td>
<td><span class="accounts-username"></span> <span class="accounts-admin"></span></td>
<td><i class="icon ri-edit-line accounts-email-edit"></i><input type="email" class="input ~neutral !normal stealth-input stealth-input-hidden accounts-email" readonly></td>
<td class="accounts-last-active"></td>
`;
this._check = this._row.querySelector("input[type=checkbox]") as HTMLInputElement;
this._username = this._row.querySelector(".accounts-username") as HTMLSpanElement;
this._admin = this._row.querySelector(".accounts-admin") as HTMLSpanElement;
this._email = this._row.querySelector(".accounts-email") as HTMLInputElement;
this._emailEditButton = this._row.querySelector(".accounts-email-edit") as HTMLElement;
this._lastActive = this._row.querySelector(".accounts-last-active") as HTMLTableDataCellElement;
this._check.onchange = () => { this.selected = this._check.checked; }
const toggleStealthInput = () => {
this._email.classList.toggle("stealth-input-hidden");
this._email.readOnly = !this._email.readOnly;
this._emailEditButton.classList.toggle("ri-check-line");
this._emailEditButton.classList.toggle("ri-edit-line");
};
const outerClickListener = (event: Event) => {
if (!(event.target instanceof HTMLElement && (this._email.contains(event.target) || this._emailEditButton.contains(event.target)))) {
toggleStealthInput();
this.email = this.email;
document.removeEventListener("click", outerClickListener);
}
};
this._emailEditButton.onclick = () => {
if (this._email.classList.contains("stealth-input-hidden")) {
document.addEventListener('click', outerClickListener);
} else {
this._updateEmail();
document.removeEventListener('click', outerClickListener);
}
toggleStealthInput();
};
this.update(user);
}
const tick = createEl(`
<i class="fa fa-check d-inline-block icon-button text-success" style="margin-left: 0.5rem; margin-right: 0.5rem;"></i>
`);
tick.onclick = (): void => {
const newEmail = entry.value;
if (!validateEmail(newEmail) || newEmail == ogEmail) {
return;
}
cross.remove();
const spinner = createEl(`
<div class="spinner-border spinner-border-sm" role="status" style="width: 1rem; height: 1rem; margin-left: 0.5rem;">
<span class="sr-only">Saving...</span>
</div>
`);
tick.replaceWith(spinner);
private _updateEmail = () => {
let oldEmail = this.email;
this.email = this._email.value;
let send = {};
send[id] = newEmail;
_post("/users/emails", send, function (): void {
if (this.readyState == 4) {
if (this.status == 200 || this.status == 204) {
entry.nextElementSibling.remove();
send[this.id] = this.email;
_post("/users/emails", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status == 200) {
window.notifications.customPositive("emailChanged", "Success:", `Changed email address of "${this.name}".`);
} else {
entry.value = ogEmail;
this.email = oldEmail;
window.notifications.customError("emailChanged", `Couldn't change email address of "${this.name}".`);
}
}
});
icon.outerHTML = iconContent;
entry.readOnly = true;
entry.classList.remove('form-control');
entry.classList.add('form-control-plaintext');
entry.placeholder = '';
};
const cross = createEl(`
<i class="fa fa-close d-inline-block icon-button text-danger"></i>
`);
cross.onclick = (): void => {
tick.remove();
cross.remove();
icon.outerHTML = iconContent;
entry.readOnly = true;
entry.classList.remove('form-control');
entry.classList.add('form-control-plaintext');
entry.placeholder = '';
entry.value = ogEmail;
};
icon.parentNode.appendChild(tick);
icon.parentNode.appendChild(cross);
};
export function populateUsers(): void {
const acList = document.getElementById('accountsList');
acList.innerHTML = `
<div class="d-flex align-items-center">
<strong>Getting Users...</strong>
<div class="spinner-border ml-auto" role="status" aria-hidden="true"></div>
</div>
`;
Unfocus(acList.parentNode.querySelector('thead'));
const accountsList = document.createElement('tbody');
accountsList.id = 'accountsList';
const generateEmail = (id: string, name: string, email: string): string => {
let entry: HTMLDivElement = document.createElement('div');
entry.id = 'email_' + id;
let emailValue: string = email;
if (emailValue == undefined) {
emailValue = "";
}
entry.innerHTML = `
<i class="fa fa-edit d-inline-block icon-button" style="margin-right: 2%;" onclick="changeEmail(this, '${id}')"></i>
<input type="email" class="form-control-plaintext form-control-sm text-muted d-inline-block addressText" id="address_${id}" style="width: auto;" value="${emailValue}" readonly>
`;
return entry.outerHTML;
};
const template = (id: string, username: string, email: string, lastActive: string, admin: boolean): string => {
let fci = "form-check-input";
if (window.bsVersion != 5) {
fci = "";
}
return `
<td nowrap="nowrap" class="align-middle" scope="row"><input class="${fci}" type="checkbox" value="" id="select_${id}" onclick="checkCheckboxes();"></td>
<td nowrap="nowrap" class="align-middle">${username}${admin ? '<span style="margin-left: 1rem;" class="badge rounded-pill bg-info text-dark">Admin</span>' : ''}</td>
<td nowrap="nowrap" class="align-middle">${generateEmail(id, name, email)}</td>
<td nowrap="nowrap" class="align-middle">${lastActive}</td>
`;
};
_get("/users", null, function (): void {
if (this.readyState == 4 && this.status == 200) {
window.jfUsers = this.response['users'];
for (const user of window.jfUsers) {
let tr = document.createElement('tr');
tr.innerHTML = template(user['id'], user['name'], user['email'], user['last_active'], user['admin']);
accountsList.appendChild(tr);
}
Focus(acList.parentNode.querySelector('thead'));
acList.replaceWith(accountsList);
}
});
}
export function populateRadios(): void {
const radioList = document.getElementById('defaultUserRadios');
radioList.textContent = '';
let first = true;
for (const i in window.jfUsers) {
const user = window.jfUsers[i];
const radio = document.createElement('div');
radio.classList.add('form-check');
let checked = '';
if (first) {
checked = 'checked';
first = false;
}
radio.innerHTML = `
<input class="form-check-input" type="radio" name="defaultRadios" id="default_${user['id']}" ${checked}>
<label class="form-check-label" for="default_${user['id']}">${user['name']}</label>`;
radioList.appendChild(radio);
}
}
update = (user: User) => {
this.id = user.id;
this.name = user.name;
this.email = user.email || "";
this.last_active = user.last_active;
this.admin = user.admin;
}
asElement = (): HTMLTableRowElement => { return this._row; }
remove = () => {
if (this.selected) {
document.dispatchEvent(this._uncheckEvent);
}
this._row.remove();
}
}
export class accountsList {
private _table = document.getElementById("accounts-list") as HTMLTableSectionElement;
private _addUserButton = document.getElementById("accounts-add-user") as HTMLSpanElement;
private _deleteUser = document.getElementById("accounts-delete-user") as HTMLSpanElement;
private _deleteNotify = document.getElementById("delete-user-notify") as HTMLInputElement;
private _deleteReason = document.getElementById("textarea-delete-user") as HTMLTextAreaElement;
private _modifySettings = document.getElementById("accounts-modify-user") as HTMLSpanElement;
private _modifySettingsProfile = document.getElementById("radio-use-profile") as HTMLInputElement;
private _modifySettingsUser = document.getElementById("radio-use-user") as HTMLInputElement;
private _profileSelect = document.getElementById("modify-user-profiles") as HTMLSelectElement;
private _userSelect = document.getElementById("modify-user-users") as HTMLSelectElement;
private _selectAll = document.getElementById("accounts-select-all") as HTMLInputElement;
private _users: { [id: string]: user };
private _checkCount: number = 0;
private _addUserForm = document.getElementById("form-add-user") as HTMLFormElement;
private _addUserName = this._addUserForm.querySelector("input[type=text]") as HTMLInputElement;
private _addUserEmail = this._addUserForm.querySelector("input[type=email]") as HTMLInputElement;
private _addUserPassword = this._addUserForm.querySelector("input[type=password]") as HTMLInputElement;
get selectAll(): boolean { return this._selectAll.checked; }
set selectAll(state: boolean) {
for (let id in this._users) {
this._users[id].selected = state;
}
this._selectAll.checked = state;
this._selectAll.indeterminate = false;
state ? this._checkCount = Object.keys(this._users).length : 0;
}
add = (u: User) => {
let domAccount = new user(u);
this._users[u.id] = domAccount;
this._table.appendChild(domAccount.asElement());
}
private _checkCheckCount = () => {
if (this._checkCount == 0) {
this._selectAll.indeterminate = false;
this._selectAll.checked = false;
this._modifySettings.classList.add("unfocused");
this._deleteUser.classList.add("unfocused");
} else {
if (this._checkCount == Object.keys(this._users).length) {
this._selectAll.checked = true;
this._selectAll.indeterminate = false;
} else {
this._selectAll.checked = false;
this._selectAll.indeterminate = true;
}
this._modifySettings.classList.remove("unfocused");
this._deleteUser.classList.remove("unfocused");
(this._checkCount == 1) ? this._deleteUser.textContent = "Delete User" : this._deleteUser.textContent = "Delete Users";
}
}
private _genCountString = (): string => { return `${this._checkCount} user${(this._checkCount > 1) ? "s" : ""}`; }
private _collectUsers = (): string[] => {
let list: string[] = [];
for (let id in this._users) {
if (this._users[id].selected) { list.push(id); }
}
return list;
}
private _addUser = (event: Event) => {
event.preventDefault();
const button = this._addUserForm.querySelector("span.submit") as HTMLSpanElement;
const send = {
"username": this._addUserName.value,
"email": this._addUserEmail.value,
"password": this._addUserPassword.value
};
for (let field in send) {
if (!send[field]) {
window.notifications.customError("addUserBlankField", "Fields were left blank.");
return;
}
}
toggleLoader(button);
_post("/users", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
toggleLoader(button);
if (req.status == 200) {
window.notifications.customPositive("addUser", "Success:", `user "${send['username']}" created.`);
}
this.reload();
window.modals.addUser.close();
}
});
}
deleteUsers = () => {
const modalHeader = document.getElementById("header-delete-user");
modalHeader.textContent = this._genCountString();
let list = this._collectUsers();
const form = document.getElementById("form-delete-user") as HTMLFormElement;
const button = form.querySelector("span.submit") as HTMLSpanElement;
this._deleteNotify.checked = false;
this._deleteReason.value = "";
this._deleteReason.classList.add("unfocused");
form.onsubmit = (event: Event) => {
event.preventDefault();
toggleLoader(button);
let send = {
"users": list,
"notify": this._deleteNotify.checked,
"reason": this._deleteNotify ? this._deleteReason.value : ""
};
_delete("/users", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
toggleLoader(button);
window.modals.deleteUser.close();
if (req.status != 200 && req.status != 204) {
let errorMsg = "Failed (check console/logs).";
if (!("error" in req.response)) {
errorMsg = "Partial failure (check console/logs).";
}
window.notifications.customError("deleteUserError", errorMsg);
} else {
window.notifications.customPositive("deleteUserSuccess", "Success:", `deleted ${this._genCountString()}.`);
}
this.reload();
}
});
};
window.modals.deleteUser.show();
}
modifyUsers = () => {
const modalHeader = document.getElementById("header-modify-user");
modalHeader.textContent = this._genCountString();
let list = this._collectUsers();
(() => {
let innerHTML = "";
for (const profile of window.availableProfiles) {
innerHTML += `<option value="${profile}">${profile}</option>`;
}
this._profileSelect.innerHTML = innerHTML;
})();
(() => {
let innerHTML = "";
for (let id in this._users) {
innerHTML += `<option value="${id}">${this._users[id].name}</option>`;
}
this._userSelect.innerHTML = innerHTML;
})();
const form = document.getElementById("form-modify-user") as HTMLFormElement;
const button = form.querySelector("span.submit") as HTMLSpanElement;
this._modifySettingsProfile.checked = true;
this._modifySettingsUser.checked = false;
form.onsubmit = (event: Event) => {
event.preventDefault();
toggleLoader(button);
let send = {
"apply_to": list,
"homescreen": (document.getElementById("modify-user-homescreen") as HTMLInputElement).checked
};
if (this._modifySettingsProfile.checked && !this._modifySettingsUser.checked) {
send["from"] = "profile";
send["profile"] = this._profileSelect.value;
} else if (this._modifySettingsUser.checked && !this._modifySettingsProfile.checked) {
send["from"] = "user";
send["id"] = this._userSelect.value;
}
_post("/users/settings", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
toggleLoader(button);
if (req.status == 500) {
let response = JSON.parse(req.response);
let errorMsg = "";
if ("homescreen" in response && "policy" in response) {
const homescreen = Object.keys(response["homescreen"]).length;
const policy = Object.keys(response["policy"]).length;
if (homescreen != 0 && policy == 0) {
errorMsg = "Settings were applied, but applying homescreen layout may have failed.";
} else if (policy != 0 && homescreen == 0) {
errorMsg = "Homescreen layout was applied, but applying settings may have failed.";
} else if (policy != 0 && homescreen != 0) {
errorMsg = "Application failed.";
}
} else if ("error" in response) {
errorMsg = response["error"];
}
window.notifications.customError("modifySettingsError", errorMsg);
} else if (req.status == 200 || req.status == 204) {
window.notifications.customPositive("modifySettingsSuccess", "Success:", `applied settings to ${this._genCountString()}.`);
}
this.reload();
window.modals.modifyUser.close();
}
});
};
window.modals.modifyUser.show();
}
constructor() {
this._users = {};
this._selectAll.checked = false;
this._selectAll.onchange = () => { this.selectAll = this._selectAll.checked };
document.addEventListener("accountCheckEvent", () => { this._checkCount++; this._checkCheckCount(); });
document.addEventListener("accountUncheckEvent", () => { this._checkCount--; this._checkCheckCount(); });
this._addUserButton.onclick = window.modals.addUser.toggle;
this._addUserForm.addEventListener("submit", this._addUser);
this._deleteNotify.onchange = () => {
if (this._deleteNotify.checked) {
this._deleteReason.classList.remove("unfocused");
} else {
this._deleteReason.classList.add("unfocused");
}
};
this._modifySettings.onclick = this.modifyUsers;
this._modifySettings.classList.add("unfocused");
const checkSource = () => {
const profileSpan = this._modifySettingsProfile.nextElementSibling as HTMLSpanElement;
const userSpan = this._modifySettingsUser.nextElementSibling as HTMLSpanElement;
if (this._modifySettingsProfile.checked) {
this._userSelect.parentElement.classList.add("unfocused");
this._profileSelect.parentElement.classList.remove("unfocused")
profileSpan.classList.add("!high");
profileSpan.classList.remove("!normal");
userSpan.classList.remove("!high");
userSpan.classList.add("!normal");
} else {
this._userSelect.parentElement.classList.remove("unfocused");
this._profileSelect.parentElement.classList.add("unfocused");
userSpan.classList.add("!high");
userSpan.classList.remove("!normal");
profileSpan.classList.remove("!high");
profileSpan.classList.add("!normal");
}
};
this._modifySettingsProfile.onchange = checkSource;
this._modifySettingsUser.onchange = checkSource;
this._deleteUser.onclick = this.deleteUsers;
this._deleteUser.classList.add("unfocused");
if (!window.usernameEnabled) {
this._addUserName.classList.add("unfocused");
this._addUserName = this._addUserEmail;
}
/*if (!window.emailEnabled) {
this._deleteNotify.parentElement.classList.add("unfocused");
this._deleteNotify.checked = false;
}*/
}
reload = () => _get("/users", null, (req: XMLHttpRequest) => {
if (req.readyState == 4 && req.status == 200) {
// same method as inviteList.reload()
let accountsOnDOM: { [id: string]: boolean } = {};
for (let id in this._users) { accountsOnDOM[id] = true; }
for (let u of (req.response["users"] as User[])) {
if (u.id in this._users) {
this._users[u.id].update(u);
delete accountsOnDOM[u.id];
} else {
this.add(u);
}
}
for (let id in accountsOnDOM) {
this._users[id].remove();
delete this._users[id];
}
this._checkCheckCount;
}
})
}

View File

@ -1,62 +0,0 @@
import { rmAttr, addAttr, _post, _get, _delete, createEl } from "../modules/common.js";
export const Focus = (el: HTMLElement): void => rmAttr(el, 'unfocused');
export const Unfocus = (el: HTMLElement): void => addAttr(el, 'unfocused');
export function storeDefaults(users: string | Array<string>): void {
const button = document.getElementById('storeDefaults') as HTMLButtonElement;
button.disabled = true;
button.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Loading...';
let data = { "homescreen": false };
if ((document.getElementById('defaultsSource') as HTMLSelectElement).value == 'profile') {
data["from"] = "profile";
data["profile"] = (document.getElementById('profileSelect') as HTMLSelectElement).value;
} else {
const radio = document.querySelector('input[name=defaultRadios]:checked') as HTMLInputElement
let id = radio.id.replace("default_", "");
data["from"] = "user";
data["id"] = id;
}
if (users != "all") {
data["apply_to"] = users;
}
if ((document.getElementById('storeDefaultHomescreen') as HTMLInputElement).checked) {
data["homescreen"] = true;
}
_post("/users/settings", data, function (): void {
if (this.readyState == 4) {
if (this.status == 200 || this.status == 204) {
button.textContent = "Success";
addAttr(button, "btn-success");
rmAttr(button, "btn-danger");
rmAttr(button, "btn-primary");
button.disabled = false;
setTimeout((): void => {
button.textContent = "Submit";
addAttr(button, "btn-primary");
rmAttr(button, "btn-success");
button.disabled = false;
window.Modals.userDefaults.hide();
}, 1000);
} else {
if ("error" in this.response) {
button.textContent = this.response["error"];
} else if (("policy" in this.response) || ("homescreen" in this.response)) {
button.textContent = "Failed (check console)";
} else {
button.textContent = "Failed";
}
addAttr(button, "btn-danger");
rmAttr(button, "btn-primary");
setTimeout((): void => {
button.textContent = "Submit";
addAttr(button, "btn-primary");
rmAttr(button, "btn-danger");
button.disabled = false;
}, 1000);
}
}
});
}

View File

@ -1,87 +0,0 @@
import { rmAttr, addAttr } from "../modules/common.js";
interface aWindow extends Window {
rotateButton(el: HTMLElement): void;
}
declare var window: aWindow;
// Used for animation on theme change
const whichTransitionEvent = (): string => {
const el = document.createElement('fakeElement');
const transitions = {
'transition': 'transitionend',
'OTransition': 'oTransitionEnd',
'MozTransition': 'transitionend',
'WebkitTransition': 'webkitTransitionEnd'
};
for (const t in transitions) {
if (el.style[t] !== undefined) {
return transitions[t];
}
}
return '';
};
var transitionEndEvent = whichTransitionEvent();
// Toggles between light and dark themes
const _toggleCSS = (): void => {
const els: NodeListOf<HTMLLinkElement> = document.querySelectorAll('link[rel="stylesheet"][type="text/css"]');
let cssEl = 0;
let remove = false;
if (els.length != 1) {
cssEl = 1;
remove = true
}
let href: string = "bs" + window.bsVersion;
if (!els[cssEl].href.includes(href + "-jf")) {
href += "-jf";
}
href += ".css";
let newEl = els[cssEl].cloneNode(true) as HTMLLinkElement;
newEl.href = href;
els[cssEl].parentNode.insertBefore(newEl, els[cssEl].nextSibling);
if (remove) {
els[0].remove();
}
document.cookie = "css=" + href;
}
// Toggles between light and dark themes, but runs animation if window small enough.
window.buttonWidth = 0;
export const toggleCSS = (el: HTMLElement): void => {
const switchToColor = window.getComputedStyle(document.body, null).backgroundColor;
// Max page width for animation to take place
let maxWidth = 1500;
if (window.innerWidth < maxWidth) {
// Calculate minimum radius to cover screen
const radius = Math.sqrt(Math.pow(window.innerWidth, 2) + Math.pow(window.innerHeight, 2));
const currentRadius = el.getBoundingClientRect().width / 2;
const scale = radius / currentRadius;
window.buttonWidth = +window.getComputedStyle(el, null).width;
document.body.classList.remove('smooth-transition');
el.style.transform = `scale(${scale})`;
el.style.color = switchToColor;
el.addEventListener(transitionEndEvent, function (): void {
if (this.style.transform.length != 0) {
_toggleCSS();
this.style.removeProperty('transform');
document.body.classList.add('smooth-transition');
}
}, false);
} else {
_toggleCSS();
el.style.color = switchToColor;
}
};
window.rotateButton = (el: HTMLElement): void => {
if (el.classList.contains("rotated")) {
rmAttr(el, "rotated")
addAttr(el, "not-rotated");
} else {
rmAttr(el, "not-rotated");
addAttr(el, "rotated");
}
};

View File

@ -1,45 +0,0 @@
declare var $: any;
class Modal implements BSModal {
el: HTMLDivElement;
modal: any;
constructor(id: string, find?: boolean) {
this.el = document.getElementById(id) as HTMLDivElement;
this.modal = $(this.el) as any;
this.modal.on("shown.b.modal", (): void => document.body.classList.add('modal-open'));
};
show(): void { this.modal.modal("show"); };
hide(): void { this.modal.modal("hide"); };
}
export class BS4 implements Bootstrap {
triggerTooltips: tooltipTrigger = function (): void {
const checkboxes = [].slice.call(document.getElementById('settingsContent').querySelectorAll('input[type="checkbox"]'));
for (const i in checkboxes) {
checkboxes[i].click();
checkboxes[i].click();
}
const tooltips = [].slice.call(document.querySelectorAll('a[data-toggle="tooltip"]'));
tooltips.map((el: HTMLAnchorElement): any => {
return ($(el) as any).tooltip();
});
};
Compat(): void {
console.log('Fixing BS4 Compatability');
const send_to_address_enabled = document.getElementById('send_to_address_enabled');
if (send_to_address_enabled) {
send_to_address_enabled.classList.remove("form-check-input");
}
const multiUseEnabled = document.getElementById('multiUseEnabled');
if (multiUseEnabled) {
multiUseEnabled.classList.remove("form-check-input");
}
}
newModal: ModalConstructor = function (id: string, find?: boolean): BSModal {
return new Modal(id, find);
};
}

View File

@ -1,37 +0,0 @@
declare var bootstrap: any;
class Modal implements BSModal {
el: HTMLDivElement;
modal: any;
constructor(id: string, find?: boolean) {
this.el = document.getElementById(id) as HTMLDivElement;
if (find) {
this.modal = bootstrap.Modal.getInstance(this.el);
} else {
this.modal = new bootstrap.Modal(this.el);
}
this.el.addEventListener('shown.bs.modal', (): void => document.body.classList.add("modal-open"));
};
show(): void { this.modal.show(); };
hide(): void { this.modal.hide(); };
}
export class BS5 implements Bootstrap {
triggerTooltips: tooltipTrigger = function (): void {
const checkboxes = [].slice.call(document.getElementById('settingsContent').querySelectorAll('input[type="checkbox"]'));
for (const i in checkboxes) {
checkboxes[i].click();
checkboxes[i].click();
}
const tooltips = [].slice.call(document.querySelectorAll('a[data-toggle="tooltip"]'));
tooltips.map((el: HTMLAnchorElement): any => {
return new bootstrap.Tooltip(el);
});
};
newModal: ModalConstructor = function (id: string, find?: boolean): BSModal {
return new Modal(id, find);
};
};

View File

@ -49,18 +49,25 @@ export const rmAttr = (el: HTMLElement, attr: string): void => {
};
export const addAttr = (el: HTMLElement, attr: string): void => el.classList.add(attr);
export const _get = (url: string, data: Object, onreadystatechange: () => void): void => {
export const _get = (url: string, data: Object, onreadystatechange: (req: XMLHttpRequest) => void): void => {
let req = new XMLHttpRequest();
req.open("GET", window.URLBase + url, true);
req.responseType = 'json';
req.setRequestHeader("Authorization", "Bearer " + window.token);
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
req.onreadystatechange = onreadystatechange;
req.onreadystatechange = () => {
if (req.status == 0) {
window.notifications.connectionError();
return;
} else if (req.status == 401) {
window.notifications.customError("401Error", "Unauthorized. Try logging back in.");
}
onreadystatechange(req);
};
req.send(JSON.stringify(data));
};
export const _post = (url: string, data: Object, onreadystatechange: () => void, response?: boolean): void => {
export const _post = (url: string, data: Object, onreadystatechange: (req: XMLHttpRequest) => void, response?: boolean): void => {
let req = new XMLHttpRequest();
req.open("POST", window.URLBase + url, true);
if (response) {
@ -68,16 +75,131 @@ export const _post = (url: string, data: Object, onreadystatechange: () => void,
}
req.setRequestHeader("Authorization", "Bearer " + window.token);
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
req.onreadystatechange = onreadystatechange;
req.onreadystatechange = () => {
if (req.status == 0) {
window.notifications.connectionError();
return;
} else if (req.status == 401) {
window.notifications.customError("401Error", "Unauthorized. Try logging back in.");
}
onreadystatechange(req);
};
req.send(JSON.stringify(data));
};
export function _delete(url: string, data: Object, onreadystatechange: () => void): void {
export function _delete(url: string, data: Object, onreadystatechange: (req: XMLHttpRequest) => void): void {
let req = new XMLHttpRequest();
req.open("DELETE", window.URLBase + url, true);
req.setRequestHeader("Authorization", "Bearer " + window.token);
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
req.onreadystatechange = onreadystatechange;
req.onreadystatechange = () => {
if (req.status == 0) {
window.notifications.connectionError();
return;
} else if (req.status == 401) {
window.notifications.customError("401Error", "Unauthorized. Try logging back in.");
}
onreadystatechange(req);
};
req.send(JSON.stringify(data));
}
export function toClipboard (str: string) {
const el = document.createElement('textarea') as HTMLTextAreaElement;
el.value = str;
el.readOnly = true;
el.style.position = "absolute";
el.style.left = "-9999px";
document.body.appendChild(el);
const selected = document.getSelection().rangeCount > 0 ? document.getSelection().getRangeAt(0) : false;
el.select();
document.execCommand("copy");
document.body.removeChild(el);
if (selected) {
document.getSelection().removeAllRanges();
document.getSelection().addRange(selected);
}
}
export class notificationBox implements NotificationBox {
private _box: HTMLDivElement;
private _errorTypes: { [type: string]: boolean } = {};
private _positiveTypes: { [type: string]: boolean } = {};
timeout: number;
constructor(box: HTMLDivElement, timeout?: number) { this._box = box; this.timeout = timeout || 5; }
private _error = (message: string): HTMLElement => {
const noti = document.createElement('aside');
noti.classList.add("aside", "~critical", "!normal", "mt-half", "notification-error");
noti.innerHTML = `<strong>Error:</strong> ${message}`;
const closeButton = document.createElement('span') as HTMLSpanElement;
closeButton.classList.add("button", "~critical", "!low", "ml-1");
closeButton.innerHTML = `<i class="icon ri-close-line"></i>`;
closeButton.onclick = () => { this._box.removeChild(noti); };
noti.appendChild(closeButton);
return noti;
}
private _positive = (bold: string, message: string): HTMLElement => {
const noti = document.createElement('aside');
noti.classList.add("aside", "~positive", "!normal", "mt-half", "notification-positive");
noti.innerHTML = `<strong>${bold}</strong> ${message}`;
const closeButton = document.createElement('span') as HTMLSpanElement;
closeButton.classList.add("button", "~positive", "!low", "ml-1");
closeButton.innerHTML = `<i class="icon ri-close-line"></i>`;
closeButton.onclick = () => { this._box.removeChild(noti); };
noti.appendChild(closeButton);
return noti;
}
connectionError = () => { this.customError("connectionError", "Couldn't connect to jfa-go."); }
customError = (type: string, message: string) => {
this._errorTypes[type] = this._errorTypes[type] || false;
const noti = this._error(message);
noti.classList.add("error-" + type);
const previousNoti: HTMLElement | undefined = this._box.querySelector("aside.error-" + type);
if (this._errorTypes[type] && previousNoti !== undefined && previousNoti != null) {
previousNoti.remove();
}
this._box.appendChild(noti);
this._errorTypes[type] = true;
setTimeout(() => { if (this._box.contains(noti)) { this._box.removeChild(noti); this._errorTypes[type] = false; } }, this.timeout*1000);
}
customPositive = (type: string, bold: string, message: string) => {
this._positiveTypes[type] = this._positiveTypes[type] || false;
const noti = this._positive(bold, message);
noti.classList.add("positive-" + type);
const previousNoti: HTMLElement | undefined = this._box.querySelector("aside.positive-" + type);
if (this._positiveTypes[type] && previousNoti !== undefined && previousNoti != null) {
previousNoti.remove();
}
this._box.appendChild(noti);
this._positiveTypes[type] = true;
setTimeout(() => { if (this._box.contains(noti)) { this._box.removeChild(noti); this._positiveTypes[type] = false; } }, this.timeout*1000);
}
}
export const whichAnimationEvent = () => {
const el = document.createElement("fakeElement");
if (el.style["animation"] !== void 0) {
return "animationend";
}
return "webkitAnimationEnd";
}
export function toggleLoader(el: HTMLElement, small: boolean = true) {
if (el.classList.contains("loader")) {
el.classList.remove("loader");
el.classList.remove("loader-sm");
const dot = el.querySelector("span.dot");
if (dot) { dot.remove(); }
} else {
el.classList.add("loader");
if (small) { el.classList.add("loader-sm"); }
const dot = document.createElement("span") as HTMLSpanElement;
dot.classList.add("dot")
el.appendChild(dot);
}
}

View File

@ -1,297 +1,630 @@
import { _get, _post, _delete } from "../modules/common.js";
import { _get, _post, _delete, toClipboard, toggleLoader } from "../modules/common.js";
interface aWindow extends Window {
setNotify(el: HTMLElement): void;
deleteInvite(code: string): void;
}
declare var window: aWindow;
const emptyInvite = (): Invite => { return { code: "None", empty: true } as Invite; }
function genUsedBy(usedBy: Array<Array<string>>): string {
let uB = "";
if (usedBy && usedBy.length != 0) {
uB = `
<ul class="list-group list-group-flush">
<li class="list-group-item py-1">Users created:</li>
`;
for (const i in usedBy) {
uB += `
<li class="list-group-item py-1 disabled">
<div class="d-flex float-left">${usedBy[i][0]}</div>
<div class="d-flex float-right">${usedBy[i][1]}</div>
</li>
`;
export class DOMInvite implements Invite {
updateNotify = (checkbox: HTMLInputElement) => {
let state: { [code: string]: { [type: string]: boolean } } = {};
let revertChanges: () => void;
if (checkbox.classList.contains("inv-notify-expiry")) {
revertChanges = () => { this.notifyExpiry = !this.notifyExpiry };
state[this.code] = { "notify-expiry": this.notifyExpiry };
} else {
revertChanges = () => { this.notifyCreation = !this.notifyCreation };
state[this.code] = { "notify-creation": this.notifyCreation };
}
uB += `</ul>`
_post("/invites/notify", state, (req: XMLHttpRequest) => {
if (req.readyState == 4 && !(req.status == 200 || req.status == 204)) {
revertChanges();
}
});
}
return uB;
}
function addItem(invite: Invite): void {
const links = document.getElementById('invites');
const container = document.createElement('div') as HTMLDivElement;
container.id = invite.code;
const item = document.createElement('div') as HTMLDivElement;
item.classList.add('list-group-item', 'd-flex', 'justify-content-between', 'd-inline-block');
let link = "";
let innerHTML = `<a>None</a>`;
if (invite.empty) {
item.innerHTML = `
<div class="d-flex align-items-center font-monospace" style="width: 40%;">
${innerHTML}
</div>
`;
container.appendChild(item);
links.appendChild(container);
return;
delete = () => _delete("/invites", { "code": this.code }, (req: XMLHttpRequest) => {
if (req.readyState == 4 && (req.status == 200 || req.status == 204)) {
this.remove();
const inviteDeletedEvent = new CustomEvent("inviteDeletedEvent", { "detail": this.code });
document.dispatchEvent(inviteDeletedEvent);
}
})
private _code: string = "None";
get code(): string { return this._code; }
set code(code: string) {
this._code = code;
this._codeLink = window.location.href.split("#")[0] + "invite/" + code;
const linkEl = this._codeArea.querySelector("a") as HTMLAnchorElement;
linkEl.textContent = code.replace(/-/g, '-');
linkEl.href = this._codeLink;
}
link = window.location.href.split('#')[0] + "invite/" + invite.code;
innerHTML = `
<div class="d-flex align-items-center font-monospace" style="width: 40%;">
<a class="invite-link" href="${link}">${invite.code.replace(/-/g, '-')}</a>
<i class="fa fa-clipboard icon-button" onclick="window.toClipboard('${link}')" style="margin-right: 0.5rem; margin-left: 0.5rem;"></i>
`;
if (invite.email) {
let email = invite.email;
if (!invite.email.includes("Failed to send to")) {
email = `Sent to ${email}`;
private _codeLink: string;
private _expiresIn: string;
get expiresIn(): string { return this._expiresIn }
set expiresIn(expiry: string) {
this._expiresIn = expiry;
this._infoArea.querySelector("span.inv-expiry").textContent = expiry;
}
private _remainingUses: string = "1";
get remainingUses(): string { return this._remainingUses; }
set remainingUses(remaining: string) {
this._remainingUses = remaining;
this._middle.querySelector("strong.inv-remaining").textContent = remaining;
}
private _email: string = "";
get email(): string { return this._email };
set email(address: string) {
this._email = address;
const container = this._infoArea.querySelector(".tooltip") as HTMLDivElement;
const icon = container.querySelector("i");
const chip = container.querySelector("span.inv-email-chip");
const tooltip = container.querySelector("span.content") as HTMLSpanElement;
if (address == "") {
container.classList.remove("mr-1");
icon.classList.remove("ri-mail-line");
icon.classList.remove("ri-mail-close-line");
chip.classList.remove("~neutral");
chip.classList.remove("~critical");
chip.classList.remove("chip");
} else {
container.classList.add("mr-1");
chip.classList.add("chip");
if (address.includes("Failed to send to")) {
icon.classList.remove("ri-mail-line");
icon.classList.add("ri-mail-close-line");
chip.classList.remove("~neutral");
chip.classList.add("~critical");
} else {
address = "Sent to " + address;
icon.classList.remove("ri-mail-close-line");
icon.classList.add("ri-mail-line");
chip.classList.remove("~critical");
chip.classList.add("~neutral");
}
}
tooltip.textContent = address;
}
private _usedBy: string[][];
get usedBy(): string[][] { return this._usedBy; }
set usedBy(uB: string[][]) {
// ub[i][0]: username, ub[i][1]: date
this._usedBy = uB;
if (uB.length == 0) {
this._right.classList.add("empty");
this._userTable.innerHTML = `<p class="content">None yet!</p>`;
return;
}
this._right.classList.remove("empty");
let innerHTML = `
<table class="table inv-table">
<thead>
<tr>
<th>Name</th>
<th>Date</th>
</tr>
</thead>
<tbody>
`;
for (let user of uB) {
innerHTML += `
<tr>
<td>${user[0]}</td>
<td>${user[1]}</td>
</tr>
`;
}
innerHTML += `
<span class="text-muted" style="margin-left: 0.4rem; font-style: italic; font-size: 0.8rem;">${email}</span>
</tbody>
</table>
`;
this._userTable.innerHTML = innerHTML;
}
innerHTML += `
</div>
<div style="text-align: right;">
<span id="${invite.code}_expiry" style="margin-right: 1rem;">${invite.expiresIn}</span>
<div style="display: inline-block;">
<button class="btn btn-outline-danger" onclick="deleteInvite('${invite.code}')">Delete</button>
<i class="fa fa-angle-down collapsed icon-button not-rotated" style="padding: 1rem; margin: -1rem -1rem -1rem 0;" data-toggle="collapse" aria-expanded="false" data-target="#${CSS.escape(invite.code)}_collapse" onclick="window.rotateButton(this)"></i>
</div>
</div>
`;
item.innerHTML = innerHTML;
container.appendChild(item);
private _created: string;
get created(): string { return this._created; }
set created(created: string) {
this._created = created;
this._middle.querySelector("strong.inv-created").textContent = created;
}
let profiles = `
<label class="input-group-text" for="profile_${CSS.escape(invite.code)}">Profile: </label>
<select class="form-select" id="profile_${CSS.escape(invite.code)}" onchange="window.setProfile(this)">
<option value="NoProfile" selected>No Profile</option>
`;
for (const i in window.availableProfiles) {
let selected = "";
if (window.availableProfiles[i] == invite.profile) {
selected = "selected";
private _notifyExpiry: boolean = false;
get notifyExpiry(): boolean { return this._notifyExpiry }
set notifyExpiry(state: boolean) {
this._notifyExpiry = state;
(this._left.querySelector("input.inv-notify-expiry") as HTMLInputElement).checked = state;
}
private _notifyCreation: boolean = false;
get notifyCreation(): boolean { return this._notifyCreation }
set notifyCreation(state: boolean) {
this._notifyCreation = state;
(this._left.querySelector("input.inv-notify-creation") as HTMLInputElement).checked = state;
}
private _profile: string;
get profile(): string { return this._profile; }
set profile(profile: string) { this.loadProfiles(profile); }
loadProfiles = (selected?: string) => {
const select = this._left.querySelector("select") as HTMLSelectElement;
let noProfile = false;
if (selected === "") {
noProfile = true;
} else {
selected = selected || select.value;
}
profiles += `<option value="${window.availableProfiles[i]}" ${selected}>${window.availableProfiles[i]}</option>`;
}
profiles += `</select>`;
let dateCreated: string;
if (invite.created) {
dateCreated = `<li class="list-group-item py-1">Created: ${invite.created}</li>`;
}
let middle: string;
if (window.notifications_enabled) {
middle = `
<div class="col" id="${CSS.escape(invite.code)}_notifyButtons">
<ul class="list-group list-group-flush">
Notify on:
<li class="list-group-item py-1 form-check">
<input class="form-check-input" type="checkbox" value="" id="${CSS.escape(invite.code)}_notifyExpiry" onclick="setNotify(this)" ${invite.notifyExpiry ? "checked" : ""}>
<label class="form-check-label" for="${CSS.escape(invite.code)}_notifyExpiry">Expiry</label>
</li>
<li class="list-group-item py-1 form-check">
<input class="form-check-input" type="checkbox" value="" id="${CSS.escape(invite.code)}_notifyCreation" onclick="setNotify(this)" ${invite.notifyCreation ? "checked" : ""}>
<label class="form-check-label" for="${CSS.escape(invite.code)}_notifyCreation">User creation</label>
</li>
</ul>
</div>
`;
}
let right: string = genUsedBy(invite.usedBy)
const dropdown = document.createElement('div') as HTMLDivElement;
dropdown.id = `${CSS.escape(invite.code)}_collapse`;
dropdown.classList.add("collapse");
dropdown.innerHTML = `
<div class="container row align-items-start card-body">
<div class="col">
<ul class="list-group list-group-flush">
<li class="input-group py-1">
${profiles}
</li>
${dateCreated}
<li class="list-group-item py-1" id="${CSS.escape(invite.code)}_remainingUses">Remaining uses: ${invite.remainingUses}</li>
</ul>
</div>
${middle}
<div class="col" id="${CSS.escape(invite.code)}_usersCreated">
${right}
</div>
</div>
`;
container.appendChild(dropdown);
links.appendChild(container);
}
function parseInvite(invite: Object): Invite {
let inv: Invite = { code: invite["code"], empty: false, };
if (invite["email"]) {
inv.email = invite["email"];
}
let time = ""
const f = ["days", "hours", "minutes"];
for (const i in f) {
if (invite[f[i]] != 0) {
time += `${invite[f[i]]}${f[i][0]} `;
let innerHTML = `<option value="noProfile" ${noProfile ? "selected" : ""}>No Profile</option>`;
for (let profile of window.availableProfiles) {
innerHTML += `<option value="${profile}" ${((profile == selected) && !noProfile) ? "selected" : ""}>${profile}</option>`;
}
}
inv.expiresIn = `Expires in ${time.slice(0, -1)}`;
if (invite["no-limit"]) {
inv.remainingUses = "∞";
} else if ("remaining-uses" in invite) {
inv.remainingUses = invite["remaining-uses"];
}
if ("used-by" in invite) {
inv.usedBy = invite["used-by"];
}
if ("created" in invite) {
inv.created = invite["created"];
}
if ("notify-expiry" in invite) {
inv.notifyExpiry = invite["notify-expiry"];
}
if ("notify-creation" in invite) {
inv.notifyCreation = invite["notify-creation"];
}
if ("profile" in invite) {
inv.profile = invite["profile"];
}
return inv;
}
window.setNotify = (el: HTMLElement): void => {
let send = {};
let code: string;
let notifyType: string;
if (el.id.includes("Expiry")) {
code = el.id.replace("_notifyExpiry", "");
notifyType = "notify-expiry";
} else if (el.id.includes("Creation")) {
code = el.id.replace("_notifyCreation", "");
notifyType = "notify-creation";
}
send[code] = {};
send[code][notifyType] = (el as HTMLInputElement).checked;
_post("/invites/notify", send, function (): void {
if (this.readyState == 4 && this.status != 200) {
(el as HTMLInputElement).checked = !(el as HTMLInputElement).checked;
}
});
}
function updateInvite(invite: Invite): void {
document.getElementById(invite.code + "_expiry").textContent = invite.expiresIn;
const remainingUses: any = document.getElementById(CSS.escape(invite.code) + "_remainingUses");
if (remainingUses) {
remainingUses.textContent = `Remaining uses: ${invite.remainingUses}`;
}
document.getElementById(CSS.escape(invite.code) + "_usersCreated").innerHTML = genUsedBy(invite.usedBy);
}
// delete invite from DOM
const hideInvite = (code: string): void => document.getElementById(CSS.escape(code)).remove();
// delete invite from jfa-go
window.deleteInvite = (code: string): void => _delete("/invites", { "code": code }, function (): void {
if (this.readyState == 4) {
generateInvites();
}
});
export function generateInvites(empty?: boolean): void {
if (empty) {
document.getElementById('invites').textContent = '';
addItem(emptyInvite());
return;
}
_get("/invites", null, function (): void {
if (this.readyState == 4) {
let data = this.response;
window.availableProfiles = data['profiles'];
const Profiles = document.getElementById('inviteProfile') as HTMLSelectElement;
let innerHTML = "";
for (let i = 0; i < window.availableProfiles.length; i++) {
const profile = window.availableProfiles[i];
innerHTML += `
<option value="${profile}" ${(i == 0) ? "selected" : ""}>${profile}</option>
`;
select.innerHTML = innerHTML;
this._profile = selected;
};
updateProfile = () => {
const select = this._left.querySelector("select") as HTMLSelectElement;
const previous = this.profile;
let profile = select.value;
if (profile == "noProfile") { profile = ""; }
_post("/invites/profile", { "invite": this.code, "profile": profile }, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (!(req.status == 200 || req.status == 204)) {
select.value = previous || "noProfile";
} else {
this._profile = profile;
}
}
});
}
private _container: HTMLDivElement;
private _header: HTMLDivElement;
private _codeArea: HTMLDivElement;
private _infoArea: HTMLDivElement;
private _details: HTMLDivElement;
private _left: HTMLDivElement;
private _middle: HTMLDivElement;
private _right: HTMLDivElement;
private _userTable: HTMLDivElement;
// whether the details card is expanded.
get expanded(): boolean {
return this._details.classList.contains("focused");
}
set expanded(state: boolean) {
const toggle = (this._infoArea.querySelector("input.inv-toggle-details") as HTMLInputElement);
if (state) {
this._details.classList.remove("unfocused");
this._details.classList.add("focused");
toggle.previousElementSibling.classList.add("rotated");
toggle.previousElementSibling.classList.remove("not-rotated");
} else {
this._details.classList.add("unfocused");
this._details.classList.remove("focused");
toggle.previousElementSibling.classList.remove("rotated");
toggle.previousElementSibling.classList.add("not-rotated");
}
}
constructor(invite: Invite) {
// first create the invite structure, then use our setter methods to fill in the data.
this._container = document.createElement('div') as HTMLDivElement;
this._container.classList.add("inv");
this._header = document.createElement('div') as HTMLDivElement;
this._container.appendChild(this._header);
this._header.classList.add("card", "~neutral", "!normal", "inv-header", "elem-pad", "no-pad", "flex-expand", "row", "mt-half", "overflow-y");
this._codeArea = document.createElement('div') as HTMLDivElement;
this._header.appendChild(this._codeArea);
this._codeArea.classList.add("inv-codearea");
this._codeArea.innerHTML = `
<a class="invite-link code monospace mr-1" href=""></a>
<span class="button ~info !normal" title="Copy invite link"><i class="ri-file-copy-line"></i></span>
`;
const copyButton = this._codeArea.querySelector("span.button") as HTMLSpanElement;
copyButton.onclick = () => {
toClipboard(this._codeLink);
const icon = copyButton.children[0];
icon.classList.remove("ri-file-copy-line");
icon.classList.add("ri-check-line");
copyButton.classList.remove("~info");
copyButton.classList.add("~positive");
setTimeout(() => {
icon.classList.remove("ri-check-line");
icon.classList.add("ri-file-copy-line");
copyButton.classList.remove("~positive");
copyButton.classList.add("~info");
}, 800);
};
this._infoArea = document.createElement('div') as HTMLDivElement;
this._header.appendChild(this._infoArea);
this._infoArea.classList.add("inv-infoarea");
this._infoArea.innerHTML = `
<div class="tooltip left">
<span class="inv-email-chip"><i></i></span>
<span class="content sm"></span>
</div>
<span class="inv-expiry mr-1"></span>
<span class="button ~critical !normal inv-delete">Delete</span>
<label>
<i class="icon clickable ri-arrow-down-s-line not-rotated"></i>
<input class="inv-toggle-details unfocused" type="checkbox">
</label>
`;
(this._infoArea.querySelector(".inv-delete") as HTMLSpanElement).onclick = this.delete;
const toggle = (this._infoArea.querySelector("input.inv-toggle-details") as HTMLInputElement);
toggle.onchange = () => { this.expanded = !this.expanded; };
this._details = document.createElement('div') as HTMLDivElement;
this._container.appendChild(this._details);
this._details.classList.add("card", "~neutral", "!normal", "mt-half", "no-pad", "inv-details");
const detailsInner = document.createElement('div') as HTMLDivElement;
this._details.appendChild(detailsInner);
detailsInner.classList.add("inv-row", "flex-expand", "row", "elem-pad", "align-top");
this._left = document.createElement('div') as HTMLDivElement;
detailsInner.appendChild(this._left);
this._left.classList.add("inv-profilearea");
let innerHTML = `
<p class="supra mb-1 top">Profile</p>
<div class="select ~neutral !normal inv-profileselect inline-block">
<select>
<option value="noProfile" selected>No Profile</option>
</select>
</div>
`;
if (window.notificationsEnabled) {
innerHTML += `
<option value="NoProfile" ${(window.availableProfiles.length == 0) ? "selected" : ""}>No Profile</option>
<p class="label supra">Notify on:</p>
<label class="switch block">
<input class="inv-notify-expiry" type="checkbox">
<span>On expiry</span>
</label>
<label class="switch block">
<input class="inv-notify-creation" type="checkbox">
<span>On user creation</span>
</label>
`;
Profiles.innerHTML = innerHTML;
if (data['invites'] == null || data['invites'].length == 0) {
document.getElementById('invites').textContent = '';
addItem(emptyInvite());
}
this._left.innerHTML = innerHTML;
(this._left.querySelector("select") as HTMLSelectElement).onchange = this.updateProfile;
if (window.notificationsEnabled) {
const notifyExpiry = this._left.querySelector("input.inv-notify-expiry") as HTMLInputElement;
notifyExpiry.onchange = () => { this._notifyExpiry = notifyExpiry.checked; this.updateNotify(notifyExpiry); };
const notifyCreation = this._left.querySelector("input.inv-notify-creation") as HTMLInputElement;
notifyCreation.onchange = () => { this._notifyCreation = notifyCreation.checked; this.updateNotify(notifyCreation); };
}
this._middle = document.createElement('div') as HTMLDivElement;
detailsInner.appendChild(this._middle);
this._middle.classList.add("block");
this._middle.innerHTML = `
<p class="supra mb-1 top">Created <strong class="inv-created"></strong></p>
<p class="supra mb-1">Remaining uses <strong class="inv-remaining"></strong></p>
`;
this._right = document.createElement('div') as HTMLDivElement;
detailsInner.appendChild(this._right);
this._right.classList.add("card", "~neutral", "!low", "inv-created-users");
this._right.innerHTML = `<strong class="supra table-header">Created users</strong>`;
this._userTable = document.createElement('div') as HTMLDivElement;
this._right.appendChild(this._userTable);
this.expanded = false;
this.update(invite);
document.addEventListener("profileLoadEvent", () => { this.loadProfiles(); }, false);
}
update = (invite: Invite) => {
this.code = invite.code;
this.created = invite.created;
this.email = invite.email;
this.expiresIn = invite.expiresIn;
if (window.notificationsEnabled) {
this.notifyCreation = invite.notifyCreation;
this.notifyExpiry = invite.notifyExpiry;
}
this.profile = invite.profile;
this.remainingUses = invite.remainingUses;
this.usedBy = invite.usedBy;
}
asElement = (): HTMLDivElement => { return this._container; }
remove = () => { this._container.remove(); }
}
export class inviteList implements inviteList {
private _list: HTMLDivElement;
private _empty: boolean;
// since invite reload sends profiles, this event it broadcast so the createInvite object can load them.
private _profileLoadEvent = new CustomEvent("profileLoadEvent");
invites: { [code: string]: DOMInvite };
constructor() {
this._list = document.getElementById('invites') as HTMLDivElement;
this.empty = true;
this.invites = {};
document.addEventListener("newInviteEvent", () => { this.reload(); }, false);
document.addEventListener("inviteDeletedEvent", (event: CustomEvent) => {
const code = event.detail;
const length = Object.keys(this.invites).length - 1; // store prior as Object.keys is undefined when there are no keys
delete this.invites[code];
if (length == 0) {
this.empty = true;
}
}, false);
}
get empty(): boolean { return this._empty; }
set empty(state: boolean) {
this._empty = state;
if (state) {
this.invites = {};
this._list.classList.add("empty");
this._list.innerHTML = `
<div class="inv inv-empty">
<div class="card ~neutral !normal inv-header flex-expand mt-half">
<div class="inv-codearea">
<span class="code monospace">None</span>
</div>
</div>
</div>
`;
} else {
this._list.classList.remove("empty");
if (this._list.querySelector(".inv-empty")) {
this._list.textContent = '';
}
}
}
add = (invite: Invite) => {
let domInv = new DOMInvite(invite);
this.invites[invite.code] = domInv;
if (this.empty) { this.empty = false; }
this._list.appendChild(domInv.asElement());
}
reload = () => _get("/invites", null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
let data = req.response;
if (req.status == 200) {
window.availableProfiles = data["profiles"];
document.dispatchEvent(this._profileLoadEvent);
}
if (data["invites"] === undefined || data["invites"] == null || data["invites"].length == 0) {
this.empty = true;
return;
}
let items = document.getElementById('invites').children;
for (const i in data['invites']) {
let match = false;
const inv = parseInvite(data['invites'][i]);
for (const x in items) {
if (items[x].id == inv.code) {
match = true;
updateInvite(inv);
break;
}
}
if (!match) {
addItem(inv);
// get a list of all current inv codes on dom
// every time we find a match in resp, delete from list
// at end delete all remaining in list from dom
let invitesOnDOM: { [code: string]: boolean } = {};
for (let code in this.invites) { invitesOnDOM[code] = true; }
for (let inv of (data["invites"] as Array<any>)) {
const invite = parseInvite(inv);
if (invite.code in this.invites) {
this.invites[invite.code].update(invite);
delete invitesOnDOM[invite.code];
} else {
this.add(invite);
}
}
// second pass to check for expired invites
items = document.getElementById('invites').children;
for (let i = 0; i < items.length; i++) {
let exists = false;
for (const x in data['invites']) {
if (items[i].id == data['invites'][x]['code']) {
exists = true;
break;
}
}
if (!exists) {
hideInvite(items[i].id);
}
for (let code in invitesOnDOM) {
this.invites[code].remove();
delete this.invites[code];
}
}
});
})
}
function parseInvite(invite: { [f: string]: string | number | string[][] | boolean }): Invite {
let parsed: Invite = {};
parsed.code = invite["code"] as string;
parsed.email = invite["email"] as string || "";
let time = "";
const fields = ["days", "hours", "minutes"];
for (let i = 0; i < fields.length; i++) {
if (invite[fields[i]] != 0) {
time += `${invite[fields[i]]}${fields[i][0]} `;
}
}
parsed.expiresIn = `Expires in ${time.slice(0, -1)}`;
parsed.remainingUses = invite["no-limit"] ? "∞" : String(invite["remaining-uses"])
parsed.usedBy = invite["used-by"] as string[][] || [];
parsed.created = invite["created"] as string || "Unknown";
parsed.profile = invite["profile"] as string || "";
parsed.notifyExpiry = invite["notify-expiry"] as boolean || false;
parsed.notifyCreation = invite["notify-creation"] as boolean || false;
return parsed;
}
export const addOptions = (length: number, el: HTMLSelectElement): void => {
for (let v = 0; v <= length; v++) {
const opt = document.createElement('option');
opt.textContent = ""+v;
opt.value = ""+v;
el.appendChild(opt);
}
el.value = "0";
};
export class createInvite {
private _sendToEnabled = document.getElementById("create-send-to-enabled") as HTMLInputElement;
private _sendTo = document.getElementById("create-send-to") as HTMLInputElement;
private _uses = document.getElementById('create-uses') as HTMLInputElement;
private _infUses = document.getElementById("create-inf-uses") as HTMLInputElement;
private _infUsesWarning = document.getElementById('create-inf-uses-warning') as HTMLParagraphElement;
private _createButton = document.getElementById("create-submit") as HTMLSpanElement;
private _profile = document.getElementById("create-profile") as HTMLSelectElement;
export function checkDuration(): void {
const boxVals: Array<number> = [+(document.getElementById("days") as HTMLSelectElement).value, +(document.getElementById("hours") as HTMLSelectElement).value, +(document.getElementById("minutes") as HTMLSelectElement).value];
const submit = document.getElementById('generateSubmit') as HTMLButtonElement;
if (boxVals.reduce((a: number, b: number): number => a + b) == 0) {
submit.disabled = true;
} else {
submit.disabled = false;
private _days = document.getElementById("create-days") as HTMLSelectElement;
private _hours = document.getElementById("create-hours") as HTMLSelectElement;
private _minutes = document.getElementById("create-minutes") as HTMLSelectElement;
// Broadcast when new invite created
private _newInviteEvent = new CustomEvent("newInviteEvent");
private _firstLoad = true;
private _count: Number = 30;
private _populateNumbers = () => {
const fieldIDs = ["create-days", "create-hours", "create-minutes"];
for (let i = 0; i < fieldIDs.length; i++) {
const field = document.getElementById(fieldIDs[i]);
field.textContent = '';
for (let n = 0; n <= this._count; n++) {
const opt = document.createElement("option") as HTMLOptionElement;
opt.textContent = ""+n;
opt.value = ""+n;
field.appendChild(opt);
}
}
}
get sendToEnabled(): boolean {
return this._sendToEnabled.checked;
}
set sendToEnabled(state: boolean) {
this._sendToEnabled.checked = state;
this._sendTo.disabled = !state;
if (state) {
this._sendToEnabled.parentElement.classList.remove("~neutral");
this._sendToEnabled.parentElement.classList.add("~urge");
} else {
this._sendToEnabled.parentElement.classList.remove("~urge");
this._sendToEnabled.parentElement.classList.add("~neutral");
}
}
get infiniteUses(): boolean {
return this._infUses.checked;
}
set infiniteUses(state: boolean) {
this._infUses.checked = state;
this._uses.disabled = state;
if (state) {
this._infUses.parentElement.classList.remove("~neutral");
this._infUses.parentElement.classList.add("~urge");
this._infUsesWarning.classList.remove("unfocused");
} else {
this._infUses.parentElement.classList.remove("~urge");
this._infUses.parentElement.classList.add("~neutral");
this._infUsesWarning.classList.add("unfocused");
}
}
get uses(): number { return this._uses.valueAsNumber; }
set uses(n: number) { this._uses.valueAsNumber = n; }
private _checkDurationValidity = () => {
if (this.days + this.hours + this.minutes == 0) {
this._createButton.setAttribute("disabled", "");
this._createButton.onclick = null;
} else {
this._createButton.removeAttribute("disabled");
this._createButton.onclick = this.create;
}
}
get days(): number {
return +this._days.value;
}
set days(n: number) {
this._days.value = ""+n;
this._checkDurationValidity();
}
get hours(): number {
return +this._hours.value;
}
set hours(n: number) {
this._hours.value = ""+n;
this._checkDurationValidity();
}
get minutes(): number {
return +this._minutes.value;
}
set minutes(n: number) {
this._minutes.value = ""+n;
this._checkDurationValidity();
}
get sendTo(): string { return this._sendTo.value; }
set sendTo(address: string) { this._sendTo.value = address; }
get profile(): string {
const val = this._profile.value;
if (val == "noProfile") {
return "";
}
return val;
}
set profile(p: string) {
if (p == "") { p = "noProfile"; }
this._profile.value = p;
}
loadProfiles = () => {
let innerHTML = `<option value="noProfile">No Profile</option>`;
for (let profile of window.availableProfiles) {
innerHTML += `<option value="${profile}">${profile}</option>`;
}
let selected = this.profile;
this._profile.innerHTML = innerHTML;
if (this._firstLoad) {
this.profile = window.availableProfiles[0] || "";
this._firstLoad = false;
} else {
this.profile = selected;
}
}
create = () => {
toggleLoader(this._createButton);
let send = {
"days": this.days,
"hours": this.hours,
"minutes": this.minutes,
"multiple-uses": (this.uses > 1 || this.infiniteUses),
"no-limit": this.infiniteUses,
"remaining-uses": this.uses,
"email": this.sendToEnabled ? this.sendTo : "",
"profile": this.profile
};
_post("/invites", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status == 200 || req.status == 204) {
document.dispatchEvent(this._newInviteEvent);
}
toggleLoader(this._createButton);
}
});
}
constructor() {
this._populateNumbers();
this.days = 0;
this.hours = 0;
this.minutes = 30;
this._infUses.onchange = () => { this.infiniteUses = this.infiniteUses; };
this.infiniteUses = false;
this._sendToEnabled.onchange = () => { this.sendToEnabled = this.sendToEnabled; };
this.sendToEnabled = false;
this._createButton.onclick = this.create;
this.sendTo = "";
this.uses = 1;
this._days.onchange = this._checkDurationValidity;
this._hours.onchange = this._checkDurationValidity;
this._minutes.onchange = this._checkDurationValidity;
document.addEventListener("profileLoadEvent", () => { this.loadProfiles(); }, false);
if (!window.emailEnabled) {
document.getElementById("create-send-to-container").classList.add("unfocused");
}
}
}

42
ts/modules/modal.ts Normal file
View File

@ -0,0 +1,42 @@
declare var window: Window;
export class Modal implements Modal {
modal: HTMLElement;
closeButton: HTMLSpanElement;
constructor(modal: HTMLElement, important: boolean = false) {
this.modal = modal;
const closeButton = this.modal.querySelector('span.modal-close')
if (closeButton !== null) {
this.closeButton = closeButton as HTMLSpanElement;
this.closeButton.onclick = this.close;
}
if (!important) {
window.addEventListener('click', (event: Event) => {
if (event.target == this.modal) { this.close(); }
});
}
}
close = (event?: Event) => {
if (event) {
event.preventDefault();
}
this.modal.classList.add('modal-hiding');
const modal = this.modal;
const listenerFunc = function () {
modal.classList.remove('modal-shown');
modal.classList.remove('modal-hiding');
modal.removeEventListener(window.animationEvent, listenerFunc);
};
this.modal.addEventListener(window.animationEvent, listenerFunc, false);
}
show = () => {
this.modal.classList.add('modal-shown');
}
toggle = () => {
if (this.modal.classList.contains('modal-shown')) {
this.close();
} else {
this.show();
}
}
}

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,164 +1,614 @@
import { _get, _post, _delete, rmAttr, addAttr } from "../modules/common.js";
import { Focus, Unfocus } from "../modules/admin.js";
import { _get, _post, toggleLoader } from "../modules/common.js";
interface Profile {
Admin: boolean;
LibraryAccess: string;
FromUser: string;
interface settingsBoolEvent extends Event {
detail: boolean;
}
export const populateProfiles = (noTable?: boolean): void => _get("/profiles", null, function (): void {
if (this.readyState == 4 && this.status == 200) {
const profileList = document.getElementById('profileList');
profileList.textContent = '';
window.availableProfiles = [this.response["default_profile"]];
for (let name in this.response["profiles"]) {
if (name != window.availableProfiles[0]) {
window.availableProfiles.push(name);
}
const reqProfile = this.response["profiles"][name];
if (!noTable && name != "default_profile") {
const profile: Profile = {
Admin: reqProfile["admin"],
LibraryAccess: reqProfile["libraries"],
FromUser: reqProfile["fromUser"]
};
profileList.innerHTML += `
<td nowrap="nowrap" class="align-middle"><strong>${name}</strong></td>
<td nowrap="nowrap" class="align-middle"><input class="${window.bs5 ? "form-check-input" : ""}" type="radio" name="defaultProfile" onclick="setDefaultProfile('${name}')" ${(name == window.availableProfiles[0]) ? "checked" : ""}></td>
<td nowrap="nowrap" class="align-middle">${profile.FromUser}</td>
<td nowrap="nowrap" class="align-middle">${profile.Admin ? "Yes" : "No"}</td>
<td nowrap="nowrap" class="align-middle">${profile.LibraryAccess}</td>
<td nowrap="nowrap" class="align-middle"><button class="btn btn-outline-danger" id="defaultProfile_${name}" onclick="deleteProfile('${name}')">Delete</button></td>
`;
}
interface Meta {
name: string;
description: string;
}
interface Setting {
name: string;
description: string;
required: boolean;
requires_restart: boolean;
type: string;
value: string | boolean | number;
depends_true?: Setting;
depends_false?: Setting;
asElement: () => HTMLElement;
update: (s: Setting) => void;
}
class DOMInput {
protected _input: HTMLInputElement;
private _container: HTMLDivElement;
private _tooltip: HTMLDivElement;
private _required: HTMLSpanElement;
private _restart: HTMLSpanElement;
get name(): string { return this._container.querySelector("span.setting-label").textContent; }
set name(n: string) { this._container.querySelector("span.setting-label").textContent = n; }
get description(): string { return this._tooltip.querySelector("span.content").textContent; }
set description(d: string) {
const content = this._tooltip.querySelector("span.content") as HTMLSpanElement;
content.textContent = d;
if (d == "") {
this._tooltip.classList.add("unfocused");
} else {
this._tooltip.classList.remove("unfocused");
}
}
});
export const openSettings = (settingsList: HTMLElement, settingsContent: HTMLElement, callback?: () => void): void => _get("/config", null, function (): void {
if (this.readyState == 4 && this.status == 200) {
settingsList.textContent = '';
window.config = this.response;
for (const i in window.config["order"]) {
const section: string = window.config["order"][i]
const sectionCollapse = document.createElement('div') as HTMLDivElement;
Unfocus(sectionCollapse);
sectionCollapse.id = section;
get required(): boolean { return this._required.classList.contains("badge"); }
set required(state: boolean) {
if (state) {
this._required.classList.add("badge", "~critical");
this._required.textContent = "*";
} else {
this._required.classList.remove("badge", "~critical");
this._required.textContent = "";
}
}
get requires_restart(): boolean { return this._restart.classList.contains("badge"); }
set requires_restart(state: boolean) {
if (state) {
this._restart.classList.add("badge", "~info");
this._restart.textContent = "R";
} else {
this._restart.classList.remove("badge", "~info");
this._restart.textContent = "";
}
}
const title: string = window.config[section]["meta"]["name"];
const description: string = window.config[section]["meta"]["description"];
const entryListID: string = `${section}_entryList`;
// const footerID: string = `${section}_footer`;
sectionCollapse.innerHTML = `
<div class="card card-body">
<small class="text-muted">${description}</small>
<div class="${entryListID}">
</div>
constructor(inputType: string, setting: Setting, section: string, name: string) {
this._container = document.createElement("div");
this._container.classList.add("setting");
this._container.innerHTML = `
<label class="label">
<span class="setting-label"></span> <span class="setting-required"></span> <span class="setting-restart"></span>
<div class="setting-tooltip tooltip right unfocused">
<i class="icon ri-information-line"></i>
<span class="content sm"></span>
</div>
`;
<input type="${inputType}" class="input ~neutral !normal mt-half">
</label>
`;
this._tooltip = this._container.querySelector("div.setting-tooltip") as HTMLDivElement;
this._required = this._container.querySelector("span.setting-required") as HTMLSpanElement;
this._restart = this._container.querySelector("span.setting-restart") as HTMLSpanElement;
this._input = this._container.querySelector("input[type=" + inputType + "]") as HTMLInputElement;
if (setting.depends_false || setting.depends_true) {
let dependant = setting.depends_true || setting.depends_false;
let state = true;
if (setting.depends_false) { state = false; }
document.addEventListener(`settings-${section}-${dependant}`, (event: settingsBoolEvent) => {
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);
}
for (const x in config[section]["order"]) {
const entry: string = config[section]["order"][x];
if (entry == "meta") {
continue;
get value(): any { return this._input.value; }
set value(v: any) { this._input.value = v; }
update = (s: Setting) => {
this.name = s.name;
this.description = s.description;
this.required = s.required;
this.requires_restart = s.requires_restart;
this.value = s.value;
}
asElement = (): HTMLDivElement => { return this._container; }
}
interface SText extends Setting {
value: string;
}
class DOMText extends DOMInput implements SText {
constructor(setting: Setting, section: string, name: string) { super("text", setting, section, name); }
type: string = "text";
get value(): string { return this._input.value }
set value(v: string) { this._input.value = v; }
}
interface SPassword extends Setting {
value: string;
}
class DOMPassword extends DOMInput implements SPassword {
constructor(setting: Setting, section: string, name: string) { super("password", setting, section, name); }
type: string = "password";
get value(): string { return this._input.value }
set value(v: string) { this._input.value = v; }
}
interface SEmail extends Setting {
value: string;
}
class DOMEmail extends DOMInput implements SEmail {
constructor(setting: Setting, section: string, name: string) { super("email", setting, section, name); }
type: string = "email";
get value(): string { return this._input.value }
set value(v: string) { this._input.value = v; }
}
interface SNumber extends Setting {
value: number;
}
class DOMNumber extends DOMInput implements SNumber {
constructor(setting: Setting, section: string, name: string) { super("number", setting, section, name); }
type: string = "number";
get value(): number { return +this._input.value; }
set value(v: number) { this._input.value = ""+v; }
}
interface SBool extends Setting {
value: boolean;
}
class DOMBool implements SBool {
protected _input: HTMLInputElement;
private _container: HTMLDivElement;
private _tooltip: HTMLDivElement;
private _required: HTMLSpanElement;
private _restart: HTMLSpanElement;
type: string = "bool";
get name(): string { return this._container.querySelector("span.setting-label").textContent; }
set name(n: string) { this._container.querySelector("span.setting-label").textContent = n; }
get description(): string { return this._tooltip.querySelector("span.content").textContent; }
set description(d: string) {
const content = this._tooltip.querySelector("span.content") as HTMLSpanElement;
content.textContent = d;
if (d == "") {
this._tooltip.classList.add("unfocused");
} else {
this._tooltip.classList.remove("unfocused");
}
}
get required(): boolean { return this._required.classList.contains("badge"); }
set required(state: boolean) {
if (state) {
this._required.classList.add("badge", "~critical");
this._required.textContent = "*";
} else {
this._required.classList.remove("badge", "~critical");
this._required.textContent = "";
}
}
get requires_restart(): boolean { return this._restart.classList.contains("badge"); }
set requires_restart(state: boolean) {
if (state) {
this._restart.classList.add("badge", "~info");
this._restart.textContent = "R";
} else {
this._restart.classList.remove("badge", "~info");
this._restart.textContent = "";
}
}
get value(): boolean { return this._input.checked; }
set value(state: boolean) { this._input.checked = state; }
constructor(setting: SBool, section: string, name: string) {
this._container = document.createElement("div");
this._container.classList.add("setting");
this._container.innerHTML = `
<label class="switch">
<input type="checkbox">
<span class="setting-label"></span> <span class="setting-required"></span> <span class="setting-restart"></span>
<div class="setting-tooltip tooltip right unfocused">
<i class="icon ri-information-line"></i>
<span class="content sm"></span>
</div>
</label>
`;
this._tooltip = this._container.querySelector("div.setting-tooltip") as HTMLDivElement;
this._required = this._container.querySelector("span.setting-required") as HTMLSpanElement;
this._restart = this._container.querySelector("span.setting-restart") as HTMLSpanElement;
this._input = this._container.querySelector("input[type=checkbox]") as HTMLInputElement;
const onValueChange = () => {
const event = new CustomEvent(`settings-${section}-${name}`, { "detail": this.value })
document.dispatchEvent(event);
};
this._input.onchange = () => {
onValueChange();
if (this.requires_restart) { document.dispatchEvent(new CustomEvent("settings-requires-restart")); }
};
document.addEventListener(`settings-loaded`, onValueChange);
if (setting.depends_false || setting.depends_true) {
let dependant = setting.depends_true || setting.depends_false;
let state = true;
if (setting.depends_false) { state = false; }
document.addEventListener(`settings-${section}-${dependant}`, (event: settingsBoolEvent) => {
this._input.disabled = (event.detail !== state);
});
}
this.update(setting);
}
update = (s: SBool) => {
this.name = s.name;
this.description = s.description;
this.required = s.required;
this.requires_restart = s.requires_restart;
this.value = s.value;
}
asElement = (): HTMLDivElement => { return this._container; }
}
interface SSelect extends Setting {
options: string[];
value: string;
}
class DOMSelect implements SSelect {
protected _select: HTMLSelectElement;
private _container: HTMLDivElement;
private _tooltip: HTMLDivElement;
private _required: HTMLSpanElement;
private _restart: HTMLSpanElement;
private _options: string[];
type: string = "bool";
get name(): string { return this._container.querySelector("span.setting-label").textContent; }
set name(n: string) { this._container.querySelector("span.setting-label").textContent = n; }
get description(): string { return this._tooltip.querySelector("span.content").textContent; }
set description(d: string) {
const content = this._tooltip.querySelector("span.content") as HTMLSpanElement;
content.textContent = d;
if (d == "") {
this._tooltip.classList.add("unfocused");
} else {
this._tooltip.classList.remove("unfocused");
}
}
get required(): boolean { return this._required.classList.contains("badge"); }
set required(state: boolean) {
if (state) {
this._required.classList.add("badge", "~critical");
this._required.textContent = "*";
} else {
this._required.classList.remove("badge", "~critical");
this._required.textContent = "";
}
}
get requires_restart(): boolean { return this._restart.classList.contains("badge"); }
set requires_restart(state: boolean) {
if (state) {
this._restart.classList.add("badge", "~info");
this._restart.textContent = "R";
} else {
this._restart.classList.remove("badge", "~info");
this._restart.textContent = "";
}
}
get value(): string { return this._select.value; }
set value(v: string) { this._select.value = v; }
get options(): string[] { return this._options; }
set options(opt: string[]) {
this._options = opt;
let innerHTML = "";
for (let option of this._options) {
innerHTML += `<option value="${option}">${option}</option>`;
}
this._select.innerHTML = innerHTML;
}
constructor(setting: SSelect, section: string, name: string) {
this._options = [];
this._container = document.createElement("div");
this._container.classList.add("setting");
this._container.innerHTML = `
<label class="label">
<span class="setting-label"></span> <span class="setting-required"></span> <span class="setting-restart"></span>
<div class="setting-tooltip tooltip right unfocused">
<i class="icon ri-information-line"></i>
<span class="content sm"></span>
</div>
<div class="select ~neutral !normal mt-half">
<select class="settings-select"></select>
</div>
</label>
`;
this._tooltip = this._container.querySelector("div.setting-tooltip") as HTMLDivElement;
this._required = this._container.querySelector("span.setting-required") as HTMLSpanElement;
this._restart = this._container.querySelector("span.setting-restart") as HTMLSpanElement;
this._select = this._container.querySelector("select.settings-select") as HTMLSelectElement;
if (setting.depends_false || setting.depends_true) {
let dependant = setting.depends_true || setting.depends_false;
let state = true;
if (setting.depends_false) { state = false; }
document.addEventListener(`settings-${section}-${dependant}`, (event: settingsBoolEvent) => {
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);
}
update = (s: SSelect) => {
this.name = s.name;
this.description = s.description;
this.required = s.required;
this.requires_restart = s.requires_restart;
this.options = s.options;
this.value = s.value;
}
asElement = (): HTMLDivElement => { return this._container; }
}
interface Section {
meta: Meta;
order: string[];
settings: { [settingName: string]: Setting };
}
class sectionPanel {
private _section: HTMLDivElement;
private _settings: { [name: string]: Setting };
private _sectionName: string;
values: { [field: string]: string } = {};
constructor(s: Section, sectionName: string) {
this._sectionName = sectionName;
this._settings = {};
this._section = document.createElement("div") as HTMLDivElement;
this._section.classList.add("settings-section", "unfocused");
this._section.innerHTML = `
<span class="heading">${s.meta.name}</span>
<p class="support lg">${s.meta.description}</p>
`;
this.update(s);
}
update = (s: Section) => {
for (let name of s.order) {
let setting: Setting = s.settings[name];
if (name in this._settings) {
this._settings[name].update(setting);
} else {
switch (setting.type) {
case "text":
setting = new DOMText(setting, this._sectionName, name);
break;
case "password":
setting = new DOMPassword(setting, this._sectionName, name);
break;
case "email":
setting = new DOMEmail(setting, this._sectionName, name);
break;
case "number":
setting = new DOMNumber(setting, this._sectionName, name);
break;
case "bool":
setting = new DOMBool(setting as SBool, this._sectionName, name);
break;
case "select":
setting = new DOMSelect(setting as SSelect, this._sectionName, name);
break;
}
let entryName: string = window.config[section][entry]["name"];
let required = false;
if (window.config[section][entry]["required"]) {
entryName += ` <sup class="text-danger">*</sup>`;
required = true;
}
if (window.config[section][entry]["requires_restart"]) {
entryName += ` <sup class="text-danger">R</sup>`;
}
if ("description" in window.config[section][entry]) {
entryName +=`
<a class="text-muted" href="#" data-toggle="tooltip" data-placement="right" title="${window.config[section][entry]['description']}"><i class="fa fa-question-circle-o"></i></a>
`;
}
const entryValue: boolean | string = window.config[section][entry]["value"];
const entryType: string = window.config[section][entry]["type"];
const entryGroup = document.createElement('div');
if (entryType == "bool") {
entryGroup.classList.add("form-check");
entryGroup.innerHTML = `
<input class="form-check-input" type="checkbox" value="" id="${section}_${entry}" ${(entryValue as boolean) ? 'checked': ''} ${required ? 'required' : ''}>
<label class="form-check-label" for="${section}_${entry}">${entryName}</label>
`;
(entryGroup.querySelector('input[type=checkbox]') as HTMLInputElement).onclick = function (): void {
const me = this as HTMLInputElement;
for (const y in window.config["order"]) {
const sect: string = window.config["order"][y];
for (const z in window.config[sect]["order"]) {
const ent: string = window.config[sect]["order"][z];
if (`${sect}_${window.config[sect][ent]['depends_true']}` == me.id) {
(document.getElementById(`${sect}_${ent}`) as HTMLInputElement).disabled = !(me.checked);
} else if (`${sect}_${window.config[sect][ent]['depends_false']}` == me.id) {
(document.getElementById(`${sect}_${ent}`) as HTMLInputElement).disabled = me.checked;
}
}
}
};
} else if ((entryType == 'text') || (entryType == 'email') || (entryType == 'password') || (entryType == 'number')) {
entryGroup.classList.add("form-group");
entryGroup.innerHTML = `
<label for="${section}_${entry}">${entryName}</label>
<input type="${entryType}" class="form-control" id="${section}_${entry}" aria-describedby="${entry}" value="${entryValue}" ${required ? 'required' : ''}>
`;
} else if (entryType == 'select') {
entryGroup.classList.add("form-group");
const entryOptions: Array<string> = window.config[section][entry]["options"];
let innerGroup = `
<label for="${section}_${entry}">${entryName}</label>
<select class="form-control" id="${section}_${entry}" ${required ? 'required' : ''}>
`;
for (const z in entryOptions) {
const entryOption = entryOptions[z];
let selected: boolean = (entryOption == entryValue);
innerGroup += `
<option value="${entryOption}" ${selected ? 'selected' : ''}>${entryOption}</option>
`;
}
innerGroup += `</select>`;
entryGroup.innerHTML = innerGroup;
}
sectionCollapse.getElementsByClassName(entryListID)[0].appendChild(entryGroup);
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._settings[name] = setting;
}
settingsList.innerHTML += `
<button type="button" class="list-group-item list-group-item-action" id="${section}_button" onclick="showSetting('${section}')">${title}</button>
`;
settingsContent.appendChild(sectionCollapse);
}
if (callback) {
callback();
}
}
});
get visible(): boolean { return !this._section.classList.contains("unfocused"); }
set visible(s: boolean) {
if (s) {
this._section.classList.remove("unfocused");
} else {
this._section.classList.add("unfocused");
}
}
export function showSetting(id: string, runBefore?: () => void): void {
const els = document.getElementById('settingsLeft').querySelectorAll("button[type=button]:not(.static)") as NodeListOf<HTMLButtonElement>;
for (let i = 0; i < els.length; i++) {
const el = els[i];
if (el.id != `${id}_button`) {
rmAttr(el, "active");
}
const sectEl = document.getElementById(el.id.replace("_button", ""));
if (sectEl.id != id) {
Unfocus(sectEl);
asElement = (): HTMLDivElement => { return this._section; }
}
interface Settings {
order: string[];
sections: { [sectionName: string]: Section };
}
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 _sidebar = document.getElementById("settings-sidebar") as HTMLDivElement;
private _sections: { [name: string]: sectionPanel }
private _buttons: { [name: string]: HTMLSpanElement }
private _needsRestart: boolean = false;
addSection = (name: string, s: Section) => {
const section = new sectionPanel(s, name);
this._sections[name] = section;
this._panel.appendChild(this._sections[name].asElement());
const button = document.createElement("span") as HTMLSpanElement;
button.classList.add("button", "~neutral", "!low", "settings-section-button", "mb-half");
button.textContent = s.meta.name;
button.onclick = () => { this._showPanel(name); };
this._buttons[name] = button;
this._sidebar.appendChild(this._buttons[name]);
}
private _showPanel = (name: string) => {
for (let n in this._sections) {
if (n == name) {
this._sections[name].visible = true;
this._buttons[name].classList.add("selected");
} else {
this._sections[n].visible = false;
this._buttons[n].classList.remove("selected");
}
}
}
addAttr(document.getElementById(`${id}_button`), "active");
const section = document.getElementById(id);
if (runBefore) {
runBefore();
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);
}
Focus(section);
if (screen.width <= 1100) {
// ugly
setTimeout((): void => section.scrollIntoView(<ScrollIntoViewOptions>{ block: "center", behavior: "smooth" }), 200);
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() {
this._sections = {};
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) => {
if (req.readyState == 4) {
if (req.status != 200) {
window.notifications.customError("settingsLoadError", "Failed to load settings.");
return;
}
let settings = req.response as Settings;
for (let name of settings.order) {
if (name in this._sections) {
this._sections[name].update(settings.sections[name]);
} else {
this.addSection(name, settings.sections[name]);
}
}
this._showPanel(settings.order[0]);
this._needsRestart = false;
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.")
}
}
});
}
}

40
ts/modules/tabs.ts Normal file
View File

@ -0,0 +1,40 @@
export class Tabs implements Tabs {
private _current: string = "";
tabs: Array<Tab>;
constructor() {
this.tabs = [];
}
addTab = (tabID: string, preFunc = () => void {}, postFunc = () => void {}) => {
let tab = {} as Tab;
tab.tabID = tabID;
tab.tabEl = document.getElementById("tab-" + tabID) as HTMLDivElement;
tab.buttonEl = document.getElementById("button-tab-" + tabID) as HTMLSpanElement;
tab.buttonEl.onclick = () => { this.switch(tabID); };
tab.preFunc = preFunc;
tab.postFunc = postFunc;
this.tabs.push(tab);
}
get current(): string { return this._current; }
set current(tabID: string) { this.switch(tabID); }
switch = (tabID: string, noRun: boolean = false) => {
this._current = tabID;
for (let t of this.tabs) {
if (t.tabID == tabID) {
t.buttonEl.classList.add("active", "~urge");
if (t.preFunc && !noRun) { t.preFunc(); }
t.tabEl.classList.remove("unfocused");
if (t.postFunc && !noRun) { t.postFunc(); }
document.dispatchEvent(new CustomEvent("tab-change", { detail: tabID }));
} else {
t.buttonEl.classList.remove("active");
t.buttonEl.classList.remove("~urge");
t.tabEl.classList.add("unfocused");
}
}
}
}

16
ts/modules/theme.ts Normal file
View File

@ -0,0 +1,16 @@
export function toggleTheme() {
document.documentElement.classList.toggle('dark-theme');
document.documentElement.classList.toggle('light-theme');
localStorage.setItem('theme', document.documentElement.classList.contains('dark-theme') ? "dark" : "light");
}
export function loadTheme() {
const theme = localStorage.getItem("theme");
if (theme == "dark") {
document.documentElement.classList.add('dark-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

@ -1,84 +0,0 @@
import { _get, _post, _delete, rmAttr, addAttr } from "./modules/common.js";
const ombiDefaultsModal = window.BS.newModal('ombiDefaults');
(document.getElementById('openOmbiDefaults') as HTMLButtonElement).onclick = function (): void {
let button = this as HTMLButtonElement;
button.disabled = true;
const ogHTML = button.innerHTML;
button.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Loading...';
_get("/ombi/users", null, function (): void {
if (this.readyState == 4) {
if (this.status == 200) {
const users = this.response['users'];
const radioList = document.getElementById('ombiUserRadios');
radioList.textContent = '';
let first = true;
for (const i in users) {
const user = users[i];
const radio = document.createElement('div') as HTMLDivElement;
radio.classList.add('form-check');
let checked = '';
if (first) {
checked = 'checked';
first = false;
}
radio.innerHTML = `
<input class="form-check-input" type="radio" name="ombiRadios" id="ombiDefault_${user['id']}" ${checked}>
<label class="form-check-label" for="ombiDefault_${user['id']}">${user['name']}</label>
`;
radioList.appendChild(radio);
}
button.disabled = false;
button.innerHTML = ogHTML;
const submitButton = document.getElementById('storeOmbiDefaults') as HTMLButtonElement;
submitButton.disabled = false;
submitButton.textContent = 'Submit';
addAttr(submitButton, "btn-primary");
rmAttr(submitButton, "btn-success");
rmAttr(submitButton, "btn-danger");
ombiDefaultsModal.show();
}
}
});
};
(document.getElementById('storeOmbiDefaults') as HTMLButtonElement).onclick = function (): void {
let button = this as HTMLButtonElement;
button.disabled = true;
button.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Loading...';
const radio = document.querySelector('input[name=ombiRadios]:checked') as HTMLInputElement;
const data = {
"id": radio.id.replace("ombiDefault_", "")
};
_post("/ombi/defaults", data, function (): void {
if (this.readyState == 4) {
if (this.status == 200 || this.status == 204) {
button.textContent = "Success";
addAttr(button, "btn-success");
rmAttr(button, "btn-danger");
rmAttr(button, "btn-primary");
button.disabled = false;
setTimeout((): void => ombiDefaultsModal.hide(), 1000);
} else {
button.textContent = "Failed";
rmAttr(button, "btn-primary");
addAttr(button, "btn-danger");
setTimeout((): void => {
button.textContent = "Submit";
addAttr(button, "btn-primary");
rmAttr(button, "btn-danger");
button.disabled = false;
}, 1000);
}
}
});
};

View File

@ -1,205 +0,0 @@
import { _post, _get, _delete, rmAttr, addAttr } from "./modules/common.js";
import { generateInvites } from "./modules/invites.js";
import { populateRadios } from "./modules/accounts.js";
import { Focus, Unfocus } from "./modules/admin.js";
import { showSetting, populateProfiles } from "./modules/settings.js";
interface aWindow extends Window {
setDefaultProfile(name: string): void;
deleteProfile(name: string): void;
createProfile(): void;
showSetting(id: string, runBefore?: () => void): void;
config: Object;
modifiedConfig: Object;
}
declare var window: aWindow;
window.config = {};
window.modifiedConfig = {};
window.showSetting = showSetting;
function sendConfig(restart?: boolean): void {
window.modifiedConfig["restart-program"] = restart;
_post("/config", window.modifiedConfig, function (): void {
if (this.readyState == 4) {
const save = document.getElementById("settingsSave") as HTMLButtonElement
if (this.status == 200 || this.status == 204) {
save.textContent = "Success";
addAttr(save, "btn-success");
rmAttr(save, "btn-primary");
setTimeout((): void => {
save.textContent = "Save";
addAttr(save, "btn-primary");
rmAttr(save, "btn-success");
}, 1000);
} else {
save.textContent = "Save";
}
if (restart) {
window.Modals.refresh.show();
}
}
});
}
(document.getElementById('openAbout') as HTMLButtonElement).onclick = (): void => {
window.Modals.about.show();
};
(document.getElementById('profiles_button') as HTMLButtonElement).onclick = (): void => showSetting("profiles", populateProfiles);
window.setDefaultProfile = (name: string): void => _post("/profiles/default", { "name": name }, function (): void {
if (this.readyState == 4) {
if (this.status != 200) {
(document.getElementById(`defaultProfile_${window.availableProfiles[0]}`) as HTMLInputElement).checked = true;
(document.getElementById(`defaultProfile_${name}`) as HTMLInputElement).checked = false;
} else {
generateInvites();
}
}
});
window.deleteProfile = (name: string): void => _delete("/profiles", { "name": name }, function (): void {
if (this.readyState == 4 && this.status == 200) {
populateProfiles();
}
});
const createProfile = (): void => _get("/users", null, function (): void {
if (this.readyState == 4 && this.status == 200) {
window.jfUsers = this.response["users"];
populateRadios();
const submitButton = document.getElementById('storeDefaults') as HTMLButtonElement;
submitButton.disabled = false;
submitButton.textContent = 'Create';
addAttr(submitButton, "btn-primary");
rmAttr(submitButton, "btn-danger");
rmAttr(submitButton, "btn-success");
document.getElementById('defaultsTitle').textContent = `Create Profile`;
document.getElementById('userDefaultsDescription').textContent = `
Create an account and configure it to your liking, then choose it from below to store the settings as a profile. Profiles can be specified per invite, so that any new user on that invite will have the settings applied.`;
document.getElementById('storeHomescreenLabel').textContent = `Store homescreen layout`;
(document.getElementById('defaultsSource') as HTMLSelectElement).value = 'fromUser';
document.getElementById('defaultsSourceSection').classList.add('unfocused');
(document.getElementById('storeDefaults') as HTMLButtonElement).onclick = storeProfile;
Focus(document.getElementById('newProfileBox'));
(document.getElementById('newProfileName') as HTMLInputElement).value = '';
Focus(document.getElementById('defaultUserRadiosBox'));
window.Modals.userDefaults.show();
}
});
window.createProfile = createProfile;
function storeProfile(): void {
this.disabled = true;
this.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Loading...';
const button = document.getElementById('storeDefaults') as HTMLButtonElement;
const radio = document.querySelector('input[name=defaultRadios]:checked') as HTMLInputElement
const name = (document.getElementById('newProfileName') as HTMLInputElement).value;
let id = radio.id.replace("default_", "");
let data = {
"name": name,
"id": id,
"homescreen": false
}
if ((document.getElementById('storeDefaultHomescreen') as HTMLInputElement).checked) {
data["homescreen"] = true;
}
_post("/profiles", data, function (): void {
if (this.readyState == 4) {
if (this.status == 200 || this.status == 204) {
button.textContent = "Success";
addAttr(button, "btn-success");
rmAttr(button, "btn-danger");
rmAttr(button, "btn-primary");
button.disabled = false;
setTimeout((): void => {
button.textContent = "Create";
addAttr(button, "btn-primary");
rmAttr(button, "btn-success");
button.disabled = false;
window.Modals.userDefaults.hide();
}, 1000);
populateProfiles();
generateInvites();
} else {
if ("error" in this.response) {
button.textContent = this.response["error"];
} else if (("policy" in this.response) || ("homescreen" in this.response)) {
button.textContent = "Failed (check console)";
} else {
button.textContent = "Failed";
}
addAttr(button, "btn-danger");
rmAttr(button, "btn-primary");
setTimeout((): void => {
button.textContent = "Create";
addAttr(button, "btn-primary");
rmAttr(button, "btn-danger");
button.disabled = false;
}, 1000);
}
}
});
}
// (document.getElementById('openSettings') as HTMLButtonElement).onclick = (): void => openSettings(document.getElementById('settingsList'), document.getElementById('settingsList'), (): void => settingsModal.show());
(document.getElementById('settingsSave') as HTMLButtonElement).onclick = function (): void {
window.modifiedConfig = {};
const save = this as HTMLButtonElement;
let restartSettingsChanged = false;
let settingsChanged = false;
for (const i in window.config["order"]) {
const section = window.config["order"][i];
for (const x in window.config[section]["order"]) {
const entry = window.config[section]["order"][x];
if (entry == "meta") {
continue;
}
let val: string;
const entryID = `${section}_${entry}`;
const el = document.getElementById(entryID) as HTMLInputElement;
if (el.type == "checkbox") {
val = el.checked.toString();
} else {
val = el.value.toString();
}
if (val != window.config[section][entry]["value"].toString()) {
if (!(section in window.modifiedConfig)) {
window.modifiedConfig[section] = {};
}
window.modifiedConfig[section][entry] = val;
settingsChanged = true;
if (window.config[section][entry]["requires_restart"]) {
restartSettingsChanged = true;
}
}
}
}
const spinnerHTML = `
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>
Loading...`;
if (restartSettingsChanged) {
save.innerHTML = spinnerHTML;
(document.getElementById('applyRestarts') as HTMLButtonElement).onclick = (): void => sendConfig();
const restartButton = document.getElementById('applyAndRestart') as HTMLButtonElement;
if (restartButton) {
restartButton.onclick = (): void => sendConfig(true);
}
window.Modals.restart.show();
} else if (settingsChanged) {
save.innerHTML = spinnerHTML;
sendConfig();
}
};
(document.getElementById('restartModalCancel') as HTMLButtonElement).onclick = (): void => {
document.getElementById('settingsSave').textContent = "Save";
};

260
ts/setup.ts Normal file
View File

@ -0,0 +1,260 @@
// Lord forgive me for this mess, i'll fix it one day i swear
document.getElementById("page-1").scrollIntoView({
behavior: "auto",
block: "center",
inline: "center"
});
const checkAuthRadio = () => {
if ((document.getElementById('manualAuthRadio') as HTMLInputElement).checked) {
document.getElementById('adminOnlyArea').style.display = 'none';
document.getElementById('manualAuthArea').style.display = '';
} else {
document.getElementById('manualAuthArea').style.display = 'none';
document.getElementById('adminOnlyArea').style.display = '';
}
};
for (let radio of ['manualAuthRadio', 'jfAuthRadio']) {
document.getElementById(radio).addEventListener('change', checkAuthRadio);
};
const checkEmailRadio = () => {
(document.getElementById('emailNextButton') as HTMLAnchorElement).href = '#page-5';
(document.getElementById('valBackButton') as HTMLAnchorElement).href = '#page-7';
if ((document.getElementById('emailSMTPRadio') as HTMLInputElement).checked) {
document.getElementById('emailCommonArea').style.display = '';
document.getElementById('emailSMTPArea').style.display = '';
document.getElementById('emailMailgunArea').style.display = 'none';
(document.getElementById('notificationsEnabled') as HTMLInputElement).checked = true;
} else if ((document.getElementById('emailMailgunRadio') as HTMLInputElement).checked) {
document.getElementById('emailCommonArea').style.display = '';
document.getElementById('emailSMTPArea').style.display = 'none';
document.getElementById('emailMailgunArea').style.display = '';
(document.getElementById('notificationsEnabled') as HTMLInputElement).checked = true;
} else if ((document.getElementById('emailDisabledRadio') as HTMLInputElement).checked) {
document.getElementById('emailCommonArea').style.display = 'none';
document.getElementById('emailSMTPArea').style.display = 'none';
document.getElementById('emailMailgunArea').style.display = 'none';
(document.getElementById('emailNextButton') as HTMLAnchorElement).href = '#page-8';
(document.getElementById('valBackButton') as HTMLAnchorElement).href = '#page-4';
(document.getElementById('notificationsEnabled') as HTMLInputElement).checked = false;
}
};
for (let radio of ['emailDisabledRadio', 'emailSMTPRadio', 'emailMailgunRadio']) {
document.getElementById(radio).addEventListener('change', checkEmailRadio);
}
const checkSSL = () => {
var label = document.getElementById('emailSSL_TLSLabel');
if ((document.getElementById('emailSSL_TLS') as HTMLInputElement).checked) {
label.textContent = 'Use SSL/TLS';
} else {
label.textContent = 'Use STARTTLS';
}
};
document.getElementById('emailSSL_TLS').addEventListener('change', checkSSL);
var pwrEnabled = document.getElementById('pwrEnabled') as HTMLInputElement;
const checkPwrEnabled = () => {
if (pwrEnabled.checked) {
document.getElementById('pwrArea').style.display = '';
} else {
document.getElementById('pwrArea').style.display = 'none';
}
};
pwrEnabled.addEventListener('change', checkPwrEnabled);
var invEnabled = document.getElementById("invEnabled") as HTMLInputElement;
const checkInvEnabled = () => {
if (invEnabled.checked) {
document.getElementById('invArea').style.display = '';
} else {
document.getElementById('invArea').style.display = 'none';
}
};
invEnabled.addEventListener('change', checkInvEnabled);
var valEnabled = document.getElementById("valEnabled") as HTMLInputElement;
const checkValEnabled = () => {
const valArea = document.getElementById("valArea");
if (valEnabled.checked) {
valArea.style.display = '';
} else {
valArea.style.display = 'none';
}
};
valEnabled.addEventListener('change', checkValEnabled);
checkValEnabled();
checkInvEnabled();
checkSSL();
checkAuthRadio();
checkEmailRadio();
checkPwrEnabled();
var jfValid = false
document.getElementById('jfTestButton').onclick = () => {
let testButton = document.getElementById('jfTestButton') as HTMLInputElement;
let nextButton = document.getElementById('jfNextButton') as HTMLAnchorElement;
let jfData = {};
jfData['jfHost'] = (document.getElementById('jfHost') as HTMLInputElement).value;
jfData['jfUser'] = (document.getElementById('jfUser') as HTMLInputElement).value;
jfData['jfPassword'] = (document.getElementById('jfPassword') as HTMLInputElement).value;
let valid = true;
for (let val in jfData) {
if (jfData[val] == "") {
valid = false;
}
}
if (!valid) {
if (!testButton.classList.contains('btn-danger')) {
testButton.classList.add('btn-danger');
testButton.textContent = 'Fill out fields above.';
setTimeout(function() {
if (testButton.classList.contains('btn-danger')) {
testButton.classList.remove('btn-danger');
testButton.textContent = 'Test';
}
}, 2000);
}
} else {
testButton.disabled = true;
testButton.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Testing...';
nextButton.classList.add('disabled');
nextButton.setAttribute('aria-disabled', 'true');
var req = new XMLHttpRequest();
req.open("POST", "/jellyfin/test", true);
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
req.responseType = 'json';
req.onreadystatechange = function() {
if (this.readyState == 4) {
testButton.disabled = false;
testButton.className = '';
if (this.response['success'] == true) {
testButton.classList.add('btn', 'btn-success');
testButton.textContent = 'Success';
nextButton.classList.remove('disabled');
nextButton.setAttribute('aria-disabled', 'false');
} else {
testButton.classList.add('btn', 'btn-danger');
testButton.textContent = 'Failed';
};
};
};
req.send(JSON.stringify(jfData));
}
};
document.getElementById('submitButton').onclick = () => {
const submitButton = document.getElementById('submitButton') as HTMLInputElement;
submitButton.disabled = true;
submitButton.innerHTML =`
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>
Submitting...
`;
let config = {};
config['jellyfin'] = {};
config['ui'] = {};
config['password_validation'] = {};
config['email'] = {};
config['password_resets'] = {};
config['invite_emails'] = {};
config['mailgun'] = {};
config['smtp'] = {};
config['notifications'] = {};
// Page 2: Auth
if ((document.getElementById('jfAuthRadio') as HTMLInputElement).checked) {
config['ui']['jellyfin_login'] = 'true';
config['ui']['admin_only'] = ""+(document.getElementById("jfAuthAdminOnly") as HTMLInputElement).checked;
} else {
config['ui']['username'] = (document.getElementById('manualAuthUsername') as HTMLInputElement).value;
config['ui']['password'] = (document.getElementById('manualAuthPassword') as HTMLInputElement).value;
config['ui']['email'] = (document.getElementById('manualAuthEmail') as HTMLInputElement).value;
};
// Page 3: Connect to jellyfin
config['jellyfin']['server'] = (document.getElementById('jfHost') as HTMLInputElement).value;
let publicAddress = (document.getElementById('jfPublicHost') as HTMLInputElement).value;
if (publicAddress != "") {
config['jellyfin']['public_server'] = publicAddress;
}
config['jellyfin']['username'] = (document.getElementById('jfUser') as HTMLInputElement).value;
config['jellyfin']['password'] = (document.getElementById('jfPassword') as HTMLInputElement).value;
// Page 4: Email (Page 5, 6, 7 are only used if this is enabled)
if ((document.getElementById('emailDisabledRadio') as HTMLInputElement).checked) {
config['password_resets']['enabled'] = 'false';
config['invite_emails']['enabled'] = 'false';
config['notifications']['enabled'] = 'false';
} else {
if ((document.getElementById('emailSMTPRadio') as HTMLInputElement).checked) {
config['smtp']['encryption'] = (document.getElementById('emailSSL_TLS') as HTMLInputElement).checked ? "ssl_tls" : "starttls";
config['email']['method'] = 'smtp';
config['smtp']['server'] = (document.getElementById('emailSMTPServer') as HTMLInputElement).value;
config['smtp']['port'] = (document.getElementById('emailSMTPPort') as HTMLInputElement).value;
config['smtp']['password'] = (document.getElementById('emailSMTPPassword') as HTMLInputElement).value;
config['email']['address'] = (document.getElementById('emailSMTPAddress') as HTMLInputElement).value;
} else {
config['email']['method'] = 'mailgun';
config['mailgun']['api_url'] = (document.getElementById('emailMailgunURL') as HTMLInputElement).value;
config['mailgun']['api_key'] = (document.getElementById('emailMailgunKey') as HTMLInputElement).value;
config['email']['address'] = (document.getElementById('emailMailgunAddress') as HTMLInputElement).value;
};
config['notifications']['enabled'] = ""+(document.getElementById('notificationsEnabled') as HTMLInputElement).checked;
// Page 5: Email formatting
config['email']['from'] = (document.getElementById('emailSender') as HTMLInputElement).value;
config['email']['date_format'] = (document.getElementById('emailDateFormat') as HTMLInputElement).value;
config['email']['use_24h'] = ""+(document.getElementById('email24hTimeRadio') as HTMLInputElement).checked;
config['email']['message'] = (document.getElementById('emailMessage') as HTMLInputElement).value;
// Page 6: Password Resets
if (pwrEnabled.checked) {
config['password_resets']['enabled'] = 'true';
config['password_resets']['watch_directory'] = (document.getElementById('pwrJfPath') as HTMLInputElement).value;
config['password_resets']['subject'] = (document.getElementById('pwrSubject') as HTMLInputElement).value;
} else {
config['password_resets']['enabled'] = 'false';
};
// Page 7: Invite Emails
if ((document.getElementById('invEnabled') as HTMLInputElement).checked) {
config['invite_emails']['enabled'] = 'true';
config['invite_emails']['url_base'] = (document.getElementById('invURLBase') as HTMLInputElement).value;
config['invite_emails']['subject'] = (document.getElementById('invSubject') as HTMLInputElement).value;
} else {
config['invite_emails']['enabled'] = 'false';
};
};
// Page 8: Password Validation
if ((document.getElementById('valEnabled') as HTMLInputElement).checked) {
config['password_validation']['enabled'] = 'true';
config['password_validation']['min_length'] = (document.getElementById('valLength') as HTMLInputElement).value;
config['password_validation']['upper'] = (document.getElementById('valUpper') as HTMLInputElement).value;
config['password_validation']['lower'] = (document.getElementById('valLower') as HTMLInputElement).value;
config['password_validation']['number'] = (document.getElementById('valNumber') as HTMLInputElement).value;
config['password_validation']['special'] = (document.getElementById('valSpecial') as HTMLInputElement).value;
} else {
config['password_validation']['enabled'] = 'false';
};
// Page 9: Messages
config['ui']['contact_message'] = (document.getElementById('msgContact') as HTMLInputElement).value;
config['ui']['help_message'] = (document.getElementById('msgHelp') as HTMLInputElement).value;
config['ui']['success_message'] = (document.getElementById('msgSuccess') as HTMLInputElement).value;
// Send it
config["restart-program"] = true;
let req = new XMLHttpRequest();
req.open("POST", "/config", true);
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
req.responseType = 'json';
req.onreadystatechange = function() {
if (this.readyState == 4) {
submitButton.disabled = false;
submitButton.className = '';
submitButton.classList.add('btn', 'btn-success');
submitButton.textContent = 'Success';
};
};
req.send(JSON.stringify(config));
};

View File

@ -1,6 +1,6 @@
{
"compilerOptions": {
"outDir": "../data/static",
"outDir": "../js",
"target": "es6",
"lib": ["dom", "es2017"],
"typeRoots": ["./node_modules/@types", "./typings"]

View File

@ -1,62 +1,91 @@
declare interface ModalConstructor {
(id: string, find?: boolean): BSModal;
declare interface Modal {
modal: HTMLElement;
closeButton: HTMLSpanElement
show: () => void;
close: (event?: Event) => void;
toggle: () => void;
}
declare interface BSModal {
el: HTMLDivElement;
modal: any;
show: () => void;
hide: () => void;
interface ArrayConstructor {
from(arrayLike: any, mapFn?, thisArg?): Array<any>;
}
declare interface Window {
getComputedStyle(element: HTMLElement, pseudoElt: HTMLElement): any;
bsVersion: number;
bs5: boolean;
BS: Bootstrap;
URLBase: string;
Modals: BSModals;
modals: Modals;
cssFile: string;
availableProfiles: Array<any>;
availableProfiles: string[];
jfUsers: Array<Object>;
notifications_enabled: boolean;
notificationsEnabled: boolean;
emailEnabled: boolean;
ombiEnabled: boolean;
usernameEnabled: boolean;
token: string;
buttonWidth: number;
transitionEvent: string;
animationEvent: string;
tabs: Tabs;
invites: inviteList;
notifications: NotificationBox;
}
declare interface tooltipTrigger {
(): void;
declare interface NotificationBox {
connectionError: () => void;
customError: (type: string, message: string) => void;
customPositive: (type: string, bold: string, message: string) => void;
}
declare interface Bootstrap {
newModal: ModalConstructor;
triggerTooltips: tooltipTrigger;
Compat?(): void;
declare interface Tabs {
current: string;
tabs: Array<Tab>;
addTab: (tabID: string, preFunc?: () => void, postFunc?: () => void) => void;
switch: (tabID: string, noRun?: boolean) => void;
}
declare interface BSModals {
login: BSModal;
userDefaults: BSModal;
users: BSModal;
restart: BSModal;
refresh: BSModal;
about: BSModal;
delete: BSModal;
newUser: BSModal;
declare interface Tab {
tabID: string;
tabEl: HTMLDivElement;
buttonEl: HTMLSpanElement;
preFunc?: () => void;
postFunc?: () => void;
}
declare interface Modals {
about: Modal;
login: Modal;
addUser: Modal;
modifyUser: Modal;
deleteUser: Modal;
settingsRestart: Modal;
settingsRefresh: Modal;
ombiDefaults?: Modal;
profiles: Modal;
addProfile: Modal;
}
interface Invite {
code?: string;
expiresIn?: string;
empty: boolean;
remainingUses?: string;
email?: string;
usedBy?: Array<Array<string>>;
usedBy?: string[][];
created?: string;
notifyExpiry?: boolean;
notifyCreation?: boolean;
profile?: string;
}
interface inviteList {
empty: boolean;
invites: { [code: string]: Invite }
add: (invite: Invite) => void;
reload: () => void;
}
declare interface SubmitEvent extends Event {
submitter: HTMLInputElement;
}
declare var config: Object;
declare var modifiedConfig: Object;

View File

@ -13,14 +13,12 @@ func gcHTML(gc *gin.Context, code int, file string, templ gin.H) {
}
func (app *appContext) AdminPage(gc *gin.Context) {
bs5 := app.config.Section("ui").Key("bs5").MustBool(false)
emailEnabled, _ := app.config.Section("invite_emails").Key("enabled").Bool()
notificationsEnabled, _ := app.config.Section("notifications").Key("enabled").Bool()
ombiEnabled := app.config.Section("ombi").Key("enabled").MustBool(false)
gcHTML(gc, http.StatusOK, "admin.html", gin.H{
"urlBase": app.URLBase,
"bs5": bs5,
"cssFile": app.cssFile,
"cssClass": app.cssClass,
"contactMessage": "",
"email_enabled": emailEnabled,
"notifications": notificationsEnabled,
@ -42,7 +40,7 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
}
gcHTML(gc, http.StatusOK, "form-loader.html", gin.H{
"urlBase": app.URLBase,
"cssFile": app.cssFile,
"cssClass": app.cssClass,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
"helpMessage": app.config.Section("ui").Key("help_message").String(),
"successMessage": app.config.Section("ui").Key("success_message").String(),
@ -50,14 +48,12 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
"validate": app.config.Section("password_validation").Key("enabled").MustBool(false),
"requirements": app.validator.getCriteria(),
"email": email,
"bs5": app.config.Section("ui").Key("bs5").MustBool(false),
"username": !app.config.Section("email").Key("no_username").MustBool(false),
"lang": app.storage.lang.Form["strings"],
})
} else {
gcHTML(gc, 404, "invalidCode.html", gin.H{
"bs5": app.config.Section("ui").Key("bs5").MustBool(false),
"cssFile": app.cssFile,
"cssClass": app.cssClass,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
})
}
@ -65,8 +61,7 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
func (app *appContext) NoRouteHandler(gc *gin.Context) {
gcHTML(gc, 404, "404.html", gin.H{
"bs5": app.config.Section("ui").Key("bs5").MustBool(false),
"cssFile": app.cssFile,
"cssClass": app.cssClass,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
})
}