From d8c12c70b6823339baa028065e13647734fbf1da Mon Sep 17 00:00:00 2001 From: CodingPhoenixx Date: Mon, 16 Feb 2026 15:34:56 +0100 Subject: [PATCH] Added a competition page --- flightscore/src/components/_components | 23 +- flightscore/src/components/ui-badge.ts | 98 +++---- flightscore/src/components/ui-tab-bar.ts | 112 ++++++++ flightscore/src/pages/competition-page.ts | 18 -- .../src/pages/competition/competition-page.ts | 262 ++++++++++++++++++ .../competition/tabs/competition-details.ts | 213 ++++++++++++++ .../tabs/competition-noticeboard.ts | 42 +++ .../competition/tabs/competition-officials.ts | 41 +++ .../competition/tabs/competition-pilots.ts | 42 +++ .../competition/tabs/competition-results.ts | 42 +++ .../competition/tabs/competition-tasks.ts | 42 +++ flightscore/src/pages/home-page.ts | 6 +- flightscore/src/router/router.ts | 1 + 13 files changed, 873 insertions(+), 69 deletions(-) create mode 100644 flightscore/src/components/ui-tab-bar.ts delete mode 100644 flightscore/src/pages/competition-page.ts create mode 100644 flightscore/src/pages/competition/competition-page.ts create mode 100644 flightscore/src/pages/competition/tabs/competition-details.ts create mode 100644 flightscore/src/pages/competition/tabs/competition-noticeboard.ts create mode 100644 flightscore/src/pages/competition/tabs/competition-officials.ts create mode 100644 flightscore/src/pages/competition/tabs/competition-pilots.ts create mode 100644 flightscore/src/pages/competition/tabs/competition-results.ts create mode 100644 flightscore/src/pages/competition/tabs/competition-tasks.ts diff --git a/flightscore/src/components/_components b/flightscore/src/components/_components index 2e5299a..4fe818b 100644 --- a/flightscore/src/components/_components +++ b/flightscore/src/components/_components @@ -182,4 +182,25 @@ _No attributes. Renders a visual separator line._ |---------|----------|--------------------| | `label` | `string` | Legend label | | `value` | `number` | Segment value | -| `color` | `string` | CSS color value | \ No newline at end of file +| `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/flightscore/src/components/ui-badge.ts b/flightscore/src/components/ui-badge.ts index f2e9d38..7d8172e 100644 --- a/flightscore/src/components/ui-badge.ts +++ b/flightscore/src/components/ui-badge.ts @@ -1,47 +1,47 @@ // components/ui-badge.ts -import { LitElement, html, css } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; +import { LitElement, html, css } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; -export type BadgeVariant = 'accent' | 'success' | 'warning' | 'error' | 'muted'; +export type BadgeVariant = "accent" | "success" | "warning" | "error" | "muted"; -@customElement('ui-badge') +@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; -} + .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 { + display: inline-flex; + align-items: center; + flex-shrink: 0; + } -.icon ::slotted(svg) { - width: 0.85rem; - height: 0.85rem; -} + .icon ::slotted(svg) { + width: 0.85rem; + height: 0.85rem; + } -.label { - display: inline-flex; - align-items: center; - padding-top: 0.05em; -} + .label { + display: inline-flex; + align-items: center; + padding-top: 0.05em; + } .badge.accent { color: var(--color-accent); @@ -62,9 +62,9 @@ export class UiBadge extends LitElement { } .badge.error { - color: #e5484d; - background: color-mix(in srgb, #e5484d 10%, transparent); - border-color: color-mix(in srgb, #e5484d 25%, transparent); + color: #e5484d; + background: color-mix(in srgb, #e5484d 10%, transparent); + border-color: color-mix(in srgb, #e5484d 25%, transparent); } .badge.muted { @@ -74,7 +74,7 @@ export class UiBadge extends LitElement { } `; - @property() variant: BadgeVariant = 'accent'; + @property() variant: BadgeVariant = "accent"; @state() private hasIcon = false; private handleSlotChange(e: Event) { @@ -84,16 +84,20 @@ export class UiBadge extends LitElement { render() { return html` - - ${this.hasIcon - ? html` - - ` - : html``} - - + + ${this.hasIcon + ? html` + + ` + : html``} + + + - - `; + `; } -} \ No newline at end of file +} diff --git a/flightscore/src/components/ui-tab-bar.ts b/flightscore/src/components/ui-tab-bar.ts new file mode 100644 index 0000000..d3c636a --- /dev/null +++ b/flightscore/src/components/ui-tab-bar.ts @@ -0,0 +1,112 @@ +// components/ui-tab-bar.ts +import { LitElement, html, css } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +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 icons: Record = { + calendar: html``, + trophy: html``, + target: html``, + clipboard: html``, + users: html``, + user: html``, + }; + + 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/flightscore/src/pages/competition-page.ts b/flightscore/src/pages/competition-page.ts deleted file mode 100644 index e48e76a..0000000 --- a/flightscore/src/pages/competition-page.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { LitElement, html } from 'lit'; -import { customElement } from 'lit/decorators.js'; - -@customElement('competition-page') -export class CompetitionPage extends LitElement { - render() { - return html` -

Welcome to the Competitions

-

Welcome to the Competitions

-

Welcome to the Competitions

-

Welcome to the Competitions

-

Welcome to the Competitions

-

Welcome to the Competitions

-

Welcome to the Competitions

-

Welcome to the Competitions

- `; - } -} \ No newline at end of file diff --git a/flightscore/src/pages/competition/competition-page.ts b/flightscore/src/pages/competition/competition-page.ts new file mode 100644 index 0000000..f0be9bc --- /dev/null +++ b/flightscore/src/pages/competition/competition-page.ts @@ -0,0 +1,262 @@ +// 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"; + +type TabModule = { default: () => unknown }; + +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` +
+
+ Loading… +
+ `; + } + + 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` +
+
+ + +

+ 22. Schweizermeisterschaften Heissluftballon / Swiss Cup +

+ +
+
+ + + + + Langenthal / BE, Switzerland +
+
+ + + + 13 – 17 May 2026 +
+ CAT2 + Upcoming +
+
+
+ + + +
${this.renderTabContent()}
+ `; + } +} \ No newline at end of file diff --git a/flightscore/src/pages/competition/tabs/competition-details.ts b/flightscore/src/pages/competition/tabs/competition-details.ts new file mode 100644 index 0000000..eb4cbfd --- /dev/null +++ b/flightscore/src/pages/competition/tabs/competition-details.ts @@ -0,0 +1,213 @@ +// tabs/competition-details.ts +import { LitElement, html, css } from "lit"; +import { customElement } from "lit/decorators.js"; + +@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` +
+
+
+
13 May 2026 – 17 May 2026
+
+ Schweizermeisterschaften 2026 + Open Swiss Nationals & Swiss Cup + Location: Berufsfachschule BZL Langenthal + Official announcement can be found in the + ENB + Registration via + https://www.smhl.ch/de/registration +
+
+ +
+ + + + +
+
+ + +
+ `; + } +} + +export default CompetitionDetails; \ No newline at end of file diff --git a/flightscore/src/pages/competition/tabs/competition-noticeboard.ts b/flightscore/src/pages/competition/tabs/competition-noticeboard.ts new file mode 100644 index 0000000..2c8fbd0 --- /dev/null +++ b/flightscore/src/pages/competition/tabs/competition-noticeboard.ts @@ -0,0 +1,42 @@ +// 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/flightscore/src/pages/competition/tabs/competition-officials.ts b/flightscore/src/pages/competition/tabs/competition-officials.ts new file mode 100644 index 0000000..f436b9a --- /dev/null +++ b/flightscore/src/pages/competition/tabs/competition-officials.ts @@ -0,0 +1,41 @@ +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/flightscore/src/pages/competition/tabs/competition-pilots.ts b/flightscore/src/pages/competition/tabs/competition-pilots.ts new file mode 100644 index 0000000..b87028d --- /dev/null +++ b/flightscore/src/pages/competition/tabs/competition-pilots.ts @@ -0,0 +1,42 @@ +// 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/flightscore/src/pages/competition/tabs/competition-results.ts b/flightscore/src/pages/competition/tabs/competition-results.ts new file mode 100644 index 0000000..e453ff1 --- /dev/null +++ b/flightscore/src/pages/competition/tabs/competition-results.ts @@ -0,0 +1,42 @@ +// 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/flightscore/src/pages/competition/tabs/competition-tasks.ts b/flightscore/src/pages/competition/tabs/competition-tasks.ts new file mode 100644 index 0000000..1e65cc7 --- /dev/null +++ b/flightscore/src/pages/competition/tabs/competition-tasks.ts @@ -0,0 +1,42 @@ +// 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/flightscore/src/pages/home-page.ts b/flightscore/src/pages/home-page.ts index 592f851..216450f 100644 --- a/flightscore/src/pages/home-page.ts +++ b/flightscore/src/pages/home-page.ts @@ -254,7 +254,7 @@ export class HomePage extends LitElement { Live Scoring Platform -

Precision scoring for balloon competitions

+

Transparent scoring for balloon competitions

Track tasks, manage participants, and deliver real-time results for hot air balloon competitions worldwide. @@ -292,12 +292,12 @@ export class HomePage extends LitElement {

Built for balloon events

- Everything organizers, judges, and pilots need in one place. + Everything organizers, officials, juries and pilots need in one place.

diff --git a/flightscore/src/router/router.ts b/flightscore/src/router/router.ts index 519f3d6..d69a9af 100644 --- a/flightscore/src/router/router.ts +++ b/flightscore/src/router/router.ts @@ -13,6 +13,7 @@ export class Router { this.routes = routes; this.outlet = outlet; window.addEventListener('popstate', () => this.resolve()); + this.resolve(); } navigate(path: string) {