Initial Release
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
// Widget: Latest Articles
|
||||
(() => {
|
||||
let articleIndex = 0
|
||||
let autoScrollEnabled = true
|
||||
const elemControls = document.querySelector<HTMLDivElement>('div.widget-latest-controls')!
|
||||
const elemArticles = document.querySelector<HTMLDivElement>('div.widget-latest-articles')!
|
||||
const listArticles = elemArticles.querySelectorAll<HTMLDivElement>('div.latest-item')
|
||||
const listDots = elemControls.querySelectorAll<HTMLDivElement>('div.dot-circle')
|
||||
const gapOffset = parseInt(window
|
||||
.getComputedStyle(elemArticles)
|
||||
.getPropertyValue('gap')
|
||||
)
|
||||
|
||||
// Autoscrolling Behaviour
|
||||
function transitionNextArticle(shouldAdd) {
|
||||
// Over/Underflow Handling
|
||||
shouldAdd ? articleIndex++ : articleIndex--
|
||||
if (articleIndex < 0) articleIndex = listArticles.length - 1
|
||||
const desiredIndex = (articleIndex % listArticles.length)
|
||||
|
||||
// Set Active Dot
|
||||
listDots.forEach((e, i) => {
|
||||
e.classList.remove('dot-active')
|
||||
if (desiredIndex === i) e.classList.add('dot-active')
|
||||
})
|
||||
|
||||
// Scroll over to post
|
||||
let scrollOffset = 0
|
||||
for (let i = 0; i < desiredIndex; i++) {
|
||||
scrollOffset += listArticles[i].offsetWidth + gapOffset
|
||||
}
|
||||
elemArticles.scrollLeft = scrollOffset
|
||||
}
|
||||
setInterval(() => autoScrollEnabled && transitionNextArticle(true), 10_000)
|
||||
elemArticles.onmouseenter = () => autoScrollEnabled = false
|
||||
elemArticles.onmouseleave = () => autoScrollEnabled = true
|
||||
elemControls.onmouseenter = () => autoScrollEnabled = false
|
||||
elemControls.onmouseleave = () => autoScrollEnabled = true
|
||||
|
||||
// Back/Next Buttons
|
||||
document
|
||||
.querySelector<HTMLButtonElement>('button#latest-next')!
|
||||
.addEventListener('click', () => transitionNextArticle(true))
|
||||
document
|
||||
.querySelector<HTMLButtonElement>('button#latest-last')!
|
||||
.addEventListener('click', () => transitionNextArticle(false))
|
||||
})()
|
||||
@@ -0,0 +1,201 @@
|
||||
(() => {
|
||||
const enum STATE {
|
||||
HELP = 0, // Help Message
|
||||
CLOSED, // Menu Hidden
|
||||
LOADING, // Loading Results
|
||||
ERRORED, // Search Error
|
||||
EMPTY, // No Results Returned
|
||||
LIST, // List all Results
|
||||
OPEN // Open Modal
|
||||
}
|
||||
const paneLoading = document.querySelector<HTMLDivElement>('div.pane#loading')!
|
||||
const paneError = document.querySelector<HTMLDivElement>('div.pane#error')!
|
||||
const paneHelp = document.querySelector<HTMLDivElement>('div.pane#help')!
|
||||
const paneEmpty = document.querySelector<HTMLDivElement>('div.pane#empty')!
|
||||
const paneResults = document.querySelector<HTMLDivElement>('div.pane#results')!
|
||||
|
||||
const elementWrapper = document.querySelector<HTMLDivElement>('div.wrapper-search')!
|
||||
const elementInput = document.querySelector<HTMLInputElement>('input.search-input')!
|
||||
const elementClose = document.querySelector<HTMLButtonElement>('button.search-close')!
|
||||
const elementOpen = document.querySelector<HTMLButtonElement>('button#search-open')!
|
||||
const elementFooter = document.querySelector<HTMLParagraphElement>('div.search-footer > p')!
|
||||
const elementError = paneError.querySelector<HTMLParagraphElement>('p')!
|
||||
|
||||
let searchTimings = 0
|
||||
let searchElements = new Array<HTMLAnchorElement>()
|
||||
let searchTimeout: ReturnType<typeof setTimeout>
|
||||
let searchState = STATE.CLOSED // Modal State
|
||||
let searchFocus = -1 // Selected Element Index (-1 for Input, -2 for Close)
|
||||
let searchQuery = '' // Previous Search Query
|
||||
let searchError = '' // API Error
|
||||
let searchResults = new Array<{ // API Data
|
||||
categoryName: string;
|
||||
occurrences: Array<{
|
||||
location: string;
|
||||
snippet: string;
|
||||
link: string;
|
||||
}>;
|
||||
}>()
|
||||
|
||||
function updateView(state: STATE) {
|
||||
paneLoading.style.opacity = '0'
|
||||
paneError.style.opacity = '0'
|
||||
paneHelp.style.opacity = '0'
|
||||
paneEmpty.style.opacity = '0'
|
||||
paneResults.style.opacity = '0'
|
||||
if (state === STATE.OPEN) {
|
||||
state = searchResults.length > 0
|
||||
? STATE.LIST
|
||||
: STATE.HELP
|
||||
}
|
||||
if (state !== STATE.CLOSED && searchState === STATE.CLOSED) {
|
||||
elementWrapper.style.pointerEvents = 'all'
|
||||
elementWrapper.style.opacity = '1'
|
||||
updateFocus(-1, true)
|
||||
}
|
||||
searchState = state
|
||||
switch (state) {
|
||||
case STATE.HELP:
|
||||
elementFooter.textContent = ''
|
||||
paneHelp.style.opacity = '1'
|
||||
return
|
||||
|
||||
case STATE.EMPTY:
|
||||
elementFooter.textContent = ''
|
||||
paneEmpty.style.opacity = '1'
|
||||
return
|
||||
|
||||
case STATE.LOADING:
|
||||
elementFooter.textContent = ''
|
||||
paneLoading.style.opacity = '1'
|
||||
return
|
||||
|
||||
case STATE.ERRORED:
|
||||
paneError.style.opacity = '1'
|
||||
elementError.textContent = searchError
|
||||
return
|
||||
|
||||
case STATE.CLOSED:
|
||||
elementWrapper.style.pointerEvents = 'none'
|
||||
elementWrapper.style.opacity = '0'
|
||||
return
|
||||
|
||||
case STATE.LIST:
|
||||
const t = Date.now() - searchTimings
|
||||
const c = searchResults.map(r => r.occurrences.length).reduce((a, b) => a + b, 0)
|
||||
elementFooter.textContent = `Returned ${c} result${c === 1 ? '' : 's'} in ${t}ms`
|
||||
|
||||
// Clear All Children
|
||||
for (const c of paneResults.children) c.remove()
|
||||
searchElements = []
|
||||
|
||||
// Display Empty Results
|
||||
if (c === 0) {
|
||||
updateView(STATE.EMPTY)
|
||||
return
|
||||
}
|
||||
|
||||
// Render Children
|
||||
for (const cat of searchResults) {
|
||||
const div = document.createElement('div')
|
||||
div.classList.add('search-result')
|
||||
|
||||
const p = document.createElement('p')
|
||||
p.classList.add('text-header', 'text-tip')
|
||||
p.textContent = cat.categoryName
|
||||
div.appendChild(p)
|
||||
|
||||
for (const o of cat.occurrences) {
|
||||
const a = document.createElement('a')
|
||||
a.classList.add('search-item')
|
||||
a.href = o.link
|
||||
|
||||
const p1 = document.createElement('p')
|
||||
p1.innerHTML = o.snippet
|
||||
a.appendChild(p1)
|
||||
|
||||
const p2 = document.createElement('p')
|
||||
p2.classList.add('text-muted', 'text-tip')
|
||||
p2.textContent = o.location
|
||||
a.appendChild(p2)
|
||||
|
||||
div.appendChild(a)
|
||||
searchElements.push(a)
|
||||
}
|
||||
|
||||
paneResults.appendChild(div)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
function updateFocus(adjust: number, set = false) {
|
||||
set ? searchFocus = adjust : searchFocus += adjust
|
||||
if (isNaN(searchFocus)) searchFocus = -1
|
||||
if (searchFocus > searchElements.length - 1) searchFocus = searchElements.length - 1
|
||||
if (searchFocus < -2) searchFocus = -2
|
||||
if (searchFocus === -2) return elementClose.focus()
|
||||
if (searchFocus === -1) return elementInput.focus()
|
||||
|
||||
console.log("FOCUS", searchElements[searchError])
|
||||
}
|
||||
|
||||
// Keybind Handling
|
||||
const pressedKeys = new Set<string>()
|
||||
document.addEventListener('keyup', ev => pressedKeys.delete(ev.key))
|
||||
document.addEventListener('keydown', ev => {
|
||||
pressedKeys.add(ev.key)
|
||||
if (pressedKeys.has('Control') && ev.key === 'k') {
|
||||
ev.preventDefault()
|
||||
updateView(searchState === STATE.CLOSED ? STATE.OPEN : STATE.CLOSED)
|
||||
}
|
||||
if (searchState !== STATE.CLOSED) {
|
||||
if (ev.key === 'Escape') updateView(STATE.CLOSED)
|
||||
if (ev.key === 'ArrowUp') updateFocus(-1)
|
||||
if (ev.key === 'ArrowDown') updateFocus(1)
|
||||
if (ev.key === 'ArrowLeft' && searchFocus === -2) updateFocus(-1, true)
|
||||
if (ev.key === 'ArrowRight' && searchFocus === -1) updateFocus(-2, true)
|
||||
}
|
||||
})
|
||||
elementClose.addEventListener('click', () => updateView(STATE.CLOSED))
|
||||
elementOpen.addEventListener('click', () => updateView(STATE.OPEN))
|
||||
elementWrapper.addEventListener('click', ev =>
|
||||
ev.target === elementWrapper && updateView(STATE.CLOSED)
|
||||
)
|
||||
elementInput.addEventListener('input', () => {
|
||||
clearTimeout(searchTimeout)
|
||||
|
||||
const query = encodeURIComponent(elementInput.value.trim())
|
||||
if (query.length === 0) return updateView(STATE.HELP)
|
||||
if (query === searchQuery) return
|
||||
searchQuery = query
|
||||
updateView(STATE.LOADING)
|
||||
|
||||
// Queue Search Request
|
||||
searchTimeout = setTimeout(() => {
|
||||
searchTimings = Date.now()
|
||||
fetch(`/library/snippet?query=${query}`)
|
||||
.then(resp => resp.json())
|
||||
.then(body => {
|
||||
searchResults = body
|
||||
updateView(STATE.LIST)
|
||||
})
|
||||
.catch(e => {
|
||||
console.error('SEARCH ERROR', e)
|
||||
searchError = String(e)
|
||||
updateView(STATE.ERRORED)
|
||||
})
|
||||
}, 200)
|
||||
})
|
||||
|
||||
// // Restore Session (if any)
|
||||
// const cacheFocus = localStorage.getItem('search_focus')
|
||||
// const cacheQuery = localStorage.getItem('search_query')
|
||||
// const cacheResult = localStorage.getItem('search_data')
|
||||
// if (cacheResult && cacheQuery && cacheFocus) {
|
||||
// elementInput.value = cacheQuery
|
||||
// searchResults = JSON.parse(cacheResult)
|
||||
// updateFocus(parseInt(cacheFocus), true)
|
||||
// } else {
|
||||
// updateView(STATE.CLOSED)
|
||||
// }
|
||||
})()
|
||||
@@ -0,0 +1,120 @@
|
||||
(async () => {
|
||||
{
|
||||
// Setup Grid Tiles
|
||||
const audioSet = document.querySelector<HTMLAudioElement>('audio#set')!
|
||||
const audioRelease = document.querySelector<HTMLAudioElement>('audio#release')!
|
||||
document.querySelectorAll<HTMLButtonElement>('button.picross-button').forEach(e => {
|
||||
e.addEventListener('click', () => {
|
||||
e.classList.remove('picross-ignore')
|
||||
e.classList.toggle('picross-active')
|
||||
? audioSet.play()
|
||||
: audioRelease.play()
|
||||
})
|
||||
e.addEventListener('contextmenu', ev => {
|
||||
ev.preventDefault()
|
||||
e.classList.remove('picross-active')
|
||||
e.classList.toggle('picross-ignore')
|
||||
audioRelease.play()
|
||||
})
|
||||
})
|
||||
|
||||
// Setup Page Timer
|
||||
let secondsElapsed = 0
|
||||
const timerElement = document.querySelector('p#time-spent')!
|
||||
setInterval(() => {
|
||||
secondsElapsed++
|
||||
timerElement.textContent = (() => {
|
||||
const hours = Math.floor(secondsElapsed / 3600)
|
||||
const minutes = Math.floor((secondsElapsed % 3600) / 60)
|
||||
const seconds = Math.floor(secondsElapsed % 60);
|
||||
return `Time Spent: ${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||
})()
|
||||
}, 1000)
|
||||
|
||||
|
||||
// Prevent annoying context menu popup when you miss a grid tile by 1 pixel :L
|
||||
document.
|
||||
querySelector('div.special-picross')!
|
||||
.addEventListener('contextmenu', e => e.preventDefault())
|
||||
}
|
||||
|
||||
// Fetch Badges for User Profile
|
||||
const resp = await CallAPI<{ public_flags: number; }>('GET', '/v1/users/@me', 'basic')
|
||||
if (resp.code === 401) {
|
||||
console.info('User is not logged in, redirecting...')
|
||||
CreateMessage(
|
||||
'Account Required', 'You must be logged in to attempt this challenge!',
|
||||
'Login', () => window.location.assign('/login?redirect=/challenge-picross')
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!resp.success) {
|
||||
console.error(`Server Responded with Code ${resp.code}`, resp.body)
|
||||
return CreateMessage(
|
||||
'Server Unavailable',
|
||||
'Encountered an error while fetching your profile',
|
||||
'Refresh',
|
||||
() => window.location.reload(),
|
||||
)
|
||||
}
|
||||
|
||||
// Setup Webpage
|
||||
const correctAnswer = 2560660350
|
||||
const completed = (resp.json.public_flags & 4) !== 0
|
||||
if (completed) CreateMessage(
|
||||
'Already Collected!',
|
||||
`You\'ve already collected this badge, but you can still replay this challenge.`,
|
||||
'Got it!',
|
||||
() => { }
|
||||
)
|
||||
|
||||
// Setup Submit Button
|
||||
document.querySelector<HTMLButtonElement>('a#done')!.addEventListener('click', async () => {
|
||||
SetDynamic(false)
|
||||
|
||||
// Filter: Correct Answer?
|
||||
let givenAnswer = 0
|
||||
document.querySelectorAll<HTMLDivElement>('div.picross-row').forEach((childRow, rowIndex) => {
|
||||
let childIndex = 0
|
||||
for (const child of childRow.children) {
|
||||
if (child.nodeName === 'BUTTON') {
|
||||
if (child.classList.contains('picross-active')) {
|
||||
givenAnswer += 1 << ((rowIndex - 3) * 9) + childIndex
|
||||
}
|
||||
childIndex++
|
||||
}
|
||||
}
|
||||
})
|
||||
if (correctAnswer !== givenAnswer) {
|
||||
CreateMessage('Nope!', 'Not quite! Check your work and try again!', 'Fine', () => { })
|
||||
SetDynamic(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Filter: Already Completed?
|
||||
if (completed) return CreateMessage(
|
||||
'Congrats!',
|
||||
'But you\'ve already collected this badge. Thank you for playing!',
|
||||
'Visit Profile',
|
||||
() => window.location.assign('/profile')
|
||||
)
|
||||
|
||||
// Redeem Reward for User
|
||||
const resp = await CallAPI(
|
||||
'POST', '/v1/users/@me/inventory/redeem', 'basic',
|
||||
{ code: 'challenge-' + givenAnswer }
|
||||
)
|
||||
if (resp.ok) {
|
||||
CreateMessage(
|
||||
'Congrats!',
|
||||
'You\'ve earned the picross badge! You it is now visible on your profile.',
|
||||
'Let me see!',
|
||||
() => window.location.assign('/profile')
|
||||
)
|
||||
} else {
|
||||
CreateMessage('Redeem Error', resp.body, 'Close', () => { })
|
||||
console.error('Redeem Error', resp)
|
||||
}
|
||||
SetDynamic(true)
|
||||
})
|
||||
})()
|
||||
@@ -0,0 +1,36 @@
|
||||
let debounce = false
|
||||
CreateModal([
|
||||
//- Login View
|
||||
{
|
||||
elements: {
|
||||
'1': { type: 'headline', label: 'Log In', },
|
||||
'2': { type: 'text', label: 'to your suzzy games account' },
|
||||
'3': { type: 'divider' },
|
||||
'email': { type: 'input', inputType: 'text', label: 'Email Address', },
|
||||
'password': { type: 'input', inputType: 'password', label: 'Password', },
|
||||
'4': { type: 'spacer', },
|
||||
'5': { type: 'link', label: 'Forgot Password?', href: '/password-reset', },
|
||||
'6': { type: 'link', label: 'Don\'t have an account? Sign up.', href: '/signup', },
|
||||
},
|
||||
closeLabel: 'Cancel',
|
||||
closeLogic: () => FollowRedirect('/'),
|
||||
submitLabel: 'Log In',
|
||||
submitLogic: async (modal, values) => {
|
||||
return { success: false, messages: 'Not Yet Implemented' }
|
||||
},
|
||||
},
|
||||
|
||||
// Verification Email Sent Prompt
|
||||
{
|
||||
submitLabel: 'Done',
|
||||
submitLogic: async m => {
|
||||
m.SetActiveView(0)
|
||||
return { success: false, messages: '' }
|
||||
},
|
||||
elements: {
|
||||
},
|
||||
},
|
||||
|
||||
])
|
||||
.then(r => console.log('done', r))
|
||||
.catch(e => console.error('fail', e))
|
||||
@@ -0,0 +1,36 @@
|
||||
CreateModal([
|
||||
{
|
||||
submitLabel: 'Submit',
|
||||
submitLogic: (modal, { email }) => new Promise(ok => {
|
||||
const result = TestEmailSuite(email)
|
||||
if (result) return ok({ success: false, messages: { 'email': result } })
|
||||
CallAPI('POST', '/v1/auth/password-reset', 'public', { email }).then(resp => {
|
||||
// This endpoint always returns a '204 No Content'
|
||||
// so anything else other than that is a server error
|
||||
if (resp.error) return ok({
|
||||
success: false,
|
||||
messages: String(resp.error)
|
||||
})
|
||||
modal.SetActiveView(1)
|
||||
return ok({ success: false, messages: '' })
|
||||
})
|
||||
}),
|
||||
elements: {
|
||||
'1': { type: 'headline', label: 'Reset Password', },
|
||||
'2': { type: 'text', label: 'for your suzzy games account' },
|
||||
'3': { type: 'divider' },
|
||||
'email': { type: 'input', inputType: 'text', label: 'Email Address' },
|
||||
'4': { type: 'spacer' },
|
||||
'5': { type: 'link', label: 'Remember your password? Log in.', href: '/login', },
|
||||
},
|
||||
},
|
||||
{
|
||||
closeLabel: 'Done',
|
||||
closeLogic: () => window.location.assign('/login'),
|
||||
elements: {
|
||||
'1': { type: 'header', label: 'Email Sent' },
|
||||
'2': { type: 'text', label: "An email with instructions on how to reset your password has been sent to your inbox." },
|
||||
'3': { type: 'text', label: "Can't find it? Try checking your spam filter." },
|
||||
}
|
||||
}
|
||||
])
|
||||
@@ -0,0 +1,47 @@
|
||||
CreateModal([
|
||||
{
|
||||
submitLabel: 'Submit',
|
||||
submitLogic: (modal, values) => new Promise(ok => {
|
||||
const messages: Record<string, string | undefined> = {}
|
||||
const key_password = 'password'
|
||||
const key_password_again = 'password_again'
|
||||
messages[key_password] = TestPasswordSuite(values[key_password])
|
||||
messages[key_password_again] = TestPasswordSuite(values[key_password_again])
|
||||
if (values[key_password] !== values[key_password_again]) {
|
||||
messages[key_password_again] = 'Passwords Do Not Match'
|
||||
}
|
||||
if (Object.values(messages).filter(e => e).length !== 0) {
|
||||
ok({ success: false, messages })
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch Token from Parameters and Send Request
|
||||
const href = document.location.href
|
||||
const token = new URLSearchParams(href.slice(href.indexOf('?') + 1)).get('token')
|
||||
CallAPI<any>(
|
||||
'PATCH', '/v1/auth/password-reset', 'basic',
|
||||
{ [key_password]: values[key_password], token }
|
||||
).then(resp => {
|
||||
if (!resp.ok) return ok({ success: false, messages: resp.json.error })
|
||||
modal.SetActiveView(1)
|
||||
ok({ success: false, messages: '' })
|
||||
})
|
||||
}),
|
||||
elements: {
|
||||
'1': { type: 'headline', label: 'Change Password', },
|
||||
'2': { type: 'text', label: 'Enter the new password you wish to use.' },
|
||||
'3': { type: 'divider' },
|
||||
'password': { type: 'input', inputType: 'password', label: 'New Password' },
|
||||
'password_again': { type: 'input', inputType: 'password', label: 'New Password Again' },
|
||||
'4': { type: 'spacer' },
|
||||
},
|
||||
},
|
||||
{
|
||||
closeLabel: 'Log In',
|
||||
closeLogic: () => window.location.assign('/login'),
|
||||
elements: {
|
||||
'1': { type: 'header', label: 'Password Changed' },
|
||||
'2': { type: 'text', label: 'You may now log in using your new password. You have not been logged out on any devices.' },
|
||||
}
|
||||
}
|
||||
])
|
||||
@@ -0,0 +1,47 @@
|
||||
CreateModal([{
|
||||
elements: {
|
||||
'1': { type: 'headline', label: 'Sign Up' },
|
||||
'2': { type: 'text', label: 'for a suzzy games account' },
|
||||
'3': { type: 'divider' },
|
||||
'username': { type: 'input', inputType: 'text', label: 'Username' },
|
||||
'email': { type: 'input', inputType: 'text', label: 'Email Address' },
|
||||
'password': { type: 'input', inputType: 'password', label: 'Password' },
|
||||
'4': { type: 'spacer' },
|
||||
'5': { type: 'link', label: 'Already have an account? Log in', href: '/login' },
|
||||
'6': { type: 'unique-links', text: 'By signing up you agree to our;Terms Of Service:/library/legal/terms-of-service;and;Privacy Policy:/library/legal/privacy-policy' }
|
||||
},
|
||||
closeLabel: 'Cancel',
|
||||
closeLogic: () => FollowRedirect('/'),
|
||||
submitLabel: 'Sign Up',
|
||||
submitLogic: (modal, { username, email, password }) => {
|
||||
return new Promise(async ok => {
|
||||
const errorMessages: Record<string, string | undefined> = {}
|
||||
errorMessages.username = TestUsernameSuite(username)
|
||||
errorMessages.password = TestPasswordSuite(password)
|
||||
errorMessages.email = TestEmailSuite(email)
|
||||
if (Object.values(errorMessages).filter(e => e).length !== 0) {
|
||||
ok({ success: false, messages: errorMessages })
|
||||
return
|
||||
}
|
||||
|
||||
// Create Account
|
||||
let resp = await CallAPI('POST', '/v1/auth/signup', 'public', { username, email, password })
|
||||
if (!resp.ok) return ok({
|
||||
success: false,
|
||||
messages: `Signup Failed (${resp.code}): ${resp.body}`
|
||||
})
|
||||
// Login Account
|
||||
// Use basic auth to allow collection of cookies
|
||||
resp = await CallAPI('POST', '/v1/auth/login', 'basic', { email, password })
|
||||
if (!resp.ok) return ok({
|
||||
success: false,
|
||||
messages: `Login Failed (${resp.code}): ${resp.body}`
|
||||
})
|
||||
FollowRedirect('/profile')
|
||||
})
|
||||
},
|
||||
}])
|
||||
|
||||
document.querySelector<HTMLInputElement>('#username')!.value = Date.now() + ''
|
||||
document.querySelector<HTMLInputElement>('#email')!.value = Date.now() + '@gmail.com'
|
||||
document.querySelector<HTMLInputElement>('#password')!.value = 'Googleman00!'
|
||||
@@ -0,0 +1,10 @@
|
||||
(() => {
|
||||
const href = document.location.href
|
||||
const token = new URLSearchParams(href.slice(href.indexOf('?') + 1)).get('token')
|
||||
if (token) CallAPI('POST', '/v1/auth/verify-email/' + token, 'public')
|
||||
CreateMessage(
|
||||
'Email Verified',
|
||||
'Thank you for verifying your email! Redirecting you to your profile...',
|
||||
'Done', () => window.location.assign('/profile')
|
||||
)
|
||||
})()
|
||||
@@ -0,0 +1,10 @@
|
||||
(() => {
|
||||
const href = document.location.href
|
||||
const token = new URLSearchParams(href.slice(href.indexOf('?') + 1)).get('token')
|
||||
if (token) CallAPI('POST', '/v1/auth/verify-login/' + token, 'public')
|
||||
CreateMessage(
|
||||
'Login Verified',
|
||||
'You can log in from the your new device. Redirecting you to the login page...',
|
||||
'Done', () => window.location.assign('/login')
|
||||
)
|
||||
})()
|
||||
@@ -0,0 +1,8 @@
|
||||
// Displays a birthday pancake beside the 'Active since May 13th, 2021'
|
||||
// on the Homepage when it's suzzy games birthday :3
|
||||
(() => {
|
||||
const t = new Date()
|
||||
if (t.getMonth() === 4 && t.getDate() === 13) {
|
||||
document.querySelector('#birthday')!.textContent += '🥞'
|
||||
}
|
||||
})()
|
||||
@@ -0,0 +1,41 @@
|
||||
document
|
||||
.querySelectorAll<HTMLDivElement>('div.widget-project')
|
||||
.forEach(project => {
|
||||
let currentImage = 0
|
||||
const galleryDots = project.querySelectorAll<HTMLDivElement>('div.gallery-dot')!
|
||||
const galleryItems = project.querySelectorAll<HTMLImageElement>('img.gallery-image')!
|
||||
const galleryScroll = project.querySelector<HTMLDivElement>('div.project-gallery-scroll')!
|
||||
const gapOffset = parseInt(window
|
||||
.getComputedStyle(galleryScroll)
|
||||
.getPropertyValue('gap')
|
||||
)
|
||||
|
||||
function ScrollImage(shouldAdd: boolean) {
|
||||
// Over/Underflow Handling
|
||||
shouldAdd ? currentImage++ : currentImage--
|
||||
if (currentImage < 0) currentImage = galleryItems.length - 1
|
||||
const desiredIndex = (currentImage % galleryItems.length)
|
||||
|
||||
// Set Active Dot
|
||||
galleryDots.forEach((e, i) => {
|
||||
i === desiredIndex ?
|
||||
e.classList.add('gallery-dot-active')
|
||||
: e.classList.remove('gallery-dot-active')
|
||||
})
|
||||
|
||||
// Scroll over to image
|
||||
let scrollOffset = 0
|
||||
for (let i = 0; i < desiredIndex; i++) {
|
||||
scrollOffset += galleryItems[i].offsetWidth + gapOffset
|
||||
}
|
||||
galleryScroll.scrollLeft = scrollOffset
|
||||
}
|
||||
|
||||
// Back/Next Buttons
|
||||
project
|
||||
.querySelector('button#last')!
|
||||
.addEventListener('click', () => ScrollImage(false))
|
||||
project
|
||||
.querySelector('button#next')!
|
||||
.addEventListener('click', () => ScrollImage(true))
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
(() => {
|
||||
// Audio Elements
|
||||
/** Converts seconds into a timestamp (ex. 0:00) */
|
||||
function formatTime(someTime) {
|
||||
const minutes = (someTime / 60) | 0
|
||||
someTime -= minutes * 60
|
||||
return `${minutes}:${(someTime | 0).toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
document.querySelectorAll('div.element-audio').forEach(audioElement => {
|
||||
const elemAudio = audioElement.querySelector<HTMLAudioElement>('audio')!
|
||||
const elemProgress = audioElement.querySelector<HTMLInputElement>('input')!
|
||||
const elemButton = audioElement.querySelector<HTMLButtonElement>('button')!
|
||||
const elemButtonIcon = audioElement.querySelector<HTMLImageElement>('button img')!
|
||||
const elemTime = audioElement.querySelector<HTMLParagraphElement>('p.audio-time')!
|
||||
const elemDownload = audioElement.querySelector<HTMLAnchorElement>('a.audio-download')!
|
||||
|
||||
let isLoaded = false
|
||||
let isDragging = false
|
||||
let ignoreInput = false
|
||||
elemButton.addEventListener('click', () => {
|
||||
if (ignoreInput) return
|
||||
if (!isLoaded) {
|
||||
// Load Audio Source
|
||||
ignoreInput = true
|
||||
elemAudio.src = elemDownload.href
|
||||
elemAudio.oncanplay = () => {
|
||||
elemProgress.removeAttribute('disabled')
|
||||
|
||||
// Audio Seeking
|
||||
elemProgress.addEventListener('mousedown', () => isDragging = true)
|
||||
elemProgress.addEventListener('mouseup', () => isDragging = false)
|
||||
elemProgress.addEventListener('change', () => {
|
||||
elemAudio.currentTime = elemAudio.duration * (parseInt(elemProgress.value) / 100)
|
||||
})
|
||||
|
||||
// Audio Controls
|
||||
elemAudio.addEventListener('play', () => elemButtonIcon.src = '/assets/icons/icon-pause.svg')
|
||||
elemAudio.addEventListener('pause', () => elemButtonIcon.src = '/assets/icons/icon-play.svg')
|
||||
elemAudio.addEventListener('timeupdate', () => {
|
||||
elemTime.textContent = (formatTime(elemAudio.currentTime) + '/' + formatTime(elemAudio.duration))
|
||||
|
||||
// Prevent progress bar from moving while user is still dragging it:
|
||||
// elemProgress 'change' event will update the bar each second causing the
|
||||
// progress bar to snap back and forth between actual playtime and desired playtime (mouse position)
|
||||
if (!isDragging) {
|
||||
elemProgress.value = (((100 / elemAudio.duration) * elemAudio.currentTime) | 0).toString()
|
||||
}
|
||||
})
|
||||
|
||||
// Play Audio and Finish Setup
|
||||
elemAudio.play()
|
||||
elemAudio.volume = 0.5
|
||||
ignoreInput = false
|
||||
isLoaded = true
|
||||
}
|
||||
} else {
|
||||
elemAudio.paused ? elemAudio.play() : elemAudio.pause()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Code Block Elements
|
||||
document.querySelectorAll('div.element-code').forEach(parentElement => {
|
||||
const action = parentElement.querySelector('button')!
|
||||
const content = parentElement.querySelector('pre')!
|
||||
action.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(content.textContent!)
|
||||
alert('Content Copied to Clipboard!')
|
||||
})
|
||||
})
|
||||
|
||||
// Chapter Hightlighting
|
||||
const chapters = new Array<[HTMLAnchorElement, HTMLParagraphElement]>()
|
||||
let scroll_debounce = false
|
||||
let scroll_offset = 0
|
||||
let scroll_search = ''
|
||||
let scroll_tracks = new Array<{ source: any; key: string; }>()
|
||||
|
||||
if (window.location.pathname.startsWith('/library')) {
|
||||
scroll_search = 'div.layout-navigation a.navigation-chapter'
|
||||
scroll_tracks = [
|
||||
{ 'source': document.querySelector('div.layout-content'), 'key': 'scrollTop' },
|
||||
{ 'source': window, key: 'scrollY' }
|
||||
]
|
||||
}
|
||||
if (window.location.pathname.startsWith('/blog')) {
|
||||
scroll_search = 'div.article-chapters a'
|
||||
scroll_tracks = [{ 'source': window, key: 'scrollY' }]
|
||||
scroll_offset = 64
|
||||
}
|
||||
|
||||
// Find and locate all Chapter Links
|
||||
document.querySelectorAll<HTMLAnchorElement>(scroll_search).forEach(anchor => {
|
||||
const href = anchor.href.split('#').at(-1)
|
||||
if (!href) return
|
||||
const head = document.querySelector<HTMLParagraphElement>(`p#${href}`)
|
||||
if (head) chapters.push([anchor, head])
|
||||
})
|
||||
|
||||
// Listen to page scroll events
|
||||
function onPageScroll(x: number) {
|
||||
chapters.forEach(([link, header], i, a) => {
|
||||
(
|
||||
x >= (!i ? 0 : header.offsetTop) &&
|
||||
x <= (a[i + 1]?.[1]?.offsetTop || Infinity)
|
||||
)
|
||||
? link.setAttribute('active', 'true')
|
||||
: link.removeAttribute('active')
|
||||
})
|
||||
}
|
||||
for (const { source, key } of scroll_tracks) {
|
||||
source.addEventListener('scroll', () => {
|
||||
if (scroll_debounce) return
|
||||
scroll_debounce = true
|
||||
window.requestAnimationFrame(() => {
|
||||
onPageScroll(source[key] + scroll_offset)
|
||||
scroll_debounce = false
|
||||
})
|
||||
})
|
||||
}
|
||||
onPageScroll(0)
|
||||
})()
|
||||
@@ -0,0 +1,98 @@
|
||||
(() => {
|
||||
// Have Footer always be just slightly out of view
|
||||
{
|
||||
const footerWrapper = document.querySelector<HTMLDivElement>('div.wrapper-footer')
|
||||
if (footerWrapper) {
|
||||
footerWrapper.style.marginTop = Math.max(
|
||||
document.documentElement.clientHeight -
|
||||
(document.body.clientHeight - footerWrapper.clientHeight), 0
|
||||
) + 'px'
|
||||
}
|
||||
}
|
||||
// Navigation Bar - Profile
|
||||
// Cache for this is invalidated and updated by another script
|
||||
{
|
||||
interface MinimalProfileResponse {
|
||||
id: number;
|
||||
avatar: string | null;
|
||||
}
|
||||
let loggedIn = true
|
||||
const profileButton = document.querySelector<HTMLAnchorElement>('a#navigation-profile')!
|
||||
const profileCache = sessionStorage.getItem('cache_profile')
|
||||
profileCache
|
||||
? UpdateProfile(JSON.parse(profileCache))
|
||||
: FetchProfile()
|
||||
|
||||
/** Redirect to profile if logged in, otherwise prompt login */
|
||||
profileButton.addEventListener('click', () => loggedIn
|
||||
? window.location.assign('/profile')
|
||||
: window.location.assign('/login')
|
||||
)
|
||||
/** Update Profile Elements */
|
||||
function UpdateProfile(data: undefined | MinimalProfileResponse) {
|
||||
if (data === undefined) return loggedIn = false
|
||||
profileButton.style.backgroundImage = `url('${ImageURL('avatars', data.id, data.avatar, true, "small")}')`
|
||||
}
|
||||
/** Fetch Profile from API */
|
||||
function FetchProfile() {
|
||||
CallAPI<MinimalProfileResponse>('GET', '/v1/users/@me', 'basic').then(resp => {
|
||||
if (resp.ok) {
|
||||
sessionStorage.setItem('cache_profile', resp.body)
|
||||
UpdateProfile(resp.json)
|
||||
} else {
|
||||
UpdateProfile(undefined)
|
||||
console.error(`[PROFILE] Fetch Failed`, resp.error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
// Navigation Bar - Status
|
||||
// Fetches player count and server status from service every 5 minutes
|
||||
// TODO: Add random offset so traffic spike doesn't disintegrate the server lol
|
||||
{
|
||||
const CLASS_RED = 'status-red'
|
||||
const CLASS_YEL = 'status-yellow'
|
||||
const CLASS_GRN = 'status-green'
|
||||
const ELEMENT_DOT = document.querySelector<HTMLDivElement>('div#status-dot')!
|
||||
const ELEMENT_TXT = document.querySelector<HTMLParagraphElement>('p#status-text')!
|
||||
FetchStatus()
|
||||
|
||||
/** Update Status Elements */
|
||||
function UpdateStatus(status: undefined | { status: "online" | "degraded" | "down" | "none"; counter_players_online?: number; }) {
|
||||
for (const c of [CLASS_RED, CLASS_YEL, CLASS_GRN]) ELEMENT_DOT.classList.remove(c)
|
||||
if (status !== undefined) switch (status.status) {
|
||||
case "online":
|
||||
ELEMENT_DOT.classList.add(CLASS_GRN)
|
||||
ELEMENT_TXT.textContent = `Online • ${status.counter_players_online} players`
|
||||
break
|
||||
case "degraded":
|
||||
ELEMENT_DOT.classList.add(CLASS_YEL)
|
||||
ELEMENT_TXT.textContent = `Service Degraded • ${status.counter_players_online} players`
|
||||
break
|
||||
case "down":
|
||||
ELEMENT_DOT.classList.add(CLASS_RED)
|
||||
ELEMENT_TXT.textContent = 'Service Down'
|
||||
break
|
||||
case "none":
|
||||
ELEMENT_TXT.textContent = "Unknown"
|
||||
break
|
||||
}
|
||||
}
|
||||
/** Fetch Status from API */
|
||||
function FetchStatus() {
|
||||
fetch(ENV_URLS.status + '/api/widget.json').then(async resp => {
|
||||
const REFRESH_IN = parseFloat(resp.headers.get('X-Refresh')!) * 1000 | 0
|
||||
if (resp.status === 503) {
|
||||
setTimeout(FetchStatus, REFRESH_IN)
|
||||
return
|
||||
} else if (resp.status === 200) {
|
||||
UpdateStatus(await resp.json())
|
||||
return
|
||||
} else {
|
||||
console.error(`[STATUS] Request Failed with Status Code ${resp.status}:`, await resp.text())
|
||||
UpdateStatus({ status: "none" })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})()
|
||||
@@ -0,0 +1,440 @@
|
||||
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<BodyType> {
|
||||
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<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
authorization: 'mfa' | 'basic' | 'public',
|
||||
withBody?: Record<string, any>
|
||||
): Promise<ApiResponse<T>> {
|
||||
return new Promise(ok => {
|
||||
|
||||
// Send HTTP Request
|
||||
let body: string | undefined
|
||||
let result: ApiResponse<T>
|
||||
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<T>(views: ModalView[]) {
|
||||
return new Promise<T>((modalClose, modalCancel) => {
|
||||
let currentView = 0
|
||||
|
||||
/** Helper: Create Element with Classes and Parent */
|
||||
function _Create<T = HTMLElement>(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<HTMLAnchorElement>('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<HTMLLabelElement>('label', 'modal-label', elements)
|
||||
const input = _Create<HTMLInputElement>('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<HTMLAnchorElement>('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<HTMLParagraphElement>('p', 'modal-alert', footer)
|
||||
if (o.closeLabel) {
|
||||
cancel = _Create<HTMLButtonElement>('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<HTMLButtonElement>('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<string, string> = {}
|
||||
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<string, HTMLLabelElement> = {}
|
||||
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<string, any> // Submit Results
|
||||
messages?: Record<string, string | undefined> | string // Submit Error
|
||||
}
|
||||
interface ModalView {
|
||||
submitLabel?: string
|
||||
submitLogic?: (modal: ModalState, values: Record<string, string>) => Promise<ModalResults>
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user