diff --git a/src/client/gameselector.ts b/src/client/gameselector.ts new file mode 100644 index 0000000..8b42294 --- /dev/null +++ b/src/client/gameselector.ts @@ -0,0 +1,86 @@ +import { Slug } from '../common/slug'; +import { MetaGame } from '../common/types'; +import { navigate, getLocationSlug } from './navigation'; +import { notify } from './notices'; + +export class GameSelector { + dialogBox: HTMLDialogElement; + closeButton: HTMLButtonElement; + openButton: HTMLButtonElement; + + constructor() { + // Grab the elements we'll be using + this.dialogBox = document.querySelector('#gameSelector'); + this.closeButton = document.querySelector('#gameSelector .close'); + this.openButton = document.querySelector('.show-game-selector'); + + // Hook up the button events + this.closeButton.addEventListener('click', () => this.hide()); + this.openButton.addEventListener('click', () => this.show()); + } + + /** + * Opens the Game Selector + * @param closeable Whether or not to display the close button + */ + show(closeable = true) { + this.closeButton.style.display = closeable ? 'block' : 'none'; + this.dialogBox.showModal(); + } + + /** + * Closes the Game Selector + */ + hide() { + this.dialogBox.close(); + this.closeButton.style.display = ''; + } + + /** + * Populates the Game Selector with buttons + * @param games List of available games + */ + regenerate(games: { [game: string]: MetaGame }) { + // Grab and clear the selector container + const container = document.querySelector('#gameSelector .games'); + container.innerHTML = ''; + + // Generate a button for each game + for (const game of Object.values(games)) { + const btn = document.createElement('button'); + btn.classList.add('game-selector', 'btn'); + btn.onclick = () => { + this.switchGame(game.id); + }; + + const icon = document.createElement('img'); + icon.src = game.icon; + icon.classList.add('icon'); + icon.style.background = game.color; + + btn.append(icon); + + const name = document.createElement('span'); + name.innerText = game.name; + + btn.append(name); + + container.append(btn); + } + } + + private async switchGame(game: string) { + this.hide(); + + try { + // Get the current location, change the game, and try to navigate to it + const l: Slug = getLocationSlug(); + l.game = game; + await navigate(l); + } catch { + // Failed to navigate! Let's just head back to the game's home page then + await navigate(new Slug(game)); + notify('This page does not exist for this game, so we put you on the homepage.', 'file-document-remove'); + } + } +} diff --git a/src/client/navigation.ts b/src/client/navigation.ts index fb8985a..9eeb242 100644 --- a/src/client/navigation.ts +++ b/src/client/navigation.ts @@ -2,9 +2,11 @@ import { MetaGame, Menu, RenderedPage } from '../common/types'; import { Slug } from '../common/slug'; import { clearNotices, notify } from './notices'; import { anchorHeaderFix, addAnchorLinks } from './anchors'; +import { GameSelector } from './gameselector'; let games: { [game: string]: MetaGame } = {}; let menu: Menu = {}; +let gameSelector: GameSelector; const params = new URLSearchParams(location.search); /** @@ -17,77 +19,30 @@ async function init() { const menuReq = await fetch('/ajax/menu.json'); menu = await menuReq.json(); - // Hook up the game selector's close button - const dialogCloseBtns = document.querySelectorAll('dialog .close'); - for (const btn of dialogCloseBtns) { - btn.addEventListener('click', () => (btn.parentElement as HTMLDialogElement).close()); - } - - // Wire up the game selector button - const showBtns = document.querySelectorAll('.show-game-selector'); - for (const btn of showBtns) { - btn.addEventListener('click', () => showGameSelector()); - } + gameSelector = new GameSelector(); //Regenerate UI - const info: Slug = parseSlug(location.pathname.slice(1)); - regenerateNav(info); - regenerateSidebar(info); - generateGameSelector(); + const slug: Slug = getLocationSlug(); + regenerateNav(slug); + regenerateSidebar(slug); + gameSelector.regenerate(games); updateAllLinkListeners(); if (params.get('force') === 'gameselect') { - showGameSelector(false); + gameSelector.show(false); } - navigate(location.pathname.slice(1), true, false); + navigate(slug, true, false); } window.addEventListener('load', init); -function generateGameSelector() { - // Grab and clear the selector container - const container = document.querySelector('.games'); - container.innerHTML = ''; - - // Generate a button for each game - for (const game of Object.values(games)) { - const btn = document.createElement('button'); - btn.classList.add('game-selector', 'btn'); - btn.onclick = () => { - switchGame(game.id); - }; - - const icon = document.createElement('img'); - icon.src = game.icon; - icon.classList.add('icon'); - icon.style.background = game.color; - - btn.append(icon); - - const name = document.createElement('span'); - name.innerText = game.name; - - btn.append(name); - - container.append(btn); - } -} -function showGameSelector(closeable = true) { - document.querySelector('#gameSelector .close').style.display = closeable ? 'block' : 'none'; - document.querySelector('#gameSelector').showModal(); -} -function hideGameSelector() { - document.querySelector('#gameSelector').close(); - document.querySelector('#gameSelector .close').style.display = ''; -} - -async function navigate(slug, replace = false, loadData = true) { - const info: Slug = parseSlug(slug); - - const path = '/ajax/article/' + info.toString() + '.json'; - const req = await fetch(path); +export async function navigate(slug: Slug, replace = false, loadData = true) { + console.log(`Navigating to "${slug.toString()}"`); + const ajaxPath = '/ajax/article/' + slug.toString() + '.json'; + const req = await fetch(ajaxPath); if (req.status === 404) { + console.log(`Could not request AJAX for "${ajaxPath}"!`); if (loadData) { throw new Error('Page not found'); } @@ -99,14 +54,14 @@ async function navigate(slug, replace = false, loadData = true) { })) as RenderedPage; document.querySelector('#content').innerHTML = data.content; - console.log('NAV RESULT', data); + //console.log('NAV RESULT', data); clearNotices(); // Grab all of the exclusive blocks and filter 'em const exclusives = document.querySelectorAll('.exclusive'); - if (info.game === 'shared') { + if (slug.game === 'shared') { // If this is the "shared" game, we want to always display all exclusive blocks for (const exclusive of exclusives) { exclusive.style.display = 'block'; @@ -116,7 +71,7 @@ async function navigate(slug, replace = false, loadData = true) { let showExclusiveNotice = false; for (const exclusive of exclusives) { - if (exclusive.className.includes(info.game)) { + if (exclusive.className.includes(slug.game)) { exclusive.style.display = 'block'; } else { showExclusiveNotice = true; @@ -142,22 +97,25 @@ async function navigate(slug, replace = false, loadData = true) { anchorHeaderFix(); } + // Change the page's URL to our new slug + // If we're on an index page, we need to snip off the "index" bit + const cleanSlug = '/' + slug.toString(true); if (replace) { - history.replaceState(slug, '', '/' + slug); + history.replaceState(slug, '', cleanSlug); } else { - history.pushState(slug, '', '/' + slug); + history.pushState(slug, '', cleanSlug); } - document.querySelector('html').className = 'theme-' + info.game; + document.querySelector('html').className = 'theme-' + slug.game; - document.title = `${data.meta.title || 'Page not found'} - ${games[info.game].name} Wiki`; - document.querySelector('#current-game').innerText = games[info.game].name; + document.title = `${data.meta.title || 'Page not found'} - ${games[slug.game].name} Wiki`; + document.querySelector('#current-game').innerText = games[slug.game].name; - document.querySelector('link[rel=icon]').href = games[info.game].favicon || games[info.game].icon; + document.querySelector('link[rel=icon]').href = games[slug.game].favicon || games[slug.game].icon; document.querySelector('link[rel=shortcut]').href = - games[info.game].favicon || games[info.game].icon; + games[slug.game].favicon || games[slug.game].icon; - document.querySelector('.top-nav .game a').href = `/${info.game}`; + document.querySelector('.top-nav .game a').href = `/${slug.game}`; if (loadData || data.path) { // Update the edit button to reflect our current page @@ -166,14 +124,13 @@ async function navigate(slug, replace = false, loadData = true) { }`; } - regenerateSidebar(info); - regenerateNav(info); - generateGameSelector(); + regenerateSidebar(slug); + regenerateNav(slug); addAnchorLinks(); updateAllLinkListeners(); } -window.addEventListener('popstate', () => navigate(location.pathname.slice(1), true)); +window.addEventListener('popstate', () => navigate(getLocationSlug(), true)); function regenerateSidebar(info: Slug) { const data = menu[info.game][info.category]; @@ -253,12 +210,11 @@ function regenerateNav(info: Slug) { */ function linkClickHandler(e) { e.preventDefault(); - console.log('GOING TO', e.target.href, 'EVENT:', e); const url = new URL(e.target.href, location.toString()); if (url.host === location.host) { document.body.classList.remove('nav-show'); - navigate(url.pathname.slice(1)); + navigate(new Slug().fromString(url.pathname.slice(1))); if (e.currentTarget.parentNode.classList.contains('categories') && e.currentTarget.innerText !== 'Home') { document.body.classList.add('nav-showTopics', 'nav-show'); @@ -268,19 +224,6 @@ function linkClickHandler(e) { } } -async function switchGame(game) { - hideGameSelector(); - - const split = location.pathname.slice(1).split('/'); - split[0] = game; - try { - await navigate(split.join('/')); - } catch { - await navigate(game); - notify('This page does not exist for this game, so we put you on the homepage.', 'file-document-remove'); - } -} - function updateAllLinkListeners() { // Override the behavior of all anchors const links = document.querySelectorAll('a'); @@ -296,11 +239,8 @@ function updateAllLinkListeners() { } /** - * Separates the slug into an easily digestible object - * @param {string} slug The slug you are trying to parse - * @returns {{game: string, topic: string, category: string, article: string}} + * Returns the current location as a slug */ -function parseSlug(slug: string): Slug { - const slugParsed = slug.split('/'); - return new Slug(slugParsed[0], slugParsed[1], slugParsed[2], slugParsed[3]); +export function getLocationSlug(): Slug { + return new Slug().fromString(location.pathname.slice(1)); } diff --git a/src/common/slug.ts b/src/common/slug.ts index 17c90ea..278b4e6 100644 --- a/src/common/slug.ts +++ b/src/common/slug.ts @@ -12,27 +12,67 @@ * ⠐⠿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠛⠁⠀⠀⠀⠀⠀ */ export class Slug { - readonly game: string; - readonly category?: string; - readonly topic?: string; - readonly article?: string; + game: string; + category?: string; + topic?: string; + article?: string; - constructor(game: string, category?: string, topic?: string, article?: string) { - if (game.toLowerCase().startsWith('ajax') || game.toLowerCase().startsWith('assets')) - throw new Error( - `Page names starting with "ajax" or "assets" are not permitted! Trying to render page ${game}/${category}/${topic}/${article}.` - ); + constructor(game?: string, category?: string, topic?: string, article?: string) { + this.set(game, category, topic, article); + } + + /** + * Sets the slug to the given article path. This will overwrite any existing content. + * @param game The Game ID + * @param category The Category ID + * @param topic The Topic ID + * @param article The Article ID + */ + set(game?: string, category?: string, topic?: string, article?: string) { + // Blank out our slug. Assume that we're on an index page. If we're not, we'll fill it in later + this.game = null; + this.category = null; + this.topic = null; + this.article = 'index'; + // game/index if (game && game.length > 0) this.game = game; + + // game/category/index if (category && category.length > 0) this.category = category; + + // game/category/topic/index if (topic && topic.length > 0) this.topic = topic; - this.article = article && article.length > 0 ? article : 'index'; + // game/category/topic/article + if (article && article.length > 0) this.article = article; } - toString() { + /** + * Converts the slug into a string + * @param excludeIndexArticle If true, any articles named "index" will not be appended + * @returns A representation of the slug as a path + */ + toString(excludeIndexArticle: boolean = false): string { let str = this.game; - for (const property of [this.category, this.topic, this.article]) if (property) str += '/' + property; + for (const property of [this.category, this.topic]) if (property) str += '/' + property; + + // Only append the index if we don't want it excluded + if (this.article && (!excludeIndexArticle || this.article !== 'index')) str += '/' + this.article; + return str; } + + /** + * Separates the slug into an easily digestible object + * @param slug The slug you are trying to parse + */ + fromString(slug: string): Slug { + // Split the string and use it as our value + const split = slug.split('/'); + this.set(split[0], split[1], split[2], split[3]); + + // Return this for ease of use + return this; + } }