From 28fd41ca664028352194f20841ab1a010c42d0b2 Mon Sep 17 00:00:00 2001 From: Eejit <76887639+Eejit43@users.noreply.github.com> Date: Tue, 5 Mar 2024 21:43:35 -0500 Subject: [PATCH] feat: add foreign-collections-list page --- src/public/data/pages.ts | 1 + src/public/scripts/pages/info/coins-list.ts | 26 ++- .../pages/info/foreign-collections-list.ts | 188 ++++++++++++++++++ .../pages/info/foreign-collections-list.css | 38 ++++ .../foreign-collections-list.ts | 85 ++++++++ src/route-handlers/index.ts | 2 + .../pages/info/foreign-collections-list.hbs | 31 +++ 7 files changed, 360 insertions(+), 11 deletions(-) create mode 100644 src/public/scripts/pages/info/foreign-collections-list.ts create mode 100644 src/public/styles/pages/info/foreign-collections-list.css create mode 100644 src/route-handlers/foreign-collections-list.ts create mode 100644 src/views/pages/info/foreign-collections-list.hbs diff --git a/src/public/data/pages.ts b/src/public/data/pages.ts index d0bc5b5..f35ca23 100644 --- a/src/public/data/pages.ts +++ b/src/public/data/pages.ts @@ -157,6 +157,7 @@ const preParsedPages: PreParsedPages = { 'coins-info': { title: 'Coins Info', icon: 'coin', description: 'Information about all coins that have been produced for circulation in the United States' }, 'coins-list': { title: 'Coins List', icon: 'coins', description: 'A list of coins I have and need' }, 'euro-coins': { title: 'Euro Coins', icon: 'euro-sign', description: 'A list of all euro coin designs', additionalData: { euroCoins } }, + 'foreign-collections-list': { title: 'Foreign Collection List', icon: 'earth-americas', description: 'A list of countries I have coins, stamps, and banknotes from' }, 'minecraft-codes': { title: 'Minecraft Formatting Codes', icon: 'gamepad-modern', diff --git a/src/public/scripts/pages/info/coins-list.ts b/src/public/scripts/pages/info/coins-list.ts index fefebb8..899f21a 100644 --- a/src/public/scripts/pages/info/coins-list.ts +++ b/src/public/scripts/pages/info/coins-list.ts @@ -29,7 +29,7 @@ loginButton.addEventListener('click', async () => { showAlert('Logged in!', 'success'); showResult(loginButton, 'success', false); - loadCoinsList(); + await loadCoinsList(); exportDataButton.disabled = false; } else { @@ -736,11 +736,13 @@ function formatMintage(mintage: number) { * @param data The data to update. */ async function updateCoinData(denominationId: string, designId: string, coinId: string, data: PartialNullable) { - const result = (await fetch('/coins-list-edit', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ denominationId, designId, coinId, data, password: passwordInput.dataset.input }), - })) as { error?: string }; + const result = (await ( + await fetch('/coins-list-edit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ denominationId, designId, coinId, data, password: passwordInput.dataset.input }), + }) + ).json()) as { error?: string }; if (result.error) showAlert(result.error, 'error'); else showAlert('Coin data updated successfully!', 'success'); @@ -754,11 +756,13 @@ async function updateCoinData(denominationId: string, designId: string, coinId: * @param coinId The id of the coin to add. */ async function addCoin(denominationId: string, designId: string, coinYear: string, coinId: string) { - const result = (await fetch('/coins-list-add-coin', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ denominationId, designId, coinYear, coinId, password: passwordInput.dataset.input }), - })) as { error?: string }; + const result = (await ( + await fetch('/coins-list-add-coin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ denominationId, designId, coinYear, coinId, password: passwordInput.dataset.input }), + }) + ).json()) as { error?: string }; if (result.error) showAlert(result.error, 'error'); else showAlert('Successfully added a new coin row!', 'success'); diff --git a/src/public/scripts/pages/info/foreign-collections-list.ts b/src/public/scripts/pages/info/foreign-collections-list.ts new file mode 100644 index 0000000..acf3ea6 --- /dev/null +++ b/src/public/scripts/pages/info/foreign-collections-list.ts @@ -0,0 +1,188 @@ +import { ForeignCollectionsList } from '../../../../route-handlers/foreign-collections-list.js'; +import { showAlert, showResult } from '../../functions.js'; + +const passwordInput = document.querySelector('#login-password') as HTMLInputElement; +const loginButton = document.querySelector('#login-button') as HTMLButtonElement; +const collectionsListMessage = document.querySelector('#collections-list-message') as HTMLDivElement; +const collectionsList = document.querySelector('#collections-list') as HTMLDivElement; +const newRowMessage = document.querySelector('#new-row-message') as HTMLTableRowElement; +const exportDataButton = document.querySelector('#export-data') as HTMLButtonElement; + +for (const type of ['input', 'paste']) + passwordInput.addEventListener(type, () => { + passwordInput.value = passwordInput.value.replaceAll(/\D/g, ''); + + if (passwordInput.value.length > 4) passwordInput.value = passwordInput.value.slice(0, 4); + }); + +passwordInput.addEventListener('keydown', (event) => { + if (event.key === 'Enter' && passwordInput.value.length > 0) loginButton.click(); +}); + +loginButton.addEventListener('click', async () => { + const { success } = (await (await fetch(`/coins-login?password=${passwordInput.value}`)).json()) as { success: boolean }; + + if (success) { + passwordInput.dataset.input = passwordInput.value; + passwordInput.value = ''; + passwordInput.disabled = true; + loginButton.disabled = true; + showAlert('Logged in!', 'success'); + showResult(loginButton, 'success', false); + + await loadCollectionList(); + + exportDataButton.disabled = false; + } else { + showAlert('Incorrect password!', 'error'); + showResult(loginButton, 'error'); + loginButton.disabled = true; + setTimeout(() => (loginButton.disabled = false), 1000); + } +}); + +let collectionData: ForeignCollectionsList; + +/** + * Load the coins list. + */ +async function loadCollectionList() { + collectionData = (await (await fetch(`/foreign-collections-list?password=${passwordInput.dataset.input!}`)).json()) as ForeignCollectionsList; + + collectionsListMessage.style.display = 'none'; + collectionsList.style.display = 'block'; + + reloadTableData(); + + newRowMessage.addEventListener('click', async () => { + let id: string; + do id = Math.floor(Math.random() * 9_000_000_000 + 1_000_000_000).toString(); + while (collectionData.some((country) => country.id === id)); + + const result = (await ( + await fetch('/foreign-collections-list-add-country', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Unknown Country', data: { coins: false, banknotes: false, stamps: false }, password: passwordInput.dataset.input }), + }) + ).json()) as { error?: string; data: ForeignCollectionsList }; + + if (result.error) showAlert(result.error, 'error'); + else { + showAlert('Successfully added a new country row!', 'success'); + + collectionData = result.data; + + reloadTableData(); + } + }); +} + +/** + * Reloads the table's data from the stored variable. + */ +function reloadTableData() { + const tableBody = collectionsList.querySelector('tbody') as HTMLTableSectionElement; + + while (tableBody.children.length > 1) tableBody.children[0].remove(); + + for (const country of collectionData) { + const row = document.createElement('tr'); + + const nameCell = document.createElement('td'); + nameCell.textContent = country.name; + nameCell.contentEditable = 'true'; + nameCell.addEventListener('blur', async () => { + const result = (await ( + await fetch('/foreign-collections-list-edit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: country.id, name: nameCell.textContent, data: country.data, password: passwordInput.dataset.input }), + }) + ).json()) as { error?: string; data: ForeignCollectionsList }; + + if (result.error) showAlert(result.error, 'error'); + else { + showAlert('Successfully edited the country name!', 'success'); + + collectionData = result.data; + + reloadTableData(); + } + }); + row.append(nameCell); + + for (const type of ['coins', 'banknotes', 'stamps'] as const) { + const obtained = country.data[type]; + + const cell = document.createElement('td'); + row.append(cell); + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.checked = obtained; + checkbox.addEventListener('change', async () => { + let sortedData = { ...country.data, [type]: checkbox.checked }; + sortedData = { coins: sortedData.coins, banknotes: sortedData.banknotes, stamps: sortedData.stamps }; + + const result = (await ( + await fetch('/foreign-collections-list-edit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: country.id, name: country.name, data: sortedData, password: passwordInput.dataset.input }), + }) + ).json()) as { error?: string; data: ForeignCollectionsList }; + + if (result.error) showAlert(result.error, 'error'); + else { + showAlert(`Successfully updated the country's "${type}" obtained status!`, 'success'); + + collectionData = result.data; + + reloadTableData(); + } + }); + cell.append(checkbox); + } + + newRowMessage.before(row); + } +} + +const parameters = new URLSearchParams(window.location.search); +const password = parameters.get('password'); + +if (password) { + const { success } = (await (await fetch(`/coins-login?password=${password}`)).json()) as { success: boolean }; + + if (success) { + passwordInput.dataset.input = password; + passwordInput.disabled = true; + loginButton.disabled = true; + showAlert('Logged in!', 'success'); + showResult(loginButton, 'success', false); + + await loadCollectionList(); + + exportDataButton.disabled = false; + } else { + showAlert('Incorrect password!', 'error'); + showResult(loginButton, 'error'); + } +} + +// Add functionality to data exporter +exportDataButton.addEventListener('click', async () => { + const coinsData = (await (await fetch(`/foreign-collections-list?password=${passwordInput.dataset.input!}`)).json()) as ForeignCollectionsList & { error?: string }; + + if (coinsData.error) return showAlert(coinsData.error, 'error'); + + const file = new Blob([JSON.stringify(coinsData)], { type: 'application/json' }); + const anchor = document.createElement('a'); + const url = URL.createObjectURL(file); + anchor.href = url; + anchor.download = `foreign-collection-list data (${new Date().toLocaleString()}).json`; + anchor.click(); + + setTimeout(() => URL.revokeObjectURL(url), 0); +}); diff --git a/src/public/styles/pages/info/foreign-collections-list.css b/src/public/styles/pages/info/foreign-collections-list.css new file mode 100644 index 0000000..a62e6c7 --- /dev/null +++ b/src/public/styles/pages/info/foreign-collections-list.css @@ -0,0 +1,38 @@ +#login-container { + margin-top: 1em; +} + +#login-password { + width: calc(4ch + 12px); +} + +#collection-list table { + margin: 20px auto; + width: 50%; + + & input[type="checkbox"] { + transform: scale(1.3); + } +} + +#collection-list th { + text-align: center; +} + +#new-row-message { + transition: all 0.2s ease; + cursor: pointer; + + &:hover { + background-color: #3e434b; + } + + & i.fa-plus { + padding-right: 6px; + } +} + +button#export-data { + margin-top: 1em; + padding: 5px; +} diff --git a/src/route-handlers/foreign-collections-list.ts b/src/route-handlers/foreign-collections-list.ts new file mode 100644 index 0000000..c31e4dc --- /dev/null +++ b/src/route-handlers/foreign-collections-list.ts @@ -0,0 +1,85 @@ +import { FastifyInstance, FastifyRequest } from 'fastify'; +import { Schema, model } from 'mongoose'; + +interface ForeignCollectionCountry { + id: string; + name: string; + data: { coins: boolean; banknotes: boolean; stamps: boolean }; +} + +export type ForeignCollectionsList = ForeignCollectionCountry[]; + +type DatabaseForeignCollectionsList = { data: ForeignCollectionsList } & { _id?: number; __v?: number }; // eslint-disable-line @typescript-eslint/naming-convention + +const foreignCollectionsList = model('foreign-collections-list', new Schema({ data: Array }, {})); + +/** + * Sets up all foreign collection related routes. + * @param fastify The Fastify instance. + */ +export default function (fastify: FastifyInstance) { + fastify.get('/foreign-collections-list', async (request: FastifyRequest<{ Querystring: { password: string } }>, reply) => { + if (request.query.password !== process.env.COINS_PASSWORD) return reply.send(JSON.stringify({ error: 'Invalid password!' }, null, 2)); + + const foundList = (await foreignCollectionsList.findOne({}).lean()) as DatabaseForeignCollectionsList; + + reply.send(JSON.stringify(foundList.data, null, 2)); + }); + + fastify.post('/foreign-collections-list-edit', async (request: FastifyRequest<{ Body: ForeignCollectionCountry & { password: string } }>, reply) => { + const { name, data, password } = request.body; + + if (password !== process.env.COINS_PASSWORD) return reply.send(JSON.stringify({ error: 'Invalid password!' }, null, 2)); + + const foundList = (await foreignCollectionsList.findOne({}).lean()) as DatabaseForeignCollectionsList; + + delete foundList._id; + delete foundList.__v; + + const foundCountry = foundList.data.find((country) => country.id === request.body.id); + if (!foundCountry) return reply.send(JSON.stringify({ error: 'Country not found!' }, null, 2)); + + foundCountry.name = name; + foundCountry.data = data; + + const sortedData = foundList.data.sort((a, b) => a.name.localeCompare(b.name)); + + await foreignCollectionsList.replaceOne({}, { data: sortedData }); + + reply.send(JSON.stringify({ success: true, data: sortedData }, null, 2)); + }); + + fastify.post('/foreign-collections-list-add-country', async (request: FastifyRequest<{ Body: ForeignCollectionCountry & { password: string } }>, reply) => { + const { name, data, password } = request.body; + + if (password !== process.env.COINS_PASSWORD) return reply.send(JSON.stringify({ error: 'Invalid password!' }, null, 2)); + + const foundList = (await foreignCollectionsList.findOne({}).lean()) as DatabaseForeignCollectionsList; + + delete foundList._id; + delete foundList.__v; + + const id = generateUniqueCountryId(foundList.data); + + foundList.data.push({ id, name, data }); + + const sortedData = foundList.data.sort((a, b) => a.name.localeCompare(b.name)); + + await foreignCollectionsList.replaceOne({}, { data: sortedData }); + + reply.send(JSON.stringify({ success: true, data: sortedData }, null, 2)); + }); +} + +/** + * Generates a unique coin ID. + * @param list The list of collections to check against. + */ +function generateUniqueCountryId(list: ForeignCollectionsList) { + let id: string; + + do id = Math.floor(Math.random() * 9_000_000_000 + 1_000_000_000).toString(); + while (list.some((country) => country.id === id)); + + return id; +} diff --git a/src/route-handlers/index.ts b/src/route-handlers/index.ts index c6fcb62..fc09d0f 100644 --- a/src/route-handlers/index.ts +++ b/src/route-handlers/index.ts @@ -5,6 +5,7 @@ import setupCalendarRoutes from './calendar.js'; import setupCoinsInfoRoute from './coins-info.js'; import setupCoinsListRoutes from './coins-list.js'; import setupCorsAnywhereRoute from './cors-anywhere.js'; +import setupForeignCollectionsList from './foreign-collections-list.js'; import setupIpInfoRoute from './ip-info.js'; import setupTidesInfoRoute from './tides-info.js'; import setupTwemojiRoute from './twemoji.js'; @@ -21,6 +22,7 @@ export default function setupRoutes(fastify: FastifyInstance) { setupCoinsInfoRoute(fastify); setupCoinsListRoutes(fastify); setupCorsAnywhereRoute(fastify); + setupForeignCollectionsList(fastify); setupIpInfoRoute(fastify); setupTidesInfoRoute(fastify); setupTwemojiRoute(fastify); diff --git a/src/views/pages/info/foreign-collections-list.hbs b/src/views/pages/info/foreign-collections-list.hbs new file mode 100644 index 0000000..21cdbec --- /dev/null +++ b/src/views/pages/info/foreign-collections-list.hbs @@ -0,0 +1,31 @@ +Welcome! Here is a list of countries I have coins, stamps, and banknotes I have! + +
+ Password: + + +
+ +
+
Please login to view this!
+ + +
+ +