1
0
mirror of https://github.com/hrfee/jfa-go.git synced 2024-12-26 19:10:10 +00:00

Compare commits

...

2 Commits

Author SHA1 Message Date
7c808b56f7
api: adjust a couple of URIs
adjusted some things, likke changing /newUser to /user/invite.
2024-08-21 20:35:08 +01:00
2057823b7a
setup: flex-ify, light/dark, keep page position on reload
got rid of a bunch of m[l/r/x/y]-x tailwind classes and used more
flex-[row/col] gap-2's. UI should be more consistent in general, and
with the admin UI.

The page you were on is actually read from the URL on reload, however
does not keep settings (implemented just for ease of UI editing,
really).

`missing-colors.js` preprocessor script now applies dark prefixes for
<section>s, but like with cards, does not apply a default ~neutral to
those without, so that <section class=""> looks different to <section
class="~neutral">.

Light/dark selector added to setup too, and the actual mode given to the
browser through CSS `color-scheme` is correct, meaning things like textareas, checkboxes and
controls are now colored according to the theme.
2024-08-21 18:31:54 +01:00
17 changed files with 634 additions and 523 deletions

View File

@ -86,7 +86,7 @@ func activitySourceToString(v ActivitySource) string {
return "anon"
}
// @Summary Get the requested set of activities, Paginated, filtered and sorted.
// @Summary Get the requested set of activities, Paginated, filtered and sorted. Is a POST because of some issues I was having, ideally should be a GET.
// @Produce json
// @Param GetActivitiesDTO body GetActivitiesDTO true "search parameters"
// @Success 200 {object} GetActivitiesRespDTO

View File

@ -11,7 +11,6 @@ import (
lm "github.com/hrfee/jfa-go/logmessages"
"github.com/itchyny/timefmt-go"
"github.com/lithammer/shortuuid/v3"
"github.com/timshannon/badgerhold/v4"
)
const (
@ -332,23 +331,8 @@ func (app *appContext) GetInvites(gc *gin.Context) {
}
invites = append(invites, invite)
}
fullProfileList := app.storage.GetProfiles()
profiles := make([]string, len(fullProfileList))
if len(profiles) != 0 {
defaultProfile := app.storage.GetDefaultProfile()
profiles[0] = defaultProfile.Name
i := 1
if len(fullProfileList) > 1 {
app.storage.db.ForEach(badgerhold.Where("Name").Ne(profiles[0]), func(p *Profile) error {
profiles[i] = p.Name
i++
return nil
})
}
}
resp := getInvitesDTO{
Profiles: profiles,
Invites: invites,
Invites: invites,
}
gc.JSON(200, resp)
}
@ -360,7 +344,7 @@ func (app *appContext) GetInvites(gc *gin.Context) {
// @Failure 500 {object} stringResponse
// @Router /invites/profile [post]
// @Security Bearer
// @tags Profiles & Settings
// @tags Invites
func (app *appContext) SetProfile(gc *gin.Context) {
var req inviteProfileDTO
gc.BindJSON(&req)

View File

@ -9,7 +9,34 @@ import (
"github.com/timshannon/badgerhold/v4"
)
// @Summary Get a list of profiles
// @Summary Get the names of all available profile.
// @Produce json
// @Success 200 {object} getProfileNamesDTO
// @Router /profiles/names [get]
// @Security Bearer
// @tags Profiles & Settings
func (app *appContext) GetProfileNames(gc *gin.Context) {
fullProfileList := app.storage.GetProfiles()
profiles := make([]string, len(fullProfileList))
if len(profiles) != 0 {
defaultProfile := app.storage.GetDefaultProfile()
profiles[0] = defaultProfile.Name
i := 1
if len(fullProfileList) > 1 {
app.storage.db.ForEach(badgerhold.Where("Name").Ne(profiles[0]), func(p *Profile) error {
profiles[i] = p.Name
i++
return nil
})
}
}
resp := getProfileNamesDTO{
Profiles: profiles,
}
gc.JSON(200, resp)
}
// @Summary Get all available profiles, indexed by their names.
// @Produce json
// @Success 200 {object} getProfilesDTO
// @Router /profiles [get]

View File

@ -20,7 +20,7 @@ import (
// @Produce json
// @Param newUserDTO body newUserDTO true "New user request object"
// @Success 200
// @Router /users [post]
// @Router /user [post]
// @Security Bearer
// @tags Users
func (app *appContext) NewUserFromAdmin(gc *gin.Context) {
@ -68,7 +68,7 @@ func (app *appContext) NewUserFromAdmin(gc *gin.Context) {
// @Param newUserDTO body newUserDTO true "New user request object"
// @Success 200 {object} PasswordValidation
// @Failure 400 {object} PasswordValidation
// @Router /newUser [post]
// @Router /user/invite [post]
// @tags Users
func (app *appContext) NewUserFromInvite(gc *gin.Context) {
/*
@ -709,7 +709,7 @@ func (app *appContext) SaveAnnounceTemplate(gc *gin.Context) {
respondBool(200, true, gc)
}
// @Summary Save an announcement as a template for use or editing later.
// @Summary Gets the names of each available announcement template.
// @Produce json
// @Success 200 {object} getAnnouncementsDTO
// @Router /users/announce [get]

View File

@ -18,6 +18,8 @@
--bg-light: #fff;
--bg-dark: #101010;
color-scheme: light;
}
.light {
@ -26,6 +28,7 @@
.dark {
--settings-section-button-filter: 80%;
color-scheme: dark !important;
}
.dark body {
@ -456,3 +459,14 @@ input[type="checkbox" i], [class^="ri-"], [class*=" ri-"], .ri-refresh-line:befo
margin-bottom: -0.5rem;
padding-bottom: 0.5rem;
}
section.section:not(.\~neutral) {
background-color: inherit;
}
@layer components {
.switch input {
@apply mr-1;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -102,6 +102,7 @@
"jellyseerr": {
"title": "Jellyseerr",
"description": "Jellyseerr is an alternative to Ombi, and integrates with jfa-go slightly better. Again, after setup is finished, go to Settings to create a profile and add a template for new Jellyseerr accounts.",
"importExisting": "Import existing users",
"importExistingDescription": "If enabled, your existing users will have contact details and preferences from jfa-go synchronized."
},
"messages": {
@ -164,6 +165,7 @@
"helpMessages": {
"title": "Help Messages",
"description": "These messages will display in the account creation page and in some emails.",
"markdownMessageNotice": "Contents of some emails, pages and messages can be customized further with markdown in Settings.",
"contactMessage": "Contact Message",
"contactMessageNotice": "Displays at the bottom of all pages except admin.",
"helpMessage": "Help Message",

View File

@ -88,6 +88,10 @@ type getProfilesDTO struct {
DefaultProfile string `json:"default_profile"`
}
type getProfileNamesDTO struct {
Profiles []string `json:"profiles"` // List of profiles (name only)
}
type profileChangeDTO struct {
Name string `json:"name" example:"DefaultProfile" binding:"required"` // Name of the profile
}
@ -123,8 +127,7 @@ type inviteDTO struct {
}
type getInvitesDTO struct {
Profiles []string `json:"profiles"` // List of profiles (name only)
Invites []inviteDTO `json:"invites"` // List of invites
Invites []inviteDTO `json:"invites"` // List of invites
}
// fake DTO, if i actually used this the code would be a lot longer

View File

@ -135,7 +135,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
router.GET(p+"/lang/:page/:file", app.ServeLang)
router.GET(p+"/token/login", app.getTokenLogin)
router.GET(p+"/token/refresh", app.getTokenRefresh)
router.POST(p+"/newUser", app.NewUserFromInvite)
router.POST(p+"/user/invite", app.NewUserFromInvite)
router.Use(static.Serve(p+"/invite/", app.webFS))
router.GET(p+"/invite/:invCode", app.InviteProxy)
if app.config.Section("captcha").Key("enabled").MustBool(false) {
@ -182,7 +182,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
router.POST(p+"/logout", app.Logout)
api.DELETE(p+"/users", app.DeleteUsers)
api.GET(p+"/users", app.GetUsers)
api.POST(p+"/users", app.NewUserFromAdmin)
api.POST(p+"/user", app.NewUserFromAdmin)
api.POST(p+"/users/extend", app.ExtendExpiry)
api.DELETE(p+"/users/:id/expiry", app.RemoveExpiry)
api.POST(p+"/users/enable", app.EnableDisableUsers)
@ -191,6 +191,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
api.DELETE(p+"/invites", app.DeleteInvite)
api.POST(p+"/invites/profile", app.SetProfile)
api.GET(p+"/profiles", app.GetProfiles)
api.GET(p+"/profiles/names", app.GetProfileNames)
api.POST(p+"/profiles/default", app.SetDefaultProfile)
api.POST(p+"/profiles", app.CreateProfile)
api.DELETE(p+"/profiles", app.DeleteProfile)

View File

@ -33,7 +33,7 @@ function fixHTML(infile, outfile) {
}
}
let doc = new parser.load(f);
for (let item of ["badge", "chip", "shield", "input", "table", "button", "portal", "select", "aside", "card", "field", "textarea"]) {
for (let item of ["badge", "chip", "shield", "input", "table", "button", "portal", "select", "aside", "card", "field", "textarea", "section"]) {
let items = doc("."+item);
items.each((i, elem) => {
let hasColor = false;
@ -50,8 +50,8 @@ function fixHTML(infile, outfile) {
}
if (!hasColor) {
if (!hasDark(doc(elem))) {
// card without ~neutral look different than with.
if (item != "card") doc(elem).addClass("~neutral");
// card (and sections in sectioned cards) without ~neutral look different than with.
if (item != "card" && item != "section") doc(elem).addClass("~neutral");
doc(elem).addClass("dark:~d_neutral");
}
}

View File

@ -6,7 +6,7 @@ import { inviteList, createInvite } from "./modules/invites.js";
import { accountsList } from "./modules/accounts.js";
import { settingsList } from "./modules/settings.js";
import { activityList } from "./modules/activity.js";
import { ProfileEditor } from "./modules/profiles.js";
import { ProfileEditor, reloadProfileNames } from "./modules/profiles.js";
import { _get, _post, notificationBox, whichAnimationEvent, bindManualDropdowns } from "./modules/common.js";
import { Updater } from "./modules/update.js";
import { Login } from "./modules/login.js";
@ -187,7 +187,7 @@ login.onLogin = () => {
console.log("Logged in.");
window.updater = new Updater();
// FIXME: Decide whether to autoload activity or not
window.invites.reload()
reloadProfileNames();
setInterval(() => { window.invites.reload(); accounts.reload(); }, 30*1000);
const currentTab = window.tabs.current;
switch (currentTab) {

View File

@ -289,7 +289,7 @@ const create = (event: SubmitEvent) => {
send.captcha_text = captcha.input.value;
}
}
_post("/newUser", send, (req: XMLHttpRequest) => {
_post("/user/invite", send, (req: XMLHttpRequest) => {
if (req.readyState != 4) return;
removeLoader(submitSpan);
let vals = req.response as ValidatorRespDTO;

View File

@ -1128,7 +1128,7 @@ export class accountsList {
}
}
toggleLoader(button);
_post("/users", send, (req: XMLHttpRequest) => {
_post("/user", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
toggleLoader(button);
if (req.status == 200 || (req.response["user"] as boolean)) {

View File

@ -1,5 +1,6 @@
import { _get, _post, _delete, toClipboard, toggleLoader, toDateString } from "../modules/common.js";
import { DiscordUser, newDiscordSearch } from "../modules/discord.js";
import { reloadProfileNames } from "../modules/profiles.js";
class DOMInvite implements Invite {
updateNotify = (checkbox: HTMLInputElement) => {
@ -431,7 +432,6 @@ 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 };
@ -502,13 +502,9 @@ export class inviteList implements inviteList {
this._list.appendChild(domInv.asElement());
}
reload = (callback?: () => void) => _get("/invites", null, (req: XMLHttpRequest) => {
reload = (callback?: () => void) => reloadProfileNames(() => _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;
@ -534,7 +530,7 @@ export class inviteList implements inviteList {
if (callback) callback();
}
})
}));
}
export const inviteURLEvent = (id: string) => { return new CustomEvent(inviteList._inviteURLEvent, {"detail": id}) };

View File

@ -1,5 +1,13 @@
import { _get, _post, _delete, toggleLoader } from "../modules/common.js";
export const profileLoadEvent = new CustomEvent("profileLoadEvent");
export const reloadProfileNames = (then?: () => void) => _get("/profiles/names", null, (req: XMLHttpRequest) => {
if (req.readyState != 4) return;
window.availableProfiles = req.response["profiles"];
document.dispatchEvent(profileLoadEvent);
if (then) then();
});
interface Profile {
admin: boolean;
libraries: string;

View File

@ -1,6 +1,7 @@
export class ThemeManager {
private _themeButton: HTMLElement = null;
private _metaTag: HTMLMetaElement;
private _beforeTransition = () => {
const doc = document.documentElement;
@ -45,6 +46,7 @@ export class ThemeManager {
}
constructor(button?: HTMLElement) {
this._metaTag = document.querySelector("meta[name=color-scheme]") as HTMLMetaElement;
const theme = localStorage.getItem("theme");
if (theme == "dark") {
this._enable(true);
@ -54,18 +56,22 @@ export class ThemeManager {
this._enable(true);
}
if (arguments.length == 1)
if (button)
this.bindButton(button);
}
private _toggle = () => {
let metaValue = "light dark";
this._beforeTransition();
if (!document.documentElement.classList.contains('dark')) {
document.documentElement.classList.add('dark');
metaValue = "dark light";
} else {
document.documentElement.classList.remove('dark');
}
localStorage.setItem('theme', document.documentElement.classList.contains('dark') ? "dark" : "light");
// this._metaTag.setAttribute("content", metaValue);
};
private _enable = (dark: boolean) => {
@ -80,6 +86,8 @@ export class ThemeManager {
document.documentElement.classList.remove(opposite);
}
document.documentElement.classList.add(mode);
// this._metaTag.setAttribute("content", `${mode} ${opposite}`);
};
enable = (dark: boolean) => {

View File

@ -1,5 +1,6 @@
import { _get, _post, toggleLoader, notificationBox } from "./modules/common.js";
import { lang, LangFile, loadLangSelector } from "./modules/lang.js";
import { ThemeManager } from "./modules/theme.js";
interface sWindow extends Window {
messages: {};
@ -8,6 +9,7 @@ interface sWindow extends Window {
declare var window: sWindow;
window.URLBase = "";
const theme = new ThemeManager(document.getElementById("button-theme"));
window.notifications = new notificationBox(document.getElementById('notification-box') as HTMLDivElement, 5);
@ -68,7 +70,16 @@ class Checkbox {
constructor(el: HTMLElement, depends?: string, dependsTrue?: boolean, section?: string, setting?: string) {
this._el = el as HTMLInputElement;
this._hideEl = this._el as HTMLElement;
if (this._hideEl.parentElement.tagName == "LABEL") { this._hideEl = this._hideEl.parentElement; }
if (this._hideEl.parentElement.tagName == "LABEL") {
this._hideEl = this._hideEl.parentElement;
} else if (this._hideEl.parentElement.classList.contains("switch")) {
if (this._hideEl.parentElement.parentElement.tagName == "LABEL") {
this._hideEl = this._hideEl.parentElement.parentElement;
} else {
this._hideEl = this._hideEl.parentElement;
}
}
if (section && setting) {
this._section = section;
this._setting = setting;
@ -86,11 +97,11 @@ class Checkbox {
});
}
if (this._el.hasAttribute("checked")) {
/* if (this._el.hasAttribute("checked")) {
this._el.checked = true;
} else {
this._el.checked = false;
}
} */
this.broadcast();
}
}
@ -217,7 +228,7 @@ class LangSelect extends Select {
}
this.value = "en-us";
}
});
}, true);
}
}
@ -448,23 +459,32 @@ settings["email"]["method"].onchange = emailMethodChange;
settings["messages"]["enabled"].onchange = emailMethodChange;
emailMethodChange();
const getParentCard = (el: HTMLElement): HTMLDivElement => {
let pEl = el.parentElement;
while (pEl.tagName != "html") {
if (pEl.classList.contains("card")) return pEl as HTMLDivElement;
pEl = pEl.parentElement;
}
return pEl as HTMLDivElement;
};
const jellyfinLoginAccessChange = () => {
const adminOnly = settings["ui"]["admin_only"].value == "true";
const allowAll = settings["ui"]["allow_all"].value == "true";
const adminOnlyEl = document.getElementById("ui-admin_only") as HTMLInputElement;
const allowAllEls = [document.getElementById("ui-allow_all"), document.getElementById("description-ui-allow_all")];
const nextButton = adminOnlyEl.parentElement.parentElement.parentElement.querySelector("span.next") as HTMLSpanElement;
const allowAllEl = document.getElementById("ui-allow_all") as HTMLInputElement;
const nextButton = getParentCard(adminOnlyEl).querySelector("span.next") as HTMLSpanElement;
if (adminOnly && !allowAll) {
(allowAllEls[0] as HTMLInputElement).disabled = true;
allowAllEl.disabled = true;
adminOnlyEl.disabled = false;
nextButton.removeAttribute("disabled");
} else if (!adminOnly && allowAll) {
adminOnlyEl.disabled = true;
(allowAllEls[0] as HTMLInputElement).disabled = false;
allowAllEl.disabled = false;
nextButton.removeAttribute("disabled");
} else {
adminOnlyEl.disabled = false;
(allowAllEls[0] as HTMLInputElement).disabled = false;
allowAllEl.disabled = false;
nextButton.setAttribute("disabled", "true")
}
};
@ -495,18 +515,17 @@ for (let section in settings) {
const pageNames: string[][] = [];
window.history.replaceState("welcome", "Setup - jfa-go");
(() => {
const pushState = window.history.pushState;
window.history.pushState = function (data: any, __: string, _: string | URL) {
pushState.apply(window.history, arguments);
let ev = { state: data as string } as PopStateEvent;
window.onpopstate(ev);
};
})();
const changePage = (title: string, pageTitle: string) => {
const urlParams = new URLSearchParams(window.location.search);
const lang = urlParams.get("lang");
let page = "/#" + title;
if (lang) { page += "?lang=" + lang; }
window.history.pushState(title || "welcome", pageTitle, page);
};
const cards = Array.from(document.getElementById("page-container").getElementsByClassName("card")) as Array<HTMLDivElement>;
(window as any).cards = cards;
window.onpopstate = (event: PopStateEvent) => {
console.log("CALLLLLLLL", event);
if (event.state === "welcome") {
cards[0].classList.remove("unfocused");
for (let i = 1; i < cards.length; i++) { cards[i].classList.add("unfocused"); }
@ -521,6 +540,35 @@ window.onpopstate = (event: PopStateEvent) => {
}
};
const cards = Array.from(document.getElementsByClassName("page-container")[0].querySelectorAll(".card.sectioned")) as Array<HTMLDivElement>;
(window as any).cards = cards;
const changePageToIndex = (title: string, pageTitle: string, i: number) => {
cards[i].classList.remove("unfocused");
const urlParams = new URLSearchParams(window.location.search);
const lang = urlParams.get("lang");
let page = "/#" + title;
if (lang) { page += "?lang=" + lang; }
console.log("pushing", title, pageTitle, page);
window.history.pushState(title || "welcome", pageTitle, page);
};
const changePage = (title: string, pageTitle: string) => {
let found = false;
for (let i = 0; i < cards.length; i++) {
if (!found && pageNames[i][0] == title && !(cards[i].classList.contains("hidden"))) {
found = true;
changePageToIndex(title, pageTitle, i);
} else {
cards[i].classList.add("unfocused");
}
}
if (!found) {
changePageToIndex(title, pageTitle, 0);
}
window.scrollTo(0, 0);
};
(() => {
for (let i = 0; i < cards.length; i++) {
const card = cards[i];
@ -534,36 +582,34 @@ window.onpopstate = (event: PopStateEvent) => {
let pageTitle = titleEl.textContent + " - jfa-go";
pageNames.push([title, pageTitle]);
if (back) { back.addEventListener("click", () => {
let found = false;
for (let ind = cards.length - 1; ind >= 0; ind--) {
cards[ind].classList.add("unfocused");
if (ind < i && !(cards[ind].classList.contains("hidden")) && !found) {
cards[ind].classList.remove("unfocused");
if (ind < i && !(cards[ind].classList.contains("hidden"))) {
changePage(pageNames[ind][0], pageNames[ind][1]);
found = true;
break;
}
}
window.scrollTo(0, 0);
}); }
if (next) {
const func = () => {
if (next.hasAttribute("disabled")) return;
let found = false;
for (let ind = 0; ind < cards.length; ind++) {
cards[ind].classList.add("unfocused");
if (ind > i && !(cards[ind].classList.contains("hidden")) && !found) {
cards[ind].classList.remove("unfocused");
if (ind > i && !(cards[ind].classList.contains("hidden"))) {
changePage(pageNames[ind][0], pageNames[ind][1]);
found = true;
break;
}
}
window.scrollTo(0, 0);
};
next.addEventListener("click", func)
}
}
})();
(() => {
let initialLocation = window.location.hash.replace("#", "") || "welcome";
changePage(initialLocation, "Setup - jfa-go");
})();
// window.history.replaceState("welcome", "Setup - jfa-go",);
(() => {
const button = document.getElementById("jellyfin-test-connection") as HTMLSpanElement;
const ogText = button.textContent;