Compare commits

..

3 Commits
main ... main

Author SHA1 Message Date
Dominic Zimmer
ba64b97136 Add extraData example 2026-01-03 19:06:19 +01:00
Dominic Zimmer
e6319ec3cf Migrate template to react19 and new API 2026-01-02 21:23:02 +01:00
Dominic Zimmer
f105e36f15 Update party template 2023-03-23 11:08:50 +01:00
16 changed files with 6216 additions and 17613 deletions

View File

@ -14,7 +14,7 @@ steps:
ssh_key: ssh_key:
from_secret: rsync_key from_secret: rsync_key
source: build/ source: build/
destination: pelipper@oreburgh.leafbla.de:/srv/docker/party/nginx/html/lan/ destination: pelipper@oreburgh.leafbla.de:/srv/docker/party/nginx/html/${DRONE_REPO_NAME}/
trigger: trigger:
branch: branch:

View File

@ -4,9 +4,27 @@ The API documentation is available [here](https://party.leafbla.de/api/docs)
# CI Setup # CI Setup
[![Build Status](https://drone.eterna.leafbla.de/api/badges/partypages/lan-2022-10/status.svg)](https://drone.eterna.leafbla.de/partypages/lan-2022-10) To enable automatic CI deployment for this repository, you need to do the following:
1. Adapt `.drone.yml` such that the output is deployed to where you want to.
By default, this is `<repo-name>.party.leafbla.de`.
1. Enable CI for the repository in [drone](https://drone.eterna.leafbla.de/).
Search the repository name in the search bar (you might need to click the "sync" button to make it show up) and click "activate repository"
1. Add the required secrets so that the CI can deploy to oreburgh
- Secret Name: `rsync_key`
Secret Value: Find "pelipper ssh key" in Bitwarden and paste the content of `id_rsa`
- Secret Name: `docker_config`
Secret Value: Find "Docker registry credentials" in Bitwarden and paste the content of the note.
The next time you push to the repository, the pipeline should run.
This Repository is configured to automatically deploy to [lan.party.leafbla.de](https://lan.party.leafbla.de).
# Getting Started with Create React App # Getting Started with Create React App

23148
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,26 +3,19 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.2.0",
"@fortawesome/free-solid-svg-icons": "^6.2.0",
"@fortawesome/react-fontawesome": "^0.2.0",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2", "@types/jest": "^27.5.2",
"@types/node": "^16.11.64", "@types/node": "^16.18.126",
"@types/react": "^18.0.21", "@types/react": "19.2.7",
"@types/react-dom": "^18.0.6", "@types/react-dom": "19.2.3",
"react": "^18.2.0", "react": "19.2.3",
"react-dom": "^18.2.0", "react-dom": "19.2.3",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"typescript": "^4.8.4", "typescript": "^4.9.5",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",
"build": "react-scripts build", "build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject" "eject": "react-scripts eject"
}, },
"eslintConfig": { "eslintConfig": {

View File

@ -7,7 +7,7 @@
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<meta <meta
name="description" name="description"
content="Web site created using create-react-app" content="Party Invitation"
/> />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!-- <!--
@ -24,7 +24,7 @@
work correctly both with client-side routing and a non-root public URL. work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`. Learn how to configure a non-root public URL by running `npm run build`.
--> -->
<title>React App</title> <title>Party Invitation</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>

View File

@ -1,227 +0,0 @@
import React, { useEffect, useRef } from "react";
const CHAR_WIDTH = 10; // canvas pixels
const CHAR_HEIGHT = 12; // canvas pixels
const ROWS = 50;
const COLS = 50;
const TRAIL_COUNT = 50;
const ALPHABET = (() => {
let alphabet = [];
// 0 - 9
for (let charCode = "0".charCodeAt(0); charCode <= "9".charCodeAt(0); ++charCode) {
alphabet.push(String.fromCharCode(charCode));
}
// A - Z
for (let charCode = "A".charCodeAt(0); charCode <= "Z".charCodeAt(0); ++charCode) {
alphabet.push(String.fromCharCode(charCode));
}
// https://en.wikipedia.org/wiki/Half-width_kana
for (let codePoint = 0xFF71; codePoint <= 0xFF9D; ++codePoint) {
alphabet.push(String.fromCodePoint(codePoint));
}
return alphabet;
})();
// TODO add more
const MEMES = [
"ABFAHRT",
"BALLERN",
"NO SLEEP",
"ZOCKEN ALTER",
"MEGA KRASSE LAN",
"アヤヤアヤヤ", // Ayaya Ayaya
"オマエハモウシンテイル", // Omae wa mou shindeiru
]
const NORMAL_STYLE = "#020";
const MEME_STYLE = "#050";
const HEAD_STYLE = "#555";
/**
* Likelihood of a meme to appear after each character.
*
* 0.1 means there is one meme approximately every 10 chars.
*/
const MEME_PROBABILITY = 0.02;
type Trail = {
/** Column in which the trail falls. Should be between 0 and `COLS` */
col: number,
/** Row where the head (lowest character) of the trail is. May be negative or above `ROWS`. */
head: number,
/** Number of characters to display above, including the head */
length: number,
/**
* Vertical segments of text to show in the trail body
*
* The sum of each `chars.length` should be at least `ROWS`, but may be greater.
*/
content: {
chars: string,
fillStyle: string, // TODO is there a better type for this?
}[],
}
function randomTrail(): Trail {
let content = []
let current = "";
let totalLength = 0;
while (totalLength < ROWS) {
if (Math.random() < MEME_PROBABILITY) {
// Commit the current non-meme chars
if (current.length > 0) {
content.push({
chars: current,
fillStyle: NORMAL_STYLE,
});
current = "";
}
// Pick and add a random meme
const meme = choice(MEMES);
// If the meme has spaces, replace them with random characters
// This also places a random character after the meme, ensuring
// that two memes don't come immediately after each other
for (let chunk of meme.split(" ")) {
content.push({
chars: chunk,
fillStyle: MEME_STYLE,
});
content.push({
chars: choice(ALPHABET),
fillStyle: NORMAL_STYLE,
});
}
totalLength += meme.length + 1;
} else {
// No meme, just add one random character
current += choice(ALPHABET);
totalLength += 1;
}
}
if (current.length > 0) {
content.push({
chars: current,
fillStyle: NORMAL_STYLE,
});
}
return {
col: randRange(0, COLS),
head: randRange(-1 * COLS, 0), // spawn above the screen initially
length: randRange(5, 20),
content: content
};
}
function randRange(from: number, to: number) {
return from + Math.floor((to - from + 1) * Math.random());
}
function choice<T>(candidates: T[]): T {
return candidates[randRange(0, candidates.length - 1)];
}
const MatrixBackground = () => {
const canvasRef: React.MutableRefObject<HTMLCanvasElement | null> = useRef(null);
useEffect(() => {
const canvas = canvasRef.current
if (canvas === null) return;
const ctx = canvas.getContext("2d");
if (ctx === null) throw new Error('getContext("2d") returned null!');
ctx.font = '10pt monospace';
const width = canvas.width;
const height = canvas.height;
const trails: Trail[] = new Array(TRAIL_COUNT).fill(0).map((_) => randomTrail());
const matrixEffect = () => {
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, width, height);
// draw all trails
for (let trail of trails) {
const x = trail.col * CHAR_WIDTH;
let idx = 0;
// TODO this can probably be optimized
outer:
for (let item of trail.content) {
for (let char of item.chars) {
// stop drawing characters after reaching the head
if (idx > trail.head) break outer;
// skip characters behind the trail end
if (idx < trail.head - trail.length) {
idx += 1;
continue;
}
// draw the character, overwriting any that are already there
const y = idx * CHAR_HEIGHT;
ctx.fillStyle = "#000";
ctx.fillRect(x, y - CHAR_HEIGHT, CHAR_WIDTH, CHAR_HEIGHT);
ctx.fillStyle = (idx === trail.head ? HEAD_STYLE : item.fillStyle);
ctx.fillText(char, x, y);
idx += 1;
}
}
}
// update all trails
for (let i in trails) {
trails[i].head += 1;
if (trails[i].head - trails[i].length > ROWS) {
// trail is completely off-screen, generate a new one
trails[i] = randomTrail();
trails[i].head = 0;
}
}
}
const interval = setInterval(matrixEffect, 100);
return () => {
clearInterval(interval);
}
}, [canvasRef]);
return (
<canvas
ref={canvasRef}
style={{
background: '#000000',
position: 'fixed',
width: '100%',
height: '100%',
zIndex: -1,
top: '0',
left: '0',
objectFit: 'cover',
imageRendering: 'crisp-edges',
}}
width={COLS * CHAR_WIDTH}
height={ROWS * CHAR_HEIGHT}
/>
)
};
export default MatrixBackground;

View File

@ -1,15 +1,17 @@
import React, { createContext, useCallback, useEffect, useMemo, useState } from 'react'; import React, { createContext, useCallback, useEffect, useMemo, useState } from 'react';
import { getPartyStatusRequest, getSelfStatusRequest, parseURI } from './partyApi'; import { getPartyStatusRequest, getSelfStatusRequest, modifySelfRequest, parseURI } from './partyApi';
import './PartyPage.css'; import './PartyPage.css';
export const PartyContext = createContext<PartyContextType>({ export const PartyContext = createContext<PartyContextType>({
party: { definitely_coming: 0, maybe_coming: 0 }, party: { definitely_coming: 0, maybe_coming: 0 },
self: { token: "", name: "", coming: "yes", grammatical_gender: "m" } self: { token: "", name: "", coming: "yes", grammatical_gender: "m" },
update: () => {},
}); });
export type PartyContextType = { export type PartyContextType = {
party: PartyStatus, party: PartyStatus,
self: SelfStatus, self: SelfStatus,
update: (u: UpdatableSelfStatus) => void,
} }
export type PartyStatus = { export type PartyStatus = {
@ -34,6 +36,8 @@ export type APIEndPoint = { partyName: string, token: string };
// Adapt this type to your desires // Adapt this type to your desires
export type SelfStatusExtraData = { export type SelfStatusExtraData = {
/* Example type: */
/* plusone: string; */
}; };
export const PartyContextProvider: React.FC<{ children: React.ReactNode }> = (props) => { export const PartyContextProvider: React.FC<{ children: React.ReactNode }> = (props) => {
@ -52,7 +56,13 @@ export const PartyContextProvider: React.FC<{ children: React.ReactNode }> = (pr
if (partyContext !== undefined) return; if (partyContext !== undefined) return;
const selfStatus = await getSelfStatusRequest(apiEndpoint); const selfStatus = await getSelfStatusRequest(apiEndpoint);
const partyStatus = await getPartyStatusRequest(apiEndpoint); const partyStatus = await getPartyStatusRequest(apiEndpoint);
const ctx = { party: partyStatus, self: selfStatus }; const update = async (newData: UpdatableSelfStatus) => {
const reply = await modifySelfRequest(apiEndpoint, newData);
const ctx = { party: partyStatus, self: reply, update: update };
setPartyContext(ctx);
return partyStatus;
};
const ctx = { party: partyStatus, self: selfStatus, update: update };
setPartyContext(ctx); setPartyContext(ctx);
}, [apiEndpoint, partyContext]); }, [apiEndpoint, partyContext]);

View File

@ -1,98 +1,12 @@
.loading { .root {
height: 100vh; margin: auto;
width: 100vw;
background-color: black;
} }
.coming-yes button[data-coming="yes"]{
.App { background-color: green;
color: white;
font-size: calc(10px + 2vmin);
text-shadow:
-1px -1px 0.2em #000,
1px -1px 0.2em #000,
-1px 1px 0.2em #000,
1px 1px 0.2em #000;
text-align: center;
display: flex;
justify-content: center;
} }
.coming-maybe button[data-coming="maybe"]{
.container { background-color: yellow;
max-width: 1024px;
} }
.coming-no button[data-coming="no"]{
.fullheight { background-color: red;
min-height: 100vh;
}
.hero {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.hero-outer {
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: center;
padding-bottom: 0.5em;
}
@media (min-width: 1024px) {
.hero-outer {
justify-content: flex-end;
}
}
h1,
h2 {
margin: 1em 0 0.1em 0;
}
p {
margin: 0.3em 0 0.3em 0;
}
.feedback {
line-height: 3em;
text-align: center;
}
input[type="radio"] {
display: none;
}
input[type="radio"]+label {
font-size: larger;
cursor: pointer;
padding: 0 1em 0 1em;
border-right: 0.1em solid white;
}
input[type="radio"]+label:hover {
text-shadow: 0 0 1em white;
}
input[type="radio"]+label:last-of-type {
border-right: none;
}
input[type="radio"]:checked+label {
color: var(--selected-color);
text-shadow: 0 0 1em var(--selected-color);
text-decoration: underline;
}
#coming-yes+label {
--selected-color: #0f0;
}
#coming-maybe+label {
--selected-color: #fc0;
}
#coming-no+label {
--selected-color: #f00;
} }

View File

@ -1,122 +1,52 @@
import React, { useContext } from 'react';
import React, { ChangeEvent, useContext, useState } from 'react';
import './PartyPage.css'; import './PartyPage.css';
import { APIEndPoint, PartyContext } from './PartyContext'; import { PartyContext } from './PartyContext';
import MatrixBackground from './MatrixBackground';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faAngleDown, faCalendarDays, faLocationDot } from '@fortawesome/free-solid-svg-icons';
import { modifySelfRequest, parseURI } from './partyApi';
const myDear = {
"m": "lieber",
"f": "liebe",
"d": "liebes",
};
export const PartyPage: React.FC = () => { export const PartyPage: React.FC = () => {
const partyContext = useContext(PartyContext); const {self: guest, update: updateGuest} = useContext(PartyContext);
const dear = myDear[partyContext.self.grammatical_gender];
const name = partyContext.self.name;
const party = partyContext.party;
const [comingState, setComingState] = useState(partyContext.self.coming); return <div>
<p>
// SAFETY: If this is undefined, the contextProvider already fails <span>Hello {guest.name}</span>
// eslint-disable-next-line no-restricted-globals </p>
const endpoint = parseURI(location.href) as APIEndPoint; <p>
Please come to the party!
const handleSelect = async (e: ChangeEvent) => { </p>
const value = (e.target as HTMLInputElement).value; <p>
if (value !== "yes" && value !== "no" && value !== "maybe") { Are you coming?
throw new Error("received invalid value?"); <div className={`coming-${guest.coming}`}>
} <button data-coming={"yes"} onClick={() => updateGuest({coming: "yes"})}>
const status = await modifySelfRequest(endpoint, { coming: value }); Yes
setComingState(status.coming); </button>
} <button data-coming={"maybe"} onClick={() => updateGuest({coming: "maybe"})}>
Maybe
let coming: string; </button>
if (party.maybe_coming === 0) { <button data-coming={"no"} onClick={() => updateGuest({coming: "no"})}>
// exact number No
if (party.definitely_coming === 0) { </button>
coming = "Bisher hat noch niemand zugesagt."
} else if (party.definitely_coming === 1) {
coming = "Bisher hat ein Gast zugesagt."
} else {
coming = `Es haben schon ${party.definitely_coming} Gäste zugesagt.`
}
} else {
// inexact
if (party.definitely_coming === 0 && party.maybe_coming === 1) {
coming = "Bisher hat ein Gast vorläufig zugesagt."
} else if (party.definitely_coming === 0) {
coming = `Bisher haben ${party.maybe_coming} Gäste vorläufig zugesagt.`
} else {
coming = `Nach den bisherigen Zusagen kommen ${party.definitely_coming} bis ${party.definitely_coming + party.maybe_coming} Gäste.`
}
}
return <div className="App">
<MatrixBackground />
<div className='container'>
<div className='hero fullheight'>
<div className='hero-outer'></div>
<h1>Hallo {dear} {name},</h1>
<p>
am <strong> 30. Oktober </strong> wird die Uhr wieder auf Winterzeit umgestellt.
Das heißt, theoretisch könnten wir <strong> 25 Stunden an einem Tag zocken!</strong>
</p>
<p>
Das wollen wir (Kai, Dominic, Jesko) uns natürlich nicht entgehen lassen.
Also veranstalten wir eine <strong> mega krasse LAN-Party. </strong>
{coming}
</p>
<p>
Wir würden uns sehr freuen, wenn auch du, {dear} {name}, am Start wärst :)
</p>
<div className='feedback'>
<input type="radio" id="coming-yes" name="coming" value="yes" checked={comingState === "yes"} onChange={handleSelect} />
<label htmlFor='coming-yes'>Ja</label>
<input type="radio" id="coming-maybe" name="coming" value="maybe" checked={comingState === "maybe"} onChange={handleSelect} />
<label htmlFor='coming-maybe'>Vielleicht</label>
<input type="radio" id="coming-no" name="coming" value="no" checked={comingState === "no"} onChange={handleSelect} />
<label htmlFor='coming-no'>Nein</label>
</div>
<div className='hero-outer'>
Mehr Infos
<FontAwesomeIcon icon={faAngleDown} />
</div>
</div>
<div className='hero fullheight'>
<h2>Wann und Wo?</h2>
<p>
<FontAwesomeIcon icon={faCalendarDays} /> <strong>29. Oktober, 19:00</strong> bis <strong>31. Oktober</strong> irgendwann.
</p>
<p>
<FontAwesomeIcon icon={faLocationDot} /> <a href="https://www.openstreetmap.org/way/213757745"><strong>Mainzer Str. 28</strong>, 66111 Saarbrücken</a>
</p>
<p>
Ja, der 30. ist ein Sonntag.
Am 1. 11. ist aber Allerheiligen, also bietet es sich an am 31. nen Brückentag zu machen.
So hat man dann auch genug Zeit um nach den 25+ Stunden ordentlich auszuschlafen.
</p>
<h2>Alter ernsthaft 25 Stunden?</h2>
<p>
Prinzipiell ja.
Wer möchte kann sich aber gerne auf eine unserer beiden Couches zurückziehen,
oder eine Luftmatratze mitbringen, oder vorher gehen, oder später dazukommen,
oder zwischendurch nach Hause fahren...
</p>
<h2>Und was wenn ich Hunger bekomme?</h2>
<p>
Wir werden ein Curry, Chili o.Ä. kochen.
Bring aber auch gerne Snacks, Getränke, Knoblauchdip oder Kuchen mit :)
</p>
<h2>Aber wo soll ich mein Auto hinstellen?</h2>
<p>
Am Waldhaus gibt es einen kostenlosen <a href="https://www.openstreetmap.org/way/111250120">Parkplatz</a>.
Wenn du mit dem Auto kommst sag Bescheid, wir planen uns dort zu treffen und dann mit einem Auto auf Kai's Premium-Parkplatz direkt neben der WG fahren.
</p>
</div> </div>
</p>
{/*
Example usage of extraData:
<div>
<button onClick={() => {
const lastNum = parseInt(selfStatus.extra?.["plusone"] ?? "NaN");
if (isNaN(lastNum) || lastNum <= 0) return;
update({extra:{plusone: "" + (lastNum - 1)}});
}}>
-1
</button>
<button onClick={() => update({extra:{plusone: "1"}})}>
Du bringst {selfStatus.extra?.["plusone"] ?? "undefined"} Plus-Eins
</button>
<button onClick={() => {
const lastNum = parseInt(selfStatus.extra?.["plusone"] ?? "NaN");
if (isNaN(lastNum)) return;
update({extra:{plusone: "" + (lastNum + 1)}});
}}>
+1
</button>
</div> </div>
*/}
</div> </div>
}; };

View File

@ -1,4 +1,7 @@
body { body {
height: 100%;
width: 100%;
display: flex;
margin: 0; margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -1,15 +0,0 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View File

@ -1,5 +0,0 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

View File

@ -1,32 +0,0 @@
:root {
--fg: red;
--bg: black;
--accent: white;
--mute: gray;
}
svg {
background-color: var(--bg);
}
.timeline {
stroke: var(--fg);
fill: none;
stroke-width: 1px;
stroke-dasharray: 314px 314px;
stroke-dashoffset: -314px;
}
.time {
stroke: var(--accent);
fill: none;
stroke-width: 0.1px;
}
.h0 {
stroke-width: 1px;
}
.h3 {
stroke-width: 0.4px;
}
.timeline-mute {
stroke: var(--mute);
fill: none;
stroke-width: 0.5px;
}

View File

@ -1,38 +0,0 @@
<html>
<head>
<link rel="stylesheet" href="style.css">
</head>
<body>
<svg viewBox="0 0 260 35">
<path class="timeline-mute" d="M 5 25 L 245 25"/>
<path id="timeline" class="timeline" d="M 5 25 L 25 25 A 5 5 90 0 0 25 15 A 5 5 90 0 0 25 25 L 255 25" />
<path class="time h0" d="M 5 24 L 5 26"/>
<path class="time " d="M 15 24 L 15 26"/>
<path class="time " d="M 25 24 L 25 26"/>
<path class="time h3" d="M 35 24 L 35 26"/>
<path class="time " d="M 45 24 L 45 26"/>
<path class="time " d="M 55 24 L 55 26"/>
<path class="time h3" d="M 65 24 L 65 26"/>
<path class="time " d="M 75 24 L 75 26"/>
<path class="time " d="M 85 24 L 85 26"/>
<path class="time h3" d="M 95 24 L 95 26"/>
<path class="time " d="M 105 24 L 105 26"/>
<path class="time " d="M 115 24 L 115 26"/>
<path class="time h0" d="M 125 24 L 125 26"/>
<path class="time " d="M 135 24 L 135 26"/>
<path class="time " d="M 145 24 L 145 26"/>
<path class="time h3" d="M 155 24 L 155 26"/>
<path class="time " d="M 165 24 L 165 26"/>
<path class="time " d="M 175 24 L 175 26"/>
<path class="time h3" d="M 185 24 L 185 26"/>
<path class="time " d="M 195 24 L 195 26"/>
<path class="time " d="M 205 24 L 205 26"/>
<path class="time h3" d="M 215 24 L 215 26"/>
<path class="time " d="M 225 24 L 225 26"/>
<path class="time " d="M 235 24 L 235 26"/>
<path class="time h0" d="M 245 24 L 245 26"/>
</svg>
<script src="timeline.js" >
</script>
</body>
</html>

View File

@ -1,31 +0,0 @@
let overridetime = undefined;
//overridetime = Date.now();
// Saturday at 23:00 UTC, the event starts
const startTime = overridetime ?? Date.parse('Sat, 29 Oct 2022 23:00:00');
const timeToOffset = (hours) => {
const speed1 = -10;
const speed2 = -31;
if (hours <= 0) return 0;
if (hours >= 25) return 24*speed1 + speed2;
if (hours <= 2) {
return hours * speed1;
} else if (hours > 2 && hours < 3) {
return 2*speed1 + (hours-2) * speed2;
} else {
return 2*speed1 + speed2 + (hours-3) * speed1;
}
};
const update = () => {
const now = Date.now();
const hoursPassed = (now-startTime) / 1000 / 3600;
// test speed here
const offset = timeToOffset(hoursPassed);
const timeline = document.getElementById("timeline");
timeline.style.strokeDashoffset = 314 + offset;
};
update();
setInterval(update, 50);