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

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.
This commit is contained in:
Harvey Tindall 2020-12-30 15:32:44 +00:00
parent 95db48d8f8
commit 1b41621569
Signed by: hrfee
GPG Key ID: BBC65952848FB1A2
7 changed files with 585 additions and 106 deletions

View File

@ -135,6 +135,10 @@ sup.\~critical, .text-critical {
color: var(--color-critical-normal-content); color: var(--color-critical-normal-content);
} }
.grey {
color: var(--color-neutral-500);
}
.aside.sm { .aside.sm {
font-size: 0.8rem; font-size: 0.8rem;
padding: 0.8rem; padding: 0.8rem;

View File

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

View File

@ -133,11 +133,13 @@
<div id="invitesTab"> <div id="invitesTab">
<div class="card ~neutral !low invites mb-1"> <div class="card ~neutral !low invites mb-1">
<span class="heading">Invites</span> <span class="heading">Invites</span>
<div id="invites">
<div class="inv"> <div class="inv">
<div class="card ~neutral !normal inv-header flex-expand mt-half"> <div class="card ~neutral !normal inv-header flex-expand mt-half">
<div class="inv-codearea"> <div class="inv-codearea">
<a href="#" class="code monospace mr-1">ZD8ZeC55Jcpmbtv54FuVM3</a> <a href="#" class="code monospace mr-1">ZD8ZeC55Jcpmbtv54FuVM3</a>
<span class="button ~info !normal">Copy</span> <span class="button ~info !normal" title="Copy invite link"><i class="ri-file-copy-line"></i></span>
<span class="support ml-1">Failed to send to this.email@addre.ss</span>
</div> </div>
<div class="inv-infoarea"> <div class="inv-infoarea">
<span class="inv-expiry mr-1">Expires in 30m</span> <span class="inv-expiry mr-1">Expires in 30m</span>
@ -241,6 +243,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="card ~neutral !low create-inv"> <div class="card ~neutral !low create-inv">
<span class="heading">Create</span> <span class="heading">Create</span>
<div class="row"> <div class="row">
@ -356,7 +359,7 @@
<div class="setting"> <div class="setting">
<label class="label" for="settings-input"> <label class="label" for="settings-input">
Input <span class="badge ~critical">*</span> Input <span class="badge ~critical">*</span>
<div class="tooltip"> <div class="tooltip right">
<i class="icon ri-information-line"></i> <i class="icon ri-information-line"></i>
<span class="content sm">An example tooltip.</span> <span class="content sm">An example tooltip.</span>
</div> </div>

View File

@ -1,6 +1,7 @@
import { toggleTheme, loadTheme } from "./modules/theme.js"; import { toggleTheme, loadTheme } from "./modules/theme.js";
import { Modal } from "./modules/modal.js"; import { Modal } from "./modules/modal.js";
import { Tabs } from "./modules/tabs.js"; import { Tabs } from "./modules/tabs.js";
import { inviteList } from "./modules/invites.js";
loadTheme(); loadTheme();
(document.getElementById('button-theme') as HTMLSpanElement).onclick = toggleTheme; (document.getElementById('button-theme') as HTMLSpanElement).onclick = toggleTheme;
@ -13,7 +14,7 @@ const whichAnimationEvent = () => {
return "webkitAnimationEnd"; return "webkitAnimationEnd";
} }
window.animationEvent = whichAnimationEvent(); window.animationEvent = whichAnimationEvent();
const toggles: HTMLInputElement[] = Array.from(document.getElementsByClassName('toggle-details')); /*const toggles: HTMLInputElement[] = Array.from(document.getElementsByClassName('toggle-details'));
for (let toggle of toggles) { for (let toggle of toggles) {
toggle.onclick = () => { toggle.onclick = () => {
const el = toggle.parentElement.parentElement.parentElement.nextElementSibling as HTMLDivElement; const el = toggle.parentElement.parentElement.parentElement.nextElementSibling as HTMLDivElement;
@ -27,7 +28,7 @@ for (let toggle of toggles) {
toggle.previousElementSibling.classList.toggle("rotated"); toggle.previousElementSibling.classList.toggle("rotated");
toggle.previousElementSibling.classList.toggle("not-rotated"); toggle.previousElementSibling.classList.toggle("not-rotated");
}; };
} }*/
const checkInfUses = function (check: HTMLInputElement, mode = 2) { const checkInfUses = function (check: HTMLInputElement, mode = 2) {
const uses = document.getElementById('inv-uses') as HTMLInputElement; const uses = document.getElementById('inv-uses') as HTMLInputElement;
@ -254,6 +255,8 @@ function login(username: string, password: string) {
const data = this.response; const data = this.response;
window.token = data["token"]; window.token = data["token"];
window.modals.login.close(); window.modals.login.close();
window.invites.reload();
setInterval(window.invites.reload, 30*1000);
/*generateInvites(); /*generateInvites();
setInterval((): void => generateInvites(), 60 * 1000); setInterval((): void => generateInvites(), 60 * 1000);
addOptions(30, document.getElementById('days') as HTMLSelectElement); addOptions(30, document.getElementById('days') as HTMLSelectElement);
@ -288,3 +291,5 @@ document.getElementById('form-login').addEventListener('submit', (event: Event)
}); });
login("", ""); login("", "");
window.invites = new inviteList();

View File

@ -50,13 +50,13 @@ export const rmAttr = (el: HTMLElement, attr: string): void => {
export const addAttr = (el: HTMLElement, attr: string): void => el.classList.add(attr); 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(); let req = new XMLHttpRequest();
req.open("GET", window.URLBase + url, true); req.open("GET", window.URLBase + url, true);
req.responseType = 'json'; req.responseType = 'json';
req.setRequestHeader("Authorization", "Bearer " + window.token); req.setRequestHeader("Authorization", "Bearer " + window.token);
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8'); req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
req.onreadystatechange = onreadystatechange; req.onreadystatechange = () => { onreadystatechange(req); };
req.send(JSON.stringify(data)); req.send(JSON.stringify(data));
}; };
@ -81,3 +81,19 @@ export function _delete(url: string, data: Object, onreadystatechange: () => voi
req.send(JSON.stringify(data)); 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);
}
}

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

@ -0,0 +1,436 @@
import { _get, _post, _delete, toClipboard } from "../modules/common.js";
export class DOMInvite implements Invite {
// TODO
updateNotify = () => {}; // SetNotify
delete = () => {}; // deleteInvite
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;
};
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");
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>
`;
(this._codeArea.querySelector("span.button") as HTMLSpanElement).onclick = () => { toClipboard(this._codeLink); };
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._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);
}
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(); }
}
// TODO:
// implement inviteList as a class
// inviteList has empty boolean value, set true adds an emptyInvite
export class inviteList implements inviteList {
private _list: HTMLDivElement;
private _empty: boolean;
invites: { [code: string]: DOMInvite };
constructor() {
this._list = document.getElementById('invites') as HTMLDivElement;
this.empty = true;
this.invites = {};
}
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"];
for (let code in this.invites) {
this.invites[code].loadProfiles();
}
if (data["invites"] === undefined || 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;
}
/*
function addInvite(invite: Invite): void {
const list = document.getElementById('invites') as HTMLDivElement;
const container = document.createElement('div') as HTMLDivElement;
container.classList.add("inv");
container.id = "invite-" + invite.code;
// invite header
const header = document.createElement("div") as HTMLDivElement;
(() => {
header.classList.add("card", "~neutral", "!normal", "inv-header", "flex-expand", "mt-half");
// code area (code, copy button, "sent to" message)
const codeArea = document.createElement('div') as HTMLDivElement;
(() => {
codeArea.classList.add('inv-codearea');
if (invite.empty) {
codeArea.innerHTML = `
<a class="code monospace">None</a>
`;
return;
}
const link = window.location.href.split("#")[0] + "invite/" + invite.code;
let innerHTML = `
<a class="code monospace mr-1" href="${link}">${invite.code.replace(/-/g, '-')}</a>
<span class="button ~info !normal" title="Copy invite link"><i class="ri-file-copy-line"></i></span>
`;
if (invite.email) {
let email = invite.email;
if (!invite.email.includes("Failed to send to")) {
email = "Sent to " + email;
}
innerHTML += `
<span class="support ml-1">${email}</span>
`;
}
codeArea.innerHTML = innerHTML;
})();
header.appendChild(codeArea);
// info area (expiry, delete, dropdown button)
const infoArea = document.createElement('div') as HTMLDivElement;
(() => {
infoArea.classList.add("inv-infoarea");
infoArea.innerHTML = `
<span class="inv-expiry mr-1">${invite.expiresIn}</span>
<span class="button ~critical !normal inv-delete">Delete</span>
<label>
<i class="icon ri-arrow-down-s-line not-rotated"></i>
<input class="toggle-details
`; /// LINE 70
})();
})();
if (invite.empty) {
container.appendChild(header);
list.appendChild(container);
return;
}
}
*/

View File

@ -21,7 +21,8 @@ declare interface Window {
buttonWidth: number; buttonWidth: number;
transitionEvent: string; transitionEvent: string;
animationEvent: string; animationEvent: string;
tabs: Tabs tabs: Tabs;
invites: inviteList;
} }
declare interface Tabs { declare interface Tabs {
@ -54,15 +55,21 @@ declare interface Modals {
interface Invite { interface Invite {
code?: string; code?: string;
expiresIn?: string; expiresIn?: string;
empty: boolean;
remainingUses?: string; remainingUses?: string;
email?: string; email?: string;
usedBy?: Array<Array<string>>; usedBy?: string[][];
created?: string; created?: string;
notifyExpiry?: boolean; notifyExpiry?: boolean;
notifyCreation?: boolean; notifyCreation?: boolean;
profile?: string; profile?: string;
} }
interface inviteList {
empty: boolean;
invites: { [code: string]: Invite }
add: (invite: Invite) => void;
reload: () => void;
}
declare var config: Object; declare var config: Object;
declare var modifiedConfig: Object; declare var modifiedConfig: Object;