diff options
author | mat <github@matdoes.dev> | 2022-02-15 03:05:09 +0000 |
---|---|---|
committer | mat <github@matdoes.dev> | 2022-02-15 03:05:09 +0000 |
commit | fcabd988bd9b98eb5afda600345e14a302fbd4ee (patch) | |
tree | 069724e9e0b3543d33fe1ee73f1523cf13b2e1f7 /src | |
parent | ffe5eea0ce73cae8657c547f881b6f41270fef37 (diff) | |
download | skyblock-stats-fcabd988bd9b98eb5afda600345e14a302fbd4ee.tar.gz skyblock-stats-fcabd988bd9b98eb5afda600345e14a302fbd4ee.tar.bz2 skyblock-stats-fcabd988bd9b98eb5afda600345e14a302fbd4ee.zip |
add stuff
Diffstat (limited to 'src')
-rw-r--r-- | src/lib/ConditionalLink.svelte | 13 | ||||
-rw-r--r-- | src/lib/GlobalTooltip.svelte | 101 | ||||
-rw-r--r-- | src/lib/Head.svelte | 17 | ||||
-rw-r--r-- | src/lib/Header.svelte | 31 | ||||
-rw-r--r-- | src/lib/Username.svelte | 49 | ||||
-rw-r--r-- | src/lib/api.ts | 7 | ||||
-rw-r--r-- | src/lib/heads/Head2d.svelte | 30 | ||||
-rw-r--r-- | src/lib/heads/Head3d.svelte | 27 | ||||
-rw-r--r-- | src/lib/utils.ts | 130 | ||||
-rw-r--r-- | src/routes/player/[player].svelte | 83 | ||||
-rw-r--r-- | src/routes/player/index.ts | 12 |
11 files changed, 500 insertions, 0 deletions
diff --git a/src/lib/ConditionalLink.svelte b/src/lib/ConditionalLink.svelte new file mode 100644 index 0000000..933d425 --- /dev/null +++ b/src/lib/ConditionalLink.svelte @@ -0,0 +1,13 @@ +<!-- https://stackoverflow.com/a/65837525 --> +<script lang="ts"> + export let isWrapped = false; + export let href: string; +</script> + +{#if isWrapped} + <a {href}> + <slot /> + </a> +{:else} + <slot /> +{/if} diff --git a/src/lib/GlobalTooltip.svelte b/src/lib/GlobalTooltip.svelte new file mode 100644 index 0000000..61e5514 --- /dev/null +++ b/src/lib/GlobalTooltip.svelte @@ -0,0 +1,101 @@ +<script lang="ts"> + import { onMount } from 'svelte' + + let tooltipEl + + // this script handles the item hover lore tooltip + onMount(() => { + const itemEls = document.getElementsByClassName('item') + let tooltipLocked = false + function moveTooltipToMouse(e) { + const mouseX = e.pageX + const mouseY = e.pageY + console.log(mouseY + tooltipEl.offsetHeight, window.innerHeight + window.scrollY - 10) + // if it's going to be off the bottom of the screen, move it up + if (mouseY + tooltipEl.offsetHeight > window.innerHeight + window.scrollY - 10) { + // put it at the bottom of the screen + tooltipEl.style.top = `${ + window.innerHeight + window.scrollY - 10 - tooltipEl.offsetHeight + }px` + } else { + // otherwise, put it at the mouse's y position + tooltipEl.style.top = mouseY + 'px' + } + // if it's going to be off the right of the screen, move it left + if (mouseX + tooltipEl.offsetWidth > window.innerWidth + window.scrollX - 10) { + // put it at the right of the screen + tooltipEl.style.left = `${ + window.innerWidth + window.scrollX - 10 - tooltipEl.offsetWidth + }px` + } else { + // otherwise, put it at the mouse's x position + tooltipEl.style.left = mouseX + 'px' + } + } + document.addEventListener('mousemove', e => { + if (!tooltipLocked && tooltipEl.style.display !== 'none') { + moveTooltipToMouse(e) + } + }) + + for (const itemEl of itemEls) { + if (!(itemEl instanceof HTMLElement)) continue + + // if the item doesn't have lore or a name, that must mean it's air + if (!itemEl.dataset.loreHtml && !itemEl.dataset.nameHtml) continue + + itemEl.addEventListener('mouseover', e => { + if (!tooltipLocked) { + moveTooltipToMouse(e) + const loreHtml = itemEl.dataset.loreHtml + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + const nameHtml = itemEl.dataset.nameHtml + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + tooltipEl.innerHTML = `<p class="item-lore-name">${nameHtml}</p><p class="item-lore-text">${loreHtml}</p>` + } + tooltipEl.style.display = 'block' + }) + itemEl.addEventListener('mouseout', () => { + if (!tooltipLocked) { + tooltipEl.innerHTML = '' + tooltipEl.style.display = 'none' + } + }) + itemEl.addEventListener('click', e => { + tooltipLocked = !tooltipLocked + moveTooltipToMouse(e) + tooltipEl.style.display = 'block' + if (tooltipLocked) { + tooltipEl.style.userSelect = 'auto' + tooltipEl.style.pointerEvents = 'auto' + } else { + tooltipEl.style.userSelect = null + tooltipEl.style.pointerEvents = null + } + const loreHtml = itemEl.dataset.loreHtml + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + const nameHtml = itemEl.dataset.nameHtml + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + tooltipEl.innerHTML = `<p class="item-lore-name">${nameHtml}</p><p class="item-lore-text">${loreHtml}</p>` + }) + document.addEventListener('mousedown', e => { + if (tooltipLocked && !tooltipEl.contains(e.target)) { + tooltipLocked = false + tooltipEl.style.userSelect = null + tooltipEl.style.pointerEvents = null + tooltipEl.style.display = 'none' + } + }) + } + }) +</script> + +<div id="global-tooltip" style="display: none" bind:this={tooltipEl} /> diff --git a/src/lib/Head.svelte b/src/lib/Head.svelte new file mode 100644 index 0000000..d301f14 --- /dev/null +++ b/src/lib/Head.svelte @@ -0,0 +1,17 @@ +<script lang="ts"> + /** The title that is shown at the top of the page and in search engines */ + export let title = 'SkyBlock Stats' + /** The description that is shown in search engines */ + export let description = '' + /** The title that is shown in platforms like Discord */ + export let metaTitle = title + /** The description that is shown in platforms like Discord */ + export let metaDescription = description +</script> + +<svelte:head> + <title>{title}</title> + <meta name="description" content={description}> + <meta property="og:title" content={metaTitle}> + <meta property="og:description" content={metaDescription}> +</svelte:head> diff --git a/src/lib/Header.svelte b/src/lib/Header.svelte new file mode 100644 index 0000000..6ab7a33 --- /dev/null +++ b/src/lib/Header.svelte @@ -0,0 +1,31 @@ +<script lang="ts"> + import { enhance } from '$lib/form' + + export let backArrowHref = '/' +</script> + +<header id="main-header"> + <a href={backArrowHref} class="back-arrow-anchor" aria-label="back"> + <svg class="back-arrow" height="33" width="23"> + <path d="M 14 0 l -13 13 l 13 13" stroke-width="2" fill="none" /> + </svg> + </a> + <form action="/player" method="post" class="user-form" use:enhance> + <!-- use:enhance={{ + result: async ({ form }) => { + form.reset() + }, + }} --> + <input + class="enter-username-button" + type="text" + placeholder="Enter username" + name="user-search" + autocomplete="off" + autocorrect="off" + autocapitalize="off" + spellcheck="false" + aria-label="Enter username" + /> + </form> +</header> diff --git a/src/lib/Username.svelte b/src/lib/Username.svelte new file mode 100644 index 0000000..764721c --- /dev/null +++ b/src/lib/Username.svelte @@ -0,0 +1,49 @@ +<script lang="ts"> + import ConditionalLink from '$lib/ConditionalLink.svelte' + import Head2d from '$lib/heads/Head2d.svelte' + import Head3d from '$lib/heads/Head3d.svelte' + import { formattingCodeToHtml } from './utils' + + export let player + export let headType: null | '3d' | '2d' = null + export let hyperlinkToProfile = false + export let prefix = false +</script> + +<!-- {%- macro username(player, headType=none, hyperlinkToProfile=false, prefix=false) -%} +{%- if hyperlinkToProfile %}<a href="/player/{{ player.username }}{% if hyperlinkToProfile|isString %}/{{ hyperlinkToProfile }}{% endif %}">{% endif -%} +{%- if headType === '3d' %}{{ head3d(player, isPartOfUsername=true) -}} +{%- elif headType === '2d' %}{{ head2d(player, isPartOfUsername=true) -}} +{%- endif -%} +{%- if prefix -%}<span class="username-rank-prefix">{{ player.rank.colored|formattingCodeToHtml|safe }} </span>{%- endif -%} + <span class="username" style="color: {{ player.rank.color }}">{{ player.username }}</span> +{%- if hyperlinkToProfile %}</a>{% endif -%} +{%- endmacro -%} --> + +<ConditionalLink href="/player/{player.username}" isWrapped={hyperlinkToProfile}> + {#if headType == '3d'} + <Head3d {player} isPartOfUsername={true} /> + {:else if headType == '2d'} + <Head2d {player} isPartOfUsername={true} /> + {/if} + <span class="username-rank-prefix"> + {@html formattingCodeToHtml(player.rank.colored)} + </span> + <span class="username" style="color: {player.rank.color}">{player.username}</span> +</ConditionalLink> + +<style> + .username { + /* usernames have the minecraft font */ + font-family: Minecraft, sans-serif; + /* reduce the size of the text because the font is too big */ + font-size: 0.8em; + overflow-wrap: anywhere; + } + + .username-rank-prefix { + font-family: Minecraft, sans-serif; + font-size: 0.8em; + overflow-wrap: anywhere; + } +</style> diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..3c1d8ec --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,7 @@ +const BASE_URL = 'https://skyblock-api.matdoes.dev/' + +export async function get(path: string) { + const resp = await fetch(BASE_URL + path) + return await resp.json() +} + diff --git a/src/lib/heads/Head2d.svelte b/src/lib/heads/Head2d.svelte new file mode 100644 index 0000000..d4e9ca8 --- /dev/null +++ b/src/lib/heads/Head2d.svelte @@ -0,0 +1,30 @@ +<script lang="ts"> + export let player + export let isPartOfUsername = false +</script> + +<img + loading="lazy" + class="head head2d" + class:userHead={isPartOfUsername} + src="https://crafatar.com/avatars/{player.uuid}?size=8&overlay" + alt="{player.username}'s face" +/> + +<style> + .head { + user-select: none; + } + + .head2d { + /* pixelated rendering on 2d heads */ + image-rendering: crisp-edges; + image-rendering: pixelated; + /* make the head centered correctly */ + position: relative; + top: 0.1em; + /* same size as font */ + height: 1em; + width: 1em; + } +</style> diff --git a/src/lib/heads/Head3d.svelte b/src/lib/heads/Head3d.svelte new file mode 100644 index 0000000..f8d2657 --- /dev/null +++ b/src/lib/heads/Head3d.svelte @@ -0,0 +1,27 @@ +<script lang="ts"> + export let player + export let isPartOfUsername = false +</script> + +<img + loading="lazy" + class="head head3d" + class:userHead={isPartOfUsername} + src="https://www.mc-heads.net/head/{player.uuid}/128" + alt="{player.username}'s head" +/> + +<style> + .head { + user-select: none; + } + + .head3d { + /* make the head centered correctly */ + position: relative; + top: 0.2em; + /* same size as font */ + height: 1em; + width: 1em; + } +</style> diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..e6f85ff --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,130 @@ +export const colorCodes: { [key: string]: string } = { + '0': '#000000', // black + '1': '#0000be', // blue + '2': '#00be00', // green + '3': '#00bebe', // cyan + '4': '#be0000', // red + '5': '#be00be', // magenta + '6': '#ffaa00', // gold + '7': '#bebebe', // light gray + '8': '#3f3f3f', // dark gray + '9': '#3f3ffe', // light blue + 'a': '#3ffe3f', // light green + 'b': '#3ffefe', // light cyan + 'c': '#fe3f3f', // light red + 'd': '#fe3ffe', // light magenta + 'e': '#fefe3f', // yellow + 'f': '#ffffff', // white +} + +const specialCodes: { [key: string]: string } = { + 'l': 'font-weight: bold' +} + +const colorCodeCharacter = '§' + +export function formattingCodeToHtml(formatted: string): string { + let htmlOutput = '' + // we store the hex code, not the formatting code + let currentColor = null + // we store the css code, not the formatting code + const activeSpecialCodes: string[] = [] + function reset() { + if (currentColor) { + htmlOutput += '</span>' + currentColor = null + } + while (activeSpecialCodes.pop()) { + htmlOutput += '</span>' + } + } + while (formatted.length > 0) { + const character = formatted[0] + formatted = formatted.slice(1) + // if it encounters § (or whatever colorCodeCharacter is), then read the next character + if (character === colorCodeCharacter) { + const colorCharacter = formatted[0] + formatted = formatted.slice(1) + if (colorCodes[colorCharacter]) { + if (currentColor !== colorCodes[colorCharacter]) { // make sure the color is different than the active one + // if there's already a color, close that tag + if (currentColor) htmlOutput += '</span>' + currentColor = colorCodes[colorCharacter] + htmlOutput += `<span style="color: ${currentColor}">` + } + } else if (specialCodes[colorCharacter]) { + if (!activeSpecialCodes.includes(specialCodes[colorCharacter])) { + activeSpecialCodes.push(specialCodes[colorCharacter]) + htmlOutput += `<span style="${specialCodes[colorCharacter]}">` + } + } else if (colorCharacter === 'r') { + reset() + } + } else { + htmlOutput += character + } + } + reset() + return htmlOutput +} +export function removeFormattingCode(formatted: string): string { + return formatted.replace(new RegExp(colorCodeCharacter + '.', 'g'), '') +} +function moveStringToEnd(word: string, thing: string) { + if (thing.startsWith(`${word}_`)) + thing = thing.substr(`${word}_`.length) + `_${word}` + return thing +} +function millisecondsToTime(totalMilliseconds: number) { + const totalSeconds = totalMilliseconds / 1000 + const totalMinutes = totalSeconds / 60 + const totalHours = totalMinutes / 60 + const totalDays = totalHours / 24 + const milliseconds = Math.floor(totalMilliseconds) % 1000 + const seconds = Math.floor(totalSeconds) % 60 + const minutes = Math.floor(totalMinutes) % 60 + const hours = Math.floor(totalHours) % 24 + const days = Math.floor(totalDays) + const stringUnits: string[] = [] + if (days > 1) stringUnits.push(`${days} days`) + else if (days == 1) stringUnits.push(`${days} day`) + if (hours > 1) stringUnits.push(`${hours} hours`) + else if (hours == 1) stringUnits.push(`${hours} hour`) + if (minutes > 1) stringUnits.push(`${minutes} minutes`) + else if (minutes == 1) stringUnits.push(`${minutes} minute`) + if (seconds > 1) stringUnits.push(`${seconds} seconds`) + else if (seconds == 1) stringUnits.push(`${seconds} second`) + if (milliseconds > 1) stringUnits.push(`${milliseconds} milliseconds`) + else if (milliseconds == 1) stringUnits.push(`${milliseconds} millisecond`) + return stringUnits.slice(0, 2).join(' and ') +} +export function cleanNumber(number: number, unit?: string): string { + switch (unit) { + case 'time': + return millisecondsToTime(number) + case 'date': + return (new Date(number * 1000)).toUTCString() + } + return number.toLocaleString() + (unit ? (' ' + unit) : '') +} +export function clean(thing: string | number) { + if (typeof thing === 'number') { + return cleanNumber(thing) + } else { + for (const string of ['deaths', 'kills', 'collection', 'skill']) + thing = moveStringToEnd(string, thing) + return thing + .replace(/^./, thing[0].toUpperCase()) + .replace(/_/g, ' ') + } +} +export function toRomanNumerals(number: number) { + return ['', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI', 'XII', 'XIII', 'XIV', 'XV', 'XVI', 'XVII', 'XVIII', 'XIX', 'XX'][number] +} +export function shuffle<T>(a: T[]): T[] { + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[a[i], a[j]] = [a[j], a[i]] + } + return a +}
\ No newline at end of file diff --git a/src/routes/player/[player].svelte b/src/routes/player/[player].svelte new file mode 100644 index 0000000..7fcc39e --- /dev/null +++ b/src/routes/player/[player].svelte @@ -0,0 +1,83 @@ +<script lang="ts" context="module"> + import { get } from '$lib/api' + import type { Load } from '@sveltejs/kit' + export const load: Load = async ({ params, fetch }) => { + const player: string = params.player + // if (browser) alert('doing get') + const res = await fetch(`https://skyblock-api.matdoes.dev/player/${player}`).then(r => r.json()) + // const res = await get(`player/${player}`) + return { + props: { + data: res, + }, + } + } +</script> + +<script lang="ts"> + import Head from '$lib/Head.svelte' + import Header from '$lib/Header.svelte' + import { browser } from '$app/env' + import Username from '$lib/Username.svelte' + + export let data + + let activeProfile = null + let activeProfileLastSave: number +</script> + +<Head title="{data.player.username}'s SkyBlock profiles" /> +<Header /> + +<svelte:head> + {#if data.customization?.backgroundUrl} + <style> + body:before { + content: ''; + display: block; + position: fixed; + left: 0; + top: 0; + width: 100%; + height: 100%; + z-index: -10; + background: url('{data.customization.backgroundUrl}') no-repeat center center; + background-size: cover; + } + </style> + {/if} +</svelte:head> + +<!-- {% endblock %} +{%- block main -%} + <h1>{{ render.username(data.player, headType='3d') }}'s profiles</h1> +{%- set activeProfile = null -%} +{%- set activeProfileLastSave = 0 -%} +{%- for profile in data.profiles -%} +{%- for member in profile.members -%} +{%- if member.uuid == data.player.uuid and member.last_save > activeProfileLastSave -%} +{%- set activeProfile = profile -%} +{%- set activeProfileLastSave = member.last_save -%} +{%- endif -%} +{%- endfor -%} +{%- endfor -%} +{%- set activeProfileOnline = getTime() - 60 < activeProfileLastSave -%} + <ul class="profile-list"> +{%- for profile in data.profiles -%} + <li class="profile-list-item{% if profile.uuid == activeProfile.uuid %} profile-list-item-active{% if activeProfileOnline %} profile-list-item-online{% endif %}{% endif %}"> + <a class="profile-name" href="/player/{{ data.player.username }}/{{ profile.name }}">{{ profile.name }}</a> +{#- This comment is necessary to remove the space between the profile name and the user list :) -#} + <span class="profile-members"> +{%- if profile.members|length > 1 %}{% for player in profile.members -%} +{#- don't unnecessarily hyperlink to the page it's already o -#} +{%- set hyperlinkToProfile = player.uuid != data.player.uuid -%} +{{- render.username(player, headType='2d', hyperlinkToProfile=hyperlinkToProfile) -}} +{%- endfor -%} +{%- else %}Solo{% endif -%} + </span> + </li> +{%- endfor -%} + <ul> +{%- endblock -%} --> + +<h1><Username player={data.player} headType="3d" />'s profiles</h1> diff --git a/src/routes/player/index.ts b/src/routes/player/index.ts new file mode 100644 index 0000000..6c36cd8 --- /dev/null +++ b/src/routes/player/index.ts @@ -0,0 +1,12 @@ +export async function post({ request }) { + const form = await request.formData() + + const player = form.get('user-search') + + return { + status: 303, + headers: { + location: `/player/${player}` + } + }; +}
\ No newline at end of file |