chore: more work on moving to svelte front end

This commit is contained in:
2024-03-01 23:03:25 +00:00
parent ce866725ff
commit e990b832d3
22 changed files with 1799 additions and 218 deletions

View File

@@ -0,0 +1,57 @@
<script lang="ts">
let {active} = $props<{active?: string}>();
function setActive(name: string) {
return () => active = name;
}
function isActive(name: string) {
return name == active;
}
</script>
<div class="tabs">
<div class="tab-buttons">
<slot name="buttons" {setActive} {isActive} />
</div>
<slot {isActive} />
</div>
<style lang="scss">
.tabs {
border-radius: 5px;
overflow: hidden;
display: flex;
flex-wrap: wrap;
box-shadow: 0 2px 8px 1px #66666655;
gap: 0 5px;
.tab-buttons {
display: flex;
overflow-x: scroll;
:global(.tab) {
padding: 5px;
background: var(--light-grey);
border-radius: 5px 5px 0 0;
box-shadow: none;
font-size: 1.1rem;
}
:global(.tab.selected) {
box-shadow: inset 0 2px 8px 1px #66666655;
}
}
:global(.content) {
display: none;
padding: 5px;
width: 100%;
}
:global(.content.selected) {
display: block;
box-shadow: 0 2px 2px 1px #66666655;
}
}
</style>

View File

@@ -1,10 +1,12 @@
import { goto } from '$app/navigation';
import { userStore } from 'routes/UserStore.svelte';
const API = "http://localhost:8000";
const API = "/api";
export async function get(url: string) {
const headers = new Headers();
//headers.append('content-type', 'application/json');
headers.append('response-type', 'application/json');
if (userStore.user) {
headers.append('token', userStore.user.token);
}
@@ -40,3 +42,50 @@ export async function post(url: string, body: any) {
return r.json();
}
export async function rdelete(url: string, body: any) {
const headers = new Headers();
headers.append('content-type', 'application/json');
if (userStore.user) {
headers.append('token', userStore.user.token);
}
let r = await fetch(`${API}/${url}`, {
method: 'DELETE',
headers: headers,
body: JSON.stringify(body),
});
if (r.status !== 200) {
throw r;
}
return r.json();
}
export async function postFormData(url: string, body: FormData) {
const headers = new Headers();
//headers.append('content-type', 'multipart/form-data');
headers.append('response-type', 'application/json');
if (userStore.user) {
headers.append('token', userStore.user.token);
}
let r = await fetch(`${API}/${url}`, {
method: 'POST',
headers: headers,
body: body,
});
if (r.status == 401) {
userStore.user = undefined;
goto('/login');
throw new Error("Redirect");
}
if (r.status !== 200) {
throw r;
}
return r.json();
}

View File

@@ -1,8 +1,26 @@
<script lang="ts">
import MessageSimple from "src/lib/MessageSimple.svelte";
import { onMount } from "svelte";
import { get } from '$lib/requests.svelte'
let list = $state<{
name: string,
id: string,
}[]>([]);
let message: MessageSimple;
onMount(async () => {
try {
list = await get("models");
} catch (e) {
if (e instanceof Response) {
message.display(await e.json())
} else {
message.display("Could not request list of models");
}
}
});
</script>
<svelte:head>
@@ -12,6 +30,7 @@
</svelte:head>
<main>
<MessageSimple bind:this={message} />
{#if list.length > 0}
<div class="list-header">
<h2>My Models</h2>
@@ -57,3 +76,29 @@
</div>
{/if}
</main>
<style lang="scss">
main {
padding: 20px 15vw;
}
.list-header {
display: flex;
padding-bottom: 10px;
}
.list-header h2 {
margin: 0;
padding: 10px 5px;
}
.list-header .expand {
flex-grow: 1;
}
.list-header .button,
.list-header button {
padding: 10px 10px;
height: calc(100% - 20px);
margin-top: 5px;
}
</style>

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import FileUpload from "src/lib/FileUpload.svelte";
import MessageSimple from "src/lib/MessageSimple.svelte";
import { postFormData } from "src/lib/requests.svelte";
import {goto} from '$app/navigation';
import "src/styles/forms.css";
@@ -18,9 +20,28 @@
file: undefined,
});
function onSubmit() {
async function onSubmit() {
message.display("");
buttonClicked = new Promise<void>(() => {});
if (!data.file || !data.name) return;
let formData = new FormData();
formData.append("name", data.name);
formData.append("file", data.file, 'base-image.png')
try {
let id = await postFormData('models/add', formData);
goto(`/models/edit?id=${id}`)
} catch (e) {
if (e instanceof Response) {
message.display(await e.json())
} else {
message.display("Was not able to create model")
}
}
buttonClicked = Promise.resolve();
}
</script>

View File

@@ -0,0 +1,525 @@
<script lang="ts" context="module">
export type Model = {
id: string;
name: string;
color_mode: string;
width: number;
height: number;
status: number;
}
export type Layer = {
layer_type: number;
shape: string;
}
export type Definitions = {
epoch: number;
epoch_progress: number;
status: number;
accuracy: number;
layers?: Layer[];
}
</script>
<script lang="ts">
import { onMount } from "svelte";
import BaseModelInfo from "./BaseModelInfo.svelte";
import DeleteModel from "./DeleteModel.svelte";
import { goto } from "$app/navigation";
import { get } from "src/lib/requests.svelte";
import 'src/styles/forms.css'
import ModelData from "./ModelData.svelte";
import DeleteZip from "./DeleteZip.svelte";
let model: Promise<Model> = $state(new Promise(() => {}));
let definitions: Promise<Definitions[]> = $state(new Promise(() => {}));
let id: string | undefined = $state()
async function getModel() {
try {
let temp_model: Model = await get(`models/edit?id=${id}`);
if (temp_model.status == 3) {
setTimeout(getModel, 2000);
}
model = Promise.resolve(temp_model);
} catch (e) {
if (e instanceof Response) {
model = Promise.reject(await e.json())
} else {
model = Promise.reject("Could not load model");
}
}
}
onMount(() => {
let url = new URLSearchParams(window.location.search);
const _id = url.get('id');
if (!_id) {
goto('/models')
return;
}
id = _id;
getModel();
});
async function resetModel() {
throw new Error("TODO");
}
// Auto reload after 2s when model.status 3,4
</script>
<svelte:head>
{#await model}
<title>
Model
</title>
{:then m}
{#if m}
<title>
Model: {m.name}
</title>
{:else}
<title>
Model
</title>
{/if}
{/await}
</svelte:head>
<!-- {{/* Is called from a diffrent endpoint so that it does not matter where this is from :) which means that . can mean what ever I want */}}
{{ define "data-model-create-class-table-table" }}
<div>
<table>
<thead>
<tr>
<th>
File Path
</th>
<th>
Mode
</th>
<th>
<!-- Img -- >
</th>
<th>
<!-- Status -- >
</th>
</tr>
</thead>
<tbody>
{{range .List}}
<tr>
<td>
{{ if eq .FilePath "id://" }}
Managed
{{ else }}
{{.FilePath}}
{{ end }}
</td>
<td>
{{ if (eq .Mode 2) }}
Testing
{{ else }}
Training
{{ end }}
</td>
<td class="text-center">
{{ if startsWith .FilePath "id://" }}
<img src="/savedData/{{ $.Model.Id }}/data/{{ .Id }}.{{ $.Model.Format }}" height="30px" width="30px" style="object-fit: contain;" />
{{ else }}
TODO
img {{ .FilePath }}
{{ end }}
</td>
<td class="text-center">
{{ if eq .Status 1 }}
<span class="bi bi-check-circle-fill" style="color: green"></span>
{{ else }}
<span class="bi bi-exclamation-triangle-fill" style="color: red"></span>
{{ end }}
</td>
</tr>
{{end}}
</tbody>
</table>
<div class="flex justify-center align-center">
<div class="grow-1 flex justify-end align-center ">
{{ if gt .Page 0 }}
<button
hx-get="/models/data/list?id={{ .Id }}&page={{ add .Page -1 }}"
hx-target=".content[data-tab='{{ .Name }}']"
hx-swap="innerHTML"
hx-headers='{"REQUEST-TYPE": "html"}'
data-tab="{{ .Name }}">
Prev
</button>
{{ end }}
</div>
<div style="padding: 10px;">
{{ .Page }}
</div>
<div class="grow-1 flex justify-start align-center">
{{ if .ShowNext }}
<button
hx-get="/models/data/list?id={{ .Id }}&page={{ add .Page 1 }}"
hx-target=".content[data-tab='{{ .Name }}']"
hx-swap="innerHTML"
hx-headers='{"REQUEST-TYPE": "html"}'
data-tab="{{ .Name }}">
Next
</button>
{{ end }}
</div>
</div>
</div>
{{ end }}
-->
<!--
{{ define "data-model-create-class-table" }}
{{ if eq (len .Classes) 0 }}
TODO CREATE TABLE
{{else}}
<div class="tabs-header">
{{/* Handle the case where there are to many buttons */}}
<div class="header">
{{ range .Classes }}
{{/* TODO Auto Load 1st */}}
<button
hx-get="/models/data/list?id={{ .Id }}"
hx-target=".content[data-tab='{{ .Name }}']"
hx-swap="innerHTML"
hx-headers='{"REQUEST-TYPE": "html"}'
hx-trigger="click"
class="tab"
data-tab="{{ .Name }}">
{{ .Name }}
</button>
{{ end }}
</div>
{{ range $i, $a := .Classes }}
{{ if eq $i 0}}
<div
hx-get="/models/data/list?id={{ .Id }}"
hx-target=".content[data-tab='{{ $a.Name }}']"
hx-swap="innerHTML"
hx-headers='{"REQUEST-TYPE": "html"}'
hx-trigger="load"
class="content"
data-tab="{{ $a.Name }}">
</div>
{{ else }}
<div
class="content"
data-tab="{{ $a.Name }}" >
</div>
{{ end }}
{{ end }}
</div>
{{end}}
{{ end }}
-->
<!--
{{ define "train-model-card" }}
<form hx-post="/models/train" hx-headers='{"REQUEST-TYPE": "html"}' hx-swap="outerHTML" {{ if .Error }} class="submitted" {{end}} >
{{ if .HasData }}
{{ if .NumberOfInvalidImages }}
{{ if gt .NumberOfInvalidImages 0 }}
<p class="danger">
There are images {{ .NumberOfInvalidImages }} that were loaded that do not have the correct format. These images will be delete when the model trains.
</p>
<input type="hidden" value="{{ .NumberOfInvalidImages }}" name="id" />
{{ end }}
{{ end }}
{{ if .ErrorMessage }}
<p class="danger">
{{ .ErrorMessage }}
</p>
{{ end }}
{{/* TODO expading mode */}}
<input type="hidden" value="{{ .Model.Id }}" name="id" />
<fieldset>
<legend>
Model Type
</legend>
<div class="input-radial">
<input id="model_type_simple" value="simple" name="model_type" type="radio" checked />
<label for="model_type_simple">Simple</label><br/>
<input id="model_type_expandable" value="expandable" name="model_type" type="radio" />
<label for="model_type_expandable">Expandable</label>
</div>
</fieldset>
{{/* TODO allow more models to be created */}}
<fieldset>
<label for="number_of_models">Number of Models</label>
<input id="number_of_models" type="number" name="number_of_models" value="1" />
</fieldset>
{{/* TODO to Change the acc */}}
<fieldset>
<label for="accuracy">Target accuracy</label>
<input id="accuracy" type="number" name="accuracy" value="95" />
</fieldset>
{{/* TODO allow to chose the base of the model */}}
{{/* TODO allow to change the shape of the model */}}
<button>
Train
</button>
{{ else }}
<h2>
To train the model please provide data to the model first
</h2>
{{ end }}
</form>
{{ end }}
-->
<!--
{{ define "run-model-card" }}
<form hx-headers='{"REQUEST-TYPE": "html"}' enctype="multipart/form-data" hx-post="/models/run" hx-swap="outerHTML">
<input type="hidden" name="id" value={{.Model.Id}} />
<fieldset class="file-upload" >
<label for="file">Image</label>
<div class="form-msg">
Run image through them model and get the result
</div>
<div class="icon-holder">
<button class="icon">
<img replace="icon" src="/imgs/upload-icon.png" />
<span replace="File Selected">
Image File
</span>
</button>
{{ if .ImageError }}
<span class="form-msg error">
The provided image was not valid for this model
</span>
{{ end }}
<input id="file" name="file" type="file" required accept="image/png" />
</div>
</fieldset>
<button>
Run
</button>
{{ if .NotFound }}
<div class="result">
<h1>
The class was not found
</h1>
</div>
{{ else if .Result }}
<div>
<h1>
Result
</h1>
The image was classified as {{.Result}}
</div>
{{ end }}
</form>
{{ end }}
-->
<main>
{#await model}
Loading
{:then m}
{#if m.status == 1}
<div>
<h1 class="text-center">
{ m.name }
</h1>
<!-- TODO add cool animation -->
<h2 class="text-center">
Preparing the model
</h2>
</div>
{:else if m.status == -1}
<div>
<h1 class="text-center">
{m.name}
</h1>
<!-- TODO improve message -->
<h2 class="text-center">
Failed to prepare model
</h2>
<div>
TODO button delete
</div>
<!--form hx-delete="/models/delete">
<input type="hidden" name="id" value="{{ .Model.Id }}" />
<button class="danger">
Delete
</button>
</form-->
</div>
<!-- PRE TRAINING STATUS -->
{:else if m.status == 2 }
<BaseModelInfo model={m} />
<ModelData model={m} on:reload={getModel} />
<!-- {{ template "train-model-card" . }} -->
<DeleteModel model={m} />
{:else if m.status == -2 }
<BaseModelInfo model={m} />
<DeleteZip model={m} on:reload={getModel} />
<DeleteModel model={m} />
{:else if m.status == 3 }
<BaseModelInfo model={m} />
<div class="card">
<!-- TODO improve this -->
Processing zip file...
</div>
{:else if m.status == -3 || m.status == -4}
<BaseModelInfo model={m} />
<form on:submit={resetModel}>
Failed Prepare for training.<br/>
<div class="spacer" ></div>
<button class="danger">
Try Again
<button>
</form>
<DeleteModel model={m} />
{:else if m.status == 4}
<BaseModelInfo model={m} />
<!-- TODO request /models/edit?id={m.id} -->
<div class="card">
<!-- TODO improve this -->
Training the model...<br/>
<!-- TODO Add progress status on definitions -->
{#await definitions}
Loading
{:then defs}
<table>
<thead>
<tr>
<th>
Done Progress
</th>
<th>
Training Round Progress
</th>
<th>
Accuracy
</th>
<th>
Status
</th>
</tr>
</thead>
<tbody>
{#each defs as def}
<tr>
<td>
{def.epoch}
</td>
<td>
{def.epoch_progress}/20
</td>
<td>
{def.accuracy}%
</td>
<td style="text-align: center;">
{#if def.status == 2}
<span class="bi bi-book" style="color: green;"></span>
{:else if [3,6,-3].includes(def.status) }
<span class="bi bi-book-half" style="color: {{
'3': 'green',
'-3': 'red',
'6': 'orange',
}[String(def.status)]};"></span>
{:else}
{def.status}
{/if}
</td>
</tr>
{#if def.status == 3 && def.layers}
<tr>
<td colspan="4">
<svg viewBox="0 200 1000 600">
{#each def.layers as layer, i}
{@const sep_mod = def.layers.length > 8 ? Math.max(10, 100 - (def.layers.length - 8) * 10) : 100}
{#if layer.layer_type == 1}
<polygon
points="50,450 200,250 200,550 50,750"
stroke="black"
stroke-width="2"
fill="green"></polygon>
{:else if layer.layer_type == 4}
<polygon
points="{50 + (i * sep_mod)},450 {200 + (i * sep_mod)},250 {200 + (i * sep_mod)},550 {50 + (i * sep_mod)},750"
stroke="black"
stroke-width="2"
fill="orange">
</polygon>
{:else if layer.layer_type == 3}
<polygon
points="{50 + (i * sep_mod)},450 {200 + (i * sep_mod)},250 {200 + (i * sep_mod)}},550 {50 + (i * sep_mod)},750"
stroke="black"
stroke-width="2"
fill="red">
</polygon>
{:else if layer.layer_type == 3}
<polygon
points="{50 + (i * sep_mod)},550 {200 + (i * sep_mod)},350 {200 + (i * sep_mod)},450 {50 + (i * sep_mod)},650"
stroke="black"
stroke-width="2"
fill="blue">
</polygon>
{:else}
<div>
{layer.layer_type}
{layer.shape}
</div>
{/if}
{/each}
</svg>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
{/await}
{{/* TODO Add ability to stop training */}}
</div>
{:else if m.status == 5}
<BaseModelInfo model={m} />
TODO run model
<!--
<form hx-delete="/models/train/reset" hx-headers='{"REQUEST-TYPE": "html"}' hx-swap="outerHTML">
Failed Prepare for training.<br/>
<div class="spacer" ></div>
<input type="hidden" name="id" value="{{ .Model.Id }}" />
<button class="danger">
Try Again
</button>
</form>
-->
<DeleteModel model={m} />
{:else}
<h1>
Unknown Status of the model.
</h1>
{/if}
{/await}
</main>
<style lang="scss">
main {
padding: 20px 15vw;
}
</style>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import type { Model } from './+page.svelte';
let { model } = $props<{ model: Model }>();
</script>
<div class="card model-card">
<h1>
{model.name}
</h1>
<div class="second-line">
<img src="/api/savedData/{model.id}/baseimage.png" alt="" />
<div class="info">
<div>
<span class="bold bigger">Image Type:</span>
{model.color_mode}
</div>
<div>
<span class="bold bigger">Image Size:</span>
{model.width}x{model.height}
</div>
</div>
</div>
</div>
<style>
.model-card {
h1 {
margin: 0;
padding-bottom: 10px;
}
img {
width: 25%;
height: 100%;
object-fit: contain;
}
.second-line {
display: flex;
gap: 20px;
}
}
</style>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import type { Model } from './+page.svelte';
let {model}: { model: Model } = $props();
let name: string = $state("");
let submmited: boolean = $state(false);
let nameDoesNotMatch: string = $state("");
function deleteModel() {
submmited = true;
nameDoesNotMatch = "";
console.error("TODO")
}
</script>
<form on:submit|preventDefault={deleteModel} class:submmited>
<fieldset>
<label for="name">
To delete this model please type "{model.name}":
</label>
<input name="name" id="name" required bind:value={name} />
{#if nameDoesNotMatch }
<span class="form-msg red">
Name does not match "{model.name}"
</span>
{/if}
</fieldset>
<button class="danger">
Delete
</button>
</form>

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import { rdelete } from "src/lib/requests.svelte";
import type { Model } from "./+page.svelte";
import MessageSimple from "src/lib/MessageSimple.svelte";
import { createEventDispatcher } from "svelte";
let message: MessageSimple;
let { model } = $props<{model: Model}>();
const dispatch = createEventDispatcher<{reload: void}>();
async function deleteZip() {
message.display("");
try {
await rdelete("models/data/delete-zip-file", { id: model.id });
dispatch('reload');
} catch (e) {
if (e instanceof Response) {
message.display(await e.json());
} else {
message.display("Could not delete the zip file");
}
}
}
</script>
<form on:submit|preventDefault={deleteZip}>
Failed to proccess the zip file.<br/>
Delete file and proccess again.<br/>
<br/>
<div class="spacer" ></div>
<MessageSimple bind:this={message} />
<button class="danger">
Delete Zip File
</button>
</form>

View File

@@ -0,0 +1,187 @@
<script lang="ts" context="module">
export type Class = {
name: string;
id: string;
}
</script>
<script lang="ts">
import FileUpload from "src/lib/FileUpload.svelte";
import Tabs from "src/lib/Tabs.svelte";
import type { Model } from "./+page.svelte";
import { postFormData, get } from "src/lib/requests.svelte";
import MessageSimple from "src/lib/MessageSimple.svelte";
import { createEventDispatcher } from "svelte";
import ModelTable from "./ModelTable.svelte";
let { model } = $props<{model: Model}>();
let classes: Class[] = $state([]);
let file: File | undefined = $state();
const dispatch = createEventDispatcher<{
reload: void
}>();
let uploading: Promise<void> = $state(Promise.resolve())
let numberOfInvalidImages = $state(0);
let uploadImage: MessageSimple;
async function uploadZip() {
if (!file) return;
uploading = new Promise(() => {});
let form = new FormData();
form.append('id', model.id);
form.append('file', file, 'upload.zip');
try {
await postFormData('models/data/upload', form);
dispatch('reload');
} catch (e) {
if (e instanceof Response) {
uploadImage.display(await e.json());
} else {
uploadImage.display('');
}
}
uploading = Promise.resolve();
}
$effect(() => {
getData();
});
async function getData() {
if (!model) return;
try {
let data = await get(`models/edit/classes?id=${model.id}`);
classes = data.classes
numberOfInvalidImages = data.number_of_invalid_images;
} catch {
return;
}
}
</script>
<div class="card">
<h3>
Training data
</h3>
{#if classes.length == 0}
<p>
You need to upload data so the model can train.
</p>
<Tabs active="upload" let:isActive>
<div slot="buttons" let:setActive let:isActive>
<button class="tab" class:selected={isActive("upload")} on:click={setActive("upload")}>
Upload
</button>
<button class="tab" class:selected={isActive("create-class")} on:click={setActive("create-class")}>
Create Class
</button>
<button class="tab" class:selected={isActive("api")} on:click={setActive("api")}>
Api
</button>
</div>
<div class="content" class:selected={isActive("upload")}>
<form on:submit|preventDefault={uploadZip}>
<fieldset class="file-upload" >
<label for="file">Data file</label>
<div class="form-msg">
Please provide a file that has the training and testing data<br/>
The file must have 2 folders one with testing images and one with training images. <br/>
Each of the folders will contain the classes of the model. The folders must be the same in testing and training.
The class folders must have the images for the classes.
<pre>
training\
class1\
img1.png
img2.png
img2.png
...
class2\
img1.png
img2.png
img2.png
...
...
testing\
class1\
img1.png
img2.png
img2.png
...
class2\
img1.png
img2.png
img2.png
...
...
</pre>
</div>
<FileUpload replace_slot bind:file={file} accept="application/zip" >
<img src="/imgs/upload-icon.png" alt="" />
<span>
Upload Zip File
</span>
<div slot="replaced">
<img src="/imgs/upload-icon.png" alt="" />
<span>
File selected
</span>
</div>
</FileUpload>
</fieldset>
<MessageSimple bind:this={uploadImage} />
{#await uploading}
<button disabled>
Uploading
</button>
{:then}
<button>
Add
</button>
{/await}
</form>
</div>
<div class="content" class:selected={isActive("create-class")}>
<ModelTable classes={classes} />
</div>
<div class="content" class:selected={isActive("api")}>
TODO
</div>
</Tabs>
<div class="tabs">
</div>
{:else}
<p>
You need to upload data so the model can train.
</p>
{#if numberOfInvalidImages > 0}
<p class="danger">
There are images {numberOfInvalidImages} that were loaded that do not have the correct format. These images will be delete when the model trains.
</p>
{/if}
<Tabs active="create-class" let:isActive>
<div slot="buttons" let:setActive let:isActive>
<button class="tab" class:selected={isActive("create-class")} on:click={setActive("create-class")}>
Create Class
</button>
<button class="tab" class:selected={isActive("api")} on:click={setActive("api")}>
Api
</button>
</div>
<div class="content" class:selected={isActive("create-class")}>
<ModelTable classes={classes} />
</div>
<div class="content" class:selected={isActive("api")}>
TODO
</div>
</Tabs>
{/if}
</div>

View File

@@ -0,0 +1,63 @@
<script lang="ts">
import Tabs from 'src/lib/Tabs.svelte';
import type { Class } from './ModelData.svelte';
import { get } from 'src/lib/requests.svelte';
let selected_class: Class | undefined = $state();
let { classes } = $props<{ classes: Class[] }>();
function setActiveClass(c: Class, tb_fn: (name: string) => (() => void)) {
selected_class = c;
console.log("test", c, classes, c.name)
tb_fn(c.name)();
}
$effect(() => {
selected_class = classes[0];
});
async function getList() {
console.log(selected_class);
try {
let url = new URLSearchParams();
url.append('id', selected_class?.id ?? '');
let res = await get('models/data/list?' + url.toString());
console.log(res);
} catch (e) {
console.error("TODO notify user", e);
}
}
$effect(() => {
if (selected_class) {
getList();
}
})
</script>
{#if classes.length == 0}
TODO CREATE TABLE
{:else}
<Tabs active={classes[0]?.name} let:isActive>
<div slot="buttons" let:setActive let:isActive>
<!-- TODO Auto Load 1st -->
{#each classes as item}
<button
on:click={() => setActiveClass(item, setActive)}
class="tab"
class:selected={isActive(item.name)}
>
{item.name}
</button>
{/each}
</div>
</Tabs>
{/if}
<style lang="scss">
</style>

View File

@@ -90,3 +90,14 @@ a.button {
.danger {
color: red;
}
.card {
box-shadow: 0 2px 5px 1px #66666655;
padding: 20px;
border-radius: 10px;
margin: 20px 0;
}
.card h3 {
margin-top: 0;
}