initial commit
Some checks failed
deploy / build-and-deploy (push) Failing after 21s

This commit is contained in:
2025-05-18 13:16:20 +02:00
commit 60f3f8a096
148 changed files with 17900 additions and 0 deletions

View File

@ -0,0 +1,205 @@
---
import astroLogo from '@assets/astro.svg';
import background from '@assets/background.svg';
---
<div id="container">
<img id="background" src={background.src} alt="" fetchpriority="high" />
<main>
<section id="hero">
<a href="https://astro.build"><img src={astroLogo.src} width="115" height="48" alt="Astro Homepage" /></a>
<h1>
To get started, open the <code><pre>src/pages</pre></code> directory in your project.
</h1>
<section id="links">
<a class="button" href="https://docs.astro.build">Read our docs</a>
<a href="https://astro.build/chat"
>Join our Discord <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 127.14 96.36"
><path
fill="currentColor"
d="M107.7 8.07A105.15 105.15 0 0 0 81.47 0a72.06 72.06 0 0 0-3.36 6.83 97.68 97.68 0 0 0-29.11 0A72.37 72.37 0 0 0 45.64 0a105.89 105.89 0 0 0-26.25 8.09C2.79 32.65-1.71 56.6.54 80.21a105.73 105.73 0 0 0 32.17 16.15 77.7 77.7 0 0 0 6.89-11.11 68.42 68.42 0 0 1-10.85-5.18c.91-.66 1.8-1.34 2.66-2a75.57 75.57 0 0 0 64.32 0c.87.71 1.76 1.39 2.66 2a68.68 68.68 0 0 1-10.87 5.19 77 77 0 0 0 6.89 11.1 105.25 105.25 0 0 0 32.19-16.14c2.64-27.38-4.51-51.11-18.9-72.15ZM42.45 65.69C36.18 65.69 31 60 31 53s5-12.74 11.43-12.74S54 46 53.89 53s-5.05 12.69-11.44 12.69Zm42.24 0C78.41 65.69 73.25 60 73.25 53s5-12.74 11.44-12.74S96.23 46 96.12 53s-5.04 12.69-11.43 12.69Z"
></path></svg
>
</a>
</section>
</section>
</main>
<a href="https://astro.build/blog/astro-5/" id="news" class="box">
<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"
><path
d="M24.667 12c1.333 1.414 2 3.192 2 5.334 0 4.62-4.934 5.7-7.334 12C18.444 28.567 18 27.456 18 26c0-4.642 6.667-7.053 6.667-14Zm-5.334-5.333c1.6 1.65 2.4 3.43 2.4 5.333 0 6.602-8.06 7.59-6.4 17.334C13.111 27.787 12 25.564 12 22.666c0-4.434 7.333-8 7.333-16Zm-6-5.333C15.111 3.555 16 5.556 16 7.333c0 8.333-11.333 10.962-5.333 22-3.488-.774-6-4-6-8 0-8.667 8.666-10 8.666-20Z"
fill="#111827"></path></svg
>
<h2>What's New in Astro 5.0?</h2>
<p>
From content layers to server islands, click to learn more about the new features and improvements in Astro 5.0
</p>
</a>
</div>
<style>
#background {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
filter: blur(100px);
}
#container {
font-family: Inter, Roboto, 'Helvetica Neue', 'Arial Nova', 'Nimbus Sans', Arial, sans-serif;
height: 100%;
}
main {
height: 100%;
display: flex;
justify-content: center;
}
#hero {
display: flex;
align-items: start;
flex-direction: column;
justify-content: center;
padding: 16px;
}
h1 {
font-size: 22px;
margin-top: 0.25em;
}
#links {
display: flex;
gap: 16px;
}
#links a {
display: flex;
align-items: center;
padding: 10px 12px;
color: #111827;
text-decoration: none;
transition: color 0.2s;
}
#links a:hover {
color: rgb(78, 80, 86);
}
#links a svg {
height: 1em;
margin-left: 8px;
}
#links a.button {
color: white;
background: linear-gradient(83.21deg, #3245ff 0%, #bc52ee 100%);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.12),
inset 0 -2px 0 rgba(0, 0, 0, 0.24);
border-radius: 10px;
}
#links a.button:hover {
color: rgb(230, 230, 230);
box-shadow: none;
}
pre {
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
font-weight: normal;
background: linear-gradient(14deg, #d83333 0%, #f041ff 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin: 0;
}
h2 {
margin: 0 0 1em;
font-weight: normal;
color: #111827;
font-size: 20px;
}
p {
color: #4b5563;
font-size: 16px;
line-height: 24px;
letter-spacing: -0.006em;
margin: 0;
}
code {
display: inline-block;
background:
linear-gradient(66.77deg, #f3cddd 0%, #f5cee7 100%) padding-box,
linear-gradient(155deg, #d83333 0%, #f041ff 18%, #f5cee7 45%) border-box;
border-radius: 8px;
border: 1px solid transparent;
padding: 6px 8px;
}
.box {
padding: 16px;
background: rgba(255, 255, 255, 1);
border-radius: 16px;
border: 1px solid white;
}
#news {
position: absolute;
bottom: 16px;
right: 16px;
max-width: 300px;
text-decoration: none;
transition: background 0.2s;
backdrop-filter: blur(50px);
}
#news:hover {
background: rgba(255, 255, 255, 0.55);
}
@media screen and (max-height: 368px) {
#news {
display: none;
}
}
@media screen and (max-width: 768px) {
#container {
display: flex;
flex-direction: column;
}
#hero {
display: block;
padding-top: 10%;
}
#links {
flex-wrap: wrap;
}
#links a.button {
padding: 14px 18px;
}
#news {
right: 16px;
left: 16px;
bottom: 2.5rem;
max-width: 100%;
}
h1 {
line-height: 1.5;
}
}
</style>

View File

@ -0,0 +1,97 @@
<script lang="ts">
// types
interface Props {
id?: string;
value?: string | null;
label?: string;
readonly?: boolean;
required?: boolean;
mustMatch?: boolean;
requestSuggestions: (query: string, limit: number) => Promise<string[]>;
onSubmit?: (value: string | null) => void;
}
// html bindings
let container: HTMLDivElement;
// inputs
let { id, value = $bindable(), label, readonly, required, mustMatch, requestSuggestions, onSubmit }: Props = $props();
// states
let inputValue = $state(value);
let suggestions = $state<string[]>([]);
let matched = $state(false);
// callbacks
async function onBodyMouseDown(e: MouseEvent) {
if (!container.contains(e.target as Node)) suggestions = [];
}
async function onSearchInput() {
if (readonly) return;
suggestions = await requestSuggestions(inputValue ?? '', 5);
let suggestion = suggestions.find((s) => s === inputValue);
if (suggestion != null) {
inputValue = value = suggestion;
matched = true;
onSubmit?.(value);
} else if (!mustMatch) {
value = inputValue;
matched = false;
} else {
value = null;
matched = false;
onSubmit?.(null);
}
}
function onSuggestionClick(suggestion: string) {
inputValue = value = suggestion;
suggestions = [];
onSubmit?.(value);
}
</script>
<svelte:body onmousedown={onBodyMouseDown} />
<fieldset class="fieldset">
<legend class="fieldset-legend">
<span>
{label}
{#if required}
<span class="text-red-700">*</span>
{/if}
</span>
</legend>
<div class="relative" bind:this={container}>
<input
{id}
{readonly}
type="search"
autocomplete="off"
class="input"
bind:value={inputValue}
oninput={() => onSearchInput()}
onfocusin={() => onSearchInput()}
pattern={mustMatch && matched ? `^(${suggestions.join('|')})$` : undefined}
/>
{#if suggestions.length > 0}
<ul class="absolute bg-base-200 w-full z-20 menu menu-sm rounded-box">
{#each suggestions as suggestion (suggestion)}
<li class="w-full text-left">
<button
class="block w-full overflow-hidden text-ellipsis whitespace-nowrap"
title={suggestion}
onclick={() => onSuggestionClick(suggestion)}>{suggestion}</button
>
</li>
{/each}
</ul>
{/if}
</div>
<p class="fieldset-label"></p>
</fieldset>

View File

@ -0,0 +1,61 @@
<script lang="ts">
import { type ActionReturnType, actions } from 'astro:actions';
import Search from '@components/admin/search/Search.svelte';
import { actionErrorPopup } from '@util/action.ts';
// types
type Teams = Exclude<ActionReturnType<typeof actions.team.teams>['data'], undefined>['teams'];
type Team = Teams[0];
interface Props {
id?: string;
value?: string | null;
label?: string;
readonly?: boolean;
required?: boolean;
mustMatch?: boolean;
onSubmit?: (team: Team | null) => void;
}
// inputs
let { id, value = $bindable(), label, readonly, required, mustMatch, onSubmit }: Props = $props();
// states
let teamSuggestionCache = $state<Teams>([]);
// functions
async function getSuggestions(query: string, limit: number) {
const { data, error } = await actions.team.teams({
name: query,
limit: limit
});
if (error) {
actionErrorPopup(error);
return [];
}
teamSuggestionCache = data.teams;
return teamSuggestionCache.map((team) => team.name);
}
async function getTeamByTeamName(teamName: string) {
let team = teamSuggestionCache.find((team) => team.name === teamName);
if (!team) {
await getSuggestions(teamName, 5);
return await getTeamByTeamName(teamName);
}
return team;
}
</script>
<Search
{id}
bind:value
{label}
{readonly}
{required}
{mustMatch}
requestSuggestions={async (teamName) => getSuggestions(teamName, 5)}
onSubmit={async (teamName) => onSubmit?.(teamName != null ? await getTeamByTeamName(teamName) : null)}
/>

View File

@ -0,0 +1,61 @@
<script lang="ts">
import { type ActionReturnType, actions } from 'astro:actions';
import Search from '@components/admin/search/Search.svelte';
import { actionErrorPopup } from '@util/action.ts';
// types
type Users = Exclude<ActionReturnType<typeof actions.user.users>['data'], undefined>['users'];
type User = Users[0];
interface Props {
id?: string;
value?: string | null;
label?: string;
readonly?: boolean;
required?: boolean;
mustMatch?: boolean;
onSubmit?: (user: User | null) => void;
}
// inputs
let { id, value = $bindable(), label, readonly, required, mustMatch, onSubmit }: Props = $props();
// states
let userSuggestionCache = $state<Users>([]);
// functions
async function getSuggestions(query: string, limit: number) {
const { data, error } = await actions.user.users({
username: query,
limit: limit
});
if (error) {
actionErrorPopup(error);
return [];
}
userSuggestionCache = data.users;
return userSuggestionCache.map((user) => user.username);
}
async function getUserByUsername(username: string) {
let user = userSuggestionCache.find((user) => user.username === username);
if (!user) {
await getSuggestions(username, 5);
return await getUserByUsername(username);
}
return user;
}
</script>
<Search
{id}
bind:value
{label}
{readonly}
{required}
{mustMatch}
requestSuggestions={async (username) => getSuggestions(username, 5)}
onSubmit={async (username) => onSubmit?.(username != null ? await getUserByUsername(username) : null)}
/>

View File

@ -0,0 +1,48 @@
<script lang="ts">
import { getContext, type Snippet } from 'svelte';
import type { Writable } from 'svelte/store';
import Icon from '@iconify/svelte';
// types
interface Props {
key?: string;
children?: Snippet;
}
interface SortableHeaderContext {
headerKey: Writable<string>;
onSort: (key: string, order: 'asc' | 'desc') => void;
}
// inputs
const { key, children, ...restProps }: Props & Record<string, any> = $props();
let { headerKey, onSort }: SortableHeaderContext = getContext('sortableHeader');
let asc = $state(false);
// callbacks
function onButtonClick() {
if (key == undefined) return;
$headerKey = key;
asc = !asc;
onSort(key, asc ? 'asc' : 'desc');
}
</script>
<th {...restProps}>
{#if key}
<button class="flex items-center gap-1" onclick={() => onButtonClick()}>
<span>{@render children?.()}</span>
{#if $headerKey === key && asc}
<Icon icon="heroicons:chevron-up-16-solid" />
{:else}
<Icon icon="heroicons:chevron-down-16-solid" />
{/if}
</button>
{:else}
{@render children?.()}
{/if}
</th>

View File

@ -0,0 +1,57 @@
<script lang="ts">
import { setContext, type Snippet } from 'svelte';
import { type Writable, writable } from 'svelte/store';
// types
interface Props {
data: Writable<{ [key: string]: any }[]>;
children: Snippet;
}
// inputs
const { data, children, ...restProps }: Props & Record<string, any> = $props();
setContext('sortableHeader', {
headerKey: writable(null),
onSort: onSort
});
// functions
function onSort(key: string, order: 'asc' | 'desc') {
data.update((old) => {
old.sort((a, b) => {
let entryA = getDataEntryByKey(key, a);
let entryB = getDataEntryByKey(key, b);
if (entryA === undefined || entryB === undefined) return 0;
if (typeof entryA === 'string') entryA = entryA.toLowerCase();
if (typeof entryB === 'string') entryB = entryB.toLowerCase();
if (order === 'asc') {
return entryA < entryB ? -1 : 1;
} else if (order === 'desc') {
return entryA > entryB ? -1 : 1;
} else {
return 0;
}
});
return old;
});
}
function getDataEntryByKey(key: string, data: { [key: string]: any }): any | undefined {
let entry = data;
for (const part of key.split('.')) {
if ((entry = entry[part]) === undefined) {
return undefined;
}
}
return entry;
}
</script>
<tr {...restProps}>
{@render children()}
</tr>

View File

@ -0,0 +1,51 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
id?: string;
checked?: boolean | null;
required?: boolean;
validation?: {
hint: string;
};
disabled?: boolean;
label?: Snippet | string;
notice?: Snippet;
}
let { id, checked = $bindable(), required, validation, disabled, label, notice }: Props = $props();
</script>
<fieldset class="fieldset">
<div class="flex items-center">
<input
{id}
name={id}
bind:checked
type="checkbox"
class="checkbox"
class:validator={required || validation}
required={required ? true : null}
disabled={disabled ? true : null}
/>
<span class="ml-1">
{#if typeof label === 'string'}
<span>{label}</span>
{:else if label}
{@render label()}
{/if}
{#if required}
<span class="text-red-700">*</span>
{/if}
</span>
</div>
<p class="fieldset-label">
{#if notice}
{@render notice()}
{/if}
</p>
{#if validation}
<p class="validator-hint mt-0">{validation.hint}</p>
{/if}
</fieldset>

View File

@ -0,0 +1,75 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
id?: string;
type?: 'color' | 'date' | 'tel' | 'text' | 'email';
value?: string | null;
label?: string;
required?: boolean;
validation?: {
min?: string;
max?: string;
pattern?: string;
hint: string;
};
hidden?: boolean;
readonly?: boolean;
disabled?: boolean;
size?: 'sm';
dynamicWidth?: boolean;
notice?: Snippet;
}
let {
id,
type,
value = $bindable(),
label,
required,
validation,
hidden,
readonly,
disabled,
size,
dynamicWidth,
notice
}: Props = $props();
</script>
<fieldset class="fieldset" {hidden}>
<legend class="fieldset-legend">
<span>
{label}
{#if required}
<span class="text-red-700">*</span>
{/if}
</span>
</legend>
<input
{id}
name={id}
bind:value
class="input"
class:input-sm={size === 'sm'}
class:validator={required || validation}
class:w-full={dynamicWidth}
type={type || 'text'}
min={validation?.min}
max={validation?.max}
required={required ? true : null}
pattern={validation?.pattern}
readonly={readonly ? true : null}
disabled={disabled ? true : null}
/>
<p class="fieldset-label">
{#if notice}
{@render notice()}
{/if}
</p>
{#if validation}
<p class="validator-hint mt-0">{validation.hint}</p>
{/if}
</fieldset>

View File

@ -0,0 +1,70 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Icon from '@iconify/svelte';
interface Props {
id?: string;
value?: string | null;
label?: string;
required?: boolean;
validation?: {
pattern?: string;
hint: string;
};
disabled?: boolean;
notice?: Snippet;
}
let { id, value = $bindable(), label, required, validation, disabled, notice }: Props = $props();
let visible = $state(false);
</script>
<fieldset class="fieldset">
<legend class="fieldset-legend">
<span>
{label}
{#if required}
<span class="text-red-700">*</span>
{/if}
</span>
</legend>
<div class="relative flex items-center">
<input
{id}
bind:value
class="input pr-9"
class:validator={required || validation}
type={visible ? 'text' : 'password'}
required={required ? true : null}
pattern={validation?.pattern}
disabled={disabled ? true : null}
data-input-visible="false"
/>
<button
type="button"
class="absolute right-2 cursor-pointer z-10"
class:hidden={!visible}
onclick={() => (visible = !visible)}
>
<Icon icon="heroicons:eye-16-solid" width={22} />
</button>
<button
type="button"
class="absolute right-2 cursor-pointer z-10"
class:hidden={visible}
onclick={() => (visible = !visible)}
>
<Icon icon="heroicons:eye-slash-16-solid" width={22} />
</button>
</div>
<p class="fieldset-label">
{#if notice}
{@render notice()}
{/if}
</p>
{#if validation}
<p class="validator-hint mt-0">{validation.hint}</p>
{/if}
</fieldset>

View File

@ -0,0 +1,71 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
id?: string;
value?: string | null;
values: { [value: string]: string };
defaultValue?: string;
label?: string;
required?: boolean;
validation?: {
hint: string;
};
disabled?: boolean;
size?: 'sm';
dynamicWidth?: boolean;
notice?: Snippet;
}
let {
id,
value = $bindable(),
values,
defaultValue,
label,
required,
validation,
disabled,
size,
dynamicWidth,
notice
}: Props = $props();
</script>
<fieldset class="fieldset">
<legend class="fieldset-legend">
<span>
{label}
{#if required}
<span class="text-red-700">*</span>
{/if}
</span>
</legend>
<select
{id}
bind:value
class="select"
class:select-sm={size === 'sm'}
class:w-full={dynamicWidth}
class:validator={required || validation}
required={required ? true : null}
disabled={disabled ? true : null}
>
{#if defaultValue != null}
<option disabled selected>{defaultValue}</option>
{/if}
{#each Object.entries(values) as [value, label] (value)}
<option {value}>{label}</option>
{/each}
</select>
<p class="fieldset-label">
{#if notice}
{@render notice()}
{/if}
</p>
{#if validation}
<p class="validator-hint mt-0">{validation.hint}</p>
{/if}
</fieldset>

View File

@ -0,0 +1,36 @@
<script lang="ts">
interface Props {
id?: string;
value?: string | null;
label?: string;
required?: boolean;
readonly?: boolean;
size?: 'sm';
dynamicWidth?: boolean;
rows?: number;
}
let { id, value = $bindable(), label, required, readonly, size, dynamicWidth, rows }: Props = $props();
</script>
<fieldset class="fieldset">
<legend class="fieldset-legend">
{label}
{#if required}
<span class="text-red-700">*</span>
{/if}
</legend>
<textarea
{id}
class="textarea"
class:textarea-sm={size === 'sm'}
class:w-full={dynamicWidth}
class:validator={required}
bind:value
{required}
{rows}
{readonly}
></textarea>
</fieldset>

View File

@ -0,0 +1,33 @@
<script lang="ts">
import { confirmPopupState } from '@components/popup/ConfirmPopup.ts';
import { onDestroy } from 'svelte';
// html bindings
let modal: HTMLDialogElement;
// lifecycle
const cancel = confirmPopupState.subscribe((value) => {
if (value) modal.show();
});
onDestroy(cancel);
// callbacks
function onModalClose() {
setTimeout(() => ($confirmPopupState = null), 300);
}
</script>
<dialog class="modal" bind:this={modal} onclose={onModalClose}>
<form method="dialog" class="modal-box">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
<div>
<h3 class="text-lg font-geist">{$confirmPopupState?.title}</h3>
<p class="py-4 whitespace-pre-line">{$confirmPopupState?.message}</p>
<button class="btn" onclick={() => $confirmPopupState?.onConfirm()}>Ok</button>
</div>
</form>
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
<button class="!cursor-default">close</button>
</form>
</dialog>

View File

@ -0,0 +1,3 @@
import { atom } from 'nanostores';
export const confirmPopupState = atom<{ title: string; message: string; onConfirm: () => void } | null>(null);

View File

@ -0,0 +1,33 @@
<script lang="ts">
import { popupState } from '@components/popup/Popup.ts';
import { onDestroy } from 'svelte';
// html bindings
let modal: HTMLDialogElement;
// lifecycle
const cancel = popupState.subscribe((value) => {
if (value) modal.show();
});
onDestroy(cancel);
// callbacks
function onModalClose() {
setTimeout(() => ($popupState = null), 300);
}
</script>
<dialog class="modal" bind:this={modal} onclose={onModalClose}>
<form method="dialog" class="modal-box">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
<div>
<h3 class="text-lg font-geist">{$popupState?.title}</h3>
<p class="py-4 whitespace-pre-line">{$popupState?.message}</p>
<button class="btn" class:btn-error={$popupState?.type === 'error'}>Ok</button>
</div>
</form>
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
<button class="!cursor-default">close</button>
</form>
</dialog>

View File

@ -0,0 +1,3 @@
import { atom } from 'nanostores';
export const popupState = atom<{ type: 'info' | 'error'; title: string; message: string } | null>(null);

View File

@ -0,0 +1,13 @@
<script lang="ts">
interface Props {
name: string;
color: string;
}
const { name, color }: Props = $props();
</script>
<div class="flex items-center gap-x-2">
<div class="rounded-sm w-3 h-3" style="background-color: {color}"></div>
<h3 class="text-xs sm:text-xl">{name}</h3>
</div>

View File

@ -0,0 +1,69 @@
<script lang="ts">
import { onDestroy } from 'svelte';
let { start, end }: { start?: number; end: number } = $props();
let title = `Spielstart ist am ${new Date(import.meta.env.PUBLIC_START_DATE).toLocaleString('de-DE', {
day: '2-digit',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})} Uhr`;
function getUntil(): [number, number, number, number] {
let diff = (end - (start || Date.now())) / 1000;
return [
Math.floor(diff / (60 * 60 * 24)),
Math.floor((diff % (60 * 60 * 24)) / (60 * 60)),
Math.floor((diff % (60 * 60)) / 60),
Math.floor(diff % 60)
];
}
let [days, hours, minutes, seconds] = $state(getUntil());
let intervalId = setInterval(() => {
[days, hours, minutes, seconds] = getUntil();
if (start) start += 1000;
}, 1000);
onDestroy(() => clearInterval(intervalId));
</script>
<div
class:hidden={days + hours + minutes + seconds < 0}
class="grid grid-flow-col gap-5 text-center auto-cols-max text-white"
>
<div class="flex flex-col p-2 bg-gray-200/5 rounded-box backdrop-blur-sm" {title}>
<span class="countdown font-mono text-3xl sm:text-6xl">
<span class="m-auto" style="--value:{days};"></span>
</span>
Tage
</div>
<div class="flex flex-col p-2 bg-gray-200/5 rounded-box backdrop-blur-sm" {title}>
<span class="countdown font-mono text-3xl sm:text-6xl">
<span class="m-auto" style="--value:{hours};"></span>
</span>
Stunden
</div>
<div class="flex flex-col p-2 bg-gray-200/5 rounded-box backdrop-blur-sm" {title}>
<span class="countdown font-mono text-3xl sm:text-6xl">
<span class="m-auto" style="--value:{minutes};"></span>
</span>
Minuten
</div>
<div class="flex flex-col p-2 bg-gray-200/5 rounded-box backdrop-blur-sm" {title}>
<span class="countdown font-mono text-3xl sm:text-6xl">
<span class="m-auto" style="--value:{seconds};"></span>
</span>
Sekunden
</div>
</div>
<style>
/* Set a custom content for the countdown before selector as it only supports numbers up to 99 */
.countdown > ::before {
content: '00\A 01\A 02\A 03\A 04\A 05\A 06\A 07\A 08\A 09\A 10\A 11\A 12\A 13\A 14\A 15\A 16\A 17\A 18\A 19\A 20\A 21\A 22\A 23\A 24\A 25\A 26\A 27\A 28\A 29\A 30\A 31\A 32\A 33\A 34\A 35\A 36\A 37\A 38\A 39\A 40\A 41\A 42\A 43\A 44\A 45\A 46\A 47\A 48\A 49\A 50\A 51\A 52\A 53\A 54\A 55\A 56\A 57\A 58\A 59\A 60\A 61\A 62\A 63\A 64\A 65\A 66\A 67\A 68\A 69\A 70\A 71\A 72\A 73\A 74\A 75\A 76\A 77\A 78\A 79\A 80\A 81\A 82\A 83\A 84\A 85\A 86\A 87\A 88\A 89\A 90\A 91\A 92\A 93\A 94\A 95\A 96\A 97\A 98\A 99\A 100\A 101\A 102\A 103\A 104\A 105\A 106\A 107\A 108\A 109\A 110\A 111\A 112\A 113\A 114\A 115\A 116\A 117\A 118\A 119\A 120\A 121\A 122\A 123\A 124\A 125\A 126\A 127\A 128\A 129\A 130\A 131\A 132\A 133\A 134\A 135\A 136\A 137\A 138\A 139\A 140\A 141\A 142\A 143\A 144\A 145\A 146\A 147\A 148\A 149\A 150\A 151\A 152\A 153\A 154\A 155\A 156\A 157\A 158\A 159\A 160\A 161\A 162\A 163\A 164\A 165\A 166\A 167\A 168\A 169\A 170\A 171\A 172\A 173\A 174\A 175\A 176\A 177\A 178\A 179\A 180\A 181\A 182\A 183\A 184\A 185\A 186\A 187\A 188\A 189\A 190\A 191\A 192\A 193\A 194\A 195\A 196\A 197\A 198\A 199\A 200\A 201\A 202\A 203\A 204\A 205\A 206\A 207\A 208\A 209\A 210\A 211\A 212\A 213\A 214\A 215\A 216\A 217\A 218\A 219\A 220\A 221\A 222\A 223\A 224\A 225\A 226\A 227\A 228\A 229\A 230\A 231\A 232\A 233\A 234\A 235\A 236\A 237\A 238\A 239\A 240\A 241\A 242\A 243\A 244\A 245\A 246\A 247\A 248\A 249\A 250\A 251\A 252\A 253\A 254\A 255\A 256\A 257\A 258\A 259\A 260\A 261\A 262\A 263\A 264\A 265\A 266\A 267\A 268\A 269\A 270\A 271\A 272\A 273\A 274\A 275\A 276\A 277\A 278\A 279\A 280\A 281\A 282\A 283\A 284\A 285\A 286\A 287\A 288\A 289\A 290\A 291\A 292\A 293\A 294\A 295\A 296\A 297\A 298\A 299\A 300\A 301\A 302\A 303\A 304\A 305\A 306\A 307\A 308\A 309\A 310\A 311\A 312\A 313\A 314\A 315\A 316\A 317\A 318\A 319\A 320\A 321\A 322\A 323\A 324\A 325\A 326\A 327\A 328\A 329\A 330\A 331\A 332\A 333\A 334\A 335\A 336\A 337\A 338\A 339\A 340\A 341\A 342\A 343\A 344\A 345\A 346\A 347\A 348\A 349\A 350\A 351\A 352\A 353\A 354\A 355\A 356\A 357\A 358\A 359\A 360\A 361\A 362\A 363\A 364\A';
}
</style>

View File

@ -0,0 +1,39 @@
<script lang="ts">
const { href } = $props();
let scrollY = $state(0);
</script>
<svelte:window bind:scrollY />
<div class="flex items-center gap-x-2 transition-opacity duration-250" class:opacity-0={scrollY > 0}>
<div class="divider divider-horizontal m-0"></div>
<a {href} aria-label="scroll to teams">
<div class="border-accent border-2 rounded-t-full rounded-b-full h-7 w-4 p-1">
<div class="bg-accent rounded-full h-1 w-1 bounce"></div>
</div>
</a>
<a class="link text-sm" {href}>Zu den Teams</a>
<div class="divider divider-horizontal m-0"></div>
</div>
<style>
@keyframes scrollDown {
0% {
transform: none;
opacity: 0.25;
}
50% {
transform: translateY(0%);
opacity: 1;
}
100% {
transform: translateY(250%);
opacity: 0;
}
}
.bounce {
animation: scrollDown 2s infinite;
}
</style>

View File

@ -0,0 +1,180 @@
<script lang="ts">
import { BASE_PATH } from 'astro:env/client';
import MenuHome from '@assets/img/menu-home.webp';
import MenuSignup from '@assets/img/menu-signup.webp';
import MenuRules from '@assets/img/menu-rules.webp';
import MenuFaq from '@assets/img/menu-faq.webp';
import MenuFeedback from '@assets/img/menu-feedback.webp';
import MenuTeam from '@assets/img/menu-team.webp';
import MenuButton from '@assets/img/menu-button.webp';
import MenuInventoryBar from '@assets/img/menu-inventory-bar.webp';
import MenuSelectedFrame from '@assets/img/menu-selected-frame.webp';
import { isBrowser } from '@antfu/utils';
import { navigate } from 'astro:transitions/client';
import { onMount } from 'svelte';
let navPaths = $state([
{
name: 'Startseite',
sprite: MenuHome.src,
href: `${BASE_PATH}/`,
active: false
},
{
name: 'Registrieren',
sprite: MenuSignup.src,
href: `${BASE_PATH}/signup`,
active: false
},
{
name: 'Regeln',
sprite: MenuRules.src,
href: `${BASE_PATH}/rules`,
active: false
},
{
name: 'FAQ',
sprite: MenuFaq.src,
href: `${BASE_PATH}/faq`,
active: false
},
{
name: 'Feedback & Kontakt',
sprite: MenuFeedback.src,
href: `${BASE_PATH}/feedback`,
active: false
},
{
name: 'Team',
sprite: MenuTeam.src,
href: `${BASE_PATH}/team`,
active: false
}
]);
let showMenuPermanent = $state(isBrowser ? localStorage.getItem('showMenuPermanent') === 'true' : false);
let isTouch = $state(false);
let isOpen = $state(false);
let windowHeight = $state(0);
$effect(() => {
localStorage.setItem('showMenuPermanent', `${showMenuPermanent}`);
});
onMount(() => {
new MutationObserver(() => {
for (let i = 0; i < navPaths.length; i++) {
console.log(navPaths[i].href, window.location.pathname);
navPaths[i].active = new URL(navPaths[i].href).pathname === window.location.pathname;
}
}).observe(document.head, { childList: true });
});
let navElem: HTMLDivElement;
</script>
<svelte:window bind:innerHeight={windowHeight} />
<svelte:body
ontouchend={(e) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (isTouch && !navElem.contains(e.target)) showMenuPermanent = false;
}}
/>
<div
class="fixed bottom-4 right-4 sm:left-4 sm:right-[initial] group/menu-bar flex flex-col-reverse justify-center items-center z-50 main-menu"
bind:this={navElem}
>
<button
class={isTouch ? 'btn btn-square relative w-16 h-16' : 'btn btn-square group/menu-button relative w-16 h-16'}
onclick={() => {
if (!isTouch) {
let activePath = navPaths.find((path) => path.active);
if (activePath !== undefined) {
navigate(activePath.href);
}
showMenuPermanent = !showMenuPermanent;
}
}}
ontouchend={() => {
isTouch = true;
showMenuPermanent = !showMenuPermanent;
}}
>
<img class="absolute w-full h-full p-1 pixelated" src={MenuButton.src} alt="menu" />
<img
class="opacity-0 transition-opacity delay-50 group-hover/menu-button:opacity-100 absolute w-full h-full p-[3px] pixelated"
class:opacity-100={isOpen || (isTouch && showMenuPermanent)}
src={MenuSelectedFrame.src}
alt="menu hover"
/>
</button>
<div
class:hidden={!(isOpen || showMenuPermanent)}
class={isTouch ? 'pb-3' : 'group-hover/menu-bar:block pb-3'}
onmouseenter={() => (isOpen = true)}
onmouseleave={() => (isOpen = false)}
>
<ul class="bg-base-200 rounded">
{#each navPaths as navPath, i (navPath.href)}
<li
class="flex justify-center tooltip"
class:tooltip-left={windowHeight > 450}
class:sm:tooltip-right={windowHeight > 450}
class:tooltip-top={windowHeight <= 450}
class:tooltip-open={isTouch || windowHeight <= 450}
data-tip={navPath.name}
>
<a
class="btn btn-square border-none group/menu-item relative w-[3.5rem] h-[3.5rem] flex justify-center items-center"
href={navPath.href}
onclick={() => navigate(navPath.href)}
>
<div
style="background-image: url({MenuInventoryBar.src}); background-position: -{i * 3.5}rem 0;"
class="block w-full h-full bg-no-repeat bg-horizontal-sprite pixelated"
></div>
<div class="absolute flex justify-center items-center w-full h-full">
<img class="w-1/2 h-1/2 pixelated" src={navPath.sprite} alt={navPath.name} />
</div>
<img
class="transition-opacity delay-50 group-hover/menu-item:opacity-100 absolute w-full h-full pixelated scale-110 z-10"
class:opacity-0={!navPath.active}
src={MenuSelectedFrame.src}
alt="menu hover"
/>
</a>
</li>
{/each}
</ul>
</div>
</div>
<style>
@media (max-height: 450px) {
.main-menu {
flex-direction: row;
}
.main-menu > div {
padding: 0.25rem 0 0 0.5rem;
}
.main-menu li {
display: inline-block;
&::before {
transform-origin: 0;
transform: rotate(-90deg);
margin-bottom: -0.5rem;
}
}
}
.pixelated {
image-rendering: pixelated;
}
.bg-horizontal-sprite {
background-size: auto 100%;
}
</style>

View File

@ -0,0 +1,94 @@
<script lang="ts">
import { registeredPopupState } from '@components/website/signup/RegisteredPopup.ts';
import Input from '@components/input/Input.svelte';
import { BASE_PATH, DISCORD_LINK, PAYPAL_LINK, START_DATE, TEAMSPEAK_LINK } from 'astro:env/client';
let skin: string | null = $state(null);
let modal: HTMLDialogElement;
registeredPopupState.subscribe(async (value) => {
if (!value) return;
modal.show();
const skinview3d = await import('skinview3d');
const skinViewer = new skinview3d.SkinViewer({
width: 200,
height: 300,
renderPaused: true
});
skinViewer.camera.rotation.x = -0.62;
skinViewer.camera.rotation.y = 0.534;
skinViewer.camera.rotation.z = 0.348;
skinViewer.camera.position.x = 30.5;
skinViewer.camera.position.y = 22.0;
skinViewer.camera.position.z = 42.0;
await skinViewer.loadSkin(`https://mc-heads.net/skin/${value.username}`);
skinViewer.render();
skin = skinViewer.canvas.toDataURL();
skinViewer.dispose();
});
</script>
<dialog class="modal" bind:this={modal} onclose={($registeredPopupState = null)}>
<form method="dialog" class="modal-box xl:w-5/12 max-w-10/12 z-10">
<h1 class="text-center text-xl sm:text-3xl mb-8">Registrierung erfolgreich</h1>
<p class="text-center font-bold">
<span>Du hast Dich erfolgreich mit dem Team&nbsp;&nbsp;</span>
<span class="inline-flex rounded-sm w-3 h-3" style="background-color: {$registeredPopupState?.teamColor}"></span>
<span>{$registeredPopupState?.team}</span>
<span>&nbsp;&nbsp;für Varo 4 registriert</span>. Spielstart ist am
<i>
{new Date(START_DATE).toLocaleString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' })}
</i>
um
<i>
{new Date(START_DATE).toLocaleString('de-DE', { hour: '2-digit', minute: '2-digit' })} Uhr
</i>.
</p>
<p class="text-center">Alle weiteren Informationen werden in der Whatsapp-Gruppe bekannt gegeben.</p>
<p class="mt-2">
Falls du uns unterstützen möchtest, kannst du dies ganz einfach über
<a class="link" href={PAYPAL_LINK} target="_blank">PayPal</a>
tun. Antworten auf häufig gestellte Fragen findest du in unserer
<a class="link" href="{BASE_PATH}/faq" target="_blank">FAQ</a>. Außerdem freuen wir uns, dich auf unserem
<a class="link" href={TEAMSPEAK_LINK} target="_blank">TeamSpeak</a>
oder in unserem
<a class="link" href={DISCORD_LINK} target="_blank">Discord</a>
begrüßen zu dürfen!
</p>
<div class="divider"></div>
<div class="flex justify-around mt-2 mb-4">
<div class="grid grid-cols-1 sm:grid-cols-2 w-full sm:w-fit gap-x-4 gap-y-2">
<Input type="text" value={$registeredPopupState?.firstname} label="Vorname" disabled />
<Input type="text" value={$registeredPopupState?.lastname} label="Nachname" disabled />
<Input
type="date"
value={$registeredPopupState?.birthday.toISOString().substring(0, 10)}
label="Geburtstag"
size="sm"
disabled
/>
<Input type="tel" value={$registeredPopupState?.phone} label="Telefonnummer" disabled />
<Input type="text" value={$registeredPopupState?.username} label="Spielername" disabled />
<Input type="text" value={$registeredPopupState?.teamMember} label="Mitspieler" disabled />
</div>
<div class="relative hidden md:flex justify-center w-[200px] my-4">
{#if skin}
<img class="absolute" src={skin} alt="" />
{:else}
<span class="loading loading-spinner loading-lg"></span>
{/if}
</div>
</div>
<div class="divider"></div>
<div class="flex justify-center gap-8">
<button class="btn">Weitere Person anmelden</button>
</div>
</form>
<div class="absolute w-full h-full bg-black/50"></div>
</dialog>

View File

@ -0,0 +1,12 @@
import { atom } from 'nanostores';
export const registeredPopupState = atom<{
firstname: string;
lastname: string;
birthday: Date;
phone: string;
username: string;
team: string;
teamMember: string;
teamColor: string;
} | null>(null);

View File

@ -0,0 +1,84 @@
<script lang="ts">
import { rulesPopupState, rulesPopupRead } from './RulesPopup.ts';
import { rulesShort } from '../../../rules.ts';
const modalTimeoutSeconds = 30;
let modalElem: HTMLDialogElement;
let modalTimer = $state(null);
let modalSecondsOpen = $state(import.meta.env.PROD ? 0 : modalTimeoutSeconds);
rulesPopupState.listen((value) => {
if (value == 'open') {
modalElem.show();
setInterval(() => modalSecondsOpen++, 1000);
} else if (value == 'closed') {
clearInterval(modalTimer!);
}
});
</script>
<dialog
id="rules-popup"
class="modal"
onclose={() => {
if ($rulesPopupState !== 'accepted') $rulesPopupState = 'closed';
}}
bind:this={modalElem}
>
<form method="dialog" class="modal-box flex flex-col max-h-[90%] max-w-[95%] md:max-w-[90%] lg:max-w-[75%]">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
<div class="overflow-auto mt-5">
<div class="mb-4">
<div class="collapse collapse-arrow">
<input type="checkbox" autocomplete="off" checked />
<div class="collapse-title">
<p>0. Vorwort</p>
</div>
<div class="collapse-content">
<p>{rulesShort.header}</p>
<p class="mt-1 text-[.75rem]">{rulesShort.footer}</p>
</div>
<span class="block w-full h-[1px] mx-auto mb-1 bg-gray-600"></span>
</div>
{#each rulesShort.sections as section, i (section.title)}
<div class="collapse collapse-arrow">
<input type="checkbox" autocomplete="off" />
<div class="collapse-title">
<p>{i + 1}. {section.title}</p>
</div>
<div class="collapse-content">
<p>{section.content}</p>
</div>
</div>
<span class="block w-full h-[1px] mx-auto mb-1 bg-gray-600"></span>
{/each}
</div>
</div>
<div
class="relative w-min"
title={modalSecondsOpen < modalTimeoutSeconds
? `Regeln können in ${Math.max(modalTimeoutSeconds - modalSecondsOpen, 0)} Sekunden akzeptiert werden`
: ''}
>
<!--div class="absolute top-0 left-0 h-full w-full overflow-hidden rounded-lg">
<div
style="width: {Math.min((modalSecondsOpen / modalTimeoutSeconds) * 100, 100)}%"
class="h-full bg-base-300"
></div>
</div-->
<button
class="btn btn-neutral"
disabled={modalSecondsOpen < modalTimeoutSeconds}
onclick={() => {
$rulesPopupRead = true;
$rulesPopupState = 'accepted';
}}>Akzeptieren</button
>
</div>
</form>
<form method="dialog" class="modal-backdrop bg-[rgba(0,0,0,.3)]">
<button class="!cursor-default">close</button>
</form>
</dialog>

View File

@ -0,0 +1,4 @@
import { atom } from 'nanostores';
export const rulesPopupState = atom<'open' | 'closed' | 'accepted'>('closed');
export const rulesPopupRead = atom(false);

View File

@ -0,0 +1,30 @@
<script lang="ts">
import { teamPopupOpen, teamPopupName } from '@components/website/signup/TeamPopup.ts';
let modal: HTMLDialogElement;
let form: HTMLFormElement;
teamPopupOpen.subscribe((value) => {
if (value) modal.show();
else form?.reset();
});
</script>
<dialog class="modal" bind:this={modal} onclose={() => ($teamPopupOpen = false)}>
<div class="modal-box">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<form method="dialog" bind:this={form} onsubmit={() => ($teamPopupName = form.teamName.value)}>
<h3 class="text-lg font-geist">Team erstellen</h3>
<p class="py-4">Es wurde noch kein Team für dich und deinen Mitspieler erstellt.</p>
<fieldset class="fieldset">
<legend class="fieldset-legend">
<span>Teamname <span class="text-red-700">*</span></span>
</legend>
<input id="teamName" name="teamName" class="input validator" type="text" required />
</fieldset>
<button class="mt-4 btn btn-neutral">Team registrieren</button>
</form>
</div>
</dialog>

View File

@ -0,0 +1,4 @@
import { atom } from 'nanostores';
export const teamPopupOpen = atom(false);
export const teamPopupName = atom<string | null>(null);