This commit is contained in:
Chaoscaot
2023-10-01 10:04:04 +02:00
parent 7728d9e177
commit e0f2702eca
49 changed files with 2589 additions and 68 deletions

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import type {ConditionsFailedEvent, RouteDefinition} from "svelte-spa-router";
import wrap from "svelte-spa-router/wrap";
import Router, {replace} from "svelte-spa-router";
import {get} from "svelte/store";
import {tokenStore} from "./repo/repo.js";
const routes: RouteDefinition = {
'/': wrap({asyncComponent: () => import('./pages/Home.svelte'), conditions: detail => get(tokenStore) != ""}),
'/perms': wrap({asyncComponent: () => import('./pages/Perms.svelte'), conditions: detail => get(tokenStore) != ""}),
'/login': wrap({asyncComponent: () => import('./pages/Login.svelte'), conditions: detail => get(tokenStore) == ""}),
'/event/:id': wrap({asyncComponent: () => import('./pages/Event.svelte'), conditions: detail => get(tokenStore) != ""}),
'/event/:id/generate': wrap({asyncComponent: () => import('./pages/Generate.svelte'), conditions: detail => get(tokenStore) != ""}),
'*': wrap({asyncComponent: () => import('./pages/NotFound.svelte')})
}
function conditionsFailed(event: ConditionsFailedEvent) {
if(event.detail.location === "/login") {
replace("/")
} else {
replace("/login")
}
}
</script>
<main class="dark:bg-gray-900 min-w-full min-h-screen text-gray-900 dark:text-gray-300">
<Router {routes} on:conditionsFailed={conditionsFailed} />
</main>

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import {Button, Modal} from "flowbite-svelte";
export let open: boolean = false;
export let error: Error | undefined;
</script>
{#if (error instanceof Error)}
<Modal bind:open title={error.message}>
<p>{error.stack}</p>
<Button slot="footer" on:click={() => open = false}>Close</Button>
</Modal>
{/if}

View File

@@ -0,0 +1,103 @@
<script lang="ts">
import {Input, Label, Select} from "flowbite-svelte";
import TypeAheadSearch from "./TypeAheadSearch.svelte";
import {gamemodes, groups, maps, players} from "../stores/stores.js";
import type {Team} from '../types/team.js';
export let teams: Team[] = [];
export let blueTeam: string;
export let redTeam: string;
export let start = "";
export let gamemode = "";
export let map = "";
export let kampfleiter = "";
export let group = "";
export let groupSearch = "";
$: selectPlayers = $players.map(player => {
return {
name: player.name,
value: player.id.toString()
}
}).sort((a, b) => a.name.localeCompare(b.name));
$: selectableTeams = teams.map(team => {
return {
name: team.name,
value: team.id.toString()
}
}).sort((a, b) => a.name.localeCompare(b.name));
$: selectableGamemodes = $gamemodes.map(gamemode => {
return {
name: gamemode,
value: gamemode
}
}).sort((a, b) => a.name.localeCompare(b.name));
$: customGamemode = !selectableGamemodes.some((e) => e.name === gamemode) && gamemode !== '';
$: selectableCustomGamemode = [
...selectableGamemodes, {
name: gamemode + ' (custom)',
value: gamemode
}
]
$: mapsStore = maps(gamemode);
$: selectableMaps = $mapsStore.map(map => {
return {
name: map,
value: map
}
}).sort((a, b) => a.name.localeCompare(b.name));
$: customMap = !selectableMaps.some((e) => e.name === map) && map !== ''
$: selectableCustomMaps = [
...selectableMaps, {
name: map + ' (custom)',
value: map
}
]
$: selectableGroups = [{
name: 'None',
value: ''
}, {
value: groupSearch,
name: `Create: '${groupSearch}'`
}, ...$groups.map(group => {
return {
name: group,
value: group
}
}).sort((a, b) => a.name.localeCompare(b.name))];
</script>
<div class="m-2">
<Label for="fight-blue">Blue Team</Label>
<Select items={selectableTeams} bind:value={blueTeam} id="fight-blue"></Select>
</div>
<div class="m-2">
<Label for="fight-red">Red Team</Label>
<Select items={selectableTeams} bind:value={redTeam} id="fight-red"></Select>
</div>
<div class="mt-4">
<Label for="fight-start">Start</Label>
<Input id="fight-start" bind:value={start} let:props>
<input type="datetime-local" {...props} bind:value={start}/>
</Input>
</div>
<div class="m-2">
<Label for="fight-gamemode">Gamemode</Label>
<Select items={customGamemode ? selectableCustomGamemode : selectableGamemodes} bind:value={gamemode} id="fight-gamemode"></Select>
</div>
<div class="m-2">
<Label for="fight-maps">Map</Label>
<Select items={customMap ? selectableCustomMaps : selectableMaps} bind:value={map} id="fight-maps" disabled={customGamemode} class={customGamemode ? "cursor-not-allowed" : ""}></Select>
</div>
<div class="m-2">
<Label for="fight-kampf">Kampfleiter</Label>
<TypeAheadSearch items={selectPlayers} bind:selected={kampfleiter}></TypeAheadSearch>
</div>
<div class="m-2">
<Label for="fight-kampf">Group</Label>
<TypeAheadSearch items={selectableGroups} bind:selected={group} bind:searchValue={groupSearch} all></TypeAheadSearch>
</div>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import {Button, Dropdown, DropdownItem, Search} from 'flowbite-svelte'
export let selected: string = null
export let items: {name: string, value: string}[] = []
export let searchValue = items.find(item => item.value === selected)?.name || ''
let open = false
$: filteredItems = items.filter(item => item.name.toLowerCase().includes(searchValue.toLowerCase())).filter((value, index) => index < 5)
$: console.log(selected)
function selectItem(item: {name: string, value: string}) {
selected = item.value
searchValue = item.name
open = false
}
</script>
<Button color="alternative" on:click={() => open = true}>{selected === null ? 'Auswählen' : items.find(value => value.value === selected)?.name}</Button>
<Dropdown bind:open class="w-60">
<div class="overflow-y-auto p-3 text-sm w-60" slot="header">
<Search bind:value={searchValue} on:focus={() => open = true} on:keydown={() => open = true}/>
</div>
{#each filteredItems as item}
<button on:click={() => selectItem(item)} class="rounded p-2 hover:bg-gray-100 dark:hover:bg-gray-600 w-full cursor-pointer border-b border-b-gray-600">
{item.name}
</button>
{/each}
</Dropdown>

View File

@@ -0,0 +1,47 @@
<script lang="ts">
import {Avatar, Navbar, NavBrand, Spinner, TabItem, Tabs} from "flowbite-svelte";
import EventEdit from "./event/EventEdit.svelte";
import {ArrowLeftSolid} from "flowbite-svelte-icons";
import FightList from "./event/FightList.svelte";
import {eventRepo} from "../repo/repo.js";
import TeamList from "./event/TeamList.svelte";
export let params: { id: number } = {};
let id = params.id;
let event = $eventRepo.getEvent(id.toString());
</script>
{#await event}
<div class="h-screen w-screen grid place-items-center">
<Spinner size={16}/>
</div>
{:then data}
<Navbar let:hidden let:toggle>
<NavBrand href="#">
<ArrowLeftSolid></ArrowLeftSolid>
<span class="ml-4 self-center whitespace-nowrap text-xl font-semibold dark:text-white">
{data.event.name}
</span>
</NavBrand>
</Navbar>
<Tabs style="pill" class="mx-4 flex shadow-lg border-b-2 border-gray-700 pb-2" contentClass="">
<TabItem open>
<span slot="title">Event</span>
<EventEdit {data} />
</TabItem>
<TabItem>
<span slot="title">Teams</span>
<TeamList {data}/>
</TabItem>
<TabItem>
<span slot="title">Fights</span>
<FightList {data}/>
</TabItem>
</Tabs>
{:catch error}
<p>
{error.message}
</p>
{/await}

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import {Avatar, Navbar, NavBrand, Spinner, TabItem, Tabs} from "flowbite-svelte";
import EventEdit from "./event/EventEdit.svelte";
import {ArrowLeftSolid} from "flowbite-svelte-icons";
import FightList from "./event/FightList.svelte";
import {eventRepo} from "../repo/repo.js";
import TeamList from "./event/TeamList.svelte";
import GroupGenerator from "./generate/GroupGenerator.svelte";
export let params: { id: number } = {};
let id = params.id;
let event = $eventRepo.getEvent(id.toString());
</script>
{#await event}
<div class="h-screen w-screen grid place-items-center">
<Spinner size={16}/>
</div>
{:then data}
<Navbar let:hidden let:toggle>
<NavBrand href="#/event/{id}">
<ArrowLeftSolid></ArrowLeftSolid>
<span class="ml-4 self-center whitespace-nowrap text-xl font-semibold dark:text-white">
{data.event.name} - Generate
</span>
</NavBrand>
</Navbar>
<Tabs style="pill" class="mx-4 flex shadow-lg border-b-2 border-gray-700 pb-2" contentClass="">
<TabItem title="Group" open>
<GroupGenerator {data}/>
</TabItem>
<TabItem title="KO">
<h1>WIP</h1>
</TabItem>
</Tabs>
{:catch error}
<p>
{error.message}
</p>
{/await}

View File

@@ -0,0 +1,73 @@
<script lang="ts">
import {Button, Modal, Navbar, NavBrand, NavHamburger, NavLi, NavUl, Spinner} from "flowbite-svelte";
import {replace} from "svelte-spa-router";
import {PlusSolid} from "flowbite-svelte-icons";
import EventCard from "./home/EventCard.svelte";
import CreateEventModal from "./home/CreateEventModal.svelte";
import {eventRepo, tokenStore} from "../repo/repo.js";
let events = $eventRepo.listEvents()
let showAdd = false
let showLogoutModal = false
let millis = Date.now()
function logout() {
$tokenStore = ""
replace("/login")
}
</script>
<Navbar let:hidden let:toggle class="shadow-lg border-b">
<NavBrand href="#">
<span class="self-center whitespace-nowrap text-xl font-semibold dark:text-white">
Eventplanner
</span>
</NavBrand>
<NavHamburger on:click={toggle} />
<NavUl {hidden}>
<NavLi href="#/perms">Permissions</NavLi>
<NavLi on:click={() => showLogoutModal = true} class="cursor-pointer select-none">Logout</NavLi>
</NavUl>
</Navbar>
<CreateEventModal bind:open={showAdd} on:create={() => events = $eventRepo.listEvents()}/>
<Modal bind:open={showLogoutModal} title="Logout?" outsideclose size="sm">
<p>Do you really want to logout?</p>
<svelte:fragment slot="footer">
<Button on:click={() => showLogoutModal = false} color="alternative" class="ml-auto">Cancel</Button>
<Button on:click={logout} color="red">Logout</Button>
</svelte:fragment>
</Modal>
{#await events}
<div class="h-screen w-screen grid place-items-center">
<Spinner size={16}/>
</div>
{:then data}
<Button class="fixed bottom-6 right-6 !p-4 z-10 shadow-lg" on:click={() => showAdd = true}>
<PlusSolid/>
</Button>
<h1 class="text-3xl mt-4 ml-4">Upcoming</h1>
<div class="grid gap-4 p-4 border-b" style="grid-template-columns: repeat(auto-fill, minmax(300px, 1fr))">
{#each data.filter((e) => e.start > millis) as event}
<EventCard {event} />
{/each}
</div>
<h1 class="text-3xl mt-4 ml-4">Past</h1>
<div class="grid gap-4 p-4" style="grid-template-columns: repeat(auto-fill, minmax(300px, 1fr))">
{#each data.filter((e) => e.start < millis).reverse() as event}
<EventCard {event} />
{/each}
</div>
{:catch error}
<p>
{error.message}
</p>
{/await}
<svelte:head>
<title>SteamWar.de Multitool - Home</title>
</svelte:head>

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import {Button, Input, Label, Spinner, Toast} from "flowbite-svelte";
import {fly} from "svelte/transition";
import {replace} from "svelte-spa-router";
import {EyeOutline, EyeSlashOutline, EyeSolid} from "flowbite-svelte-icons";
import {tokenStore} from "../repo/repo.js";
let show = false;
let loading = false;
let value = "";
let error = false;
async function handleSubmit() {
loading = true;
let res = await fetch("https://steamwar.de/eventplanner-api/data", {headers: {"X-SW-Auth": value}})
loading = false;
if(res.ok) {
$tokenStore = value;
await replace("/");
} else {
error = true;
value = "";
setTimeout(() => {
error = false;
}, 5000)
}
}
</script>
<div class="h-screen w-screen grid place-items-center overflow-hidden">
<form on:submit|preventDefault={handleSubmit} class="grid">
<div class="grid gap-6 mb-6 md:grid-cols-1">
<div>
<Label for="token-xyz" class="mb-2">Token</Label>
<Input type={show?'text':'password'} id="token-xyz" placeholder="•••••••••" required size="lg" bind:value>
<button slot="left" on:click={() => (show = !show)} class="pointer-events-auto" type="button">
{#if show}
<EyeOutline />
{:else}
<EyeSlashOutline />
{/if}
</button>
</Input>
</div>
</div>
<Button type="submit">
{#if loading}
<Spinner size={4} class="mr-3" color="white"/> <span>Loading...</span>
{:else}
<span>Submit</span>
{/if}
</Button>
</form>
</div>
<Toast color="red" position="bottom-left" bind:open={error} transition={fly} params="{{x: -200}}">
<svelte:fragment slot="icon">
<svg aria-hidden="true" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>
<span class="sr-only">Error icon</span>
</svelte:fragment>
Invalid Token.
</Toast>
<svelte:head>
<title>SteamWar.de Multitool - Login</title>
</svelte:head>

View File

@@ -0,0 +1,9 @@
<script lang="ts">
import {onMount} from "svelte";
import {replace} from "svelte-spa-router";
onMount(() => {
replace('/')
});
</script>

View File

@@ -0,0 +1,123 @@
<script lang="ts">
import {Button, Card, Checkbox, Input, Label, Navbar, NavBrand, Radio, Spinner} from "flowbite-svelte";
import {ArrowLeftSolid} from "flowbite-svelte-icons";
import {players} from "../stores/stores.ts";
import {permsRepo} from "../repo/repo.ts";
import {capitalize} from "../util.ts";
let search = "";
$: lowerCaseSearch = search.toLowerCase();
$: filteredPlayers = $players.filter(value => value.name.toLowerCase().includes(lowerCaseSearch));
let selectedPlayer = null;
$: player = $players.find(value => value.id === selectedPlayer);
let playerPerms = loadPlayer(selectedPlayer);
$: playerPerms = loadPlayer(selectedPlayer);
let prefixEdit = "PREFIX_NONE";
let activePerms = [];
function loadPlayer(id: number) {
if (!id) {
return;
}
return $permsRepo.getPerms(id).then(value => {
activePerms = value.perms;
prefixEdit = value.prefix.name;
return value;
})
}
function togglePerm(perm: string) {
return () => {
if (activePerms.includes(perm)) {
activePerms = activePerms.filter(value => value !== perm);
} else {
activePerms = [...activePerms, perm];
}
}
}
function save() {
playerPerms.then(async perms => {
if (perms.prefix.name != prefixEdit) {
await $permsRepo.setPrefix(selectedPlayer, prefixEdit);
}
for (let value of activePerms) {
if (!perms.perms.includes(value)) {
await $permsRepo.addPerm(selectedPlayer, value);
}
}
for (let value of perms.perms) {
if (!activePerms.includes(value)) {
await $permsRepo.removePerm(selectedPlayer, value);
}
}
playerPerms = loadPlayer(selectedPlayer);
})
}
let permsFuture = $permsRepo.listPerms();
</script>
<div class="flex flex-col h-screen overflow-hidden">
<Navbar let:hidden let:toggle>
<NavBrand href="#">
<ArrowLeftSolid></ArrowLeftSolid>
<span class="ml-4 self-center whitespace-nowrap text-xl font-semibold dark:text-white">
Permissions
</span>
</NavBrand>
</Navbar>
<div class="p-4 flex-1 overflow-hidden">
<div class="grid md:grid-cols-3 grid-cols-1 h-full gap-8">
<Card class="h-full flex flex-col overflow-hidden !max-w-full">
<div class="border-b border-b-gray-600 pb-2">
<Label for="user_search" class="mb-2">Search Users...</Label>
<Input type="text" id="user_search" placeholder="Name..." bind:value={search}/>
</div>
{#if filteredPlayers.length < 100}
<ul class="flex-1 overflow-scroll">
{#each filteredPlayers as player}
<li class="p-4 transition-colors hover:bg-gray-700 cursor-pointer" class:text-orange-500={player.id === selectedPlayer} on:click|preventDefault={() => selectedPlayer = player.id}>
{player.name}
</li>
{/each}
</ul>
{/if}
</Card>
<Card class="!max-w-full" style="grid-column: 2/4">
{#if selectedPlayer}
<h1 class="text-3xl">{player.name}</h1>
{#await permsFuture}
<Spinner></Spinner>
{:then perms}
{#await playerPerms}
<Spinner></Spinner>
{:then player}
<h1>Prefix</h1>
{#each Object.entries(perms.prefixes) as [key, prefix]}
<Radio name="prefix" bind:group={prefixEdit} value={prefix.name}>{capitalize(prefix.name.substring(7).toLowerCase())}</Radio>
{/each}
<h1>Permissions</h1>
{#each perms.perms as perm}
<Checkbox checked={activePerms.includes(perm)} on:click={togglePerm(perm)}>{capitalize(perm.toLowerCase())}</Checkbox>
{/each}
<div class="mt-4">
<Button disabled={prefixEdit === player.prefix.name && activePerms === player.perms} on:click={save}>Save</Button>
</div>
{:catch error}
<p>{error.toString()}</p>
{/await}
{:catch error}
<p>{error.toString()}</p>
{/await}
{/if}
</Card>
</div>
</div>
</div>

View File

@@ -0,0 +1,140 @@
<script lang="ts">
import type {ExtendedEvent} from "../../types/event.js";
import {Button, Heading, Input, Label, Modal, Range, Select, Toast, Toggle} from "flowbite-svelte";
import {schemTypes} from "../../stores/stores.js";
import moment from "moment/moment.js";
import type {UpdateEvent} from "../../repo/event.js";
import {eventRepo} from "../../repo/repo.js";
import ErrorModal from "../../components/ErrorModal.svelte";
import {replace} from "svelte-spa-router";
import {CheckCircleOutline} from "flowbite-svelte-icons";
export let data: ExtendedEvent;
let event = data.event;
let name = event.name;
let deadline = moment(event.deadline).utc(true).toISOString().slice(0, -1);
let start = moment(event.start).utc(true).toISOString().slice(0, -1);
let end = moment(event.end).utc(true).toISOString().slice(0, -1);
let member = event.maxTeamMembers;
let schemType = event.schemType;
let publicOnly = event.publicSchemsOnly;
let spectateSystem = event.spectateSystem;
let errorOpen = false;
let error: Error = undefined;
let deleteOpen = false;
$: deadlineDate = moment(deadline);
$: startDate = moment(start);
$: endDate = moment(end);
$: selectTypes = [{
value: null,
name: "None"
}, ...$schemTypes.map((type) => {
return {
value: type.db,
name: type.name
}
})];
$: changed = name !== event.name ||
deadlineDate.diff(moment(event.deadline)) !== 0 ||
startDate.diff(moment(event.start)) !== 0 ||
endDate.diff(moment(event.end)) !== 0 ||
member !== event.maxTeamMembers ||
schemType != event.schemType ||
publicOnly !== event.publicSchemsOnly ||
spectateSystem !== event.spectateSystem;
async function del() {
try {
await $eventRepo.deleteEvent(event.id.toString());
await replace("/")
} catch (e) {
error = e;
errorOpen = true;
}
}
let successToast: boolean = false;
async function update() {
let ev: UpdateEvent = {
deadline: deadlineDate,
end: endDate,
maxTeamMembers: member,
name: name,
publicSchemsOnly: publicOnly,
schemType: schemType ?? 'null',
spectateSystem: spectateSystem,
start: startDate
};
try {
event = await $eventRepo.updateEvent(event.id.toString(), ev);
successToast = true;
setTimeout(() => successToast = false, 5000);
} catch (e) {
error = e;
errorOpen = true;
}
}
</script>
<svelte:head>
<title>{event.name} - Edit</title>
</svelte:head>
<form class="m-4">
<div class="mt-4">
<Label for="event-name">Name</Label>
<Input type="text" id="event-name" bind:value={name} class="w-80" size="lg"></Input>
</div>
<div class="mt-4">
<Label for="event-deadline">Deadline</Label>
<Input id="event-deadline" bind:value={name} class="w-80" let:props size="lg">
<input type="datetime-local" {...props} bind:value={deadline}/>
</Input>
</div>
<div class="mt-4">
<Label for="event-start">Start</Label>
<Input id="event-start" bind:value={name} class="w-80" let:props size="lg">
<input type="datetime-local" {...props} bind:value={start}/>
</Input>
</div>
<div class="mt-4">
<Label for="event-end">End</Label>
<Input id="event-end" bind:value={name} class="w-80" let:props size="lg">
<input type="datetime-local" {...props} bind:value={end}/>
</Input>
</div>
<div class="mt-4">
<Label for="event-member">Member: {member}</Label>
<Range id="event-member" bind:value={member} step="1" min="1" max="30"/>
</div>
<div class="mt-4">
<Label for="event-type">Schematic Type:</Label>
<Select bind:value={schemType} items={selectTypes}/>
</div>
<Toggle bind:checked={publicOnly} class="mt-4">Public Schematics Only</Toggle>
<Toggle bind:checked={spectateSystem} class="mt-4">Spectate System</Toggle>
<div class="flex mt-4">
<Button disabled={!changed} on:click={update}>Update</Button>
<Button class="ml-4" color="red" on:click={() => deleteOpen = true}>Delete</Button>
</div>
</form>
<ErrorModal bind:open={errorOpen} bind:error={error}/>
<Modal bind:open={deleteOpen} outsideclose title="Delete {event.name}?">
<p>Are you sure you want to delete {event.name}?</p>
<div class="flex justify-end">
<Button on:click={() => deleteOpen = false}>Cancel</Button>
<Button class="ml-4" color="red" on:click={del}>Delete</Button>
</div>
</Modal>
<Toast bind:open={successToast} position="bottom-left" color="green">
<CheckCircleOutline slot="icon"/>
Updated Successfully
</Toast>

View File

@@ -0,0 +1,96 @@
<script lang="ts">
import {EditOutline, InboxSolid, TrashBinOutline} from "flowbite-svelte-icons";
import {Button, Checkbox, Modal, Toolbar, ToolbarButton} from "flowbite-svelte";
import type {EventFight, ExtendedEvent} from "../../types/event.js";
import FightEditModal from "./modals/FightEditModal.svelte";
import {createEventDispatcher, onMount} from "svelte";
import {fightRepo} from "../../repo/repo.js";
import {isWide} from "../../stores/stores.js";
export let fight: EventFight;
export let data: ExtendedEvent;
export let i: number;
export let selected: boolean = false;
export let hideEdit: boolean = false;
let deleteOpen = false;
let editOpen = false;
let dispatcher = createEventDispatcher();
function dispatchSelect() {
setTimeout(() => {
if (!deleteOpen && !editOpen) {
dispatcher('select');
}
}, 1);
}
async function deleteFight() {
await $fightRepo.deleteFight(fight.id);
dispatcher('update');
}
</script>
<div class="flex h-16 {i % 2 === 0 ? 'bg-gray-800' : ''} mx-4 mt-6 rounded border {selected ? 'border-orange-700' : 'border-gray-700'} p-2 hover:bg-gray-700 transition justify-between shadow-lg cursor-pointer"
on:click={dispatchSelect} on:keypress={dispatchSelect} role="checkbox" aria-checked={selected} tabindex="0"
>
<div class="flex">
<div class="flex flex-col">
<div>
<span>{$isWide ? fight.blueTeam.name : fight.blueTeam.kuerzel}</span>
vs.
<span>{$isWide ? fight.redTeam.name : fight.redTeam.kuerzel}</span>
</div>
{#if (fight.ergebnis === 3)}
<span class="ml-2">Unentschieden</span>
{:else if (fight.ergebnis !== 0)}
<span class="ml-2">{fight.ergebnis === 1 ? 'Sieger: ' + ($isWide ? fight.blueTeam.name : fight.blueTeam.kuerzel) : 'Sieger: ' + ($isWide ? fight.redTeam.name : fight.redTeam.kuerzel)}</span>
{:else}
<span class="ml-2">{$isWide ? 'Noch nicht gespielt' : 'kommend'}</span>
{/if}
</div>
</div>
<div class="flex">
<div class="mr-2 flex flex-col">
<span>
{new Intl.DateTimeFormat(Intl.Locale.name, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).format(fight.start)}
</span>
<span>
{new Intl.DateTimeFormat(Intl.Locale.name, {
day: '2-digit',
month: '2-digit',
year: '2-digit'
}).format(fight.start)}
</span>
</div>
{#if !hideEdit}
<Toolbar embedded>
<ToolbarButton on:click={() => editOpen = true}>
<EditOutline/>
</ToolbarButton>
<ToolbarButton color="red" on:click={() => deleteOpen = true}>
<TrashBinOutline />
</ToolbarButton>
</Toolbar>
{/if}
</div>
</div>
<Modal title="Delete {fight.blueTeam.name} vs. {fight.redTeam.name}" bind:open={deleteOpen} autoclose outsideclose size="xs">
<div class="text-center">
<p class="mb-5">
Are you sure you want to delete this fight?
</p>
<Button color="red" on:click={deleteFight}>Delete Fight</Button>
<Button color="alternative">Cancel</Button>
</div>
</Modal>
{#if (editOpen)}
<FightEditModal {fight} bind:data bind:open={editOpen} on:update/>
{/if}

View File

@@ -0,0 +1,267 @@
<script lang="ts">
import type {EventFight, ExtendedEvent} from "../../types/event.js";
import {
Button,
Checkbox, Input, Label,
Modal,
SpeedDial,
SpeedDialButton,
Toolbar,
ToolbarButton,
ToolbarGroup,
Tooltip
} from "flowbite-svelte";
import {
ArrowsRepeatSolid, CalendarWeekOutline,
PlusSolid, ProfileCardOutline, TrashBinOutline, UsersGroupOutline,
} from "flowbite-svelte-icons";
import FightCard from "./FightCard.svelte";
import CreateFightModal from "./modals/CreateFightModal.svelte";
import {fightRepo} from "../../repo/repo.js";
import {groups, players} from "../../stores/stores.js";
import TypeAheadSearch from "../../components/TypeAheadSearch.svelte";
import type {UpdateFight} from "../../repo/fight.js";
import moment from "moment";
export let data: ExtendedEvent;
let createOpen = false;
let fights = data.fights;
let selectedFights: Set<EventFight> = new Set();
$: groupsMap = new Set(fights.map(fight => fight.group));
$: groupedFights = Array.from(groupsMap).map(group => {
return {
group: group,
fights: fights.filter(fight => fight.group === group)
}
});
function cycleSelect() {
if (selectedFights.size === fights.length) {
selectedFights = new Set();
} else if(selectedFights.size === 0){
selectedFights = new Set(fights.filter(fight => fight.start > Date.now()));
if (selectedFights.size === 0) {
selectedFights = new Set(fights);
}
} else {
selectedFights = new Set(fights);
}
}
function cycleGroup(groupFights: EventFight[]) {
if(groupFights.every(gf => selectedFights.has(gf))) {
groupFights.forEach(fight => selectedFights.delete(fight));
} else {
groupFights.forEach(fight => selectedFights.add(fight));
}
selectedFights = selectedFights;
}
let deleteOpen = false;
async function deleteFights() {
for (const fight of selectedFights) {
await $fightRepo.deleteFight(fight.id);
}
fights = await $fightRepo.listFights(data.event.id);
selectedFights = new Set();
deleteOpen = false;
}
let kampfleiterOpen = false;
$: selectPlayers = $players.map(player => {
return {
name: player.name,
value: player.id.toString()
}
}).sort((a, b) => a.name.localeCompare(b.name));
let kampfleiter = "";
async function updateKampfleiter() {
for (const fight of selectedFights) {
let f: UpdateFight = {
blueTeam: null,
group: null,
kampfleiter: Number.parseInt(kampfleiter),
map: null,
redTeam: null,
spielmodus: null,
start: null
};
await $fightRepo.updateFight(fight.id, f);
}
fights = await $fightRepo.listFights(data.event.id);
selectedFights = new Set();
kampfleiter = "";
kampfleiterOpen = false;
}
let groupChangeOpen = false;
let group = "";
let groupSearch = "";
$: selectableGroups = [{
name: 'None',
value: ''
}, {
value: groupSearch,
name: `Create: '${groupSearch}'`
}, ...$groups.map(group => {
return {
name: group,
value: group
}
}).sort((a, b) => a.name.localeCompare(b.name))];
async function updateGroup() {
for (const fight of selectedFights) {
let f: UpdateFight = {
blueTeam: null,
group: group,
kampfleiter: null,
map: null,
redTeam: null,
spielmodus: null,
start: null
};
await $fightRepo.updateFight(fight.id, f);
}
fights = await $fightRepo.listFights(data.event.id);
selectedFights = new Set();
group = "";
groupSearch = "";
groupChangeOpen = false;
}
$: minTime = moment(Math.min(...fights.map(fight => fight.start))).utc(true);
let changeTimeOpen = false;
let changedTime = moment(Math.min(...fights.map(fight => fight.start)))?.utc(true)?.toISOString()?.slice(0, -1);
$: deltaTime = moment.duration(moment(changedTime).utc(true).diff(minTime))
async function updateStartTime() {
for (const fight of selectedFights) {
let f: UpdateFight = {
blueTeam: null,
group: null,
kampfleiter: null,
map: null,
redTeam: null,
spielmodus: null,
start: moment(fight.start).add(deltaTime.asMilliseconds(), 'millisecond')
};
await $fightRepo.updateFight(fight.id, f);
}
fights = await $fightRepo.listFights(data.event.id);
changedTime = minTime.toISOString().slice(0, -1);
selectedFights = new Set();
changeTimeOpen = false;
}
</script>
<svelte:head>
<title>{data.event.name} - Fights</title>
</svelte:head>
<div class="pb-28">
<Toolbar class="mx-4 mt-2 w-fit">
<ToolbarGroup>
<Checkbox class="ml-2" checked={selectedFights.size === fights.length} on:click={cycleSelect}/>
<Tooltip>Select Upcoming</Tooltip>
</ToolbarGroup>
<ToolbarGroup>
<ToolbarButton on:click={() => selectedFights.size > 0 ? changeTimeOpen = true : changeTimeOpen = false}>
<CalendarWeekOutline/>
</ToolbarButton>
<Tooltip>Reschedule Fights</Tooltip>
<ToolbarButton on:click={() => selectedFights.size > 0 ? kampfleiterOpen = true : kampfleiterOpen = false}>
<ProfileCardOutline/>
</ToolbarButton>
<Tooltip>Change Kampfleiter</Tooltip>
<ToolbarButton on:click={() => selectedFights.size > 0 ? groupChangeOpen = true : groupChangeOpen = false}>
<UsersGroupOutline/>
</ToolbarButton>
<Tooltip>Change Group</Tooltip>
</ToolbarGroup>
<ToolbarGroup>
<ToolbarButton color="red" on:click={() => selectedFights.size > 0 ? deleteOpen = true : deleteOpen = false}>
<TrashBinOutline/>
</ToolbarButton>
<Tooltip>Delete</Tooltip>
</ToolbarGroup>
</Toolbar>
{#each groupedFights as group}
<div class="flex mt-4">
<Checkbox class="ml-2 text-center" checked={group.fights.every(gf => selectedFights.has(gf))} on:click={() => cycleGroup(group.fights)}/>
<h1 class="ml-4 text-2xl">{group.group ?? "Ungrouped"}</h1>
</div>
{#each group.fights.sort((a, b) => a.start - b.start) as fight, i}
<FightCard {fight} {i} {data} selected={selectedFights.has(fight)}
on:select={() => {
if (selectedFights.has(fight)) {
selectedFights.delete(fight);
} else {
selectedFights.add(fight);
}
selectedFights = selectedFights;
}}
on:update={async () => fights = await $fightRepo.listFights(data.event.id)}
/>
{/each}
{/each}
</div>
<CreateFightModal {data} bind:open={createOpen} on:create={async () => data.fights = await $fightRepo.listFights(data.event.id)}></CreateFightModal>
<Modal bind:open={deleteOpen} title="Delete {selectedFights.size} Fights" autoclose size="sm">
<p>Are you sure you want to delete {selectedFights.size} fights?</p>
<svelte:fragment slot="footer">
<Button color="red" class="ml-auto" on:click={deleteFights}>Delete</Button>
<Button on:click={() => deleteOpen = false} color="alternative">Cancel</Button>
</svelte:fragment>
</Modal>
<Modal bind:open={kampfleiterOpen} title="Change Kampfleiter" size="sm">
<div class="m-2">
<Label for="fight-kampf">Kampfleiter</Label>
<TypeAheadSearch items={selectPlayers} bind:selected={kampfleiter}></TypeAheadSearch>
</div>
<svelte:fragment slot="footer">
<Button class="ml-auto" on:click={updateKampfleiter}>Change</Button>
<Button on:click={() => kampfleiterOpen = false} color="alternative">Cancel</Button>
</svelte:fragment>
</Modal>
<Modal bind:open={groupChangeOpen} title="Change Group" size="sm">
<div class="m-2">
<Label for="fight-kampf">Group</Label>
<TypeAheadSearch items={selectableGroups} bind:selected={group} bind:searchValue={groupSearch} all></TypeAheadSearch>
</div>
<svelte:fragment slot="footer">
<Button class="ml-auto" on:click={updateGroup}>Change</Button>
<Button on:click={() => groupChangeOpen = false} color="alternative">Cancel</Button>
</svelte:fragment>
</Modal>
<Modal bind:open={changeTimeOpen} title="Change Start Time" size="sm">
<div class="m-2">
<Label for="fight-start">New Start Time:</Label>
<Input id="fight-start" bind:value={changedTime} let:props>
<input type="datetime-local" {...props} bind:value={changedTime}/>
</Input>
</div>
<p>{deltaTime.asMilliseconds() < 0 ? '' : '+'}{("0" + deltaTime.hours()).slice(-2)}:{("0" + deltaTime.minutes()).slice(-2)}</p>
<svelte:fragment slot="footer">
<Button class="ml-auto" on:click={updateStartTime}>Update</Button>
<Button on:click={() => changeTimeOpen = false} color="alternative">Cancel</Button>
</svelte:fragment>
</Modal>
<SpeedDial>
<SpeedDialButton name="Add" on:click={() => createOpen = true}>
<PlusSolid/>
</SpeedDialButton>
<SpeedDialButton name="Generate" href="#/event/{data.event.id}/generate">
<ArrowsRepeatSolid/>
</SpeedDialButton>
</SpeedDial>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import {Avatar, Button, Modal} from "flowbite-svelte";
import type {ExtendedEvent} from "../../types/event.js";
export let data: ExtendedEvent;
</script>
<div class="m-4">
{#each data.teams as team}
<div class="flex flex-row my-2">
<Avatar size="lg">{team.kuerzel}</Avatar>
<div class="m-2">
<h1 class="text-2xl">{team.name}</h1>
<h2 class="text-lg text-gray-400">Fights: {data.fights.filter(value => value.blueTeam.id === team.id || value.redTeam.id === team.id).length}</h2>
</div>
</div>
{/each}
</div>
<svelte:head>
<title>{data.event.name} - Teams</title>
</svelte:head>

View File

@@ -0,0 +1,87 @@
<script lang="ts">
import {Button, Modal} from "flowbite-svelte";
import type {ExtendedEvent} from "../../../types/event.js";
import FightEditPart from "../../../components/FightEditPart.svelte";
import {fightRepo} from "../../../repo/repo.js";
import type {CreateFight} from "../../../repo/fight.ts";
import ErrorModal from "../../../components/ErrorModal.svelte";
import {createEventDispatcher} from "svelte";
import moment from "moment";
let dispatch = createEventDispatcher();
export let open: boolean = false;
export let data: ExtendedEvent;
let blueTeam: string = "";
let redTeam: string = "";
let start: string = "";
let gamemode: string = "";
let map: string = "";
let kampfleiter: string | null = null;
let group: string | null = null;
let groupSearch = "";
let errorOpen = false;
let error: Error | null = null;
$: canCreate = blueTeam !== "" && redTeam !== "" && start !== "" && gamemode !== "" && map !== "";
async function create() {
try {
open = false;
let res = await $fightRepo.createFight(data.event.id, {
spielmodus: gamemode,
blueTeam: parseInt(blueTeam),
redTeam: parseInt(redTeam),
start: moment(start),
map,
kampfleiter: parseInt(kampfleiter),
group,
});
reset()
dispatch("create")
} catch (e) {
error = e;
errorOpen = true;
reset()
}
}
function reset() {
blueTeam = "";
redTeam = "";
start = "";
gamemode = "";
map = "";
kampfleiter = "";
group = null;
groupSearch = "";
}
</script>
<Modal bind:open outsideclose title="Create Fight" on:hide={reset}>
<div class="text-center">
<FightEditPart
bind:blueTeam
bind:redTeam
bind:start
bind:kampfleiter
bind:gamemode
bind:map
bind:group
bind:groupSearch
teams={data.teams}
/>
</div>
<svelte:fragment slot="footer">
<Button on:click={create} class="mr-auto" disabled={!canCreate}>Create</Button>
<Button color="light" on:click={() => open = false}>Cancel</Button>
</svelte:fragment>
</Modal>
<ErrorModal bind:open={errorOpen} bind:error={error} on:close={() => errorOpen = false}/>

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import {Button, Input, Label, Modal, Select} from "flowbite-svelte";
import moment from "moment";
import {gamemodes, groups, maps, players} from "../../../stores/stores.js";
import type {EventFight, ExtendedEvent} from "../../../types/event.js";
import TypeAheadSearch from "../../../components/TypeAheadSearch.svelte";
import FightEditPart from "../../../components/FightEditPart.svelte";
import type {UpdateFight} from "../../../repo/fight.js";
import {fightRepo} from "../../../repo/repo.js";
import ErrorModal from "../../../components/ErrorModal.svelte";
import {createEventDispatcher} from "svelte";
export let fight: EventFight;
export let data: ExtendedEvent;
export let open = false;
let redTeam = fight.redTeam.id.toString();
let blueTeam = fight.blueTeam.id.toString();
let start = moment(fight.start).utc(true).toISOString().slice(0, -1);
let kampfleiter = fight.kampfleiter.id.toString();
let gamemode = fight.spielmodus
let map = fight.map;
let group = fight.group;
let groupSearch = fight.group ?? "";
let errorOpen = false;
let error = undefined;
let dispatch = createEventDispatcher();
function save() {
const update: UpdateFight = {
blueTeam: parseInt(blueTeam), group: group === "" ? null : group, kampfleiter: parseInt(kampfleiter), map: map, redTeam: parseInt(redTeam), spielmodus: gamemode, start: moment(start)
}
$fightRepo.updateFight(fight.id, update)
.then(value => {
open = false;
fight = value;
dispatch("update", value);
})
.catch((e) => {
error = e.message;
errorOpen = true;
})
}
</script>
<Modal title="Edit {fight.blueTeam.name} vs. {fight.redTeam.name}" bind:open outsideclose size="xs">
<div class="text-center">
<FightEditPart
bind:blueTeam
bind:redTeam
bind:start
bind:kampfleiter
bind:gamemode
bind:map
bind:group
bind:groupSearch
teams={data.teams}
/>
</div>
<div class="flex">
<Button on:click={save}>Save</Button>
<Button color="light" class="ml-auto" on:click={() => open = false}>Cancel</Button>
</div>
</Modal>
<ErrorModal bind:open={errorOpen} bind:error={error} on:close={() => errorOpen = false}/>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import {createEventDispatcher} from "svelte";
let dragover = false;
function handleDragOver(e) {
e.preventDefault();
dragover = true;
}
const dispatch = createEventDispatcher();
function handleDrop(ev: DragEvent) {
ev.preventDefault();
dragover = false;
dispatch('drop', ev)
}
</script>
<div class="w-56 bg-gray-800 p-4 rounded" class:border={dragover} class:m-px={!dragover} on:drop={handleDrop} on:dragover={handleDragOver} on:dragleave={() => dragover = false}>
<slot></slot>
</div>
<style>
div {
min-height: 14rem;
}
</style>

View File

@@ -0,0 +1,236 @@
<script lang="ts">
import type {ExtendedEvent} from "../../types/event.js";
import TeamChip from "./TeamChip.svelte";
import type {Team} from "../../types/team.js";
import DragAcceptor from "./DragAcceptor.svelte";
import moment from "moment";
import {Button, Input, Label, Modal, Range, Select} from "flowbite-svelte";
import {gamemodes, maps} from "../../stores/stores.js";
import {PlusSolid} from "flowbite-svelte-icons";
import {fightRepo} from "../../repo/repo.js";
import {replace} from "svelte-spa-router";
export let data: ExtendedEvent;
$: teams = new Map<number, Team>(data.teams.map(team => [team.id, team]));
let groups: number[][] = [];
$: teamsNotInGroup = data.teams.filter(team => !groups.flat().includes(team.id));
function dragToNewGroup(event: CustomEvent<DragEvent>) {
event.detail.preventDefault();
let teamId = parseInt(event.detail.dataTransfer.getData("team"));
groups = [...groups.map(value => value.filter(value1 => value1 != teamId)), [teamId]].filter(value => value.length > 0);
}
function teamDragStart(ev: DragEvent, team: Team) {
ev.dataTransfer.setData("team", team.id.toString())
}
let resetDragOver = false;
function resetDragOverEvent(ev: DragEvent) {
resetDragOver = true;
ev.preventDefault()
}
function dropReset(ev: DragEvent) {
ev.preventDefault();
let teamId = parseInt(ev.dataTransfer.getData("team"));
groups = groups.map(group => group.filter(team => team !== teamId)).filter(group => group.length > 0);
resetDragOver = false;
}
function dropGroup(ev: CustomEvent<DragEvent>, groupIndex: number) {
ev.preventDefault();
let teamId = parseInt(ev.detail.dataTransfer.getData("team"));
groups = groups.map((group, i) => i === groupIndex ? [...group.filter(value => value != teamId), teamId] : group.filter(value => value != teamId)).filter(group => group.length > 0);
}
let startTime = moment(data.event.start).utc(true).toISOString().slice(0, -1)
$: startMoment = moment(startTime);
let gamemode = ''
let map = ''
$: selectableGamemodes = $gamemodes.map(gamemode => {
return {
name: gamemode,
value: gamemode
}
}).sort((a, b) => a.name.localeCompare(b.name));
$: mapsStore = maps(gamemode);
$: selectableMaps = $mapsStore.map(map => {
return {
name: map,
value: map
}
}).sort((a, b) => a.name.localeCompare(b.name));
let roundTime = 30;
let startDelay = 30;
let showAutoGrouping = false;
let groupCount = Math.floor(data.teams.length / 2);
function createGroups() {
let teams = data.teams.map(team => team.id).sort(() => Math.random() - 0.5);
groups = [];
for (let i = 0; i < groupCount; i++) {
groups.push([])
}
while (teams.length > 0) {
groups[teams.length % groupCount].push(teams.pop() as number)
}
showAutoGrouping = false;
groups = groups.filter(group => group.length > 0);
}
function generateGroups(groups): number[][][][] {
let groupFights = [];
groups.forEach((group) => {
let round = group.length + (group.length % 2) - 1;
let groupFight = [];
for (let i = 0; i < round; i++) {
let availableTeams = [...group];
if(group.length % 2 === 1) {
availableTeams = availableTeams.filter((team, index) => index !== i)
}
let roundFights = [];
while (availableTeams.length > 0) {
let team1 = availableTeams.pop() as number;
let team2 = availableTeams.at(i % availableTeams.length) as number;
availableTeams = availableTeams.filter(team => team !== team2);
let fight = [team1, team2];
fight.sort(() => Math.random() - 0.5);
roundFights.push(fight)
}
groupFight.push(roundFights)
}
groupFights.push(groupFight)
})
return groupFights;
}
$: groupsFights = generateGroups(groups)
$: generateDisabled = groupsFights.length > 0 && groupsFights.every(value => value.every(value1 => value1.length > 0)) && gamemode !== '' && map !== ''
async function generateFights() {
groupsFights.forEach((group, i) => {
group.forEach((round, j) => {
round.forEach(async (fight, k) => {
let blueTeam = teams.get(fight[0])
let redTeam = teams.get(fight[1])
await $fightRepo.createFight(data.event.id, {
blueTeam: blueTeam.id,
redTeam: redTeam.id,
group: "Gruppe " + (i + 1),
kampfleiter: 0,
map: map,
spielmodus: gamemode,
start: startMoment.clone().add(roundTime * j, "minutes").add(startDelay * (k + (i * round.length)), "seconds")
})
})
})
})
await replace("#/event/" + data.event.id)
}
</script>
<div class="flex justify-between">
<div id="reseter" class:border-white={resetDragOver} class="flex m-2 bg-gray-800 w-fit p-2 border border-gray-700 rounded ml-4 h-20 pt-6 relative" on:dragover={resetDragOverEvent} on:dragleave={() => resetDragOver = false} on:drop={dropReset} role="group">
{#each teamsNotInGroup as team}
<TeamChip {team} on:dragstart={ev => teamDragStart(ev, team)}/>
{/each}
</div>
<div class="flex items-center mr-4">
<Button on:click={() => showAutoGrouping = true}>Automatic Grouping</Button>
</div>
</div>
<div class="flex m-4 gap-4 border-b border-gray-700 pb-4">
{#each groups as group, i}
<DragAcceptor on:drop={ev => dropGroup(ev, i)}>
<h1>Group {i + 1} ({group.length})</h1>
{#each group as teamId}
<TeamChip team={teams.get(teamId)} on:dragstart={ev => teamDragStart(ev, teams.get(teamId))}/>
{/each}
</DragAcceptor>
{/each}
<DragAcceptor on:drop={dragToNewGroup}>
<h1>Create Group</h1>
</DragAcceptor>
</div>
<div class="m-4 border-b border-gray-700 pb-4">
<Label for="event-end">Start Time</Label>
<Input id="event-end" bind:value={startTime} class="w-80" let:props size="lg">
<input type="datetime-local" {...props} bind:value={startTime}/>
</Input>
<div class="mt-2">
<Label for="event-roundtime">Round time: {roundTime}m</Label>
<Range id="event-roundtime" bind:value={roundTime} step="1" min="5" max="60"/>
</div>
<div class="mt-2">
<Label for="event-member">Start delay: {startDelay}</Label>
<Range id="event-member" bind:value={startDelay} step="1" min="0" max="30"/>
</div>
<div class="mt-2">
<Label for="fight-gamemode">Gamemode</Label>
<Select items={selectableGamemodes} bind:value={gamemode} id="fight-gamemode"></Select>
</div>
<div class="mt-2">
<Label for="fight-maps">Map</Label>
<Select items={selectableMaps} bind:value={map} id="fight-maps"></Select>
</div>
</div>
<div class="text-center mx-2">
{#each groupsFights as fightsGroup, i}
<div>
<h1 class="text-4xl">Group: {i + 1}</h1>
{#each fightsGroup as fightsRound, j}
<div class="border-b border-gray-700">
<h1 class="text-2xl">Round: {j + 1}</h1>
{#each fightsRound as fightTeams, k}
<div class="text-left p-4">
<span class="bg-gray-800 p-2 border border-gray-700 rounded">{startMoment.clone().add(roundTime * j, "minutes").add(startDelay * (k + (i * fightsRound.length)), "seconds").format("DD.MM.yyyy HH:mm:ss")}</span>
{teams.get(fightTeams[0]).name} vs. {teams.get(fightTeams[1]).name}
</div>
{/each}
</div>
{/each}
</div>
{/each}
</div>
<Button class="!p-4 fixed bottom-4 right-4" pill disabled={!generateDisabled} on:click={generateFights}>
<PlusSolid/>
</Button>
<Modal bind:open={showAutoGrouping} outsideclose title="Auto Grouping" size="sm">
<Label for="event-member">Groups: {groupCount}</Label>
<Range id="event-member" bind:value={groupCount} step="1" min="1" max={Math.floor(data.teams.length / 2)}/>
<svelte:fragment slot="footer">
<Button class="ml-auto" on:click={createGroups}>Create</Button>
<Button color="alternative" on:click={() => showAutoGrouping = false}>Cancel</Button>
</svelte:fragment>
</Modal>
<style lang="scss">
#reseter::before {
content: 'Reset';
position: absolute;
top: 0;
color: gray;
}
#reseter {
min-width: 14rem;
}
</style>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import type {Team} from "../../types/team.js";
import {brightness, colorFromTeam, lighten} from "../../util.js";
export let team: Team;
let hover = false;
</script>
<div class="rounded w-fit p-2 border-gray-600 border cursor-grab select-none m-1 flex place-items-center"
style:background-color={hover ? lighten(colorFromTeam(team)) : colorFromTeam(team)} class:text-black={brightness(colorFromTeam(team))} draggable="true"
on:dragstart
on:mouseenter={() => hover = true}
on:mouseleave={() => hover = false}>
<span>{team.name}</span>
</div>

View File

@@ -0,0 +1,71 @@
<script lang="ts">
import {Button, Input, Label, Modal} from "flowbite-svelte";
import moment from "moment";
import {createEventDispatcher} from "svelte";
import ErrorModal from "../../components/ErrorModal.svelte";
import {eventRepo} from "../../repo/repo.js";
import type {SWEvent} from "../../types/event.js";
export let open = false;
const dispatch = createEventDispatcher();
let errorOpen = false;
let error = undefined;
let eventName = "";
let start = "";
$: startDate = moment(start)
let end = "";
$: endDate = moment(end)
$: canSubmit = eventName.length > 0 && startDate.isValid() && endDate.isValid() && startDate.isBefore(endDate)
async function createEvent() {
try {
await $eventRepo.createEvent({
name: eventName,
start: startDate,
end: endDate
})
dispatch("create");
open = false;
} catch (e) {
error = e;
errorOpen = true;
open = false;
}
}
function clear() {
eventName = "";
start = "";
end = "";
}
</script>
<Modal bind:open title="Create Event" outsideclose size="sm" on:hide={clear}>
<div class="flex flex-col place-items-center text-center">
<div class="w-2/3 m-2">
<Label for="event-create-name">Event Name</Label>
<Input id="event-create-name" bind:value={eventName} placeholder="Name..."></Input>
</div>
<div class="w-2/3 m-2">
<Label for="event-create-start">End</Label>
<Input id="event-create-start" bind:value={start} let:props>
<input type="datetime-local" {...props} bind:value={start}/>
</Input>
</div>
<div class="w-2/3 m-2">
<Label for="event-create-start">End</Label>
<Input id="event-create-start" bind:value={end} let:props>
<input type="datetime-local" {...props} bind:value={end}/>
</Input>
</div>
</div>
<svelte:fragment slot="footer">
<Button color="alternative" on:click={() => open = false} class="mr-auto">Cancel</Button>
<Button on:click={createEvent} disabled={!canSubmit}>Create</Button>
</svelte:fragment>
</Modal>
<ErrorModal bind:open={errorOpen} bind:error={error}/>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import {Card} from "flowbite-svelte";
import {link} from 'svelte-spa-router'
import type {ShortEvent} from "../../types/event.js";
export let event: ShortEvent;
$: sameDate = new Intl.DateTimeFormat().format(event.start) === new Intl.DateTimeFormat().format(event.end);
</script>
<a href="/event/{event.id}" use:link>
<Card class="hover:scale-105 transition">
<h5 class="mb-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white">{event.name}</h5>
{#if !sameDate}
<p>Startet: {new Intl.DateTimeFormat().format(event.start)}</p>
<p>Endet: {new Intl.DateTimeFormat().format(event.end)}</p>
{:else}
<p>Am: {new Intl.DateTimeFormat().format(event.start)}</p>
<p>&nbsp;</p>
{/if}
</Card>
</a>

View File

@@ -0,0 +1,96 @@
import type {ExtendedEvent, ShortEvent, SWEvent} from "../types/event.js";
import {fetchWithToken} from "./repo.js";
import type {Moment} from "moment";
import {ExtendedEventSchema, ShortEventSchema, SWEventSchema} from "../types/event.js";
import {z} from "zod";
export interface CreateEvent {
name: string
start: Moment
end: Moment
}
export interface UpdateEvent {
name: string
start: Moment
end: Moment
deadline: Moment
maxTeamMembers: number
schemType: string | null
publicSchemsOnly: boolean
spectateSystem: boolean
}
export class EventRepo {
constructor(private token: string) {}
public async listEvents(): Promise<ShortEvent[]> {
const res = await fetchWithToken(this.token, "/events");
if (res.ok) {
return z.array(ShortEventSchema).parse(await res.json());
} else {
throw new Error("Could not fetch events: " + res.statusText);
}
}
public async getEvent(id: string): Promise<ExtendedEvent> {
const res = await fetchWithToken(this.token, `/events/${id}`);
if (res.ok) {
return ExtendedEventSchema.parse(await res.json());
} else {
throw new Error("Could not fetch event: " + res.statusText);
}
}
public async createEvent(event: CreateEvent): Promise<SWEvent> {
const res = await fetchWithToken(this.token, "/events", {
method: "POST",
body: JSON.stringify({
name: event.name,
start: +event.start,
end: +event.end
}),
});
if (res.ok) {
return SWEventSchema.parse(await res.json());
} else {
throw new Error("Could not create event: " + res.statusText);
}
}
public async updateEvent(id: string, event: UpdateEvent): Promise<SWEvent> {
const res = await fetchWithToken(this.token, `/events/${id}`, {
method: "PUT",
body: JSON.stringify({
name: event.name,
start: +event.start,
end: +event.end,
deadline: +event.deadline,
maxTeamMembers: event.maxTeamMembers,
schemType: event.schemType,
publicSchemsOnly: event.publicSchemsOnly,
spectateSystem: event.spectateSystem
}),
headers: {
"Content-Type": "application/json"
}
});
if (res.ok) {
return SWEventSchema.parse(await res.json());
} else {
throw new Error("Could not update event: " + res.statusText);
}
}
public async deleteEvent(id: string): Promise<boolean> {
const res = await fetchWithToken(this.token, `/events/${id}`, {
method: "DELETE"
});
return res.ok;
}
}

View File

@@ -0,0 +1,92 @@
import type {EventFight} from "../types/event.js";
import {fetchWithToken} from "./repo.js";
import type {Moment} from "moment";
import {z} from "zod";
import {EventFightSchema} from "../types/event.js";
export interface CreateFight {
spielmodus: string
map: string
blueTeam: number
redTeam: number
start: Moment
kampfleiter: number | null
group: string | null
}
export interface UpdateFight {
spielmodus: string | null
map: string | null
blueTeam: number | null
redTeam: number | null
start: Moment | null
kampfleiter: number | null
group: string | null
}
export class FightRepo {
constructor(private token: string) {}
public async listFights(eventId: number): Promise<EventFight[]> {
const res = await fetchWithToken(this.token, `/events/${eventId}/fights`);
if (res.ok) {
return z.array(EventFightSchema).parse(await res.json());
} else {
throw new Error("Could not fetch fights: " + res.statusText);
}
}
public async createFight(eventId: number, fight: CreateFight): Promise<EventFight> {
let res = await fetchWithToken(this.token, `/fights`, {
method: "POST",
body: JSON.stringify({
event: eventId,
spielmodus: fight.spielmodus,
map: fight.map,
blueTeam: fight.blueTeam,
redTeam: fight.redTeam,
start: +fight.start,
kampfleiter: fight.kampfleiter,
group: fight.group
})
})
if (res.ok) {
return EventFightSchema.parse(await res.json());
} else {
throw new Error("Could not create fight: " + res.statusText);
}
}
public async updateFight(fightId: number, fight: UpdateFight): Promise<EventFight> {
let res = await fetchWithToken(this.token, `/fights/${fightId}`, {
method: "PUT",
body: JSON.stringify({
spielmodus: fight.spielmodus,
map: fight.map,
blueTeam: fight.blueTeam,
redTeam: fight.redTeam,
start: fight.start?.valueOf(),
kampfleiter: fight.kampfleiter,
group: fight.group
})
})
if (res.ok) {
return EventFightSchema.parse(await res.json());
} else {
throw new Error("Could not update fight: " + res.statusText);
}
}
public async deleteFight(fightId: number): Promise<void> {
let res = await fetchWithToken(this.token, `/fights/${fightId}`, {
method: "DELETE"
})
if (!res.ok) {
throw new Error("Could not delete fight: " + res.statusText);
}
}
}

View File

@@ -0,0 +1,57 @@
import type {Perms, UserPerms} from "../types/perms.js";
import {fetchWithToken} from "./repo.js";
import {PermsSchema, UserPermsSchema} from "../types/perms.js";
export class PermsRepo {
constructor(private token: string) {}
public async listPerms(): Promise<Perms> {
const res = await fetchWithToken(this.token, "/perms");
if (res.ok) {
return PermsSchema.parse(await res.json());
} else {
throw new Error("Could not fetch perms: " + res.statusText);
}
}
public async getPerms(userId: number): Promise<UserPerms> {
const res = await fetchWithToken(this.token, `/perms/user/${userId}`);
if (res.ok) {
return UserPermsSchema.parse(await res.json());
} else {
throw new Error("Could not fetch perms: " + res.statusText);
}
}
public async setPrefix(userId: number, prefix: string): Promise<void> {
const res = await fetchWithToken(this.token, `/perms/user/${userId}/prefix/${prefix}`, {
method: "PUT",
});
if (!res.ok) {
throw new Error("Could not set prefix: " + res.statusText);
}
}
public async addPerm(userId: number, perm: string): Promise<void> {
const res = await fetchWithToken(this.token, `/perms/user/${userId}/${perm}`, {
method: "PUT",
});
if (!res.ok) {
throw new Error("Could not add perm: " + res.statusText);
}
}
public async removePerm(userId: number, perm: string): Promise<void> {
const res = await fetchWithToken(this.token, `/perms/user/${userId}/${perm}`, {
method: "DELETE",
});
if (!res.ok) {
throw new Error("Could not remove perm: " + res.statusText);
}
}
}

View File

@@ -0,0 +1,15 @@
import {derived, writable} from "svelte/store";
import {EventRepo} from "./event.js";
import {FightRepo} from "./fight.js";
import {PermsRepo} from "./perms.js";
export { EventRepo } from "./event.js"
export const fetchWithToken = (token: string, url: string, params: RequestInit = {}) => fetch(`https://steamwar.de/eventplanner-api${url}`, {...params, headers: {"X-SW-Auth": token, "Content-Type": "application/json", ...params.headers}});
export const tokenStore = writable(localStorage.getItem("sw-api-token") ?? "")
tokenStore.subscribe((value) => localStorage.setItem("sw-api-token", value))
export const eventRepo = derived(tokenStore, ($token) => new EventRepo($token))
export const fightRepo = derived(tokenStore, ($token) => new FightRepo($token))
export const permsRepo = derived(tokenStore, ($token) => new PermsRepo($token))

View File

@@ -0,0 +1,63 @@
import {readonly, writable} from "svelte/store";
import type {Readable, Subscriber, Unsubscriber} from "svelte/store";
export interface Cached<T> extends Readable<T>{
reload: () => void;
}
export function cached<T>(normal: T, init: () => Promise<T>): Cached<T> {
const store = writable<T>(normal);
let first = true;
const reload = () => {
init().then(data => {
store.set(data);
});
}
return {
...readonly(store),
subscribe: (run: Subscriber<T>, invalidate?: (value?: T) => void): Unsubscriber => {
if(first) {
first = false;
reload();
}
return store.subscribe(run, invalidate);
},
reload
};
}
export function cachedFamily<T, K>(normal: K, init: (T) => Promise<K>): (T) => Cached<K> {
const stores: Map<T, Cached<K>> = new Map();
return (arg: T) => {
if(stores.has(arg)) {
return stores.get(arg);
} else {
const store = writable<K>(normal);
let first = true;
const reload = () => {
init(arg).then(data => {
store.set(data);
});
}
const cachedStore = {
...readonly(store),
subscribe: (run: Subscriber<K>, invalidate?: (value?: K) => void): Unsubscriber => {
if(first) {
first = false;
reload();
}
return store.subscribe(run, invalidate);
},
reload
} as Cached<K>;
stores.set(arg, cachedStore);
return cachedStore;
}
}
}

View File

@@ -0,0 +1,47 @@
import type {Player, SchematicType} from "../types/data.js";
import {PlayerSchema} from "../types/data.js";
import {cached, cachedFamily} from "./cached.js";
import type {Team} from "../types/team.js";
import {TeamSchema} from "../types/team.js";
import {get, writable} from "svelte/store";
import {permsRepo, tokenStore} from "../repo/repo.js";
import {z} from "zod";
export const schemTypes = cached<SchematicType[]>([], () => {
return fetch("https://steamwar.de/eventplanner-api/data/schematicTypes", {headers: {"X-SW-Auth": get(tokenStore)}})
.then(res => res.json())
})
export const players = cached<Player[]>([], async () => {
const res = await fetch("https://steamwar.de/eventplanner-api/data/users", {headers: {"X-SW-Auth": get(tokenStore)}});
return z.array(PlayerSchema).parse(await res.json());
})
export const gamemodes = cached<string[]>([], async () => {
const res = await fetch("https://steamwar.de/eventplanner-api/data/gamemodes", {headers: {"X-SW-Auth": get(tokenStore)}});
return z.array(z.string()).parse(await res.json());
})
export const maps = cachedFamily<string, string[]>([], async (gamemode) => {
if (get(gamemodes).every(value => value !== gamemode)) return [];
const res = await fetch(`https://steamwar.de/eventplanner-api/data/gamemodes/${gamemode}/maps`, {headers: {"X-SW-Auth": get(tokenStore)}});
if (!res.ok) {
return [];
} else {
return res.json();
}
})
export const groups = cached<string[]>([], async () => {
const res = await fetch("https://steamwar.de/eventplanner-api/data/groups", {headers: {"X-SW-Auth": get(tokenStore)}});
return z.array(z.string()).parse(await res.json());
})
export const teams = cached<Team[]>([], async () => {
const res = await fetch("https://steamwar.de/eventplanner-api/team", {headers: {"X-SW-Auth": get(tokenStore)}});
return z.array(TeamSchema).parse(await res.json());
})
export const isWide = writable(window.innerWidth >= 640);
window.addEventListener("resize", () => isWide.set(window.innerWidth >= 640));

View File

@@ -0,0 +1,16 @@
import {z} from "zod";
export const SchematicTypeSchema = z.object({
name: z.string(),
db: z.string(),
})
export type SchematicType = z.infer<typeof SchematicTypeSchema>;
export const PlayerSchema = z.object({
id: z.number(),
name: z.string(),
uuid: z.string(),
})
export type Player = z.infer<typeof PlayerSchema>;

View File

@@ -0,0 +1,46 @@
import type {Team} from "./team.js";
import type {Player} from "./data.js";
import {z} from "zod";
import {TeamSchema} from "./team.js";
import {PlayerSchema} from "./data.js";
export const ShortEventSchema = z.object({
id: z.number(),
name: z.string(),
start: z.number(),
end: z.number(),
})
export type ShortEvent = z.infer<typeof ShortEventSchema>;
export const SWEventSchema = ShortEventSchema.extend({
deadline: z.number(),
maxTeamMembers: z.number(),
schemType: z.string().nullable(),
publicSchemsOnly: z.boolean(),
spectateSystem: z.boolean(),
})
export type SWEvent = z.infer<typeof SWEventSchema>;
export const EventFightSchema = z.object({
id: z.number(),
spielmodus: z.string(),
map: z.string(),
blueTeam: TeamSchema,
redTeam: TeamSchema,
kampfleiter: PlayerSchema.nullable(),
start: z.number(),
ergebnis: z.number(),
group: z.string().nullable(),
})
export type EventFight = z.infer<typeof EventFightSchema>;
export const ExtendedEventSchema = z.object({
event: SWEventSchema,
teams: z.array(TeamSchema),
fights: z.array(EventFightSchema),
})
export type ExtendedEvent = z.infer<typeof ExtendedEventSchema>;

View File

@@ -0,0 +1,23 @@
import {z} from "zod";
export const PrefixSchema = z.object({
name: z.string().startsWith("PREFIX_"),
colorCode: z.string().length(2).startsWith("§"),
chatPrefix: z.string()
})
export type Prefix = z.infer<typeof PrefixSchema>;
export const PermsSchema = z.object({
perms: z.array(z.string()),
prefixes: z.record(PrefixSchema),
})
export type Perms = z.infer<typeof PermsSchema>;
export const UserPermsSchema = z.object({
prefix: PrefixSchema,
perms: z.array(z.string()),
})
export type UserPerms = z.infer<typeof UserPermsSchema>;

View File

@@ -0,0 +1,12 @@
import {z} from "zod";
export const TeamSchema = z.object({
id: z.number(),
name: z.string(),
kuerzel: z.string().min(1).max(4),
color: z.string().max(1),
})
export type Team = z.infer<typeof TeamSchema>;

View File

@@ -0,0 +1,49 @@
import Color from "color";
import type {Team} from "./types/team.js";
export const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
export function colorFromTeam(team: Team): string {
switch (team.color) {
case "1":
return "#0000AA";
case "2":
return "#00AA00";
case "3":
return "#00AAAA";
case "4":
return "#AA0000";
case "5":
return "#AA00AA";
case "6":
return "#FFAA00";
case "7":
return "#AAAAAA";
case "8":
return "#555555";
case "9":
return "#5555FF";
case "a":
return "#55FF55";
case "b":
return "#55FFFF";
case "c":
return "#FF5555";
case "d":
return "#FF55FF";
case "e":
return "#FFFF55";
case "f":
return "#FFFFFF";
default:
return "#000000";
}
}
export function lighten(color: string) {
return brightness(color) ? Color(color).lighten(0.2).hex() : Color(color).darken(0.2).hex()
}
export function brightness(color: string) {
return Color(color).isLight()
}