const FLAGS_OAUTH2_SCOPES = [ [1 << 0, 'View your Profile'], [1 << 1, 'View your Email'], [1 << 2, 'View your Connections'], ] const FLAGS_PROFILE_BADGES = [ [1 << 0, 'The Boss', '/icons/badge-crown'], [1 << 1, 'V.I.P', '/icons/badge-star'], [1 << 2, 'Picross Master', '/icons/badge-picross'], ] /** Variables that change if in localhost */ const ENV_URLS = (() => { const IN_DEBUG = window.location.hostname.includes('localhost') return { 'status': IN_DEBUG ? 'http://localhost:8200' : 'https://status.suzzygames.com', 'api': IN_DEBUG ? 'http://localhost:8000' : 'https://apis.suzzygames.com', 'cdn': IN_DEBUG ? 'http://localhost:8100' : 'https://content.suzzygames.com', } })() function TestEmailSuite(v: string): string | undefined { if (v.length === 0) return 'Required Field' if (!/^[^@]+@[^@]+\.[^@]+$/.test(v)) return 'Invalid or Malformed Email Address' } function TestPasswordSuite(v: string): string | undefined { if (v.length === 0) return 'Required Field' if (!/[^A-Za-z0-9]/.test(v)) return 'Password must contain a Special Character' if (!/[A-Z]/.test(v)) return 'Password must contain an Uppercase Character' if (!/[a-z]/.test(v)) return 'Password must contain a Lowercase Character' if (!/[0-9]/.test(v)) return 'Password must contain a Number' if (v.length < 8) return 'Password cannot be shorter than 8 characters' if (v.length > 256) return 'Password cannot be longer than 256 characters' } function TestUsernameSuite(v: string): string | undefined { if (v.length === 0) return 'Required Field' if (!/[\w]/.test(v)) return 'Username must be Alphanumeric [a-zA-z0-9_]' if (v.length < 3) return 'Username cannot be shorter than 3 characters' if (v.length > 32) return 'Password cannot be longer than 32 characters' } function TestPasscodeSuite(v: string): string | undefined { if (v.length === 0) return 'Required Field' if (!/^([0-9]{6}|[0-9ABCDEF]{8})$/.test(v)) return 'Invalid or Malformed Passcode' } /** Set **disabled** attribute to all DOM elements with the **dynamic** attribute */ function SetDynamic(enabled: boolean) { document.querySelectorAll('*[dynamic]').forEach(e => { enabled ? e.removeAttribute('disabled') : e.setAttribute('disabled', 'true') }) } /** Follow Redirect using the **redirect** param in url */ function FollowRedirect(defaultPath: string) { const search = new URLSearchParams(document.location.href.split('?').at(1) || '') const redirect = search.get('redirect') || defaultPath try { // Follow Given URL only if whitelisted const url = new URL(redirect) window.location.assign( url.host.endsWith('suzzygames.com') ? redirect : defaultPath ) } catch (err) { // Expect a Redirect that begins with a forward slash window.location.assign( redirect.startsWith('/') ? redirect : defaultPath ) } } /** Generate an URL to a User Avatar/banner or Application Icon */ function ImageURL( folder: 'avatars' | 'banners' | 'icons', id: number, hash: string | null, animated: boolean, size: 'small' | 'medium' | 'large' ) { // Use Default avatars/icons otherwise fetch from CDN return hash === null ? '/assets/images/' + (folder === 'avatars' ? `default-user${(id % 6)}.png` : 'default-icon.png') : ENV_URLS.cdn + `/${folder}/${id}/${hash}/${animated && hash.startsWith('a_') ? 'gif' : 'webp'}/${size}` } interface ApiResponse { success: boolean // Request Sucessful? error?: Error // Request Error (if any) ok: boolean // Status Code ranges between 200-299 code: number // HTTP Response Code body: string // HTTP Response Body json: BodyType // HTTP Response Body parsed as JSON } function CallAPI( method: string, path: string, authorization: 'mfa' | 'basic' | 'public', withBody?: Record ): Promise> { return new Promise(ok => { // Send HTTP Request let body: string | undefined let result: ApiResponse let headers = new Headers() let credentials: RequestCredentials = (authorization === 'public' ? 'omit' : 'include') if (authorization === 'mfa') { // Check for MFA Token throw 'mfa not implemented' } if (withBody !== undefined) { headers.set('content-type', 'application/json') body = JSON.stringify(withBody) } fetch(ENV_URLS.api + path, { method, body, headers, credentials }) .then(async resp => { let json: any = undefined const text = await resp.text() try { json = JSON.parse(text) } catch (e) { } result = { 'success': true, 'error': undefined, 'ok': resp.ok, 'code': resp.status, 'body': text, 'json': json, } }) .catch(error => { result = { success: false, error, ok: false, code: -1, body: '', json: undefined as any } }) .finally(() => { console.log(`[HTTP] ${method} ${path} (Auth: ${authorization}) (Body: ${body?.length || 0}B)`, result) ok(result) }) }) } /** Creates a modal window */ function CreateModal(views: ModalView[]) { return new Promise((modalClose, modalCancel) => { let currentView = 0 /** Helper: Create Element with Classes and Parent */ function _Create(tagName: string, classes: string, parent: HTMLElement) { const elem = document.createElement(tagName) elem.classList.add(...classes.split(',').filter(c => c.length)) parent.appendChild(elem) return elem as T } function _Close() { SetDynamic(true) modalWrapper.style.opacity = '0' setTimeout(() => modalWrapper.remove(), 200) } /** Scroll to desired view index */ function SetActiveView(viewIndex: number) { const targetView = modalViews[viewIndex] if (targetView) { modalViews.forEach(({ wrapper }, i) => wrapper.style.height = (viewIndex === i) ? `${targetView.wrapper.scrollHeight}px` : '0' ) modalContainer.scrollLeft = (viewIndex * 600) currentView = viewIndex SetCanInteract(true) } } /** Enable/Disable Modal Interaction */ function SetCanInteract(canInteract: boolean) { function Enabled(elem: Element, value: boolean) { value ? elem.removeAttribute('disabled') : elem.setAttribute('disabled', 'true') } modalViews.forEach((v, i) => { const newValue = (i === currentView) ? canInteract : false for (const e of v.elements.children) { if (e.tagName === 'A') e.setAttribute('tabindex', '0') if (e.tagName === 'INPUT') Enabled(e, newValue) } if (v.submit) Enabled(v.submit, newValue) if (v.cancel) Enabled(v.cancel, newValue) }) } const modalWrapper = _Create('div', 'modal-wrapper', document.body) const modalContainer = _Create('div', 'modal-content,special-shadow', modalWrapper) const modalViews = views.map(o => { const wrapper = _Create('div', 'modal-view', modalContainer) const elements = _Create('div', 'modal-elements', wrapper) _Create('div', 'modal-divider', wrapper) const footer = _Create('div', 'modal-footer', wrapper) // Create Elements for (const [key, value] of Object.entries(o.elements || {})) { switch (value.type) { // Static Elements case 'headline': _Create('p', 'modal-headline', elements).textContent = value.label break case 'header': _Create('p', 'modal-header', elements).textContent = value.label break case 'divider': _Create('div', 'modal-divider', elements) break case 'spacer': _Create('div', 'modal-spacer', elements) break case 'link': const a = _Create('a', 'modal-link', elements) a.textContent = value.label a.href = value.href a.tabIndex = -1 break case 'text': _Create('p', 'modal-text', elements).textContent = value.label break // Interactive Elements case 'input': const label = _Create('label', 'modal-label', elements) const input = _Create('input', 'modal-input', elements) label.textContent = value.label label.title = value.label label.htmlFor = key input.type = value.inputType input.id = key input.disabled = true break // Unique Elements case 'unique-links': const linkWrapper = _Create('div', 'modal-flex', elements) for (const e of value.text.split(';')) { const [text, url] = e.split(':', 2) if (url) { const a = _Create('a', 'modal-link', linkWrapper) a.textContent = text a.target = '_blank' a.href = url } else { _Create('p', 'modal-link', linkWrapper).textContent = text } } break } } // Create Footer Elements let submit: HTMLButtonElement | undefined let cancel: HTMLButtonElement | undefined let alert = _Create('p', 'modal-alert', footer) if (o.closeLabel) { cancel = _Create('button', 'default', footer) cancel.textContent = o.closeLabel cancel.disabled = true cancel.onclick = async () => { if (o.closeLogic) await o.closeLogic() modalCancel('User Closed Modal' as any) _Close() } } if (o.submitLabel) { submit = _Create('button', 'default', footer) submit.textContent = o.submitLabel submit.disabled = true submit.onclick = async () => { if (o.submitLogic) { // Collect Inputs const view = modalViews[currentView] const values: Record = {} for (const e of view.elements.children) { if (e instanceof HTMLInputElement) { values[e.id] = e.value continue } } // Run Logic SetCanInteract(false) o.submitLogic({ SetCanInteract, SetActiveView }, values) .then(results => { // Reset Errors and Create Index for Label Elements view.alert.textContent = '' const labels: Record = {} for (const e of view.elements.children) { if (e instanceof HTMLLabelElement) { labels[e.getAttribute('for')!] = e e.textContent = e.title e.removeAttribute('error') } } // Complete Promise and Return Results if (results.success) { modalClose(results.values as T) _Close() return } if (results.messages) { if (typeof results.messages === 'string') { // Show Basic Alert view.alert.textContent = results.messages } else { // Show Rich Error(s) for (const [id, label] of Object.entries(labels)) { const message = results.messages[id] if (message) { label.textContent = `${label.title} - ${message || 'No Error Message Provided'}` label.setAttribute('error', 'true') } } } } }) .catch(error => { // Set Error view.alert.textContent = `Script Error: ${error}` console.error('[MODAL] Script error', error) }) .finally(() => SetCanInteract(true)) } } } return { wrapper, elements, footer, submit, cancel, alert } }) // A little silly but heights and offsets break until everything (AND I MEAN EVERYTHING) // has loaded in completely and browser has rendered the first frame requestIdleCallback(() => { requestAnimationFrame(() => { modalWrapper.style.opacity = '1' // Fade in Modal SetDynamic(false) // Disable all Interactable Elements SetActiveView(currentView) // Initialize View }) }) }) } /** Simpler method of creating modals for quick messages */ function CreateMessage( title: ElementHeader['label'], message: ElementText['label'], buttonLabel: ModalView['closeLabel'], buttonLogic: ModalView['closeLogic'] ) { return CreateModal([{ 'closeLabel': buttonLabel, 'closeLogic': buttonLogic, 'submitLogic': async () => { return { success: true } }, 'elements': { 'title': { type: 'header', label: title }, 'message': { type: 'text', label: message } } }]) } interface ModalState { SetActiveView: (viewIndex: number) => void SetCanInteract: (canInteract: boolean) => void } interface ModalResults { success: boolean // Submit Success values?: Record // Submit Results messages?: Record | string // Submit Error } interface ModalView { submitLabel?: string submitLogic?: (modal: ModalState, values: Record) => Promise closeLabel?: string closeLogic?: () => void elements?: { [key: string]: ModalElement } } type ModalElement = ElementHeader | ElementHeadline | ElementDivider | ElementSpacer | ElementText | ElementInput | ElementLink | ElementUniqueLinks interface ElementBase { type: string } interface ElementHeader extends ElementBase { type: 'header' label: string } interface ElementHeadline extends ElementBase { type: 'headline' label: string } interface ElementDivider extends ElementBase { type: 'divider' } interface ElementSpacer extends ElementBase { type: 'spacer' } interface ElementText extends ElementBase { type: 'text' label: string } interface ElementInput extends ElementBase { type: 'input' inputType: string label: string } interface ElementLink { type: 'link'; label: string; href: string; } interface ElementUniqueLinks { type: 'unique-links' text: string; }