aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authormat <github@matdoes.dev>2022-02-15 03:05:09 +0000
committermat <github@matdoes.dev>2022-02-15 03:05:09 +0000
commitfcabd988bd9b98eb5afda600345e14a302fbd4ee (patch)
tree069724e9e0b3543d33fe1ee73f1523cf13b2e1f7 /src
parentffe5eea0ce73cae8657c547f881b6f41270fef37 (diff)
downloadskyblock-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.svelte13
-rw-r--r--src/lib/GlobalTooltip.svelte101
-rw-r--r--src/lib/Head.svelte17
-rw-r--r--src/lib/Header.svelte31
-rw-r--r--src/lib/Username.svelte49
-rw-r--r--src/lib/api.ts7
-rw-r--r--src/lib/heads/Head2d.svelte30
-rw-r--r--src/lib/heads/Head3d.svelte27
-rw-r--r--src/lib/utils.ts130
-rw-r--r--src/routes/player/[player].svelte83
-rw-r--r--src/routes/player/index.ts12
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(/&lt;/g, '<')
+ .replace(/&gt;/g, '>')
+ .replace(/&quot;/g, '"')
+ const nameHtml = itemEl.dataset.nameHtml
+ .replace(/&lt;/g, '<')
+ .replace(/&gt;/g, '>')
+ .replace(/&quot;/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(/&lt;/g, '<')
+ .replace(/&gt;/g, '>')
+ .replace(/&quot;/g, '"')
+ const nameHtml = itemEl.dataset.nameHtml
+ .replace(/&lt;/g, '<')
+ .replace(/&gt;/g, '>')
+ .replace(/&quot;/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