diff --git a/public/auth/hero.jpeg b/public/auth/hero.jpeg
new file mode 100644
index 0000000..e2d3e70
Binary files /dev/null and b/public/auth/hero.jpeg differ
diff --git a/public/locales/de.json b/public/locales/de.json
new file mode 100644
index 0000000..f297559
--- /dev/null
+++ b/public/locales/de.json
@@ -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."
+}
diff --git a/public/locales/en.json b/public/locales/en.json
new file mode 100644
index 0000000..034c009
--- /dev/null
+++ b/public/locales/en.json
@@ -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."
+}
diff --git a/public/logo.svg b/public/logo.svg
index 893eebc..9c85a67 100644
--- a/public/logo.svg
+++ b/public/logo.svg
@@ -1,25 +1,79 @@
-
diff --git a/src/app-root.ts b/src/app-root.ts
index b06b650..c507dde 100644
--- a/src/app-root.ts
+++ b/src/app-root.ts
@@ -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
diff --git a/src/components/_components b/src/components/_components
deleted file mode 100644
index 4fe818b..0000000
--- a/src/components/_components
+++ /dev/null
@@ -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 |
\ No newline at end of file
diff --git a/src/components/auth-card.ts b/src/components/auth-card.ts
deleted file mode 100644
index 952221c..0000000
--- a/src/components/auth-card.ts
+++ /dev/null
@@ -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`
-
-
-
-
-
-
- `;
- }
-}
\ No newline at end of file
diff --git a/src/components/button/primary-button.ts b/src/components/button/primary-button.ts
new file mode 100644
index 0000000..c261f8c
--- /dev/null
+++ b/src/components/button/primary-button.ts
@@ -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``
+ : null;
+
+ return html`
+
+ `;
+ }
+}
diff --git a/src/components/button/secondary-button.ts b/src/components/button/secondary-button.ts
new file mode 100644
index 0000000..89ae820
--- /dev/null
+++ b/src/components/button/secondary-button.ts
@@ -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``
+ : null;
+
+ return html`
+
+ `;
+ }
+}
diff --git a/src/components/card-backdrop.ts b/src/components/card-backdrop.ts
deleted file mode 100644
index 27eac12..0000000
--- a/src/components/card-backdrop.ts
+++ /dev/null
@@ -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``;
- }
-}
\ No newline at end of file
diff --git a/src/components/card-header.ts b/src/components/card-header.ts
deleted file mode 100644
index 3293f8b..0000000
--- a/src/components/card-header.ts
+++ /dev/null
@@ -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`
-
${this.heading}
- ${this.subheading ? html`${this.subheading}
` : null}
- `;
- }
-}
\ No newline at end of file
diff --git a/src/components/cc/cc-nav-bar.css b/src/components/cc/cc-nav-bar.css
deleted file mode 100644
index bc9fb06..0000000
--- a/src/components/cc/cc-nav-bar.css
+++ /dev/null
@@ -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);
-}
\ No newline at end of file
diff --git a/src/components/cc/cc-nav-bar.ts b/src/components/cc/cc-nav-bar.ts
deleted file mode 100644
index 6b4a0ff..0000000
--- a/src/components/cc/cc-nav-bar.ts
+++ /dev/null
@@ -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`
-
- `;
- }
-}
\ No newline at end of file
diff --git a/src/components/circle-chart.ts b/src/components/circle-chart.ts
deleted file mode 100644
index f478cab..0000000
--- a/src/components/circle-chart.ts
+++ /dev/null
@@ -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`
- 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`
-
- ${this.heading
- ? html`
${this.heading}
`
- : null}
-
-
-
${arcs}
-
- ${total}
- ${this.centerText}
-
-
- ${hovered
- ? html`
-
- ${hovered.label}
- ${hovered.value}
- `
- : null}
-
-
-
- ${this.segments.map(
- (seg) => html`
-
-
- ${seg.label}
- ${seg.value}
-
- `
- )}
-
-
-
- `;
- }
-}
\ No newline at end of file
diff --git a/src/components/footer-bar.ts b/src/components/footer-bar.ts
deleted file mode 100644
index a0779c3..0000000
--- a/src/components/footer-bar.ts
+++ /dev/null
@@ -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`
-
- `;
- }
-}
\ No newline at end of file
diff --git a/src/components/form-input.ts b/src/components/form-input.ts
deleted file mode 100644
index bba9cf5..0000000
--- a/src/components/form-input.ts
+++ /dev/null
@@ -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`
-
-
- `;
- }
-}
\ No newline at end of file
diff --git a/src/components/hero-icon.ts b/src/components/hero-icon.ts
new file mode 100644
index 0000000..901e776
--- /dev/null
+++ b/src/components/hero-icon.ts
@@ -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) {
+ 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}
+ `;
+ }
+}
\ No newline at end of file
diff --git a/src/components/horizontal-divider.ts b/src/components/horizontal-divider.ts
deleted file mode 100644
index c7a2405..0000000
--- a/src/components/horizontal-divider.ts
+++ /dev/null
@@ -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``;
- }
-}
\ No newline at end of file
diff --git a/src/components/icon-card.ts b/src/components/icon-card.ts
deleted file mode 100644
index 8574865..0000000
--- a/src/components/icon-card.ts
+++ /dev/null
@@ -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`
-
-
-
-
-
${this.heading}
-
${this.description}
-
- `;
- }
-}
\ No newline at end of file
diff --git a/src/components/icons.ts b/src/components/icons.ts
deleted file mode 100644
index 34b9ee6..0000000
--- a/src/components/icons.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { html, type TemplateResult } from "lit";
-
-export class Icons {
- public static icons: Record = {
- calendar: html``,
- globe: html``,
- trophy: html``,
- target: html``,
- clipboard: html``,
- users: html``,
- user: html``,
- home: html``,
- mail: html``,
- desktop: html``,
- tools: html``,
- map: html``,
- map_pin: html``,
- clock: html``,
- table: html``,
- calculator: html``,
- chart_pie: html``,
- gear: html``,
- document: html``,
- document_text: html``,
- bin: html``,
- stack: html``,
- visible: html``,
- hide: html``,
- folder: html``,
- warning: html``,
- info: html``,
- error: html``,
- success: html``,
- };
-}
diff --git a/src/components/icons/arrow-long-right.ts b/src/components/icons/arrow-long-right.ts
new file mode 100644
index 0000000..ec4d127
--- /dev/null
+++ b/src/components/icons/arrow-long-right.ts
@@ -0,0 +1,6 @@
+import { svg } from "lit";
+export const icon = svg`
+
+
+
+`;
diff --git a/src/components/icons/arrow-right.ts b/src/components/icons/arrow-right.ts
new file mode 100644
index 0000000..74f4770
--- /dev/null
+++ b/src/components/icons/arrow-right.ts
@@ -0,0 +1,6 @@
+import { svg } from "lit";
+export const icon = svg`
+
+
+
+`;
diff --git a/src/components/icons/download.ts b/src/components/icons/download.ts
new file mode 100644
index 0000000..af98d5c
--- /dev/null
+++ b/src/components/icons/download.ts
@@ -0,0 +1,6 @@
+import { svg } from "lit";
+export const icon = svg`
+
+
+
+`;
diff --git a/src/components/input/text-input.ts b/src/components/input/text-input.ts
new file mode 100644
index 0000000..c6f590a
--- /dev/null
+++ b/src/components/input/text-input.ts
@@ -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`
+
+ `;
+ }
+}
diff --git a/src/components/line-chart.ts b/src/components/line-chart.ts
deleted file mode 100644
index 980059b..0000000
--- a/src/components/line-chart.ts
+++ /dev/null
@@ -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`
-
- ${Math.round(val)}
- `);
- }
- for (
- let val = xScale.min;
- val <= xScale.max + xScale.step * 0.01;
- val += xScale.step
- ) {
- const x = sx(val);
- gridLines.push(svg`
- ${Math.round(val)}
- `);
- }
-
- 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`` : null}
-
- `;
- });
-
- const hoverOverlay = svg`
-
- this._onMouseMove(e, sx, sy, xScale)}
- />
- `;
-
- const hoverMarkers = this._hover
- ? svg`
-
- ${this._hover.points.map(
- (pt) => svg`
-
- `
- )}
- `
- : 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`
-
- `;
- })()
- : nothing;
-
- return html`
-
- ${this.heading || this.subtitle
- ? html`
-
- `
- : nothing}
-
this._onMouseLeave()}
- >
-
- ${gridLines} ${lines} ${hoverMarkers} ${hoverOverlay}
-
- ${this.xLabel}
-
-
- ${this.yLabel}
-
-
- ${tooltipHtml}
-
- ${this.series.length > 1
- ? html`
-
- ${this.series.map(
- (s, i) => html`
-
-
- ${s.label}
-
- `
- )}
-
- `
- : nothing}
-
- `;
- }
-}
\ No newline at end of file
diff --git a/src/components/loading-bar.ts b/src/components/loading-bar.ts
deleted file mode 100644
index 8c3dfda..0000000
--- a/src/components/loading-bar.ts
+++ /dev/null
@@ -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`
-
- ${this.label || !this.hideValue
- ? html`
-
- ${this.label
- ? html`${this.label}`
- : html``}
- ${!this.hideValue && !this.indeterminate
- ? html`${clamped}%`
- : null}
-
- `
- : null}
-
-
- `;
- }
-}
\ No newline at end of file
diff --git a/src/components/nav-bar.ts b/src/components/nav-bar.ts
deleted file mode 100644
index fc8cc34..0000000
--- a/src/components/nav-bar.ts
+++ /dev/null
@@ -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`
-
- `;
- }
-}
\ No newline at end of file
diff --git a/src/components/notify-bar.ts b/src/components/notify-bar.ts
deleted file mode 100644
index 3676ae8..0000000
--- a/src/components/notify-bar.ts
+++ /dev/null
@@ -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`
-
- ${this.renderIcon()}
- ${this.message}
- ${this.dismissible
- ? html`
-
- `
- : null}
-
- `;
- }
-}
diff --git a/src/components/stat-card.ts b/src/components/stat-card.ts
deleted file mode 100644
index 84dbd7f..0000000
--- a/src/components/stat-card.ts
+++ /dev/null
@@ -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`
-
- ${this.value}
- ${this.label}
-
- `;
- }
-}
\ No newline at end of file
diff --git a/src/components/ui-badge.ts b/src/components/ui-badge.ts
deleted file mode 100644
index 5b15ece..0000000
--- a/src/components/ui-badge.ts
+++ /dev/null
@@ -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`
-
- ${this.hasIcon
- ? html`
-
- `
- : html``}
-
-
-
-
- `;
- }
-}
diff --git a/src/components/ui-button-secondary.ts b/src/components/ui-button-secondary.ts
deleted file mode 100644
index c543156..0000000
--- a/src/components/ui-button-secondary.ts
+++ /dev/null
@@ -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`
-
- `;
- }
-}
\ No newline at end of file
diff --git a/src/components/ui-button.ts b/src/components/ui-button.ts
deleted file mode 100644
index 919b08f..0000000
--- a/src/components/ui-button.ts
+++ /dev/null
@@ -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`
-
- `;
- }
-}
\ No newline at end of file
diff --git a/src/components/ui-card.ts b/src/components/ui-card.ts
deleted file mode 100644
index aaa1b95..0000000
--- a/src/components/ui-card.ts
+++ /dev/null
@@ -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`
-
-
-
- `;
- }
-}
\ No newline at end of file
diff --git a/src/components/ui-link.ts b/src/components/ui-link.ts
deleted file mode 100644
index b9d14a2..0000000
--- a/src/components/ui-link.ts
+++ /dev/null
@@ -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`
-
-
-
- `;
- }
-}
\ No newline at end of file
diff --git a/src/components/ui-tab-bar.ts b/src/components/ui-tab-bar.ts
deleted file mode 100644
index e96fa17..0000000
--- a/src/components/ui-tab-bar.ts
+++ /dev/null
@@ -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`
-
- ${this.tabs.map(
- (t) => html`
-
- `
- )}
-
- `;
- }
-}
\ No newline at end of file
diff --git a/src/i18n/keys.ts b/src/i18n/keys.ts
new file mode 100644
index 0000000..4989437
--- /dev/null
+++ b/src/i18n/keys.ts
@@ -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";
diff --git a/src/i18n/locale-controller.ts b/src/i18n/locale-controller.ts
new file mode 100644
index 0000000..a120e2f
--- /dev/null
+++ b/src/i18n/locale-controller.ts
@@ -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;
+ }
+}
diff --git a/src/i18n/locale.ts b/src/i18n/locale.ts
new file mode 100644
index 0000000..772c395
--- /dev/null
+++ b/src/i18n/locale.ts
@@ -0,0 +1,44 @@
+import type { TranslationKey } from "./keys.js";
+
+export type { TranslationKey };
+export type Locale = "de" | "en";
+
+type TranslationMap = Record;
+
+let _current: Locale = (localStorage.getItem("locale") as Locale) ?? "de";
+const _cache = new Map();
+const _listeners = new Set<() => void>();
+
+async function _load(l: Locale): Promise {
+ 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 {
+ await _load(_current);
+ _listeners.forEach((fn) => fn());
+ },
+
+ async set(l: Locale): Promise {
+ 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);
+ },
+};
diff --git a/src/main.ts b/src/main.ts
index 8728dc3..f5af323 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,2 +1,4 @@
-import './styles/theme.css';
-import './app-root';
\ No newline at end of file
+import "./styles/theme.css";
+import { locale } from "./i18n/locale.js";
+
+locale.init().then(() => import("./app-root.js"));
diff --git a/src/pages/admin/admin-home-page.ts b/src/pages/admin/admin-home-page.ts
deleted file mode 100644
index e4322ad..0000000
--- a/src/pages/admin/admin-home-page.ts
+++ /dev/null
@@ -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`
- Welcome to the Competition Center Homepage
- Analyze your tracks visually.
- `;
- }
-}
\ No newline at end of file
diff --git a/src/pages/auth/login-page.ts b/src/pages/auth/login-page.ts
index d376939..fab16f0 100644
--- a/src/pages/auth/login-page.ts
+++ b/src/pages/auth/login-page.ts
@@ -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[0]) => this._t.t(k);
+
return html`
-
- (this.email = e.detail.value)}
- >
+
+
+
+
+

+
+
`;
}
}
diff --git a/src/pages/auth/register-page.ts b/src/pages/auth/register-page.ts
index 0b6313b..31067ce 100644
--- a/src/pages/auth/register-page.ts
+++ b/src/pages/auth/register-page.ts
@@ -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`
-
-
Country
-
-
- ${selected
- ? html`
${selected.label}`
- : html`
Select your country`}
-
-
-
-
-
-
- ${this.dropdownOpen
- ? html`
-
-
- (this.dropdownSearch = (
- e.target as HTMLInputElement
- ).value)}
- @click=${(e: MouseEvent) => e.stopPropagation()}
- />
-
-
- `
- : ""}
-
- `;
- }
render() {
return html`
-
-
-
- (this.firstname = e.detail.value)}
- style="flex: 1; min-width: 0;"
- >
-
-
- (this.lastname = e.detail.value)}
- style="flex: 1; min-width: 0;"
- >
-
-
- (this.email = e.detail.value)}
- >
-
-
- (this.phonenumber = e.detail.value)}
- >
-
- ${this.renderDropdown()}
-
- (this.password = e.detail.value)}
- >
-
-
-
-
- ${this.loading ? "Creating account..." : "Create account"}
-
-
-
-
-
-
`;
}
}
diff --git a/src/pages/competition/competition-page.ts b/src/pages/competition/competition-page.ts
deleted file mode 100644
index aa42352..0000000
--- a/src/pages/competition/competition-page.ts
+++ /dev/null
@@ -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 Promise> = {
- 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();
- @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`
-
- `;
- }
-
- switch (this.activeTab) {
- case "details":
- return html``;
- case "results":
- return html``;
- case "tasks":
- return html``;
- case "noticeboard":
- return html``;
- case "pilots":
- return html``;
- case "officials":
- return html``;
- default:
- return nothing;
- }
- }
-
- render() {
- return html`
-
-
-
-
- ${this.renderTabContent()}
- `;
- }
-}
diff --git a/src/pages/competition/tabs/competition-details.ts b/src/pages/competition/tabs/competition-details.ts
deleted file mode 100644
index 55515ef..0000000
--- a/src/pages/competition/tabs/competition-details.ts
+++ /dev/null
@@ -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`
-
-
-
-
DATE_FROM – DATE_UNTIL
-
- COMPETITION_DETAILS
-
-
-
-
-
-
- `;
- }
-}
-
-export default CompetitionDetails;
\ No newline at end of file
diff --git a/src/pages/competition/tabs/competition-noticeboard.ts b/src/pages/competition/tabs/competition-noticeboard.ts
deleted file mode 100644
index 2c8fbd0..0000000
--- a/src/pages/competition/tabs/competition-noticeboard.ts
+++ /dev/null
@@ -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`
-
-
Noticeboard
-
Content will appear here once the competition is underway.
-
- `;
- }
-}
-
-export default CompetitionNoticeboard;
\ No newline at end of file
diff --git a/src/pages/competition/tabs/competition-officials.ts b/src/pages/competition/tabs/competition-officials.ts
deleted file mode 100644
index f436b9a..0000000
--- a/src/pages/competition/tabs/competition-officials.ts
+++ /dev/null
@@ -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`
-
-
Officials
-
Content will appear here once the competition is underway.
-
- `;
- }
-}
-
-export default CompetitionOfficials;
\ No newline at end of file
diff --git a/src/pages/competition/tabs/competition-pilots.ts b/src/pages/competition/tabs/competition-pilots.ts
deleted file mode 100644
index b87028d..0000000
--- a/src/pages/competition/tabs/competition-pilots.ts
+++ /dev/null
@@ -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`
-
-
Pilots
-
Content will appear here once the competition is underway.
-
- `;
- }
-}
-
-export default CompetitionPilots;
\ No newline at end of file
diff --git a/src/pages/competition/tabs/competition-results.ts b/src/pages/competition/tabs/competition-results.ts
deleted file mode 100644
index e453ff1..0000000
--- a/src/pages/competition/tabs/competition-results.ts
+++ /dev/null
@@ -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`
-
-
Results
-
Content will appear here once the competition is underway.
-
- `;
- }
-}
-
-export default CompetitionResults;
\ No newline at end of file
diff --git a/src/pages/competition/tabs/competition-tasks.ts b/src/pages/competition/tabs/competition-tasks.ts
deleted file mode 100644
index 1e65cc7..0000000
--- a/src/pages/competition/tabs/competition-tasks.ts
+++ /dev/null
@@ -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`
-
-
Task Data
-
Content will appear here once the competition is underway.
-
- `;
- }
-}
-
-export default CompetitionTasks;
\ No newline at end of file
diff --git a/src/pages/dev-page.ts b/src/pages/dev-page.ts
deleted file mode 100644
index 857fde3..0000000
--- a/src/pages/dev-page.ts
+++ /dev/null
@@ -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`
-
-
-
-
-
-
Buttons
-
Primary and secondary action buttons
-
-
-
Primary
-
- Default
-
-
-
-
-
- With Icon
-
- Disabled
-
-
Primary Full Width
-
- Full Width Button
-
-
Secondary
-
-
Default
-
-
-
-
-
- With Icon
-
-
Disabled
-
-
Secondary Full Width
-
-
- Full Width Secondary
-
-
-
-
-
-
-
-
Badges
-
Status and category indicators
-
-
-
Variants
-
- Accent
- Success
- Warning
- Error
- Muted
-
-
With Icon
-
-
- ${Icons.icons.stack}
- Platform
-
-
- ${Icons.icons.success}
- Live
-
-
- ${Icons.icons.error}
- Offline
-
-
-
-
-
-
-
-
Notification Bars
-
Inline feedback messages
-
-
-
Types
-
-
-
-
-
-
-
Non-Dismissible
-
-
-
-
-
-
-
-
-
Links
-
Navigation links with underline animation
-
-
-
States
-
-
Default Link
-
-
Active Link
-
-
-
-
-
-
-
Form Inputs
-
Text input fields with labels
-
-
-
Types
-
-
-
- (this.inputValue = e.detail.value)}
- >
-
-
-
-
-
Live Value
-
-
- Current value: "${this.inputValue}"
-
-
-
-
-
-
-
-
Stat Cards
-
Metric display cards
-
-
-
-
-
-
-
Icon Cards
-
Feature highlight cards
-
-
-
Grid
-
-
- ${Icons.icons.clock}
-
-
- ${Icons.icons.map_pin}
-
-
-
-
-
-
-
-
Cards
-
Container cards and layout primitives
-
-
-
ui-card (default)
-
-
-
-
- This is the default card layout with a header component
- inside.
-
-
- Action
-
-
-
ui-card (centered)
-
-
-
- 404
-
-
-
- Action
-
-
-
-
-
-
-
-
Divider
-
Visual separator for content sections
-
-
-
horizontal-divider
-
-
- Content above
-
-
-
- Content below
-
-
-
-
-
-
-
-
Composed: Auth Card
-
Pre-composed card for authentication flows
-
-
-
Login Example
-
-
-
-
-
-
- Sign in
-
-
- No account?
- Create one
-
-
-
-
-
-
-
-
-
Loading Bars
-
Progress indicators with variants and states
-
-
-
Variants
-
-
-
-
-
-
-
Sizes
-
-
-
-
-
-
Indeterminate
-
-
-
-
-
-
-
-
-
Line Chart
-
Trend lines with optional area fill
-
-
-
-
-
-
-
Circle Chart
-
Donut chart for proportional data
-
-
-
-
- `;
- }
-}
diff --git a/src/pages/home-page.ts b/src/pages/home-page.ts
index c5395ba..f8be8c3 100644
--- a/src/pages/home-page.ts
+++ b/src/pages/home-page.ts
@@ -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`
-
-
- ${Icons.icons.stack}
- Live Scoring Platform
-
-
Transparent scoring for balloon competitions
-
- Track tasks, manage participants, and deliver real-time results for
- hot air balloon competitions worldwide.
-
-
- this.navigate("/competitions")}>
- Browse competitions
-
- this.navigate("/register")}>
- Create account
-
-
-
+
+
+
+
+
+
+
+ console.log(v)}
+ >
+
-
- At a glance
- Platform in numbers
-
- FlightScore powers balloon competitions across the globe.
-
-
-
-
-
-
-
-
-
-
- Features
- Built for balloon events
-
- Everything organizers, officials, juries and pilots need in one place.
-
-
-
- ${Icons.icons.clock}
-
-
- ${Icons.icons.document_text}
-
-
- ${Icons.icons.users}
-
-
- ${Icons.icons.desktop}
-
-
- ${Icons.icons.map_pin}
-
-
- ${Icons.icons.table}
-
-
-
-
-
- Competitions
- Upcoming and recent events
-
- Discover balloon competitions scored with FlightScore.
-
-
-
this.navigate("/competitions/1")}
- >
-
- 18
- Mar
-
-
- European Balloon Challenge 2026
-
- ${Icons.icons.map_pin} Salzburg, Austria
-
-
-
Upcoming
-
-
-
this.navigate("/competitions/2")}
- >
-
- 14
- Feb
-
-
- Cappadocia Open 2026
-
- ${Icons.icons.map_pin} Goreme, Turkey
-
-
-
Live
-
-
-
this.navigate("/competitions/3")}
- >
-
- 02
- Feb
-
-
- Albuquerque Winter Fiesta
-
- ${Icons.icons.map_pin} New Mexico, USA
-
-
-
Ended
-
-
-
this.navigate("/competitions/4")}
- >
-
- 11
- Jan
-
-
- Swiss Alpine Balloon Trophy
-
- ${Icons.icons.map_pin} Chateau-d'Oex, Switzerland
-
-
-
Canceled
-
-
-
`;
}
}
diff --git a/src/pages/not-found-page.ts b/src/pages/not-found-page.ts
deleted file mode 100644
index e5aaa89..0000000
--- a/src/pages/not-found-page.ts
+++ /dev/null
@@ -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`
-
-
- 404
- Page not found
-
- The page you are looking for does not exist or has been
- moved.
-
-
-
-
-
- this.navigate('/')}>
-
-
-
-
- Back to home
-
-
-
- `;
- }
-}
\ No newline at end of file
diff --git a/src/pages/tt/tt-home-page.ts b/src/pages/tt/tt-home-page.ts
deleted file mode 100644
index 6505157..0000000
--- a/src/pages/tt/tt-home-page.ts
+++ /dev/null
@@ -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`
- Welcome to the Target Team Homepage
- Analyze your tracks visually.
- `;
- }
-}
\ No newline at end of file
diff --git a/src/styles/theme.css b/src/styles/theme.css
index 2ff70b8..8a11555 100644
--- a/src/styles/theme.css
+++ b/src/styles/theme.css
@@ -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;
}
-
-