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

Compare commits

...

3 Commits

Author SHA1 Message Date
2d443fb50b
fully functional invites tab, flesh out bugs
also:
* fix select chevrons appearing above modals
* add custom errors and use them for http errors, also now appear above
  modals.
* functional logout button
* slightly cleaned up admin.ts
2020-12-30 23:48:01 +00:00
28187d0aa0
add connection error notification, implement notify/delete function 2020-12-30 18:31:38 +00:00
1b41621569
implement invites as a class, use tooltip for email send status
the DOMInvite class represents an invite on the dom, and modifying its
attributes applies the changes on the web page. Email send status
message is now on the right of the invite and represented by an icon.
Hovering reveals the "Sent to"/"Failed to send to" message.
2020-12-30 15:32:44 +00:00
8 changed files with 770 additions and 229 deletions

View File

@ -135,6 +135,10 @@ 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;
@ -270,6 +274,10 @@ sup.\~critical, .text-critical {
resize: vertical;
}
.overflow {
overflow: visible;
}
select {
color: inherit;
border: 0 solid var(--color-neutral-300);
@ -287,4 +295,9 @@ p.top {
margin-top: 0px;
}
#notification-box {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: 16;
}

View File

@ -1,7 +1,7 @@
.modal {
display: none;
position: fixed;
z-index: 1;
z-index: 12;
top: 0;
left: 0;
width: 100%;

View File

@ -16,9 +16,17 @@
position: absolute;
z-index: 1;
top: -1rem;
}
.tooltip.right .content {
left: 120%;
}
.tooltip.left .content {
right: 120%;
}
.tooltip .content.sm {
font-size: 0.8rem;
}

View File

@ -15,7 +15,6 @@
<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">
<aside class="aside sm ~critical mb-half unfocused"></aside>
<input type="submit" class="button ~urge !normal full-width center supra submit" value="Login">
</form>
</div>
@ -30,7 +29,7 @@
<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="images/banner.svg" class="mt-1" alt="jfa-go banner">
<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>
@ -113,6 +112,7 @@
<input type="submit" class="button ~urge !normal full-width center supra submit" value="Submit">
</form>
</div>
<div id="notification-box"></div>
<div class="page-container max-w-screen-lg px-6 py-4 mx-auto lg:mx-auto md:py-8">
<div class="mb-1">
<header class="flex flex-wrap items-center justify-between">
@ -125,171 +125,61 @@
</div>
<div class="mb-1">
<div class="text-neutral-700">
<span class="button ~critical !normal mb-1">Logout</span>
<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>
<span id="modalButton" class="button ~neutral !normal mb-1">Trigger Login</span>
</div>
</div>
<div id="invitesTab">
<div class="card ~neutral !low invites mb-1">
<span class="heading">Invites</span>
<div class="inv">
<div class="card ~neutral !normal inv-header flex-expand mt-half">
<div class="inv-codearea">
<a href="#" class="code monospace mr-1">ZD8ZeC55Jcpmbtv54FuVM3</a>
<span class="button ~info !normal">Copy</span>
</div>
<div class="inv-infoarea">
<span class="inv-expiry mr-1">Expires in 30m</span>
<span class="button ~critical !normal">Delete</span>
<label>
<i class="icon ri-arrow-down-s-line not-rotated"></i>
<input type="checkbox" class="toggle-details unfocused">
</label>
</div>
</div>
<div class="card ~neutral !normal mt-half inv-details mt-half unfocused">
<div class="inv-row flex-expand align-top">
<div class="inv-profilearea">
<p class="supra mb-1 top">Profile</p>
<div class="select ~neutral !normal inv-profileselect inline-block">
<select>
<option>Friends</option>
<option>Family</option>
<option>No Profile</option>
</select>
</div>
<p class="label supra">Notify on:</p>
<label class="switch block">
<input type="checkbox" class="inv-notify-expiry">
<span>On expiry</span>
</label>
<label class="switch block">
<input type="checkbox" class="inv-notify-creation">
<span>On user creation</span>
</label>
</div>
<div class="block">
<p class="supra mb-1 top">Created <strong class="inv-created">10/12/20 18:46</strong></p>
<p class="supra mb-1">Remaining uses <strong class="inv-remaining">8</strong></p>
</div>
<div class="card ~neutral !low inv-created-users">
<strong class="supra table-header">Created users</strong>
<table class="table inv-table">
<thead>
<tr>
<th>Name</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<tr>
<td>jeff</td>
<td>10/12/20 19:00</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="inv">
<div class="card ~neutral !normal inv-header flex-expand mt-half">
<div class="inv-codearea">
<a href="#" class="code monospace mr-1">ZD8ZeC55Jcpmbtv54FuVM3</a>
<span class="button ~info !normal">Copy</span>
</div>
<div class="inv-infoarea">
<span class="inv-expiry mr-1">Expires in 30m</span>
<span class="button ~critical !normal">Delete</span>
<label>
<i class="icon ri-arrow-down-s-line not-rotated"></i>
<input type="checkbox" class="toggle-details unfocused">
</label>
</div>
</div>
<div class="card ~neutral !normal mt-half inv-details mt-half unfocused">
<div class="inv-row flex-expand align-top">
<div class="inv-profilearea">
<p class="supra mb-1 top">Profile</p>
<div class="select ~neutral !normal inv-profileselect inline-block">
<select>
<option>Friends</option>
<option>Family</option>
<option>No Profile</option>
</select>
</div>
<p class="label supra">Notify on:</p>
<label class="switch block">
<input type="checkbox" class="inv-notify-expiry">
<span>On expiry</span>
</label>
<label class="switch block">
<input type="checkbox" class="inv-notify-creation">
<span>On user creation</span>
</label>
</div>
<div class="block">
<p class="supra mb-1 top">Created <strong class="inv-created">10/12/20 18:46</strong></p>
<p class="supra mb-1">Remaining uses <strong class="inv-remaining">8</strong></p>
</div>
<div class="card ~neutral !low inv-created-users empty">
<strong class="supra table-header">Created users</strong>
<p class="content">None yet!</p>
</div>
</div>
</div>
</div>
<div id="invites"></div>
</div>
<div class="card ~neutral !low create-inv">
<div class="card ~neutral !low">
<span class="heading">Create</span>
<div class="row">
<div class="row" id="create-inv">
<div class="card ~neutral !normal col">
<label class="label supra" for="inv-days">Days</label>
<label class="label supra" for="create-days">Days</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="inv-days">
<select id="create-days">
<option>0</option>
</select>
</div>
<label class="label supra" for="inv-hours">Hours</label>
<label class="label supra" for="create-hours">Hours</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="inv-hours">
<select id="create-hours">
<option>0</option>
</select>
</div>
<label class="label supra" for="inv-minutes">Minutes</label>
<label class="label supra" for="create-minutes">Minutes</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="inv-minutes">
<select id="create-minutes">
<option>0</option>
</select>
</div>
</div>
<div class="card ~neutral !normal col">
<label class="label supra" for="inv-uses">Number of uses</label>
<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="inv-uses" class="input ~neutral !normal mr-1" value=1>
<label for="inv-inf-uses" class="button ~neutral !normal">
<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="inv-inf-uses" aria-label="Set uses to infinite">
<input type="checkbox" class="unfocused" id="create-inf-uses" aria-label="Set uses to infinite">
</label>
</div>
<p class="support unfocused"><span class="badge ~critical">Warning</span> invites with infinite uses can be used abusively.</p>
<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" id="inv-profile">
<select>
<option>Friends</option>
<option>Family</option>
<option>No Profile</option>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="create-profile">
</select>
</div>
<label class="label supra">Send to</label>
<div class="flex-expand mb-1 mt-half">
<input type="email" id="inv-email" class="input ~neutral !normal mr-1" placeholder="example@example.com">
<label for="inv-email-enabled" class="button ~neutral !normal">
<input type="checkbox" id="inv-email-enabled" aria-label="Send to address enabled">
<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>
<span class="button ~urge !normal supra full-width center lg">Create</span>
<span class="button ~urge !normal supra full-width center lg" id="create-submit">Create</span>
</div>
</div>
</div>
@ -356,7 +246,7 @@
<div class="setting">
<label class="label" for="settings-input">
Input <span class="badge ~critical">*</span>
<div class="tooltip">
<div class="tooltip right">
<i class="icon ri-information-line"></i>
<span class="content sm">An example tooltip.</span>
</div>

View File

@ -1,84 +1,23 @@
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 { _post, notificationBox, whichAnimationEvent } from "./modules/common.js";
loadTheme();
(document.getElementById('button-theme') as HTMLSpanElement).onclick = toggleTheme;
const whichAnimationEvent = () => {
const el = document.createElement("fakeElement");
if (el.style["animation"] !== void 0) {
return "animationend";
}
return "webkitAnimationEnd";
}
window.animationEvent = whichAnimationEvent();
const toggles: HTMLInputElement[] = Array.from(document.getElementsByClassName('toggle-details'));
for (let toggle of toggles) {
toggle.onclick = () => {
const el = toggle.parentElement.parentElement.parentElement.nextElementSibling as HTMLDivElement;
if (el.classList.contains("focused")) {
el.classList.toggle("focused");
el.classList.toggle("unfocused");
} else {
el.classList.toggle("unfocused");
el.classList.toggle("focused");
}
toggle.previousElementSibling.classList.toggle("rotated");
toggle.previousElementSibling.classList.toggle("not-rotated");
};
}
const checkInfUses = function (check: HTMLInputElement, mode = 2) {
const uses = document.getElementById('inv-uses') as HTMLInputElement;
if (mode == 2) {
uses.disabled = check.checked;
check.parentElement.classList.toggle('~neutral');
check.parentElement.classList.toggle('~urge');
check.parentElement.parentElement.nextElementSibling.classList.toggle('unfocused');
} else if (mode == 1) {
uses.disabled = true;
check.checked = true;
check.parentElement.classList.remove('~neutral');
check.parentElement.classList.add('~urge');
check.parentElement.parentElement.nextElementSibling.classList.remove('unfocused');
} else {
uses.disabled = false;
check.checked = false;
check.parentElement.classList.remove('~urge');
check.parentElement.classList.add('~neutral');
check.parentElement.parentElement.nextElementSibling.classList.add('unfocused');
}
};
window.token = "";
let invInfUses = document.getElementById('inv-inf-uses') as HTMLInputElement;
invInfUses.onclick = () => { checkInfUses(invInfUses, 2); };
window.availableProfiles = window.availableProfiles || [];
const checkEmailEnabled = function (check: HTMLInputElement, mode = 2) {
const input = document.getElementById('inv-email') as HTMLInputElement;
if (mode == 2) {
input.disabled = !check.checked;
check.parentElement.classList.toggle('~neutral');
check.parentElement.classList.toggle('~urge');
} else if (mode == 1) {
input.disabled = false;
check.checked = true;
check.parentElement.classList.remove('~neutral');
check.parentElement.classList.add('~urge');
} else {
input.disabled = true;
check.checked = false;
check.parentElement.classList.remove('~urge');
check.parentElement.classList.add('~neutral');
}
};
var inviteCreator = new createInvite();
let invEmailEnabled = document.getElementById('inv-email-enabled') as HTMLInputElement;
invEmailEnabled.onchange = () => { checkEmailEnabled(invEmailEnabled, 2); };
checkInfUses(invInfUses, 0);
checkEmailEnabled(invEmailEnabled, 0);
window.invites = new inviteList();
window.notifications = new notificationBox(document.getElementById('notification-box') as HTMLDivElement, 5);
const loadAccounts = function () {
@ -186,7 +125,6 @@ window.tabs.addTab("accountsTab", loadAccounts);
window.modals = {} as Modals;
window.modals.login = new Modal(document.getElementById('modal-login'), true);
document.getElementById('modalButton').onclick = window.modals.login.toggle;
window.modals.addUser = new Modal(document.getElementById('modal-add-user'));
(document.getElementById('accounts-add-user') as HTMLSpanElement).onclick = window.modals.addUser.toggle;
@ -211,15 +149,6 @@ window.tabs.addTab("accountsTab", loadAccounts);
document.getElementById('form-ombi-defaults').addEventListener('submit', window.modals.ombiDefaults.close);
})();
function errorMessage(aside: HTMLElement, content: string, timeout: number = 4) {
aside.textContent = content;
aside.classList.remove("unfocused");
setTimeout(() => { aside.classList.add("unfocused"); }, timeout*1000);
}
window.token = "";
function login(username: string, password: string) {
const req = new XMLHttpRequest();
req.responseType = 'json';
@ -245,8 +174,7 @@ function login(username: string, password: string) {
errorMsg = "Unknown error";
}
if (!refresh) {
errorMessage(window.modals.login.modal.querySelector("aside"), errorMsg, 5);
window.notifications.customError("loginError", errorMsg);
} else {
window.modals.login.show();
}
@ -254,6 +182,10 @@ function login(username: string, password: string) {
const data = this.response;
window.token = data["token"];
window.modals.login.close();
window.invites.reload();
setInterval(window.invites.reload, 30*1000);
document.getElementById("logout-button").classList.remove("unfocused");
/*generateInvites();
setInterval((): void => generateInvites(), 60 * 1000);
addOptions(30, document.getElementById('days') as HTMLSelectElement);
@ -281,10 +213,18 @@ document.getElementById('form-login').addEventListener('submit', (event: Event)
const username = (document.getElementById("login-user") as HTMLInputElement).value;
const password = (document.getElementById("login-password") as HTMLInputElement).value;
if (!username || !password) {
errorMessage(window.modals.login.modal.querySelector("aside"), "The username and/or password were left blank." , 4);
window.notifications.customError("loginError", "The username and/or password were left blank.");
return;
}
login(username, password, true);
login(username, password);
});
login("", "");
(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;
}
});

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,90 @@ 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 } = {};
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;
}
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);
}
}
export const whichAnimationEvent = () => {
const el = document.createElement("fakeElement");
if (el.style["animation"] !== void 0) {
return "animationend";
}
return "webkitAnimationEnd";
}

596
ts/modules/invites.ts Normal file
View File

@ -0,0 +1,596 @@
import { _get, _post, _delete, toClipboard } from "../modules/common.js";
export class DOMInvite implements Invite {
// TODO
// add setProfile
//
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 };
}
_post("/invites/notify", state, (req: XMLHttpRequest) => {
if (req.readyState == 4 && !(req.status == 200 || req.status == 204)) {
revertChanges();
}
});
}
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;
}
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 icon = this._infoArea.querySelector(".tooltip i");
const chip = this._infoArea.querySelector(".tooltip span.inv-email-chip");
const tooltip = this._infoArea.querySelector(".tooltip span.content") as HTMLSpanElement;
if (address == "") {
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 {
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 += `
</tbody>
</table>
`;
this._userTable.innerHTML = innerHTML;
}
private _created: string;
get created(): string { return this._created; }
set created(created: string) {
this._created = created;
this._middle.querySelector("strong.inv-created").textContent = created;
}
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;
}
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>`;
}
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", "flex-expand", "mt-half", "overflow");
this._codeArea = document.createElement('div') as HTMLDivElement;
this._header.appendChild(this._codeArea);
this._codeArea.classList.add("inv-codearea");
this._codeArea.innerHTML = `
<a class="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 mr-1">
<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 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", "inv-details");
const detailsInner = document.createElement('div') as HTMLDivElement;
this._details.appendChild(detailsInner);
detailsInner.classList.add("inv-row", "flex-expand", "align-top");
this._left = document.createElement('div') as HTMLDivElement;
detailsInner.appendChild(this._left);
this._left.classList.add("inv-profilearea");
this._left.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>
<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>
`;
(this._left.querySelector("select") as HTMLSelectElement).onchange = this.updateProfile;
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;
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;
window.availableProfiles = data["profiles"];
document.dispatchEvent(this._profileLoadEvent);
if (data["invites"] === undefined || data["invites"] == null || data["invites"].length == 0) {
this.empty = true;
return;
}
// 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);
}
}
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 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 HTMLInputElement; // Actually a <span> but this allows "disabled"
private _profile = document.getElementById("create-profile") 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 = () => {
this._createButton.disabled = (this.days + this.hours + this.minutes == 0);
}
get days(): number {
return +(document.getElementById("create-days") as HTMLSelectElement).value;
}
set days(n: number) {
(document.getElementById("create-days") as HTMLSelectElement).value = ""+n;
this._checkDurationValidity();
}
get hours(): number {
return +(document.getElementById("create-hours") as HTMLSelectElement).value;
}
set hours(n: number) {
(document.getElementById("create-hours") as HTMLSelectElement).value = ""+n;
this._checkDurationValidity();
}
get minutes(): number {
return +(document.getElementById("create-minutes") as HTMLSelectElement).value;
}
set minutes(n: number) {
(document.getElementById("create-minutes") as HTMLSelectElement).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 = () => {
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 && (req.status == 200 || req.status == 204)) {
document.dispatchEvent(this._newInviteEvent);
}
});
}
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;
document.addEventListener("profileLoadEvent", () => { this.loadProfiles(); }, false);
}
}

View File

@ -14,14 +14,21 @@ declare interface Window {
URLBase: string;
modals: Modals;
cssFile: string;
availableProfiles: Array<any>;
availableProfiles: string[];
jfUsers: Array<Object>;
notifications_enabled: boolean;
token: string;
buttonWidth: number;
transitionEvent: string;
animationEvent: string;
tabs: Tabs
tabs: Tabs;
invites: inviteList;
notifications: NotificationBox;
}
declare interface NotificationBox {
connectionError: () => void;
customError: (type: string, message: string) => void;
}
declare interface Tabs {
@ -54,15 +61,21 @@ declare interface Modals {
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 var config: Object;
declare var modifiedConfig: Object;