Refactored codebase

This commit is contained in:
Jan Meinl
2026-05-15 18:08:33 +02:00
parent 642bae23f3
commit 1da43adfe7
54 changed files with 790 additions and 4636 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+14
View File
@@ -0,0 +1,14 @@
{
"login.eyebrow": "Wettbewerbsbüro",
"login.title": "Einloggen",
"login.subtitle": "Bitte gebe deine Zugangsdaten ein um dich im digitalen Wettbewerbsbüro anzumelden.",
"login.email": "E-MAIL",
"login.password": "PASSWORT",
"login.remember": "Auf diesem Gerät angemeldet bleiben",
"login.forgot": "Passwort vergessen?",
"login.submit": "Anmelden",
"login.loading": "Lädt…",
"login.error.credentials": "E-Mail oder Passwort falsch.",
"login.error.failed": "Login fehlgeschlagen.",
"login.error.network": "Netzwerkfehler. Bitte erneut versuchen."
}
+14
View File
@@ -0,0 +1,14 @@
{
"login.eyebrow": "Competition Center",
"login.title": "Sign in",
"login.subtitle": "Please enter your login credentials to log in to the digital competition office.",
"login.email": "EMAIL",
"login.password": "PASSWORD",
"login.remember": "Keep me signed in on this device",
"login.forgot": "Forgot password?",
"login.submit": "Sign in",
"login.loading": "Loading…",
"login.error.credentials": "Incorrect email or password.",
"login.error.failed": "Login failed.",
"login.error.network": "Network error. Please try again."
}
+78 -24
View File
@@ -1,25 +1,79 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="40 20 430 550" width="512" height="640">
<g fill="none" stroke="black" stroke-width="8" stroke-linecap="round" stroke-linejoin="round">
<path d="
M256 30
C140 30 70 130 70 240
C70 350 160 410 220 445
L232 460
L280 460
L292 445
C352 410 442 350 442 240
C442 130 372 30 256 30Z
"/>
<path d="M200 38 C180 120 172 200 178 280 C184 360 200 410 220 445"/>
<path d="M256 30 C248 120 246 200 248 280 C250 360 253 410 256 460"/>
<path d="M312 38 C332 120 340 200 334 280 C328 360 312 410 292 445"/>
<line x1="232" y1="460" x2="238" y2="520"/>
<line x1="280" y1="460" x2="274" y2="520"/>
<rect x="228" y="520" width="56" height="40" rx="4"/>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
viewBox="0 0 203 257"
id="svg8"
sodipodi:docname="logo_edit_inkscape.svg"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
width="203"
height="257"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs8" />
<sodipodi:namedview
id="namedview8"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="5.4838822"
inkscape:cx="86.252765"
inkscape:cy="161.8379"
inkscape:window-width="1920"
inkscape:window-height="1052"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="g6" />
<!-- Ballonsegmente -->
<g
transform="translate(-184.20105,-0.51465046)"
id="g6">
<path
d="M 252.4541,176.40178 C 242.5541,168.00178 207.6,133.4 193.4,102.1 c -7.6,11.6 -13.3,32.8 8.2,53.2 19.2,18.3 45.84768,32.58327 59.27473,35.99502 8.49977,4.02271 -1.92063,-9.69324 -8.42063,-14.89324 z"
fill="#e7362b"
id="path1"
sodipodi:nodetypes="ccccc" />
<path
d="M 263.6,162.3 C 229.6,104.2 225.7,52 225.3,35.6 c -16.8,0.3 -48.8,23.6 -29.8,60.5 17.5,34.1 50.34976,71.08519 62.94976,82.78519 6.9158,8.45697 8.79315,14.9726 20.37946,10.48886 C 285.8833,186.64421 275.11582,185.40332 263.6,162.3 Z"
fill="#f39200"
id="path2"
sodipodi:nodetypes="ccccsc" />
<path
d="m 285,156.8 c 0.5,-23.3 6.8,-130.6 6.8,-136.4 0,-5.8 -15,-18.2 -27.4,-18.2 -12.4,0 -37.4,7.9 -33.7,51.6 3.2,37.8 27.4,90.2 36,106.8 2.21885,2.83257 10.15094,21.3813 13.7744,20.81432 C 284.52622,180.78031 284.20814,160.6537 285,156.8 Z"
fill="#ffcc00"
id="path3"
sodipodi:nodetypes="cssccsc" />
<path
d="m 302.6,161.6 c 9.5,-16.7 59.2,-101.1 47.1,-129.7 -12.8,-30.2 -44.2,-18.7 -51,-8.7 -1,19.1 -8.4,111.5 -10.7,133.8 0.33776,2.99764 -5.43819,23.47341 -3.20178,24.69128 3.0606,1.66671 14.62389,-16.85562 17.80178,-20.09128 z"
fill="#a0c519"
id="path4"
sodipodi:nodetypes="ccccsc" />
<path
d="m 355.6,55 c -7.5,41 -40.9,93.9 -50.8,108 -2.2826,4.92187 -23.16898,25.39521 -19.71277,26.72798 17.18444,6.62665 13.07718,-2.66555 21.46669,-9.48012 8.85543,-7.26405 20.02829,-14.88232 30.43987,-24.10556 C 353.11513,141.86101 368.15059,126.13171 376.9,112.4 385,99.7 394,66 355.6,55 Z"
fill="#0a7bc0"
id="path5"
sodipodi:nodetypes="ccscscc" />
<path
d="M 376.82597,119.92102 C 365.02597,136.82102 327.4,170.2 313.4,177.9 c -7.2,4.3 -11.61708,10.2847 -9.01708,13.7847 14.7,-3.2 35.5442,-14.00437 47.5442,-20.60437 13.3,-7.5 32.19885,-31.95931 24.89885,-51.15931 z"
fill="#602483"
id="path6"
sodipodi:nodetypes="ccccc" />
</g>
</svg>
<!-- Ballonöffnung (Mund der Hülle) -->
<path
d="m 84.548953,194.48535 q 15,-3 29.999997,0 -2,10 -14.999997,11 -13,-1 -15,-11 z"
fill="#3a3a39"
id="path7" />
<!-- Korb -->
<path
d="m 87.548953,217.48535 q 12,-4 23.999997,0 l 6,34 q -17.999997,6 -35.999997,0 z"
fill="#a87445"
id="path8" />
</svg>

Before

Width:  |  Height:  |  Size: 781 B

After

Width:  |  Height:  |  Size: 3.4 KiB

-39
View File
@@ -3,9 +3,6 @@ import { customElement, state } from 'lit/decorators.js';
import { Router } from './router/router';
import { authService } from './services/auth.service';
import type { AuthLevel } from './router/router';
import './components/footer-bar.js';
import './components/nav-bar';
import './components/cc/cc-nav-bar';
//import './components/tt/tt-nav-bar';
@customElement('app-root')
@@ -33,22 +30,6 @@ export class AppRoot extends LitElement {
return document.createElement('home-page');
},
},
{
path: '/competitions',
auth: 'public',
view: async () => {
await import('./pages/competition/competition-page.js');
return document.createElement('competition-page');
},
},
{
path: '/dev',
auth: 'public',
view: async () => {
await import('./pages/dev-page.js');
return document.createElement('dev-page');
},
},
{
path: '/login',
auth: 'public',
@@ -66,16 +47,6 @@ export class AppRoot extends LitElement {
},
},
// ── Target Team (Code-geschützt) ─────────────────────
{
path: '/tt',
auth: 'code',
view: async () => {
await import('./pages/tt/tt-home-page.js');
return document.createElement('tt-home-page');
},
},
// ── Competition Center (User-Login) ──────────────────
{
path: '/cc',
@@ -85,16 +56,6 @@ export class AppRoot extends LitElement {
return document.createElement('cc-home-page');
},
},
// ── System Admin ─────────────────────────────────────
{
path: '/admin',
auth: 'admin',
view: async () => {
await import('./pages/admin/admin-home-page.js');
return document.createElement('admin-home-page');
},
},
],
// Guard-Logik
-206
View File
@@ -1,206 +0,0 @@
# Component Library
## ui-button
| Attribute | Type | Description |
|------------|-----------|----------------------------|
| `disabled` | `boolean` | Disables the button |
| `full` | `boolean` | Full-width layout |
| Slot | Description |
|-----------|---------------------|
| `default` | Button label text |
| `icon` | Leading SVG icon |
---
## ui-button-secondary
| Attribute | Type | Description |
|------------|-----------|----------------------------|
| `disabled` | `boolean` | Disables the button |
| `full` | `boolean` | Full-width layout |
| Slot | Description |
|-----------|---------------------|
| `default` | Button label text |
| `icon` | Leading SVG icon |
---
## ui-badge
| Attribute | Type | Values |
|-----------|----------|------------------------------------------------------|
| `variant` | `string` | `accent` · `success` · `warning` · `error` · `muted` |
| Slot | Description |
|-----------|-------------------|
| `default` | Badge label text |
| `icon` | Leading SVG icon |
---
## notify-bar
| Attribute | Type | Values / Description |
|---------------|-----------|-----------------------------------------|
| `type` | `string` | `success` · `warning` · `error` |
| `message` | `string` | Notification text |
| `dismissible` | `boolean` | Whether the bar can be dismissed (default `true`) |
---
## ui-link
| Attribute | Type | Description |
|-----------|-----------|--------------------------------|
| `href` | `string` | Navigation target |
| `active` | `boolean` | Renders in active/highlighted state |
| Slot | Description |
|-----------|-------------|
| `default` | Link text |
---
## form-input
| Attribute | Type | Description |
|---------------|----------|------------------------------|
| `label` | `string` | Field label |
| `type` | `string` | `text` · `email` · `password` |
| `placeholder` | `string` | Placeholder text |
| `value` | `string` | Current input value |
| Event | Detail | Description |
|-----------------|----------------|-------------------------|
| `value-changed` | `{ value: string }` | Fires on input change |
---
## stat-card
| Attribute | Type | Description |
|-----------|----------|----------------------|
| `value` | `string` | Displayed metric |
| `label` | `string` | Metric description |
---
## icon-card
| Attribute | Type | Description |
|---------------|----------|--------------------|
| `heading` | `string` | Card title |
| `description` | `string` | Card body text |
| Slot | Description |
|--------|----------------|
| `icon` | Leading SVG icon |
---
## ui-card
| Attribute | Type | Description |
|------------|-----------|-----------------------------|
| `centered` | `boolean` | Centers all child content |
| Slot | Description |
|-----------|----------------------|
| `default` | Arbitrary content |
---
## card-header
| Attribute | Type | Description |
|--------------|----------|---------------|
| `heading` | `string` | Title text |
| `subheading` | `string` | Subtitle text |
---
## card-backdrop
_No attributes or slots observed in usage._
---
## horizontal-divider
_No attributes. Renders a visual separator line._
---
## loading-bar
| Attribute | Type | Values / Description |
|-----------------|-----------|-----------------------------------------------|
| `label` | `string` | Text label next to the bar |
| `value` | `number` | Progress percentage (`0``100`) |
| `variant` | `string` | _(default)_ · `success` · `warning` · `error` |
| `size` | `string` | `sm` · `md` · `lg` |
| `indeterminate` | `boolean` | Infinite animation, no fixed value |
| `hideValue` | `boolean` | Hides the percentage label |
---
## line-chart
| Attribute | Type | Description |
|------------|-----------|------------------------------------|
| `heading` | `string` | Chart title |
| `subtitle` | `string` | Chart subtitle |
| `xLabel` | `string` | X-axis label |
| `yLabel` | `string` | Y-axis label |
| `showArea` | `boolean` | Fill area beneath lines |
| `series` | `Array` | Array of series objects (see below) |
**Series object:**
| Key | Type | Description |
|----------|----------|------------------------------------------|
| `label` | `string` | Legend label |
| `color` | `string` | CSS color value |
| `points` | `Array` | Array of `{ x: number, y: number }` |
---
## circle-chart
| Attribute | Type | Description |
|--------------|----------|--------------------------------------|
| `heading` | `string` | Chart title |
| `centerText` | `string` | Text rendered in the donut center |
| `segments` | `Array` | Array of segment objects (see below) |
**Segment object:**
| Key | Type | Description |
|---------|----------|--------------------|
| `label` | `string` | Legend label |
| `value` | `number` | Segment value |
| `color` | `string` | CSS color value |
---
## ui-tab-bar
| Attribute | Type | Description |
|------------|----------|------------------------------------|
| `tabs` | `Array` | Array of tab objects (see below) |
| `active` | `string` | The id of the currently active tab |
**Tab object:**
| Key | Type | Description |
|----------|----------|---------------------------|
| `id` | `string` | Tab identifier |
| `label` | `string` | Tab display text |
| `icon` | `string` | Optional icon identifier |
| Event | Detail | Description |
|---------------|--------------------|-----------------------------|
| `tab-change` | `{ tab: string }` | Fired when tab is selected |
-32
View File
@@ -1,32 +0,0 @@
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import './card-backdrop';
import './ui-card';
import './card-header';
@customElement('auth-card')
export class AuthCard extends LitElement {
static styles = css`
:host {
flex: 1;
display: flex;
}
`;
@property() heading = '';
@property() subheading = '';
render() {
return html`
<card-backdrop>
<ui-card>
<card-header
heading=${this.heading}
subheading=${this.subheading}
></card-header>
<slot></slot>
</ui-card>
</card-backdrop>
`;
}
}
+84
View File
@@ -0,0 +1,84 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import "../hero-icon.js";
@customElement("primary-button")
export class PrimaryButton extends LitElement {
@property({ type: String }) label = "Button";
@property({ type: String }) icon = "";
@property({ type: String }) iconPosition: "left" | "right" = "right";
@property({ type: Boolean }) disabled = false;
@property({ attribute: false }) onClick?: () => void;
static styles = css`
:host {
display: inline-block;
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 0.625rem 1.25rem;
background-color: var(--color-accent, #2b6cb0);
color: var(--color-on-accent, #ffffff);
border: none;
border-radius: 0.375rem;
font-family: "Inter", system-ui, sans-serif;
font-size: var(--font-size-base, 0.9375rem);
font-weight: var(--font-weight-medium, 500);
cursor: pointer;
transition: filter 0.2s ease, background-color 0.25s ease;
}
button:hover {
filter: brightness(1.1);
}
button:active {
filter: brightness(0.92);
}
button:focus-visible {
outline: 2px solid var(--color-accent, #2b6cb0);
outline-offset: 3px;
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
filter: none;
}
button:disabled:hover,
button:disabled:active {
filter: none;
}
hero-icon {
width: 1.1rem;
height: 1.1rem;
flex-shrink: 0;
}
`;
private _handleClick() {
this.onClick?.();
}
render() {
const iconEl = this.icon
? html`<hero-icon name=${this.icon}></hero-icon>`
: null;
return html`
<button @click=${this._handleClick} ?disabled=${this.disabled}>
${this.iconPosition === "left" ? iconEl : null}
${this.label}
${this.iconPosition === "right" ? iconEl : null}
</button>
`;
}
}
+84
View File
@@ -0,0 +1,84 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("secondary-button")
export class SecondaryButton extends LitElement {
@property({ type: String }) label = "Button";
@property({ type: String }) icon = "";
@property({ type: String }) iconPosition: "left" | "right" = "right";
@property({ type: Boolean }) disabled = false;
@property({ attribute: false }) onClick?: () => void;
static styles = css`
:host {
display: inline-block;
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 0.625rem 1.25rem;
background-color: transparent;
color: var(--color-text, #111);
border: 1.5px solid var(--color-border-strong, #aaa);
border-radius: 0.375rem;
font-family: "Inter", system-ui, sans-serif;
font-size: var(--font-size-base, 0.9375rem);
font-weight: var(--font-weight-medium, 500);
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.25s ease, color 0.25s ease;
}
button:hover {
background-color: var(--color-border, #ddd);
}
button:active {
background-color: var(--color-border, #ddd);
filter: brightness(0.95);
}
button:focus-visible {
outline: 2px solid var(--color-accent, #2b6cb0);
outline-offset: 3px;
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
filter: none;
}
button:disabled:hover,
button:disabled:active {
background-color: transparent;
}
hero-icon {
width: 1.1rem;
height: 1.1rem;
flex-shrink: 0;
}
`;
private _handleClick() {
this.onClick?.();
}
render() {
const iconEl = this.icon
? html`<hero-icon name=${this.icon}></hero-icon>`
: null;
return html`
<button @click=${this._handleClick} ?disabled=${this.disabled}>
${this.iconPosition === "left" ? iconEl : null}
${this.label}
${this.iconPosition === "right" ? iconEl : null}
</button>
`;
}
}
-31
View File
@@ -1,31 +0,0 @@
import { LitElement, html, css } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('card-backdrop')
export class CardBackdrop extends LitElement {
static styles = css`
:host {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
background:
radial-gradient(
ellipse at 30% 20%,
color-mix(in srgb, var(--color-accent) 15%, transparent) 0%,
transparent 50%
),
radial-gradient(
ellipse at 70% 80%,
color-mix(in srgb, var(--color-accent) 10%, transparent) 0%,
transparent 50%
),
var(--color-bg);
padding: 1.5rem;
}
`;
render() {
return html`<slot></slot>`;
}
}
-38
View File
@@ -1,38 +0,0 @@
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
@customElement('card-header')
export class CardHeader extends LitElement {
static styles = css`
:host {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: 0.25rem;
}
h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--color-text);
}
p {
margin: 0;
font-size: 0.875rem;
color: color-mix(in srgb, var(--color-text) 60%, transparent);
}
`;
@property() heading = '';
@property() subheading = '';
render() {
return html`
<h2>${this.heading}</h2>
${this.subheading ? html`<p>${this.subheading}</p>` : null}
`;
}
}
-76
View File
@@ -1,76 +0,0 @@
nav {
backdrop-filter: blur(10px);
background: var(--color-bg-nav);
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1.5rem;
position: sticky;
top: 0;
z-index: 100;
}
.brand {
font-weight: 700;
font-size: 1.1rem;
user-select: none;
width: fit-content;
display: flex;
justify-content: center;
align-items: center;
padding-right: 1rem;
}
.brand img {
width: auto;
height: 2rem;
padding-right: 1rem;
}
.links {
display: flex;
gap: 1.25rem;
align-items: center;
}
.links a {
text-decoration: none;
}
a {
color: var(--color-text);
font-weight: 500;
position: relative;
}
a::after {
content: '';
position: absolute;
bottom: -6px;
left: 0;
width: 0%;
height: 2px;
background: var(--color-accent);
transition: 0.3s ease;
}
a:hover::after {
width: 100%;
}
button {
background: none;
border: 1px solid var(--color-border);
border-radius: 5px;
padding: 0.4rem 0.6rem;
color: var(--color-text);
cursor: pointer;
transition: all 0.3s ease;
}
button:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
-49
View File
@@ -1,49 +0,0 @@
import { LitElement, html, css, unsafeCSS } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import styles from './cc-nav-bar.css?inline';
import '../ui-link';
@customElement('cc-nav-bar')
export class CCNavBar extends LitElement {
static styles = css`${unsafeCSS(styles)}`;
@state() theme: 'light' | 'dark' =
(localStorage.getItem('theme') as 'light' | 'dark') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light');
firstUpdated() {
document.documentElement.setAttribute('data-theme', this.theme);
}
toggleTheme() {
this.theme = this.theme === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', this.theme);
localStorage.setItem('theme', this.theme);
}
navigate(path: string) {
this.dispatchEvent(
new CustomEvent('nav', { detail: { path }, bubbles: true, composed: true })
);
}
render() {
return html`
<nav>
<div class="brand"><img src="/logo.svg" alt="Flight Score Logo" /> FlightScore</div>
<div class="links">
<ui-link href="/">Home</ui-link>
<ui-link href="/competitions">Competitions</ui-link>
<ui-link href="/login">Logout</ui-link>
<button @click=${this.toggleTheme}>
${this.theme === 'light' ? '🌙 Dark' : '☀️ Light'}
</button>
</div>
</nav>
`;
}
}
-291
View File
@@ -1,291 +0,0 @@
import { LitElement, html, css, svg } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
export interface CircleSegment {
label: string;
value: number;
color: string;
}
@customElement('circle-chart')
export class CircleChart extends LitElement {
static styles = css`
:host {
display: block;
}
.chart-wrapper {
background: var(--color-bg-nav);
border: 1px solid var(--color-border);
border-radius: 0.75rem;
padding: 1.75rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
transition: border-color 0.25s ease;
}
.chart-wrapper:hover {
border-color: color-mix(
in srgb,
var(--color-accent) 40%,
transparent
);
}
.title {
font-size: 1rem;
font-weight: 650;
color: var(--color-text);
margin: 0;
}
.chart-body {
display: flex;
align-items: center;
gap: 2rem;
flex-wrap: wrap;
justify-content: center;
}
.svg-container {
position: relative;
width: 160px;
height: 160px;
flex-shrink: 0;
}
.center-label {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
pointer-events: none;
}
.center-value {
font-size: 1.5rem;
font-weight: 800;
letter-spacing: -0.03em;
color: var(--color-text);
}
.center-text {
font-size: 0.72rem;
font-weight: 500;
color: color-mix(in srgb, var(--color-text) 45%, transparent);
text-transform: uppercase;
letter-spacing: 0.04em;
}
svg {
transform: rotate(-90deg);
width: 100%;
height: 100%;
}
circle {
transition:
stroke-dasharray 0.6s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.25s ease;
cursor: pointer;
}
circle:hover {
opacity: 0.8;
filter: brightness(1.15);
}
.tooltip {
position: absolute;
pointer-events: none;
background: var(--color-bg-nav, #1a1a2e);
border: 1px solid var(--color-border, #333);
border-radius: 0.5rem;
padding: 0.35rem 0.65rem;
display: flex;
align-items: center;
gap: 0.4rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
transform: translate(-50%, -120%);
z-index: 10;
white-space: nowrap;
opacity: 0;
transition: opacity 0.15s ease;
}
.tooltip.visible {
opacity: 1;
}
.tooltip-dot {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
flex-shrink: 0;
}
.tooltip-label {
font-size: 0.75rem;
color: color-mix(in srgb, var(--color-text) 65%, transparent);
}
.tooltip-value {
font-size: 0.75rem;
font-weight: 700;
color: var(--color-text);
}
.legend {
display: flex;
flex-direction: column;
gap: 0.6rem;
min-width: 120px;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.55rem;
cursor: default;
}
.legend-dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
flex-shrink: 0;
}
.legend-label {
font-size: 0.82rem;
color: color-mix(in srgb, var(--color-text) 65%, transparent);
flex: 1;
}
.legend-value {
font-size: 0.82rem;
font-weight: 700;
color: var(--color-text);
font-variant-numeric: tabular-nums;
}
`;
@property() heading = '';
@property() centerText = 'Total';
@property({ type: Array }) segments: CircleSegment[] = [];
@state() private _hoveredIndex = -1;
@state() private _tooltipX = 0;
@state() private _tooltipY = 0;
private _onArcEnter(e: MouseEvent, index: number) {
this._hoveredIndex = index;
this._updateTooltipPos(e);
}
private _onArcMove(e: MouseEvent) {
if (this._hoveredIndex < 0) return;
this._updateTooltipPos(e);
}
private _onArcLeave() {
this._hoveredIndex = -1;
}
private _updateTooltipPos(e: MouseEvent) {
const container = this.shadowRoot!.querySelector(
'.svg-container'
) as HTMLElement;
if (!container) return;
const rect = container.getBoundingClientRect();
this._tooltipX = e.clientX - rect.left;
this._tooltipY = e.clientY - rect.top;
}
render() {
const total = this.segments.reduce((s, d) => s + d.value, 0);
const radius = 62;
const circumference = 2 * Math.PI * radius;
const gapAngle = 0.02;
const totalGap = gapAngle * this.segments.length;
const usable = Math.max(0, circumference * (1 - totalGap));
const gapSize = circumference * gapAngle;
let offset = 0;
const arcs = this.segments.map((seg, i) => {
const pct = total > 0 ? seg.value / total : 0;
const dash = pct * usable;
const gap = circumference - dash;
const currentOffset = offset;
offset += dash + gapSize;
return svg`
<circle
cx="80" cy="80" r="${radius}"
fill="none"
stroke="${seg.color}"
stroke-width="16"
stroke-dasharray="${dash} ${gap}"
stroke-dashoffset="${-currentOffset}"
@mouseenter=${(e: MouseEvent) => this._onArcEnter(e, i)}
@mousemove=${(e: MouseEvent) => this._onArcMove(e)}
@mouseleave=${() => this._onArcLeave()}
/>
`;
});
const hovered =
this._hoveredIndex >= 0
? this.segments[this._hoveredIndex]
: null;
return html`
<div class="chart-wrapper">
${this.heading
? html`<h3 class="title">${this.heading}</h3>`
: null}
<div class="chart-body">
<div class="svg-container">
<svg viewBox="0 0 160 160">${arcs}</svg>
<div class="center-label">
<span class="center-value">${total}</span>
<span class="center-text">${this.centerText}</span>
</div>
<div
class="tooltip ${hovered ? 'visible' : ''}"
style="left:${this._tooltipX}px;top:${this._tooltipY}px"
>
${hovered
? html`
<span
class="tooltip-dot"
style="background:${hovered.color}"
></span>
<span class="tooltip-label">${hovered.label}</span>
<span class="tooltip-value">${hovered.value}</span>
`
: null}
</div>
</div>
<div class="legend">
${this.segments.map(
(seg) => html`
<div class="legend-item">
<span
class="legend-dot"
style="background:${seg.color}"
></span>
<span class="legend-label">${seg.label}</span>
<span class="legend-value">${seg.value}</span>
</div>
`
)}
</div>
</div>
</div>
`;
}
}
-70
View File
@@ -1,70 +0,0 @@
import { LitElement, html, css } from 'lit';
import { customElement } from 'lit/decorators.js';
import './ui-link';
@customElement('footer-bar')
export class FooterBar extends LitElement {
static styles = css`
footer {
background: var(--color-bg-nav);
border-top: 1px solid var(--color-border);
color: var(--color-text);
display: flex;
justify-content: space-around;
align-items: center;
padding: 0.8rem 0;
font-size: 0.9rem;
flex-wrap: wrap;
backdrop-filter: blur(10px);
position: relative;
width: 100%;
}
a {
color: var(--color-text);
text-decoration: none;
margin-left: 1.2rem;
font-weight: 500;
}
a:hover {
color: var(--color-accent);
}
.center {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 1rem;
}
@media (max-width: 600px) {
footer {
flex-direction: column;
gap: 0.6rem;
text-align: center;
}
a {
margin: 0;
}
}
`;
render() {
const year = new Date().getFullYear();
return html`
<footer>
<div class="center">
<ui-link href="/privacy">Privacy</ui-link>
<ui-link href="/imprint">Imprint</ui-link>
<ui-link href="/contact">Contact</ui-link>
</div>
<div class="right">
<span>© ${year} Jan Meinl</span>
</div>
</footer>
`;
}
}
-73
View File
@@ -1,73 +0,0 @@
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
@customElement('form-input')
export class FormInput extends LitElement {
static styles = css`
:host {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
label {
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.02em;
color: color-mix(in srgb, var(--color-text) 70%, transparent);
text-transform: uppercase;
}
input {
border: 1px solid var(--color-border);
border-radius: 0.5rem;
padding: 0.65rem 0.75rem;
font-size: 0.95rem;
background: var(--color-bg);
color: var(--color-text);
outline: none;
transition:
border-color 0.25s ease,
box-shadow 0.25s ease;
}
input::placeholder {
color: color-mix(in srgb, var(--color-text) 35%, transparent);
}
input:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--color-accent) 15%, transparent);
}
`;
@property() label = '';
@property() type = 'text';
@property() placeholder = '';
@property() value = '';
private handleInput(e: InputEvent) {
const target = e.target as HTMLInputElement;
this.value = target.value;
this.dispatchEvent(
new CustomEvent('value-changed', {
detail: { value: target.value },
bubbles: true,
composed: true,
})
);
}
render() {
return html`
<label>${this.label}</label>
<input
type=${this.type}
placeholder=${this.placeholder}
.value=${this.value}
@input=${this.handleInput}
/>
`;
}
}
+50
View File
@@ -0,0 +1,50 @@
import { LitElement, html, css, type SVGTemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
@customElement("hero-icon")
export class HeroIcon extends LitElement {
@property({ type: String })
name = "";
@state()
private iconTemplate: SVGTemplateResult | null = null;
static styles = css`
:host {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
}
svg {
width: 100%;
height: 100%;
fill: var(--icon-fill, none);
stroke: var(--icon-stroke, currentColor);
stroke-width: var(--icon-stroke-width, 1.5);
}
`;
updated(changedProperties: Map<string, any>) {
if (changedProperties.has("name")) {
this.loadIcon();
}
}
async loadIcon() {
try {
const module = await import(`./icons/${this.name}.ts`);
this.iconTemplate = module.icon;
} catch (e) {
this.iconTemplate = null;
}
}
render() {
return html`
${this.iconTemplate}
`;
}
}
-19
View File
@@ -1,19 +0,0 @@
import { LitElement, html, css } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('horizontal-divider')
export class FormDivider extends LitElement {
static styles = css`
:host {
display: block;
width: 100%;
height: 1px;
background: var(--color-border);
margin: 0.25rem 0;
}
`;
render() {
return html``;
}
}
-79
View File
@@ -1,79 +0,0 @@
// components/icon-card.ts
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
@customElement('icon-card')
export class IconCard extends LitElement {
static styles = css`
:host {
display: block;
}
.card {
background: var(--color-bg-nav);
border: 1px solid var(--color-border);
border-radius: 0.75rem;
padding: 1.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
transition: border-color 0.25s ease;
}
.card:hover {
border-color: color-mix(
in srgb,
var(--color-accent) 40%,
transparent
);
}
.icon {
width: 2.25rem;
height: 2.25rem;
display: grid;
place-items: center;
border-radius: 0.5rem;
background: color-mix(
in srgb,
var(--color-accent) 10%,
transparent
);
color: var(--color-accent);
}
::slotted(svg) {
width: 1.15rem;
height: 1.15rem;
}
h3 {
margin: 0;
font-size: 1rem;
font-weight: 650;
color: var(--color-text);
}
p {
margin: 0;
font-size: 0.875rem;
line-height: 1.6;
color: color-mix(in srgb, var(--color-text) 55%, transparent);
}
`;
@property() heading = '';
@property() description = '';
render() {
return html`
<div class="card">
<div class="icon">
<slot></slot>
</div>
<h3>${this.heading}</h3>
<p>${this.description}</p>
</div>
`;
}
}
-35
View File
@@ -1,35 +0,0 @@
import { html, type TemplateResult } from "lit";
export class Icons {
public static icons: Record<string, TemplateResult> = {
calendar: html`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z" /></svg>`,
globe: html`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" /></svg>`,
trophy: html`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 18.75h-9m9 0a3 3 0 0 1 3 3h-15a3 3 0 0 1 3-3m9 0v-3.375c0-.621-.503-1.125-1.125-1.125h-.871M7.5 18.75v-3.375c0-.621.504-1.125 1.125-1.125h.872m5.007 0H9.497m5.007 0a7.454 7.454 0 0 1-.982-3.172M9.497 14.25a7.454 7.454 0 0 0 .981-3.172M5.25 4.236c-.982.143-1.954.317-2.916.52A6.003 6.003 0 0 0 7.73 9.728M5.25 4.236V4.5c0 2.108.966 3.99 2.48 5.228M5.25 4.236V2.721C7.456 2.41 9.71 2.25 12 2.25c2.291 0 4.545.16 6.75.47v1.516M7.73 9.728a6.726 6.726 0 0 0 2.748 1.35m8.272-6.842V4.5c0 2.108-.966 3.99-2.48 5.228m2.48-5.492a46.32 46.32 0 0 1 2.916.52 6.003 6.003 0 0 1-5.395 4.972m0 0a6.726 6.726 0 0 1-2.749 1.35m0 0a6.772 6.772 0 0 1-3.044 0" /></svg>`,
target: html`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M6 6.878V6a2.25 2.25 0 0 1 2.25-2.25h7.5A2.25 2.25 0 0 1 18 6v.878m-12 0c.235-.083.487-.128.75-.128h10.5c.263 0 .515.045.75.128m-12 0A2.25 2.25 0 0 0 4.5 9v.878m13.5-3A2.25 2.25 0 0 1 19.5 9v.878m0 0a2.246 2.246 0 0 0-.75-.128H5.25c-.263 0-.515.045-.75.128m15 0A2.25 2.25 0 0 1 21 12v6a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 18v-6c0-.98.626-1.813 1.5-2.122" /></svg>`,
clipboard: html`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" /></svg>`,
users: html`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" /></svg>`,
user: html`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" /></svg>`,
home: html`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" /></svg>`,
mail: html`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" /></svg>`,
desktop: html`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 15V5.25m18 0A2.25 2.25 0 0 0 18.75 3H5.25A2.25 2.25 0 0 0 3 5.25m18 0V12a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 12V5.25" /></svg>`,
tools: html`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17 17.25 21A2.652 2.652 0 0 0 21 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 1 1-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 0 0 4.486-6.336l-3.276 3.277a3.004 3.004 0 0 1-2.25-2.25l3.276-3.276a4.5 4.5 0 0 0-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437 1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008Z" /></svg>`,
map: html`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M9 6.75V15m6-6v8.25m.503 3.498 4.875-2.437c.381-.19.622-.58.622-1.006V4.82c0-.836-.88-1.38-1.628-1.006l-3.869 1.934c-.317.159-.69.159-1.006 0L9.503 3.252a1.125 1.125 0 0 0-1.006 0L3.622 5.689C3.24 5.88 3 6.27 3 6.695V19.18c0 .836.88 1.38 1.628 1.006l3.869-1.934c.317-.159.69-.159 1.006 0l4.994 2.497c.317.158.69.158 1.006 0Z" /></svg>`,
map_pin: html`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0Z" /></svg>`,
clock: html`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>`,
table: html`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h7.5c.621 0 1.125-.504 1.125-1.125m-9.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-7.5A1.125 1.125 0 0 1 12 18.375m9.75-12.75c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125m19.5 0v1.5c0 .621-.504 1.125-1.125 1.125M2.25 5.625v1.5c0 .621.504 1.125 1.125 1.125m0 0h17.25m-17.25 0h7.5c.621 0 1.125.504 1.125 1.125M3.375 8.25c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m17.25-3.75h-7.5c-.621 0-1.125.504-1.125 1.125m8.625-1.125c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M12 10.875v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125M13.125 12h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125M20.625 12c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5M12 14.625v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 14.625c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m0 1.5v-1.5m0 0c0-.621.504-1.125 1.125-1.125m0 0h7.5" /></svg>`,
calculator: html`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 15.75V18m-7.5-6.75h.008v.008H8.25v-.008Zm0 2.25h.008v.008H8.25V13.5Zm0 2.25h.008v.008H8.25v-.008Zm0 2.25h.008v.008H8.25V18Zm2.498-6.75h.007v.008h-.007v-.008Zm0 2.25h.007v.008h-.007V13.5Zm0 2.25h.007v.008h-.007v-.008Zm0 2.25h.007v.008h-.007V18Zm2.504-6.75h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V13.5Zm0 2.25h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V18Zm2.498-6.75h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V13.5ZM8.25 6h7.5v2.25h-7.5V6ZM12 2.25c-1.892 0-3.758.11-5.593.322C5.307 2.7 4.5 3.65 4.5 4.757V19.5a2.25 2.25 0 0 0 2.25 2.25h10.5a2.25 2.25 0 0 0 2.25-2.25V4.757c0-1.108-.806-2.057-1.907-2.185A48.507 48.507 0 0 0 12 2.25Z" /></svg>`,
chart_pie: html`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6a7.5 7.5 0 1 0 7.5 7.5h-7.5V6Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 10.5H21A7.5 7.5 0 0 0 13.5 3v7.5Z" /></svg>`,
gear: html`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 0 1 1.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.559.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.894.149c-.424.07-.764.383-.929.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 0 1-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.398.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 0 1-.12-1.45l.527-.737c.25-.35.272-.806.108-1.204-.165-.397-.506-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.108-1.204l-.526-.738a1.125 1.125 0 0 1 .12-1.45l.773-.773a1.125 1.125 0 0 1 1.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>`,
document: html`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" /></svg>`,
document_text: html`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" /></svg>`,
bin: html`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /></svg>`,
stack: html`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M6.429 9.75 2.25 12l4.179 2.25m0-4.5 5.571 3 5.571-3m-11.142 0L2.25 7.5 12 2.25l9.75 5.25-4.179 2.25m0 0L21.75 12l-4.179 2.25m0 0 4.179 2.25L12 21.75 2.25 16.5l4.179-2.25m11.142 0-5.571 3-5.571-3" /></svg>`,
visible: html`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>`,
hide: html`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" /></svg>`,
folder: html`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 0 0-1.883 2.542l.857 6a2.25 2.25 0 0 0 2.227 1.932H19.05a2.25 2.25 0 0 0 2.227-1.932l.857-6a2.25 2.25 0 0 0-1.883-2.542m-16.5 0V6A2.25 2.25 0 0 1 6 3.75h3.879a1.5 1.5 0 0 1 1.06.44l2.122 2.12a1.5 1.5 0 0 0 1.06.44H18A2.25 2.25 0 0 1 20.25 9v.776" /></svg>`,
warning: html`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" /></svg>`,
info: html`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" /></svg>`,
error: html`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>`,
success: html`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>`,
};
}
+6
View File
@@ -0,0 +1,6 @@
import { svg } from "lit";
export const icon = svg`
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 8.25 21 12m0 0-3.75 3.75M21 12H3" />
</svg>
`;
+6
View File
@@ -0,0 +1,6 @@
import { svg } from "lit";
export const icon = svg`
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
</svg>
`;
+6
View File
@@ -0,0 +1,6 @@
import { svg } from "lit";
export const icon = svg`
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
`;
+70
View File
@@ -0,0 +1,70 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
@customElement("text-input")
export class TextInput extends LitElement {
@property({ type: String }) value = "";
@property({ type: String }) placeholder = "";
@property({ type: String }) name = "";
@property({ type: String }) autocomplete = "";
@property({ type: Boolean }) password = false;
@property({ type: Boolean }) disabled = false;
@property({ attribute: false }) onValueChange?: (value: string) => void;
static styles = css`
:host {
display: block;
}
input {
display: block;
width: 100%;
box-sizing: border-box;
padding: 0.625rem 0.875rem;
background-color: var(--color-bg, #fff);
color: var(--color-text, #111);
border: 1.5px solid var(--color-border-strong, #aaa);
border-radius: 0.375rem;
font-family: "Inter", system-ui, sans-serif;
font-size: var(--font-size-base, 0.9375rem);
font-weight: var(--font-weight-normal, 400);
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
input::placeholder {
color: var(--color-text-muted, #999);
}
input:focus {
border-color: var(--color-accent, #2b6cb0);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent, #2b6cb0) 20%, transparent);
}
input:disabled {
opacity: 0.45;
cursor: not-allowed;
}
`;
private _handleInput(e: Event) {
const input = e.target as HTMLInputElement;
this.value = input.value;
this.onValueChange?.(this.value);
}
render() {
return html`
<input
.value=${this.value}
type=${this.password ? "password" : "text"}
placeholder=${this.placeholder}
name=${ifDefined(this.name || undefined)}
autocomplete=${ifDefined(this.autocomplete || undefined)}
?disabled=${this.disabled}
@input=${this._handleInput}
/>
`;
}
}
-557
View File
@@ -1,557 +0,0 @@
import { LitElement, html, css, svg, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
export interface LineSeries {
label: string;
color?: string;
points: { x: number; y: number }[];
}
interface SnapPoint {
label: string;
color: string;
x: number;
y: number;
screenX: number;
screenY: number;
}
interface HoverState {
snapX: number;
pctX: number;
pctY: number;
points: SnapPoint[];
}
@customElement('line-chart')
export class LineChart extends LitElement {
static styles = css`
:host {
display: block;
width: 100%;
}
.chart-wrapper {
background: var(--color-bg-nav);
border: 1px solid var(--color-border);
border-radius: 0.75rem;
padding: 1.75rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
transition: border-color 0.25s ease;
height: 100%;
box-sizing: border-box;
}
.chart-wrapper:hover {
border-color: color-mix(in srgb, var(--color-accent) 40%, transparent);
}
.header {
display: flex;
justify-content: space-between;
align-items: baseline;
flex-shrink: 0;
}
.title {
font-size: 1rem;
font-weight: 650;
color: var(--color-text);
margin: 0;
}
.subtitle {
font-size: 0.78rem;
color: color-mix(in srgb, var(--color-text) 45%, transparent);
margin: 0;
}
.svg-container {
width: 100%;
min-height: 0;
flex: 1 1 auto;
aspect-ratio: 500 / 300;
overflow: visible;
position: relative;
}
:host([style*='height']) .svg-container {
aspect-ratio: unset;
}
svg {
width: 100%;
height: 100%;
overflow: visible;
display: block;
}
.grid-line {
stroke: var(--color-border);
stroke-width: 0.5;
}
.axis-label {
font-size: 9px;
fill: color-mix(in srgb, var(--color-text) 40%, transparent);
font-weight: 500;
font-family: inherit;
}
.line-path {
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
transition: opacity 0.25s ease;
}
.area-path {
opacity: 0.08;
transition: opacity 0.25s ease;
}
.dot-indicator {
pointer-events: none;
}
.crosshair {
stroke-dasharray: 3 3;
pointer-events: none;
}
.overlay {
fill: transparent;
cursor: crosshair;
}
.tooltip {
position: absolute;
pointer-events: auto;
background: var(--color-bg-nav);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
padding: 0.5rem 0.65rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 120px;
z-index: 10;
}
.tooltip-header {
font-size: 0.7rem;
font-weight: 700;
color: var(--color-text);
padding-bottom: 0.25rem;
border-bottom: 1px solid var(--color-border);
margin-bottom: 0.15rem;
}
.tooltip-scroll {
display: flex;
flex-direction: column;
gap: 0.2rem;
max-height: 130px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--color-text) 20%, transparent)
transparent;
}
.tooltip-row {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.72rem;
white-space: nowrap;
}
.tooltip-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.tooltip-label {
color: color-mix(in srgb, var(--color-text) 65%, transparent);
font-weight: 500;
}
.tooltip-value {
margin-left: auto;
font-weight: 700;
color: var(--color-text);
}
.legend {
display: flex;
gap: 1rem;
flex-wrap: wrap;
flex-shrink: 0;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.4rem;
}
.legend-line {
width: 1rem;
height: 2px;
border-radius: 1px;
flex-shrink: 0;
}
.legend-label {
font-size: 0.78rem;
color: color-mix(in srgb, var(--color-text) 60%, transparent);
font-weight: 500;
}
`;
@property() heading = '';
@property() subtitle = '';
@property() xLabel = 'X';
@property() yLabel = 'Y';
@property({ type: Array }) series: LineSeries[] = [];
@property({ type: Boolean }) showArea = false;
@state() private _hover: HoverState | null = null;
private defaultColors = [
'var(--color-accent)',
'#30a46c',
'#e79d13',
'#e5484d',
'#6e56cf',
'#0091ff',
];
private padding = { top: 20, right: 25, bottom: 40, left: 45 };
private width = 500;
private height = 300;
private niceScale(min: number, max: number, ticks: number) {
const range = max - min || 1;
const rough = range / ticks;
const mag = Math.pow(10, Math.floor(Math.log10(rough)));
const norm = rough / mag;
let step: number;
if (norm <= 1.5) step = 1 * mag;
else if (norm <= 3) step = 2 * mag;
else if (norm <= 7) step = 5 * mag;
else step = 10 * mag;
const nMin = Math.floor(min / step) * step;
const nMax = Math.ceil(max / step) * step;
return { min: nMin, max: nMax, step };
}
private _formatVal(v: number): string {
return Number.isInteger(v) ? v.toString() : v.toFixed(2);
}
private _onMouseMove(
e: MouseEvent,
sx: (v: number) => number,
sy: (v: number) => number,
xScale: { min: number; max: number }
) {
const svgEl = this.renderRoot.querySelector('svg');
if (!svgEl) return;
const rect = svgEl.getBoundingClientRect();
const ratioX = this.width / rect.width;
const ratioY = this.height / rect.height;
const mouseVpX = (e.clientX - rect.left) * ratioX;
const mouseVpY = (e.clientY - rect.top) * ratioY;
const p = this.padding;
const w = this.width - p.left - p.right;
const xRange = xScale.max - xScale.min || 1;
const dataX = xScale.min + ((mouseVpX - p.left) / w) * xRange;
let closestX = Infinity;
let closestDist = Infinity;
for (const s of this.series) {
for (const pt of s.points) {
const dist = Math.abs(pt.x - dataX);
if (dist < closestDist) {
closestDist = dist;
closestX = pt.x;
}
}
}
if (!isFinite(closestX)) {
this._hover = null;
return;
}
const points: SnapPoint[] = [];
this.series.forEach((s, i) => {
const color =
s.color || this.defaultColors[i % this.defaultColors.length];
const sorted = [...s.points].sort((a, b) => a.x - b.x);
const exact = sorted.find((pt) => pt.x === closestX);
if (exact) {
points.push({
label: s.label,
color,
x: exact.x,
y: exact.y,
screenX: sx(exact.x),
screenY: sy(exact.y),
});
return;
}
let left = null;
let right = null;
for (let j = 0; j < sorted.length; j++) {
if (sorted[j].x <= closestX) left = sorted[j];
if (sorted[j].x >= closestX && !right) right = sorted[j];
}
if (left && right && left !== right) {
const t = (closestX - left.x) / (right.x - left.x);
const interpY = left.y + t * (right.y - left.y);
points.push({
label: s.label,
color,
x: closestX,
y: interpY,
screenX: sx(closestX),
screenY: sy(interpY),
});
}
});
if (!points.length) {
this._hover = null;
return;
}
this._hover = {
snapX: sx(closestX),
pctX: (sx(closestX) / this.width) * 100,
pctY: (mouseVpY / this.height) * 100,
points,
};
}
private _onMouseLeave() {
this._hover = null;
}
render() {
const p = this.padding;
const w = this.width - p.left - p.right;
const h = this.height - p.top - p.bottom;
const allPts = this.series.flatMap((s) => s.points);
const xs = allPts.map((d) => d.x);
const ys = allPts.map((d) => d.y);
const rawXMin = Math.min(...xs, 0);
const rawXMax = Math.max(...xs, 1);
const rawYMin = Math.min(...ys, 0);
const rawYMax = Math.max(...ys, 1);
const xScale = this.niceScale(rawXMin, rawXMax, 5);
const yScale = this.niceScale(rawYMin, rawYMax, 5);
const sx = (v: number) =>
p.left + ((v - xScale.min) / (xScale.max - xScale.min)) * w;
const sy = (v: number) =>
p.top + h - ((v - yScale.min) / (yScale.max - yScale.min)) * h;
const gridLines = [];
for (
let val = yScale.min;
val <= yScale.max + yScale.step * 0.01;
val += yScale.step
) {
const y = sy(val);
gridLines.push(svg`
<line class="grid-line" x1="${p.left}" y1="${y}" x2="${this.width - p.right}" y2="${y}" />
<text class="axis-label" x="${p.left - 8}" y="${y + 3}" text-anchor="end">${Math.round(val)}</text>
`);
}
for (
let val = xScale.min;
val <= xScale.max + xScale.step * 0.01;
val += xScale.step
) {
const x = sx(val);
gridLines.push(svg`
<text class="axis-label" x="${x}" y="${this.height - p.bottom + 18}" text-anchor="middle">${Math.round(val)}</text>
`);
}
const baseLine = sy(yScale.min);
const lines = this.series.map((s, i) => {
const color =
s.color || this.defaultColors[i % this.defaultColors.length];
const sorted = [...s.points].sort((a, b) => a.x - b.x);
if (!sorted.length) return svg``;
const d = sorted
.map(
(pt, j) => `${j === 0 ? 'M' : 'L'}${sx(pt.x)},${sy(pt.y)}`
)
.join(' ');
const areaD =
`M${sx(sorted[0].x)},${baseLine} ` +
sorted.map((pt) => `L${sx(pt.x)},${sy(pt.y)}`).join(' ') +
` L${sx(sorted[sorted.length - 1].x)},${baseLine} Z`;
return svg`
${this.showArea ? svg`<path class="area-path" d="${areaD}" fill="${color}" />` : null}
<path class="line-path" d="${d}" stroke="${color}" />
`;
});
const hoverOverlay = svg`
<rect
class="overlay"
x="${p.left}" y="${p.top}"
width="${w}" height="${h}"
@mousemove=${(e: MouseEvent) =>
this._onMouseMove(e, sx, sy, xScale)}
/>
`;
const hoverMarkers = this._hover
? svg`
<line
class="crosshair"
x1="${this._hover.snapX}" y1="${p.top}"
x2="${this._hover.snapX}" y2="${p.top + h}"
stroke="var(--color-text)" stroke-opacity="0.15"
/>
${this._hover.points.map(
(pt) => svg`
<circle
class="dot-indicator"
cx="${pt.screenX}" cy="${pt.screenY}"
r="4.5"
fill="${pt.color}"
stroke="var(--color-bg-nav)"
stroke-width="2"
/>
`
)}
`
: nothing;
const tooltipHtml = this._hover
? (() => {
const hv = this._hover;
const onRight = hv.pctX <= 60;
const tooltipY = Math.max(0, Math.min(hv.pctY - 10, 70));
const posStyle = onRight
? `left: calc(${hv.pctX}% + 16px);`
: `right: calc(${100 - hv.pctX}% + 16px);`;
return html`
<div class="tooltip" style="${posStyle} top: ${tooltipY}%;">
<div class="tooltip-header">
${this.xLabel}: ${this._formatVal(hv.points[0].x)}
</div>
<div class="tooltip-scroll">
${hv.points.map(
(pt) => html`
<div class="tooltip-row">
<span
class="tooltip-dot"
style="background:${pt.color}"
></span>
<span class="tooltip-label">${pt.label}</span>
<span class="tooltip-value">
${this._formatVal(pt.y)}
</span>
</div>
`
)}
</div>
</div>
`;
})()
: nothing;
return html`
<div class="chart-wrapper">
${this.heading || this.subtitle
? html`
<div class="header">
${this.heading
? html`<h3 class="title">${this.heading}</h3>`
: nothing}
${this.subtitle
? html`<p class="subtitle">${this.subtitle}</p>`
: nothing}
</div>
`
: nothing}
<div
class="svg-container"
@mouseleave=${() => this._onMouseLeave()}
>
<svg
viewBox="0 0 ${this.width} ${this.height}"
preserveAspectRatio="none"
>
${gridLines} ${lines} ${hoverMarkers} ${hoverOverlay}
<text
class="axis-label"
x="${this.width / 2}"
y="${this.height - 4}"
text-anchor="middle"
>
${this.xLabel}
</text>
<text
class="axis-label"
x="12"
y="${this.height / 2}"
text-anchor="middle"
transform="rotate(-90, 12, ${this.height / 2})"
>
${this.yLabel}
</text>
</svg>
${tooltipHtml}
</div>
${this.series.length > 1
? html`
<div class="legend">
${this.series.map(
(s, i) => html`
<div class="legend-item">
<span
class="legend-line"
style="background: ${s.color ||
this.defaultColors[i % this.defaultColors.length]}"
></span>
<span class="legend-label">${s.label}</span>
</div>
`
)}
</div>
`
: nothing}
</div>
`;
}
}
-139
View File
@@ -1,139 +0,0 @@
// components/loading-bar.ts
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
@customElement('loading-bar')
export class LoadingBar extends LitElement {
static styles = css`
:host {
display: block;
width: 100%;
}
.wrapper {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.meta {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.label {
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.02em;
color: color-mix(in srgb, var(--color-text) 70%, transparent);
text-transform: uppercase;
}
.value {
font-size: 0.85rem;
font-weight: 700;
color: var(--color-text);
font-variant-numeric: tabular-nums;
}
.track {
width: 100%;
height: 0.5rem;
background: color-mix(in srgb, var(--color-text) 8%, transparent);
border-radius: 1rem;
overflow: hidden;
position: relative;
}
:host([size='sm']) .track {
height: 0.3rem;
}
:host([size='lg']) .track {
height: 0.75rem;
}
.fill {
height: 100%;
border-radius: 1rem;
background: var(--color-accent);
transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1);
position: relative;
}
.fill.success {
background: #30a46c;
}
.fill.warning {
background: #e79d13;
}
.fill.error {
background: #e5484d;
}
:host([indeterminate]) .fill {
width: 40% !important;
animation: indeterminate 1.4s ease-in-out infinite;
}
@keyframes indeterminate {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(350%);
}
}
.fill::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.15) 50%,
transparent 100%
);
border-radius: inherit;
}
`;
@property({ type: Number }) value = 0;
@property() label = '';
@property() variant: 'accent' | 'success' | 'warning' | 'error' =
'accent';
@property({ type: Boolean, reflect: true }) indeterminate = false;
@property({ type: Boolean }) hideValue = false;
@property({ reflect: true }) size: 'sm' | 'md' | 'lg' = 'md';
render() {
const clamped = Math.max(0, Math.min(100, this.value));
return html`
<div class="wrapper">
${this.label || !this.hideValue
? html`
<div class="meta">
${this.label
? html`<span class="label">${this.label}</span>`
: html`<span></span>`}
${!this.hideValue && !this.indeterminate
? html`<span class="value">${clamped}%</span>`
: null}
</div>
`
: null}
<div class="track">
<div
class="fill ${this.variant}"
style="width: ${this.indeterminate ? 40 : clamped}%"
></div>
</div>
</div>
`;
}
}
-175
View File
@@ -1,175 +0,0 @@
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import './ui-link';
@customElement('nav-bar')
export class NavBar extends LitElement {
static styles = css`
nav {
backdrop-filter: blur(18px) saturate(180%);
-webkit-backdrop-filter: blur(18px) saturate(180%);
background: var(--color-bg-nav);
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 2rem;
height: 3.5rem;
position: sticky;
top: 0;
z-index: 100;
transition: background 0.3s ease;
}
.brand {
display: flex;
align-items: center;
gap: 0.65rem;
user-select: none;
cursor: default;
}
.brand img {
width: auto;
height: 1.6rem;
flex-shrink: 0;
}
.brand span {
font-weight: 700;
font-size: 1.05rem;
letter-spacing: -0.02em;
background: linear-gradient(
135deg,
var(--color-text) 0%,
var(--color-accent) 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.links {
display: flex;
gap: 1.75rem;
align-items: center;
}
.theme-toggle {
position: relative;
display: grid;
place-items: center;
width: 2.25rem;
height: 2.25rem;
padding: 0;
margin-left: 0.5rem;
background: transparent;
border: 1px solid var(--color-border);
border-radius: 0.5rem;
color: var(--color-text);
cursor: pointer;
transition:
border-color 0.25s ease,
color 0.25s ease,
background 0.25s ease,
transform 0.2s ease;
overflow: hidden;
}
.theme-toggle:hover {
border-color: var(--color-accent);
color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
transform: scale(1.05);
}
.theme-toggle:active {
transform: scale(0.95);
}
.icon {
position: absolute;
width: 1.1rem;
height: 1.1rem;
opacity: 0;
transform: rotate(-90deg) scale(0.6);
transition:
opacity 0.35s ease,
transform 0.35s ease;
pointer-events: none;
}
.icon.visible {
opacity: 1;
transform: rotate(0deg) scale(1);
}
`;
@state() theme: 'light' | 'dark' =
(localStorage.getItem('theme') as 'light' | 'dark') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light');
firstUpdated() {
document.documentElement.setAttribute('data-theme', this.theme);
}
toggleTheme() {
this.theme = this.theme === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', this.theme);
localStorage.setItem('theme', this.theme);
}
render() {
return html`
<nav>
<div class="brand">
<img src="/logo.svg" alt="Flight Score Logo" />
<span>FlightScore</span>
</div>
<div class="links">
<ui-link href="/">Home</ui-link>
<ui-link href="/competitions">Competitions</ui-link>
<ui-link href="/login">Login</ui-link>
<button
class="theme-toggle"
@click=${this.toggleTheme}
aria-label="Toggle theme"
>
<svg
class="icon ${this.theme === 'light' ? 'visible' : ''}"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 12.79A9 9 0 1111.21 3a7 7 0 009.79 9.79z" />
</svg>
<svg
class="icon ${this.theme === 'dark' ? 'visible' : ''}"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
</button>
</div>
</nav>
`;
}
}
-150
View File
@@ -1,150 +0,0 @@
import { LitElement, html, css } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Icons } from "./icons";
export type NotificationType = "success" | "warning" | "error" | "info";
@customElement("notify-bar")
export class NotifyBar extends LitElement {
static styles = css`
:host {
display: block;
}
:host([hidden]) {
display: none;
}
.bar {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.85rem;
font-size: 0.85rem;
font-weight: 500;
border-radius: 0.5rem;
border: 1px solid transparent;
animation: slideIn 0.25s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.icon {
flex-shrink: 0;
width: 1.15rem;
height: 1.15rem;
}
.content {
flex: 1;
line-height: 1.4;
}
.close {
flex-shrink: 0;
background: none;
border: none;
padding: 0.15rem;
cursor: pointer;
color: inherit;
opacity: 0.5;
transition: opacity 0.2s ease;
display: grid;
place-items: center;
}
.close:hover {
opacity: 1;
}
.close svg {
width: 0.9rem;
height: 0.9rem;
}
.bar.error {
color: #e5484d;
background: color-mix(in srgb, #e5484d 8%, transparent);
border-color: color-mix(in srgb, #e5484d 25%, transparent);
}
.bar.warning {
color: #e79d13;
background: color-mix(in srgb, #e79d13 8%, transparent);
border-color: color-mix(in srgb, #e79d13 25%, transparent);
}
.bar.success {
color: #30a46c;
background: color-mix(in srgb, #30a46c 8%, transparent);
border-color: color-mix(in srgb, #30a46c 25%, transparent);
}
.bar.info {
color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
border-color: color-mix(in srgb, var(--color-accent) 25%, transparent);
}
`;
@property() type: NotificationType = "error";
@property() message: string | null = null;
@property({ type: Boolean }) dismissible = true;
private dismiss() {
this.message = null;
this.dispatchEvent(
new CustomEvent("dismissed", { bubbles: true, composed: true }),
);
this.requestUpdate();
}
private renderIcon() {
if (this.type === "error") {
return Icons.icons.error;
}
if (this.type === "warning") {
return Icons.icons.warning;
}
if (this.type === "info") {
return Icons.icons.info;
}
return Icons.icons.success;
}
render() {
if (!this.message) return null;
return html`
<div class="bar ${this.type}">
<span class="icon">${this.renderIcon()}</span>
<span class="content">${this.message}</span>
${this.dismissible
? html`
<button class="close" @click=${this.dismiss} aria-label="Close">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
`
: null}
</div>
`;
}
}
-56
View File
@@ -1,56 +0,0 @@
// components/stat-card.ts
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
@customElement('stat-card')
export class StatCard extends LitElement {
static styles = css`
:host {
display: block;
}
.card {
background: var(--color-bg-nav);
border: 1px solid var(--color-border);
border-radius: 0.75rem;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
transition: border-color 0.25s ease;
}
.card:hover {
border-color: color-mix(
in srgb,
var(--color-accent) 40%,
transparent
);
}
.value {
font-size: 2rem;
font-weight: 800;
letter-spacing: -0.03em;
color: var(--color-accent);
}
.label {
font-size: 0.85rem;
color: color-mix(in srgb, var(--color-text) 55%, transparent);
font-weight: 500;
}
`;
@property() value = '';
@property() label = '';
render() {
return html`
<div class="card">
<span class="value">${this.value}</span>
<span class="label">${this.label}</span>
</div>
`;
}
}
-116
View File
@@ -1,116 +0,0 @@
// components/ui-badge.ts
import { LitElement, html, css } from "lit";
import { customElement, property, state } from "lit/decorators.js";
export type BadgeVariant = "accent" | "success" | "warning" | "error" | "muted";
@customElement("ui-badge")
export class UiBadge extends LitElement {
static styles = css`
:host {
display: inline-flex;
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
border-radius: 2rem;
border: 1px solid transparent;
white-space: nowrap;
line-height: 1;
}
.icon {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.icon ::slotted(*) {
display: inline-flex;
width: 0.85rem;
height: 0.85rem;
}
.icon ::slotted(svg),
.icon ::slotted(* > svg) {
width: 0.85rem;
height: 0.85rem;
}
.label {
display: inline-flex;
align-items: center;
padding-top: 0.05em;
}
.badge.white {
color: #ffffff;
background: color-mix(in srgb, #ffffff 10%, transparent);
border-color: color-mix(in srgb, #ffffff 25%, transparent);
}
.badge.accent {
color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
border-color: color-mix(in srgb, var(--color-accent) 25%, transparent);
}
.badge.success {
color: #30a46c;
background: color-mix(in srgb, #30a46c 10%, transparent);
border-color: color-mix(in srgb, #30a46c 25%, transparent);
}
.badge.warning {
color: #e79d13;
background: color-mix(in srgb, #e79d13 10%, transparent);
border-color: color-mix(in srgb, #e79d13 25%, transparent);
}
.badge.error {
color: #e5484d;
background: color-mix(in srgb, #e5484d 10%, transparent);
border-color: color-mix(in srgb, #e5484d 25%, transparent);
}
.badge.muted {
color: color-mix(in srgb, var(--color-text) 45%, transparent);
background: color-mix(in srgb, var(--color-text) 6%, transparent);
border-color: var(--color-border);
}
`;
@property() variant: BadgeVariant = "accent";
@state() private hasIcon = false;
private handleSlotChange(e: Event) {
const slot = e.target as HTMLSlotElement;
this.hasIcon = slot.assignedNodes().length > 0;
}
render() {
return html`
<span class="badge ${this.variant}">
${this.hasIcon
? html`<span class="icon">
<slot name="icon" @slotchange=${this.handleSlotChange}></slot>
</span>`
: html`<slot
name="icon"
@slotchange=${this.handleSlotChange}
style="display:none"
></slot>`}
<span class="label">
<slot></slot>
</span>
</span>
`;
}
}
-91
View File
@@ -1,91 +0,0 @@
// components/ui-button-secondary.ts
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
@customElement('ui-button-secondary')
export class UiButtonSecondary extends LitElement {
static styles = css`
:host {
display: inline-flex;
}
:host([full]) {
display: flex;
}
:host([full]) button {
width: 100%;
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
padding: 0.65rem 1.25rem;
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text);
background: transparent;
border: 1px solid var(--color-border);
border-radius: 0.5rem;
cursor: pointer;
transition:
border-color 0.25s ease,
color 0.25s ease,
background 0.25s ease,
transform 0.15s ease;
}
button:hover:not(:disabled) {
border-color: var(--color-accent);
color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 6%, transparent);
}
button:active:not(:disabled) {
transform: scale(0.98);
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.icon {
display: inline-flex;
align-items: center;
}
.icon:not(:has(*)) {
display: none;
}
.icon ::slotted(svg) {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
`;
@property({ type: Boolean }) disabled = false;
@property({ type: Boolean, reflect: true }) full = false;
@state() private hasIcon = false;
private handleSlotChange(e: Event) {
const slot = e.target as HTMLSlotElement;
this.hasIcon = slot.assignedNodes().length > 0;
}
render() {
return html`
<button ?disabled=${this.disabled}>
${this.hasIcon ? html`<span class="icon">
<slot name="icon" @slotchange=${this.handleSlotChange}></slot>
</span>` : html`<slot name="icon" @slotchange=${this.handleSlotChange} style="display:none"></slot>`}
<slot></slot>
</button>
`;
}
}
-90
View File
@@ -1,90 +0,0 @@
// components/ui-button.ts
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
@customElement('ui-button')
export class UiButton extends LitElement {
static styles = css`
:host {
display: inline-flex;
}
:host([full]) {
display: flex;
}
:host([full]) button {
width: 100%;
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
padding: 0.65rem 1.25rem;
font-size: 0.95rem;
font-weight: 600;
letter-spacing: 0.01em;
color: #fff;
background: var(--color-accent);
border: none;
border-radius: 0.5rem;
cursor: pointer;
transition:
background 0.25s ease,
box-shadow 0.25s ease,
transform 0.15s ease,
opacity 0.25s ease;
}
button:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-accent) 85%, black);
box-shadow: 0 4px 14px
color-mix(in srgb, var(--color-accent) 35%, transparent);
}
button:active:not(:disabled) {
transform: scale(0.98);
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.icon {
display: inline-flex;
align-items: center;
}
.icon:not(:has(*)) {
display: none;
}
.icon ::slotted(svg) {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
`;
@property({ type: Boolean }) disabled = false;
@property({ type: Boolean, reflect: true }) full = false;
@state() private hasIcon = false;
private handleSlotChange(e: Event) {
const slot = e.target as HTMLSlotElement;
this.hasIcon = slot.assignedNodes().length > 0;
}
render() {
return html`
<button ?disabled=${this.disabled}>
${this.hasIcon ? html`<span class="icon">
<slot name="icon" @slotchange=${this.handleSlotChange}></slot>
</span>` : html`<slot name="icon" @slotchange=${this.handleSlotChange} style="display:none"></slot>`}
<slot></slot>
</button>
`;
}
}
-43
View File
@@ -1,43 +0,0 @@
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
@customElement('ui-card')
export class UiCard extends LitElement {
static styles = css`
:host {
display: block;
width: 100%;
max-width: 420px;
}
.card {
width: 100%;
background: var(--color-bg-nav);
border: 1px solid var(--color-border);
border-radius: 1rem;
padding: 2.5rem 2rem 2rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.08),
0 12px 40px rgba(0, 0, 0, 0.12);
backdrop-filter: blur(12px);
}
:host([centered]) .card {
align-items: center;
text-align: center;
}
`;
@property({ type: Boolean, reflect: true }) centered = false;
render() {
return html`
<div class="card">
<slot></slot>
</div>
`;
}
}
-71
View File
@@ -1,71 +0,0 @@
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
@customElement('ui-link')
export class UiLink extends LitElement {
static styles = css`
a {
position: relative;
color: var(--color-text);
font-weight: 500;
font-size: 0.9rem;
letter-spacing: 0.01em;
text-decoration: none;
display: inline-block;
padding: 0.25rem 0;
transition: color 0.25s ease;
}
a::after {
content: "";
position: absolute;
left: 50%;
bottom: -1px;
width: 0;
height: 2px;
background: var(--color-accent);
border-radius: 1px;
transform: translateX(-50%);
transition: width 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
a:hover {
color: var(--color-accent);
}
a:hover::after,
a.active::after {
width: 100%;
}
a.active {
color: var(--color-accent);
}
`;
@property() href = '/';
@property({ type: Boolean }) active = false;
navigate(e: Event) {
e.preventDefault();
this.dispatchEvent(
new CustomEvent('nav', {
detail: { path: this.href },
bubbles: true,
composed: true,
})
);
}
render() {
return html`
<a
href=${this.href}
class=${this.active ? 'active' : ''}
@click=${this.navigate}
>
<slot></slot>
</a>
`;
}
}
-106
View File
@@ -1,106 +0,0 @@
// components/ui-tab-bar.ts
import { LitElement, html, css } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Icons } from "./icons";
export interface Tab {
id: string;
label: string;
icon?: string;
}
@customElement("ui-tab-bar")
export class UiTabBar extends LitElement {
static styles = css`
:host {
display: block;
background: var(--color-bg-nav);
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
z-index: 10;
}
.tabs {
max-width: 1200px;
margin: 0 auto;
display: flex;
gap: 0;
overflow-x: auto;
scrollbar-width: none;
}
.tabs::-webkit-scrollbar {
display: none;
}
.tab {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem 1.25rem;
font-size: 0.875rem;
font-weight: 500;
color: color-mix(in srgb, var(--color-text) 55%, transparent);
border: none;
background: none;
cursor: pointer;
white-space: nowrap;
border-bottom: 2px solid transparent;
transition: all 0.15s ease;
font-family: inherit;
}
.tab:hover {
color: var(--color-accent);
background: color-mix(
in srgb,
var(--color-accent) 8%,
transparent
);
}
.tab.active {
color: var(--color-accent);
border-bottom-color: var(--color-accent);
}
.tab svg {
width: 16px;
height: 16px;
}
`;
@property({ type: Array }) tabs: Tab[] = [];
@property() active = "";
private handleClick(id: string) {
this.dispatchEvent(
new CustomEvent("tab-change", {
detail: { tab: id },
bubbles: true,
composed: true,
})
);
}
render() {
return html`
<div class="tabs">
${this.tabs.map(
(t) => html`
<button
class="tab ${this.active === t.id ? "active" : ""}"
@click=${() => this.handleClick(t.id)}
>
${t.icon ? Icons.icons[t.icon] ?? html`` : html``}
${t.label}
</button>
`
)}
</div>
`;
}
}
+13
View File
@@ -0,0 +1,13 @@
export type TranslationKey =
| "login.eyebrow"
| "login.title"
| "login.subtitle"
| "login.email"
| "login.password"
| "login.remember"
| "login.forgot"
| "login.submit"
| "login.loading"
| "login.error.credentials"
| "login.error.failed"
| "login.error.network";
+29
View File
@@ -0,0 +1,29 @@
import type { ReactiveController, ReactiveControllerHost } from "lit";
import { locale, type TranslationKey } from "./locale.js";
export class LocaleController implements ReactiveController {
private _host: ReactiveControllerHost;
private _unsub?: () => void;
constructor(host: ReactiveControllerHost) {
this._host = host;
host.addController(this);
}
hostConnected() {
this._unsub = locale.subscribe(() => this._host.requestUpdate());
locale.init().then(() => this._host.requestUpdate());
}
hostDisconnected() {
this._unsub?.();
}
t(key: TranslationKey): string {
return locale.t(key);
}
get current() {
return locale.current;
}
}
+44
View File
@@ -0,0 +1,44 @@
import type { TranslationKey } from "./keys.js";
export type { TranslationKey };
export type Locale = "de" | "en";
type TranslationMap = Record<string, string>;
let _current: Locale = (localStorage.getItem("locale") as Locale) ?? "de";
const _cache = new Map<Locale, TranslationMap>();
const _listeners = new Set<() => void>();
async function _load(l: Locale): Promise<void> {
if (_cache.has(l)) return;
const res = await fetch(`/locales/${l}.json`);
if (!res.ok) throw new Error(`Failed to load locale "${l}"`);
_cache.set(l, await res.json());
}
export const locale = {
get current(): Locale {
return _current;
},
async init(): Promise<void> {
await _load(_current);
_listeners.forEach((fn) => fn());
},
async set(l: Locale): Promise<void> {
await _load(l);
_current = l;
localStorage.setItem("locale", l);
_listeners.forEach((fn) => fn());
},
t(key: TranslationKey): string {
return _cache.get(_current)?.[key] ?? key;
},
subscribe(fn: () => void): () => void {
_listeners.add(fn);
return () => _listeners.delete(fn);
},
};
+4 -2
View File
@@ -1,2 +1,4 @@
import './styles/theme.css';
import './app-root';
import "./styles/theme.css";
import { locale } from "./i18n/locale.js";
locale.init().then(() => import("./app-root.js"));
-12
View File
@@ -1,12 +0,0 @@
import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('cc-home-page')
export class CCHomePage extends LitElement {
render() {
return html`
<h1>Welcome to the Competition Center Homepage</h1>
<p>Analyze your tracks visually.</p>
`;
}
}
+237 -58
View File
@@ -1,39 +1,192 @@
import { LitElement, html, css } from "lit";
import { customElement, state } from "lit/decorators.js";
import { authService, type UserSession } from "../../services/auth.service";
import "../../components/auth-card";
import "../../components/form-input";
import "../../components/ui-button";
import "../../components/notify-bar";
import "../../components/horizontal-divider";
import "../../components/ui-link";
import { authService } from "../../services/auth.service.js";
import { LocaleController } from "../../i18n/locale-controller.js";
import "../../components/button/primary-button.js";
import "../../components/input/text-input.js";
@customElement("login-page")
export class LoginPage extends LitElement {
private _t = new LocaleController(this);
@state() private email = "";
@state() private password = "";
@state() private remember = false;
@state() private error: string | null = null;
@state() private loading = false;
static styles = css`
:host {
flex: 1;
display: flex;
}
.footer {
text-align: center;
font-size: 0.875rem;
.layout {
display: grid;
grid-template-columns: minmax(320px, 34rem) 1fr;
width: 100%;
min-height: 100%;
}
/* ── Left panel ──────────────────────────── */
.form-panel {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 3.5rem 4rem;
background: var(--color-bg, #f5f5f5);
}
.form-content {
max-width: 26rem;
}
.eyebrow {
margin: 0 0 1rem;
font-size: var(--font-size-xs, 0.75rem);
font-weight: var(--font-weight-medium, 500);
letter-spacing: 0.12em;
color: color-mix(in srgb, var(--color-text) 50%, transparent);
}
h1 {
margin: 0 0 0.75rem;
font-size: 1.75rem;
font-weight: var(--font-weight-bold, 700);
color: var(--color-text, #111);
line-height: 1.2;
}
.subtitle {
margin: 0 0 2.5rem;
font-size: var(--font-size-sm, 0.875rem);
color: color-mix(in srgb, var(--color-text) 60%, transparent);
line-height: 1.55;
}
form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.field-label {
font-size: var(--font-size-xs, 0.75rem);
font-weight: var(--font-weight-medium, 500);
letter-spacing: 0.08em;
color: color-mix(in srgb, var(--color-text) 65%, transparent);
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.remember {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: var(--font-size-sm, 0.875rem);
color: color-mix(in srgb, var(--color-text) 70%, transparent);
cursor: pointer;
user-select: none;
}
.remember input[type="checkbox"] {
width: 1rem;
height: 1rem;
cursor: pointer;
accent-color: var(--color-accent, #2b6cb0);
flex-shrink: 0;
}
.forgot {
font-size: var(--font-size-sm, 0.875rem);
color: var(--color-accent, #2b6cb0);
text-decoration: none;
white-space: nowrap;
flex-shrink: 0;
}
.forgot:hover {
text-decoration: underline;
}
.error {
font-size: var(--font-size-sm, 0.875rem);
color: #c53030;
margin: 0;
}
.footer {
margin-top: 2rem;
font-size: var(--font-size-xs, 0.75rem);
color: color-mix(in srgb, var(--color-text) 40%, transparent);
}
/* ── Right panel ─────────────────────────── */
.hero-panel {
position: relative;
background: var(--color-border, #ddd);
overflow: hidden;
}
.hero-panel img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
user-select: none;
-webkit-user-drag: none;
}
/* ── Responsive ──────────────────────────── */
@media (max-width: 34rem) {
.layout {
grid-template-columns: 1fr;
}
.hero-panel {
display: none;
}
.form-panel {
max-width: 34rem;
margin: 0 auto;
padding: 2.5rem 1.5rem;
}
.form-content {
width: 100%;
margin: 0 auto;
}
}
`;
@state() email = "";
@state() password = "";
@state() error: string | null = null;
@state() loading = false;
private _navigate(path: string) {
window.dispatchEvent(
new CustomEvent("nav", { detail: { path }, bubbles: true, composed: true }),
);
}
private async _handleSubmit(e: Event) {
e.preventDefault();
if (this.loading) return;
async handleLogin() {
this.error = null;
this.loading = true;
try {
//todo const response = await fetch('/api/auth/login', {
const response = await fetch("http://127.0.0.1:8080/auth/login", {
method: "POST",
credentials: "include",
@@ -44,8 +197,8 @@ export class LoginPage extends LitElement {
if (!response.ok) {
this.error =
response.status === 401
? "E-Mail oder Passwort falsch."
: "Login fehlgeschlagen.";
? this._t.t("login.error.credentials")
: this._t.t("login.error.failed");
return;
}
@@ -65,58 +218,84 @@ export class LoginPage extends LitElement {
);
window.dispatchEvent(new CustomEvent("auth-changed"));
const target = authService.isAdmin ? "/admin" : "/cc";
window.dispatchEvent(
new CustomEvent("nav", {
detail: { path: target },
bubbles: true,
composed: true,
}),
);
} catch (e) {
console.error("Fehler:", e);
this.error = "Netzwerkfehler. Bitte erneut versuchen.";
this._navigate(authService.isAdmin ? "/admin" : "/cc");
} catch {
this.error = this._t.t("login.error.network");
} finally {
this.loading = false;
}
}
render() {
const t = (k: Parameters<typeof this._t.t>[0]) => this._t.t(k);
return html`
<auth-card
heading="Welcome back"
subheading="Sign in to your FlightScore account"
>
<form-input
label="Email"
type="email"
placeholder="you@example.com"
.value=${this.email}
@value-changed=${(e: CustomEvent) => (this.email = e.detail.value)}
></form-input>
<div class="layout">
<div class="form-panel">
<div class="form-content">
<p class="eyebrow">${t("login.eyebrow")}</p>
<h1>${t("login.title")}</h1>
<p class="subtitle">${t("login.subtitle")}</p>
<form-input
label="Password"
type="password"
placeholder="Enter your password"
.value=${this.password}
@value-changed=${(e: CustomEvent) => (this.password = e.detail.value)}
></form-input>
<form @submit=${this._handleSubmit} novalidate>
<div class="field">
<span class="field-label">${t("login.email")}</span>
<text-input
name="email"
autocomplete="email"
placeholder="name@example.com"
.value=${this.email}
.onValueChange=${(v: string) => { this.email = v; }}
></text-input>
</div>
<notify-bar type="error" .message=${this.error}></notify-bar>
<div class="field">
<span class="field-label">${t("login.password")}</span>
<text-input
name="password"
autocomplete="current-password"
password
.value=${this.password}
.onValueChange=${(v: string) => { this.password = v; }}
></text-input>
</div>
<ui-button full ?disabled=${this.loading} @click=${this.handleLogin}>
${this.loading ? "Signing in..." : "Sign in"}
</ui-button>
<div class="row">
<label class="remember">
<input
type="checkbox"
.checked=${this.remember}
@change=${(e: Event) =>
(this.remember = (e.target as HTMLInputElement).checked)}
/>
${t("login.remember")}
</label>
<a class="forgot" href="/forgot-password" @click=${(e: Event) => {
e.preventDefault();
this._navigate("/forgot-password");
}}>
${t("login.forgot")}
</a>
</div>
<horizontal-divider></horizontal-divider>
${this.error ? html`<p class="error">${this.error}</p>` : null}
<p class="footer">
No account?
<ui-link href="/register">Create one</ui-link>
</p>
</auth-card>
<primary-button
label=${this.loading ? t("login.loading") : t("login.submit")}
?disabled=${this.loading}
.onClick=${() => {}}
icon="arrow-right"
></primary-button>
</form>
</div>
<p class="footer">© ${new Date().getFullYear()} FlightScore</p>
</div>
<div class="hero-panel">
<img src="/auth/hero.jpeg" alt="" aria-hidden="true" />
</div>
</div>
`;
}
}
-141
View File
@@ -1,11 +1,5 @@
import { LitElement, html, css } from "lit";
import { customElement, state } from "lit/decorators.js";
import "../../components/auth-card.ts";
import "../../components/form-input.ts";
import "../../components/ui-button";
import "../../components/notify-bar.ts";
import "../../components/horizontal-divider";
import "../../components/ui-link";
const COUNTRIES = [
{ code: "AD", label: "Andorra" },
@@ -457,145 +451,10 @@ export class RegisterPage extends LitElement {
}
}
private renderDropdown() {
const selected = COUNTRIES.find((c) => c.code === this.country);
const filtered = COUNTRIES.filter((c) =>
c.label.toLowerCase().includes(this.dropdownSearch.toLowerCase()),
);
return html`
<div class="dropdown-wrapper">
<span class="dropdown-label">Country</span>
<div
class="dropdown-trigger ${this.dropdownOpen ? "open" : ""}"
@click=${this.toggleDropdown}
>
${selected
? html`<span>${selected.label}</span>`
: html`<span class="placeholder">Select your country</span>`}
<svg
class="chevron ${this.dropdownOpen ? "open" : ""}"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
${this.dropdownOpen
? html`
<div class="dropdown-list">
<input
class="dropdown-search"
type="text"
placeholder="Search country..."
.value=${this.dropdownSearch}
@input=${(e: InputEvent) =>
(this.dropdownSearch = (
e.target as HTMLInputElement
).value)}
@click=${(e: MouseEvent) => e.stopPropagation()}
/>
<div class="dropdown-scroll">
${filtered.length > 0
? filtered.map(
(c) => html`
<div
class="dropdown-item ${this.country === c.code
? "selected"
: ""}"
@click=${() => this.selectCountry(c.code)}
>
${c.label}
</div>
`,
)
: html`<div
class="dropdown-item"
style="opacity:0.5;cursor:default"
>
No results
</div>`}
</div>
</div>
`
: ""}
</div>
`;
}
render() {
return html`
<auth-card
heading="Create account"
subheading="Get started with FlightScore"
>
<div style="display: flex; gap: 12px;">
<form-input
label="First name"
placeholder="Your first name"
.value=${this.firstname}
@value-changed=${(e: CustomEvent) =>
(this.firstname = e.detail.value)}
style="flex: 1; min-width: 0;"
></form-input>
<form-input
label="Last name"
placeholder="Your last name"
.value=${this.lastname}
@value-changed=${(e: CustomEvent) =>
(this.lastname = e.detail.value)}
style="flex: 1; min-width: 0;"
></form-input>
</div>
<form-input
label="Email"
type="email"
placeholder="you@example.com"
.value=${this.email}
@value-changed=${(e: CustomEvent) => (this.email = e.detail.value)}
></form-input>
<form-input
label="Phone number"
type="tel"
placeholder="+49 152 12345678"
.value=${this.phonenumber}
@value-changed=${(e: CustomEvent) =>
(this.phonenumber = e.detail.value)}
></form-input>
${this.renderDropdown()}
<form-input
label="Password"
type="password"
placeholder="Choose a password"
.value=${this.password}
@value-changed=${(e: CustomEvent) => (this.password = e.detail.value)}
></form-input>
<notify-bar type="error" .message=${this.error}></notify-bar>
<ui-button full ?disabled=${this.loading} @click=${this.handleRegister}>
${this.loading ? "Creating account..." : "Create account"}
</ui-button>
<horizontal-divider></horizontal-divider>
<p class="footer">
Already have an account?
<ui-link href="/login">Sign in</ui-link>
</p>
</auth-card>
`;
}
}
-229
View File
@@ -1,229 +0,0 @@
// pages/competition-page.ts
import { LitElement, html, css, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import type { Tab } from "../../components/ui-tab-bar.js";
import "../../components/ui-tab-bar.js";
import "../../components/ui-badge.js";
import { Icons } from "../../components/icons.js";
const TAB_LOADERS: Record<string, () => Promise<unknown>> = {
details: () => import("./tabs/competition-details.js"),
results: () => import("./tabs/competition-results.js"),
tasks: () => import("./tabs/competition-tasks.js"),
noticeboard: () => import("./tabs/competition-noticeboard.js"),
pilots: () => import("./tabs/competition-pilots.js"),
officials: () => import("./tabs/competition-officials.js"),
};
@customElement("competition-page")
export class CompetitionPage extends LitElement {
@state() private activeTab = "details";
@state() private loadedTabs = new Set<string>();
@state() private loadingTab = "";
private tabs: Tab[] = [
{ id: "details", label: "Event Details", icon: "calendar" },
{ id: "results", label: "Results", icon: "trophy" },
{ id: "tasks", label: "Task Data", icon: "target" },
{ id: "noticeboard", label: "Noticeboard", icon: "clipboard" },
{ id: "pilots", label: "Pilots", icon: "users" },
{ id: "officials", label: "Officials", icon: "user" },
];
static styles = css`
:host {
display: block;
font-family:
system-ui,
-apple-system,
sans-serif;
color: var(--color-text);
background: var(--color-bg);
min-height: 100vh;
}
.hero {
background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%);
color: white;
padding: 3rem 1.5rem 2rem;
}
.hero-inner {
max-width: 1200px;
margin: 0 auto;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
opacity: 0.8;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.breadcrumb a {
color: white;
text-decoration: none;
}
.breadcrumb a:hover {
text-decoration: underline;
}
.breadcrumb span {
opacity: 0.6;
}
.hero h1 {
font-size: 1.75rem;
font-weight: 700;
margin: 0 0 1rem;
line-height: 1.3;
}
.hero-meta {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
align-items: center;
font-size: 0.9rem;
}
.hero-meta-item {
display: flex;
align-items: center;
gap: 0.4rem;
}
.hero-meta-item svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.tab-content {
max-width: 1200px;
margin: 0 auto;
padding: 1.5rem;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 4rem;
color: color-mix(in srgb, var(--color-text) 45%, transparent);
font-size: 0.9rem;
}
.spinner {
width: 1.25rem;
height: 1.25rem;
border: 2px solid var(--color-border);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 0.6s linear infinite;
margin-right: 0.75rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
`;
connectedCallback() {
super.connectedCallback();
this.loadTab(this.activeTab);
}
private async loadTab(id: string) {
if (this.loadedTabs.has(id)) return;
const loader = TAB_LOADERS[id];
if (!loader) return;
this.loadingTab = id;
try {
await loader();
this.loadedTabs = new Set([...this.loadedTabs, id]);
} catch (e) {
console.error(`Failed to load tab "${id}":`, e);
} finally {
this.loadingTab = "";
}
}
private handleTabChange(e: CustomEvent<{ tab: string }>) {
this.activeTab = e.detail.tab;
this.loadTab(e.detail.tab);
}
private renderTabContent() {
if (this.loadingTab === this.activeTab) {
return html`
<div class="loading">
<div class="spinner"></div>
Loading…
</div>
`;
}
switch (this.activeTab) {
case "details":
return html`<competition-details></competition-details>`;
case "results":
return html`<competition-results></competition-results>`;
case "tasks":
return html`<competition-tasks></competition-tasks>`;
case "noticeboard":
return html`<competition-noticeboard></competition-noticeboard>`;
case "pilots":
return html`<competition-pilots></competition-pilots>`;
case "officials":
return html`<competition-officials></competition-officials>`;
default:
return nothing;
}
}
render() {
return html`
<div class="hero">
<div class="hero-inner">
<div class="breadcrumb">
<a href="/">Home</a>
<span>/</span>
<a href="/competitions">Competitions</a>
<span>/</span>
<span>EVENT_NAME</span>
</div>
<h1>EVENT_NAME</h1>
<div class="hero-meta">
<div class="hero-meta-item">
${Icons.icons.map_pin} COMPETITION_LOCATION
</div>
<div class="hero-meta-item">
${Icons.icons.calendar} DATE_FROM_UNTIL_SHORT
</div>
<ui-badge variant="white">EVENT_TYPE</ui-badge>
<ui-badge variant="success">EVENT_STATUS</ui-badge>
</div>
</div>
</div>
<ui-tab-bar
.tabs=${this.tabs}
.active=${this.activeTab}
@tab-change=${this.handleTabChange}
></ui-tab-bar>
<div class="tab-content">${this.renderTabContent()}</div>
`;
}
}
@@ -1,190 +0,0 @@
// tabs/competition-details.ts
import { LitElement, html, css } from "lit";
import { customElement } from "lit/decorators.js";
import { Icons } from "../../../components/icons";
@customElement("competition-details")
export class CompetitionDetails extends LitElement {
static styles = css`
:host {
display: block;
}
.layout {
display: grid;
grid-template-columns: 1fr 380px;
gap: 1.5rem;
align-items: start;
}
@media (max-width: 900px) {
.layout {
grid-template-columns: 1fr;
}
}
.main-card {
background: var(--color-bg-nav);
border: 1px solid var(--color-border);
border-radius: 0.75rem;
overflow: hidden;
}
.card-date {
padding: 1rem 1.5rem;
font-weight: 600;
font-size: 0.95rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg);
color: var(--color-text);
}
.card-body {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
font-size: 0.9rem;
line-height: 1.6;
color: color-mix(in srgb, var(--color-text) 65%, transparent);
}
.card-body a {
color: var(--color-accent);
text-decoration: none;
}
.card-body a:hover {
text-decoration: underline;
}
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem;
margin-top: 1.5rem;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.sidebar-card {
background: var(--color-bg-nav);
border: 1px solid var(--color-border);
border-radius: 0.75rem;
overflow: hidden;
}
.sidebar-card-header {
padding: 1rem 1.5rem;
font-weight: 600;
font-size: 0.95rem;
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--color-text);
}
.sidebar-card-header svg {
width: 18px;
height: 18px;
color: var(--color-accent);
}
.sidebar-card-body {
padding: 1.25rem 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.detail-row {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.875rem;
color: color-mix(in srgb, var(--color-text) 65%, transparent);
}
.detail-row svg {
width: 16px;
height: 16px;
color: var(--color-accent);
flex-shrink: 0;
}
.detail-row a {
color: var(--color-accent);
text-decoration: none;
}
.detail-row a:hover {
text-decoration: underline;
}
`;
render() {
return html`
<div class="layout">
<div>
<div class="main-card">
<div class="card-date">DATE_FROM DATE_UNTIL</div>
<div class="card-body">
COMPETITION_DETAILS
</div>
</div>
</div>
<div class="sidebar">
<div class="sidebar-card">
<div class="sidebar-card-header">
${Icons.icons.tools}
Competition Details
</div>
<div class="sidebar-card-body">
<div class="detail-row">
${Icons.icons.user}
Director: DIRECTOR_NAME
</div>
<div class="detail-row">
${Icons.icons.globe}
EVENT_TYPE [National, Continental] (CAT 1 | CAT 2)
</div>
<div class="detail-row">
${Icons.icons.desktop}
LOGGER_TYPE [Combined Logger, Marker Event]
</div>
</div>
</div>
<div class="sidebar-card">
<div class="sidebar-card-header">
${Icons.icons.mail}
Contact Details
</div>
<div class="sidebar-card-body">
<div class="detail-row">
${Icons.icons.user}
ORGANISATOR_NAME
</div>
<div class="detail-row">
${Icons.icons.home}
<a href="#" target="_blank" rel="noopener">COMPETITION_WEBSITE</a>
</div>
<div class="detail-row">
${Icons.icons.mail}
<a href="mailto:-">CONTACT_EMAIL</a>
</div>
</div>
</div>
</div>
</div>
`;
}
}
export default CompetitionDetails;
@@ -1,42 +0,0 @@
// tabs/competition-noticeboard.ts
import { LitElement, html, css } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("competition-noticeboard")
export class CompetitionNoticeboard extends LitElement {
static styles = css`
:host {
display: block;
}
.placeholder {
background: var(--color-bg-nav);
border: 1px solid var(--color-border);
border-radius: 0.75rem;
padding: 3rem;
text-align: center;
color: color-mix(in srgb, var(--color-text) 55%, transparent);
}
.placeholder p:first-child {
font-size: 1.1rem;
font-weight: 500;
}
.placeholder p:last-child {
font-size: 0.875rem;
margin-top: 0.5rem;
}
`;
render() {
return html`
<div class="placeholder">
<p>Noticeboard</p>
<p>Content will appear here once the competition is underway.</p>
</div>
`;
}
}
export default CompetitionNoticeboard;
@@ -1,41 +0,0 @@
import { LitElement, html, css } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("competition-officials")
export class CompetitionOfficials extends LitElement {
static styles = css`
:host {
display: block;
}
.placeholder {
background: var(--color-bg-nav);
border: 1px solid var(--color-border);
border-radius: 0.75rem;
padding: 3rem;
text-align: center;
color: color-mix(in srgb, var(--color-text) 55%, transparent);
}
.placeholder p:first-child {
font-size: 1.1rem;
font-weight: 500;
}
.placeholder p:last-child {
font-size: 0.875rem;
margin-top: 0.5rem;
}
`;
render() {
return html`
<div class="placeholder">
<p>Officials</p>
<p>Content will appear here once the competition is underway.</p>
</div>
`;
}
}
export default CompetitionOfficials;
@@ -1,42 +0,0 @@
// tabs/competition-pilots.ts
import { LitElement, html, css } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("competition-pilots")
export class CompetitionPilots extends LitElement {
static styles = css`
:host {
display: block;
}
.placeholder {
background: var(--color-bg-nav);
border: 1px solid var(--color-border);
border-radius: 0.75rem;
padding: 3rem;
text-align: center;
color: color-mix(in srgb, var(--color-text) 55%, transparent);
}
.placeholder p:first-child {
font-size: 1.1rem;
font-weight: 500;
}
.placeholder p:last-child {
font-size: 0.875rem;
margin-top: 0.5rem;
}
`;
render() {
return html`
<div class="placeholder">
<p>Pilots</p>
<p>Content will appear here once the competition is underway.</p>
</div>
`;
}
}
export default CompetitionPilots;
@@ -1,42 +0,0 @@
// tabs/competition-results.ts
import { LitElement, html, css } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("competition-results")
export class CompetitionResults extends LitElement {
static styles = css`
:host {
display: block;
}
.placeholder {
background: var(--color-bg-nav);
border: 1px solid var(--color-border);
border-radius: 0.75rem;
padding: 3rem;
text-align: center;
color: color-mix(in srgb, var(--color-text) 55%, transparent);
}
.placeholder p:first-child {
font-size: 1.1rem;
font-weight: 500;
}
.placeholder p:last-child {
font-size: 0.875rem;
margin-top: 0.5rem;
}
`;
render() {
return html`
<div class="placeholder">
<p>Results</p>
<p>Content will appear here once the competition is underway.</p>
</div>
`;
}
}
export default CompetitionResults;
@@ -1,42 +0,0 @@
// tabs/competition-tasks.ts
import { LitElement, html, css } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("competition-tasks")
export class CompetitionTasks extends LitElement {
static styles = css`
:host {
display: block;
}
.placeholder {
background: var(--color-bg-nav);
border: 1px solid var(--color-border);
border-radius: 0.75rem;
padding: 3rem;
text-align: center;
color: color-mix(in srgb, var(--color-text) 55%, transparent);
}
.placeholder p:first-child {
font-size: 1.1rem;
font-weight: 500;
}
.placeholder p:last-child {
font-size: 0.875rem;
margin-top: 0.5rem;
}
`;
render() {
return html`
<div class="placeholder">
<p>Task Data</p>
<p>Content will appear here once the competition is underway.</p>
</div>
`;
}
}
export default CompetitionTasks;
-630
View File
@@ -1,630 +0,0 @@
import { LitElement, html, css } from "lit";
import { customElement, state } from "lit/decorators.js";
import "../components/ui-button";
import "../components/ui-button-secondary";
import "../components/ui-badge";
import "../components/ui-card";
import "../components/card-backdrop";
import "../components/card-header";
import "../components/auth-card";
import "../components/stat-card";
import "../components/icon-card";
import "../components/form-input";
import "../components/horizontal-divider";
import "../components/notify-bar";
import "../components/ui-link";
import "../components/circle-chart";
import "../components/loading-bar";
import "../components/line-chart";
import { Icons } from "../components/icons";
@customElement("dev-page")
export class DevPage extends LitElement {
static styles = css`
:host {
flex: 1;
display: flex;
flex-direction: column;
background: var(--color-bg);
}
.container {
max-width: 1100px;
width: 100%;
margin: 0 auto;
padding: 2.5rem 1.5rem 4rem;
display: flex;
flex-direction: column;
gap: 3rem;
}
.page-header {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--color-border);
}
.page-header h1 {
margin: 0;
font-size: 2rem;
font-weight: 800;
letter-spacing: -0.03em;
color: var(--color-text);
}
.page-header p {
margin: 0;
font-size: 0.95rem;
color: color-mix(in srgb, var(--color-text) 55%, transparent);
}
.section {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.section-head {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.section-head h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--color-text);
}
.section-head p {
margin: 0;
font-size: 0.85rem;
color: color-mix(in srgb, var(--color-text) 50%, transparent);
}
.component-group {
background: var(--color-bg-nav);
border: 1px solid var(--color-border);
border-radius: 0.75rem;
overflow: hidden;
}
.component-label {
padding: 0.6rem 1.25rem;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: color-mix(in srgb, var(--color-text) 45%, transparent);
background: color-mix(in srgb, var(--color-text) 4%, transparent);
border-bottom: 1px solid var(--color-border);
}
.component-preview {
padding: 1.5rem 1.25rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.component-preview.col {
flex-direction: column;
align-items: stretch;
}
.component-preview.grid-2 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
.component-preview.grid-4 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.component-preview + .component-label {
border-top: 1px solid var(--color-border);
}
.spacer {
width: 1px;
height: 1.5rem;
background: var(--color-border);
margin: 0 0.25rem;
}
.input-row {
max-width: 360px;
width: 100%;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.card-demo {
display: flex;
justify-content: center;
padding: 2rem;
background:
radial-gradient(
ellipse at 30% 20%,
color-mix(in srgb, var(--color-accent) 8%, transparent) 0%,
transparent 50%
),
var(--color-bg);
border-radius: 0 0 0.75rem 0.75rem;
}
.toggle-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.toggle-label {
font-size: 0.85rem;
color: color-mix(in srgb, var(--color-text) 60%, transparent);
}
`;
@state() inputValue = "";
@state() notifyVisible = true;
render() {
return html`
<div class="container">
<div class="page-header">
<h1>Component Library</h1>
<p>All available UI components with variants and states</p>
</div>
<div class="section">
<div class="section-head">
<h2>Buttons</h2>
<p>Primary and secondary action buttons</p>
</div>
<div class="component-group">
<div class="component-label">Primary</div>
<div class="component-preview">
<ui-button>Default</ui-button>
<ui-button>
<svg
slot="icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
With Icon
</ui-button>
<ui-button disabled>Disabled</ui-button>
</div>
<div class="component-label">Primary Full Width</div>
<div class="component-preview col">
<ui-button full>Full Width Button</ui-button>
</div>
<div class="component-label">Secondary</div>
<div class="component-preview">
<ui-button-secondary>Default</ui-button-secondary>
<ui-button-secondary>
<svg
slot="icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="19" y1="12" x2="5" y2="12" />
<polyline points="12 19 5 12 12 5" />
</svg>
With Icon
</ui-button-secondary>
<ui-button-secondary disabled> Disabled </ui-button-secondary>
</div>
<div class="component-label">Secondary Full Width</div>
<div class="component-preview col">
<ui-button-secondary full>
Full Width Secondary
</ui-button-secondary>
</div>
</div>
</div>
<div class="section">
<div class="section-head">
<h2>Badges</h2>
<p>Status and category indicators</p>
</div>
<div class="component-group">
<div class="component-label">Variants</div>
<div class="component-preview">
<ui-badge variant="accent">Accent</ui-badge>
<ui-badge variant="success">Success</ui-badge>
<ui-badge variant="warning">Warning</ui-badge>
<ui-badge variant="error">Error</ui-badge>
<ui-badge variant="muted">Muted</ui-badge>
</div>
<div class="component-label">With Icon</div>
<div class="component-preview">
<ui-badge variant="accent">
<span slot="icon">${Icons.icons.stack}</span>
Platform
</ui-badge>
<ui-badge variant="success">
<span slot="icon">${Icons.icons.success}</span>
Live
</ui-badge>
<ui-badge variant="error">
<span slot="icon">${Icons.icons.error}</span>
Offline
</ui-badge>
</div>
</div>
</div>
<div class="section">
<div class="section-head">
<h2>Notification Bars</h2>
<p>Inline feedback messages</p>
</div>
<div class="component-group">
<div class="component-label">Types</div>
<div class="component-preview col">
<notify-bar
type="info"
message="Competition is currently active."
></notify-bar>
<notify-bar
type="success"
message="Competition successfully created."
></notify-bar>
<notify-bar
type="warning"
message="GPS data incomplete. Some scores may be inaccurate."
></notify-bar>
<notify-bar
type="error"
message="Login failed. Please check your credentials."
></notify-bar>
</div>
<div class="component-label">Non-Dismissible</div>
<div class="component-preview col">
<notify-bar
type="success"
message="This notification cannot be dismissed."
.dismissible=${false}
></notify-bar>
</div>
</div>
</div>
<div class="section">
<div class="section-head">
<h2>Links</h2>
<p>Navigation links with underline animation</p>
</div>
<div class="component-group">
<div class="component-label">States</div>
<div class="component-preview">
<ui-link href="#">Default Link</ui-link>
<div class="spacer"></div>
<ui-link href="#" active>Active Link</ui-link>
</div>
</div>
</div>
<div class="section">
<div class="section-head">
<h2>Form Inputs</h2>
<p>Text input fields with labels</p>
</div>
<div class="component-group">
<div class="component-label">Types</div>
<div class="component-preview col">
<div class="input-row">
<form-input
label="Text"
placeholder="Enter some text"
.value=${this.inputValue}
@value-changed=${(e: CustomEvent) =>
(this.inputValue = e.detail.value)}
></form-input>
<form-input
label="Email"
type="email"
placeholder="you@example.com"
></form-input>
<form-input
label="Password"
type="password"
placeholder="Enter your password"
></form-input>
</div>
</div>
<div class="component-label">Live Value</div>
<div class="component-preview">
<span
style="font-size:0.85rem;color:color-mix(in srgb,var(--color-text) 60%,transparent)"
>
Current value: "${this.inputValue}"
</span>
</div>
</div>
</div>
<div class="section">
<div class="section-head">
<h2>Stat Cards</h2>
<p>Metric display cards</p>
</div>
<div class="component-group">
<div class="component-label">Grid</div>
<div class="component-preview grid-4">
<stat-card value="42" label="Competitions"></stat-card>
<stat-card value="318" label="Pilots"></stat-card>
<stat-card value="1,240" label="Tasks"></stat-card>
<stat-card value="12" label="Countries"></stat-card>
</div>
</div>
</div>
<div class="section">
<div class="section-head">
<h2>Icon Cards</h2>
<p>Feature highlight cards</p>
</div>
<div class="component-group">
<div class="component-label">Grid</div>
<div class="component-preview grid-2">
<icon-card
heading="Real-Time Scoring"
description="Scores update live as judges submit results."
>
${Icons.icons.clock}
</icon-card>
<icon-card
heading="GPS Integration"
description="Import GPS tracks and calculate distances automatically."
>
${Icons.icons.map_pin}
</icon-card>
</div>
</div>
</div>
<div class="section">
<div class="section-head">
<h2>Cards</h2>
<p>Container cards and layout primitives</p>
</div>
<div class="component-group">
<div class="component-label">ui-card (default)</div>
<div class="card-demo">
<ui-card>
<card-header
heading="Card Title"
subheading="A subtitle goes here"
></card-header>
<p
style="margin:0;font-size:0.9rem;color:color-mix(in srgb,var(--color-text) 60%,transparent)"
>
This is the default card layout with a header component
inside.
</p>
<horizontal-divider></horizontal-divider>
<ui-button full>Action</ui-button>
</ui-card>
</div>
<div class="component-label">ui-card (centered)</div>
<div class="card-demo">
<ui-card centered>
<p
style="margin:0;font-size:3rem;font-weight:800;letter-spacing:-0.03em;color:var(--color-accent)"
>
404
</p>
<card-header
heading="Centered Content"
subheading="Used for error pages and similar layouts"
></card-header>
<horizontal-divider></horizontal-divider>
<ui-button>Action</ui-button>
</ui-card>
</div>
</div>
</div>
<div class="section">
<div class="section-head">
<h2>Divider</h2>
<p>Visual separator for content sections</p>
</div>
<div class="component-group">
<div class="component-label">horizontal-divider</div>
<div class="component-preview col">
<p
style="margin:0;font-size:0.85rem;color:color-mix(in srgb,var(--color-text) 60%,transparent)"
>
Content above
</p>
<horizontal-divider></horizontal-divider>
<p
style="margin:0;font-size:0.85rem;color:color-mix(in srgb,var(--color-text) 60%,transparent)"
>
Content below
</p>
</div>
</div>
</div>
<div class="section">
<div class="section-head">
<h2>Composed: Auth Card</h2>
<p>Pre-composed card for authentication flows</p>
</div>
<div class="component-group">
<div class="component-label">Login Example</div>
<div class="card-demo">
<ui-card>
<card-header
heading="Welcome back"
subheading="Sign in to your FlightScore account"
></card-header>
<form-input
label="Email"
type="email"
placeholder="you@example.com"
></form-input>
<form-input
label="Password"
type="password"
placeholder="Enter your password"
></form-input>
<notify-bar
type="error"
message="Invalid email or password."
></notify-bar>
<ui-button full>Sign in</ui-button>
<horizontal-divider></horizontal-divider>
<p
style="margin:0;text-align:center;font-size:0.875rem;color:color-mix(in srgb,var(--color-text) 60%,transparent)"
>
No account?
<ui-link href="#">Create one</ui-link>
</p>
</ui-card>
</div>
</div>
</div>
<div class="section">
<div class="section-head">
<h2>Loading Bars</h2>
<p>Progress indicators with variants and states</p>
</div>
<div class="component-group">
<div class="component-label">Variants</div>
<div class="component-preview col">
<loading-bar label="Accent" value="72"></loading-bar>
<loading-bar
label="Success"
value="100"
variant="success"
></loading-bar>
<loading-bar
label="Warning"
value="45"
variant="warning"
></loading-bar>
<loading-bar
label="Error"
value="18"
variant="error"
></loading-bar>
</div>
<div class="component-label">Sizes</div>
<div class="component-preview col">
<loading-bar label="Small" value="60" size="sm"></loading-bar>
<loading-bar label="Medium" value="60" size="md"></loading-bar>
<loading-bar label="Large" value="60" size="lg"></loading-bar>
</div>
<div class="component-label">Indeterminate</div>
<div class="component-preview col">
<loading-bar
label="Loading…"
indeterminate
hideValue
></loading-bar>
</div>
</div>
</div>
<div class="section">
<div class="section-head">
<h2>Line Chart</h2>
<p>Trend lines with optional area fill</p>
</div>
<div class="component-group">
<div class="component-label">Multi-Series</div>
<div class="component-preview col">
<line-chart
heading="Score Progression"
subtitle="Last 6 Rounds"
xLabel="Round"
yLabel="Score"
showArea
.series=${[
{
label: "Pilot A",
color: "var(--color-accent)",
points: [
{ x: 0, y: 20 },
{ x: 1, y: 420 },
{ x: 2, y: 580 },
{ x: 3, y: 540 },
{ x: 4, y: 710 },
{ x: 5, y: 690 },
{ x: 6, y: 820 },
],
},
{
label: "Pilot B",
color: "#30a46c",
points: [
{ x: 1, y: 380 },
{ x: 2, y: 450 },
{ x: 3, y: 620 },
{ x: 4, y: 590 },
{ x: 5, y: 750 },
{ x: 6, y: 780 },
{ x: 7, y: 780 },
{ x: 8, y: 780 },
{ x: 9, y: 780 },
{ x: 10, y: 780 },
{ x: 11, y: 780 },
{ x: 12, y: 780 },
],
},
]}
></line-chart>
</div>
</div>
</div>
<div class="section">
<div class="section-head">
<h2>Circle Chart</h2>
<p>Donut chart for proportional data</p>
</div>
<div class="component-group">
<div class="component-label">Example</div>
<div class="component-preview col">
<circle-chart
heading="Pilots by Category"
centerText="Pilots"
.segments=${[
{ label: "Sport", value: 124, color: "var(--color-accent)" },
{ label: "Serial", value: 89, color: "#30a46c" },
{ label: "Open", value: 47, color: "#e79d13" },
{ label: "Tandem", value: 18, color: "#e5484d" },
]}
></circle-chart>
</div>
</div>
</div>
</div>
`;
}
}
+24 -369
View File
@@ -1,13 +1,10 @@
// pages/home/home-page.ts (updated)
import { LitElement, html, css } from "lit";
import { customElement } from "lit/decorators.js";
import "../components/ui-button";
import "../components/ui-button-secondary";
import "../components/ui-badge";
import "../components/stat-card";
import "../components/icon-card";
import "../components/ui-link";
import { Icons } from "../components/icons";
import "../components/hero-icon.ts";
import "../components/button/primary-button.ts";
import "../components/button/secondary-button.ts";
import "../components/input/text-input.ts";
@customElement("home-page")
export class HomePage extends LitElement {
@@ -17,213 +14,6 @@ export class HomePage extends LitElement {
display: flex;
flex-direction: column;
}
.hero {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 5rem 1.5rem 4rem;
background:
radial-gradient(
ellipse at 40% 0%,
color-mix(in srgb, var(--color-accent) 18%, transparent) 0%,
transparent 55%
),
radial-gradient(
ellipse at 80% 60%,
color-mix(in srgb, var(--color-accent) 8%, transparent) 0%,
transparent 50%
),
var(--color-bg);
overflow: hidden;
}
.hero::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background: var(--color-border);
}
.hero h1 {
margin: 1.5rem 0 0;
font-size: clamp(2.2rem, 5vw, 3.5rem);
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1.1;
color: var(--color-text);
max-width: 700px;
}
.hero h1 span {
background: linear-gradient(
135deg,
var(--color-accent),
color-mix(in srgb, var(--color-accent) 60%, transparent)
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-sub {
margin: 1.25rem 0 2rem;
font-size: 1.05rem;
line-height: 1.65;
color: color-mix(in srgb, var(--color-text) 60%, transparent);
max-width: 520px;
}
.hero-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: center;
}
section {
padding: 4rem 1.5rem;
max-width: 1100px;
margin: 0 auto;
width: 100%;
}
section + section {
border-top: 1px solid var(--color-border);
}
.section-label {
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--color-accent);
margin: 0 0 0.5rem;
}
.section-title {
margin: 0 0 0.5rem;
font-size: 1.75rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--color-text);
}
.section-desc {
margin: 0 0 2.5rem;
font-size: 0.95rem;
color: color-mix(in srgb, var(--color-text) 55%, transparent);
max-width: 520px;
line-height: 1.6;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.25rem;
}
.competitions-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.comp-row {
display: flex;
align-items: center;
gap: 1.25rem;
background: var(--color-bg-nav);
border: 1px solid var(--color-border);
border-radius: 0.75rem;
padding: 1.25rem 1.5rem;
transition: border-color 0.25s ease;
cursor: pointer;
}
.comp-row:hover {
border-color: color-mix(in srgb, var(--color-accent) 40%, transparent);
}
.comp-date {
display: flex;
flex-direction: column;
align-items: center;
min-width: 3.5rem;
padding: 0.5rem 0.65rem;
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
border-radius: 0.5rem;
flex-shrink: 0;
}
.comp-date-day {
font-size: 1.25rem;
font-weight: 800;
color: var(--color-accent);
line-height: 1;
}
.comp-date-month {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-accent);
margin-top: 0.15rem;
}
.comp-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.comp-name {
font-size: 1rem;
font-weight: 650;
color: var(--color-text);
}
.comp-location {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.82rem;
color: color-mix(in srgb, var(--color-text) 50%, transparent);
}
.comp-location svg {
width: 0.8rem;
height: 0.8rem;
flex-shrink: 0;
}
@media (max-width: 600px) {
.hero {
padding: 3.5rem 1.25rem 3rem;
}
section {
padding: 3rem 1.25rem;
}
.comp-row {
flex-wrap: wrap;
gap: 0.75rem;
}
}
`;
private navigate(path: string) {
@@ -238,162 +28,27 @@ export class HomePage extends LitElement {
render() {
return html`
<div class="hero">
<ui-badge variant="accent">
<span slot="icon">${Icons.icons.stack}</span>
Live Scoring Platform
</ui-badge>
<h1>Transparent scoring for <span>balloon competitions</span></h1>
<p class="hero-sub">
Track tasks, manage participants, and deliver real-time results for
hot air balloon competitions worldwide.
</p>
<div class="hero-actions">
<ui-button @click=${() => this.navigate("/competitions")}>
Browse competitions
</ui-button>
<ui-button-secondary @click=${() => this.navigate("/register")}>
Create account
</ui-button-secondary>
</div>
</div>
<primary-button name="Login"></primary-button>
<primary-button label="Download" icon="download"></primary-button>
<primary-button
label="Download"
icon="download"
disabled
></primary-button>
<secondary-button name="Login"></secondary-button>
<secondary-button label="Download" icon="download"></secondary-button>
<secondary-button
label="Download"
icon="download"
disabled
></secondary-button>
<text-input placeholder="Nicht editierbar" disabled></text-input>
<text-input
value="a.brunner@cia-d.de"
.onChange=${(v: string) => console.log(v)}
></text-input>
<text-input placeholder="Passwort" password></text-input>
<section>
<p class="section-label">At a glance</p>
<h2 class="section-title">Platform in numbers</h2>
<p class="section-desc">
FlightScore powers balloon competitions across the globe.
</p>
<div class="stats-grid">
<stat-card value="42" label="Competitions scored"></stat-card>
<stat-card value="318" label="Registered pilots"></stat-card>
<stat-card value="1,240" label="Tasks completed"></stat-card>
<stat-card value="12" label="Countries"></stat-card>
</div>
</section>
<section>
<p class="section-label">Features</p>
<h2 class="section-title">Built for balloon events</h2>
<p class="section-desc">
Everything organizers, officials, juries and pilots need in one place.
</p>
<div class="features-grid">
<icon-card
heading="Real-Time Scoring"
description="Scores update live as judges submit results. Pilots and spectators always see the latest standings."
>
${Icons.icons.clock}
</icon-card>
<icon-card
heading="Task Management"
description="Define and manage competition tasks with support for all standard ballooning task types."
>
${Icons.icons.document_text}
</icon-card>
<icon-card
heading="Pilot Profiles"
description="Every pilot gets a profile with competition history, rankings, and performance statistics."
>
${Icons.icons.users}
</icon-card>
<icon-card
heading="Live Leaderboard"
description="Public leaderboards let spectators follow the action from anywhere in the world."
>
${Icons.icons.desktop}
</icon-card>
<icon-card
heading="GPS Integration"
description="Import GPS tracks and calculate distances to targets automatically for precise scoring."
>
${Icons.icons.map_pin}
</icon-card>
<icon-card
heading="Result Export"
description="Export final standings and detailed score sheets as PDF or CSV for official records."
>
${Icons.icons.table}
</icon-card>
</div>
</section>
<section>
<p class="section-label">Competitions</p>
<h2 class="section-title">Upcoming and recent events</h2>
<p class="section-desc">
Discover balloon competitions scored with FlightScore.
</p>
<div class="competitions-list">
<div
class="comp-row"
@click=${() => this.navigate("/competitions/1")}
>
<div class="comp-date">
<span class="comp-date-day">18</span>
<span class="comp-date-month">Mar</span>
</div>
<div class="comp-info">
<span class="comp-name"> European Balloon Challenge 2026 </span>
<span class="comp-location">
${Icons.icons.map_pin} Salzburg, Austria
</span>
</div>
<ui-badge variant="accent">Upcoming</ui-badge>
</div>
<div
class="comp-row"
@click=${() => this.navigate("/competitions/2")}
>
<div class="comp-date">
<span class="comp-date-day">14</span>
<span class="comp-date-month">Feb</span>
</div>
<div class="comp-info">
<span class="comp-name">Cappadocia Open 2026</span>
<span class="comp-location">
${Icons.icons.map_pin} Goreme, Turkey
</span>
</div>
<ui-badge variant="success">Live</ui-badge>
</div>
<div
class="comp-row"
@click=${() => this.navigate("/competitions/3")}
>
<div class="comp-date">
<span class="comp-date-day">02</span>
<span class="comp-date-month">Feb</span>
</div>
<div class="comp-info">
<span class="comp-name"> Albuquerque Winter Fiesta </span>
<span class="comp-location">
${Icons.icons.map_pin} New Mexico, USA
</span>
</div>
<ui-badge variant="muted">Ended</ui-badge>
</div>
<div
class="comp-row"
@click=${() => this.navigate("/competitions/4")}
>
<div class="comp-date">
<span class="comp-date-day">11</span>
<span class="comp-date-month">Jan</span>
</div>
<div class="comp-info">
<span class="comp-name"> Swiss Alpine Balloon Trophy </span>
<span class="comp-location">
${Icons.icons.map_pin} Chateau-d'Oex, Switzerland
</span>
</div>
<ui-badge variant="error">Canceled</ui-badge>
</div>
</div>
</section>
`;
}
}
-123
View File
@@ -1,123 +0,0 @@
import { LitElement, html, css } from 'lit';
import { customElement } from 'lit/decorators.js';
import '../components/card-backdrop';
import '../components/ui-card';
import '../components/horizontal-divider';
import '../components/ui-button';
@customElement('not-found-page')
export class NotFoundPage extends LitElement {
static styles = css`
:host {
flex: 1;
display: flex;
}
.icon {
width: 3rem;
height: 3rem;
color: color-mix(in srgb, var(--color-text) 30%, transparent);
}
.code {
font-size: 5rem;
font-weight: 800;
letter-spacing: -0.04em;
line-height: 1;
margin: 0;
background: linear-gradient(
135deg,
var(--color-accent),
color-mix(in srgb, var(--color-accent) 50%, transparent)
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
h2 {
margin: 0;
font-size: 1.35rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--color-text);
}
p {
margin: 0;
font-size: 0.9rem;
line-height: 1.6;
color: color-mix(in srgb, var(--color-text) 55%, transparent);
}
.back-btn {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.6rem 1.25rem;
font-size: 0.9rem;
font-weight: 600;
color: #fff;
background: var(--color-accent);
border: none;
border-radius: 0.5rem;
cursor: pointer;
transition:
background 0.25s ease,
box-shadow 0.25s ease,
transform 0.15s ease;
}
.back-btn:hover {
background: color-mix(in srgb, var(--color-accent) 85%, black);
box-shadow: 0 4px 14px
color-mix(in srgb, var(--color-accent) 35%, transparent);
}
.back-btn:active {
transform: scale(0.98);
}
.back-btn svg {
width: 1rem;
height: 1rem;
}
`;
private navigate(path: string) {
this.dispatchEvent(
new CustomEvent('nav', {
detail: { path },
bubbles: true,
composed: true,
})
);
}
render() {
return html`
<card-backdrop>
<ui-card centered>
<p class="code">404</p>
<h2>Page not found</h2>
<p>
The page you are looking for does not exist or has been
moved.
</p>
<horizontal-divider></horizontal-divider>
<ui-button @click=${()=> this.navigate('/')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>
Back to home
</ui-button>
</ui-card>
</card-backdrop>
`;
}
}
-12
View File
@@ -1,12 +0,0 @@
import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('cc-home-page')
export class CCHomePage extends LitElement {
render() {
return html`
<h1>Welcome to the Target Team Homepage</h1>
<p>Analyze your tracks visually.</p>
`;
}
}
+27 -5
View File
@@ -2,20 +2,44 @@
--color-bg: #f5f5f5;
--color-bg-nav: #ffffffcc;
--color-text: #111;
--color-text-primary: #2b6cb0;
--color-accent: #2b6cb0;
--color-on-accent: #ffffff;
--color-border: #ddd;
--color-border-strong: #aaa;
--color-text-muted: #999;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 0.9375rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-bold: 700;
}
:root[data-theme='dark'] {
:root[data-theme="dark"] {
--color-bg: #1a1a1a;
--color-bg-nav: #222;
--color-text: #f5f5f5;
--color-accent: #63b3ed;
--color-on-accent: #1a1a1a;
--color-border: #333;
--color-border-strong: #666;
--color-text-muted: #777;
}
.text-primary {
color: var(--color-text-primary);
}
* {
transition: background-color 0.25s ease, color 0.25s ease, border-color 0.25s ease;
transition:
background-color 0.25s ease,
color 0.25s ease,
border-color 0.25s ease;
}
html,
@@ -24,7 +48,5 @@ body {
padding: 0;
background: var(--color-bg);
color: var(--color-text);
font-family: 'Inter', system-ui, sans-serif;
font-family: "Inter", system-ui, sans-serif;
}