feat: add new projects bauntown, presi, voxel-lava, whopixels
- apps/bauntown: Developer community website (Astro landing) - apps/presi: Presentation project - games/voxel-lava: Voxel lava game (SvelteKit) - games/whopixels: Whopixels game 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
26
apps/bauntown/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# build output
|
||||
dist/
|
||||
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
.env.local
|
||||
.netlify
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
121
apps/bauntown/CLAUDE.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# CLAUDE.md - BaunTown
|
||||
|
||||
This file provides guidance to Claude Code when working with the BaunTown project.
|
||||
|
||||
## Project Overview
|
||||
|
||||
BaunTown is a community website for developers and creators with:
|
||||
- Multilingual support (DE, EN, IT)
|
||||
- Payment integration (Stripe, PayPal)
|
||||
- Content collections for news, projects, tutorials, etc.
|
||||
- Netlify deployment with serverless functions
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
apps/bauntown/
|
||||
├── apps/
|
||||
│ └── landing/ # Astro landing page
|
||||
│ ├── netlify/ # Serverless functions
|
||||
│ ├── public/ # Static assets
|
||||
│ ├── src/ # Source code
|
||||
│ ├── astro.config.mjs
|
||||
│ ├── netlify.toml
|
||||
│ └── package.json # @bauntown/landing
|
||||
├── packages/ # For future shared packages
|
||||
├── readme/ # Documentation
|
||||
├── package.json # Root orchestrator
|
||||
└── CLAUDE.md # This file
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
# From monorepo root
|
||||
pnpm install
|
||||
|
||||
# Start BaunTown landing page
|
||||
pnpm bauntown:dev
|
||||
|
||||
# Or directly
|
||||
pnpm dev:bauntown:landing
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
# Build for production
|
||||
pnpm --filter @bauntown/landing build
|
||||
|
||||
# Preview production build
|
||||
pnpm --filter @bauntown/landing preview
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Create `apps/bauntown/apps/landing/.env`:
|
||||
```bash
|
||||
STRIPE_SECRET_KEY=sk_...
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
GOOGLE_SHEETS_CREDENTIALS=...
|
||||
PUBLIC_STRIPE_KEY=pk_...
|
||||
```
|
||||
|
||||
## Technology Stack
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|------------|
|
||||
| Framework | Astro 5.x |
|
||||
| Styling | CSS/Tailwind |
|
||||
| i18n | astro-i18n-aut (DE, EN, IT) |
|
||||
| Payments | Stripe, PayPal |
|
||||
| Analytics | Plausible (via Partytown) |
|
||||
| APIs | Google Sheets/Docs |
|
||||
| Deployment | Netlify (SSR + Functions) |
|
||||
|
||||
## Content Collections
|
||||
|
||||
BaunTown uses Astro Content Collections:
|
||||
|
||||
| Collection | Purpose |
|
||||
|------------|---------|
|
||||
| tools | Design, Development, Productivity tools |
|
||||
| news | AI, Web, Design, Community news |
|
||||
| models | AI models (Text, Image) |
|
||||
| projects | Web, Mobile, Desktop projects |
|
||||
| tutorials | Courses (UI/UX, Business, Marketing) |
|
||||
| missions | Community challenges |
|
||||
| vision | Long-term vision items |
|
||||
| join | Join page content |
|
||||
| members | Team members |
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
- **TypeScript**: Strict mode (extends "astro/tsconfigs/strict")
|
||||
- **Components**: Use `.astro` files, keep small and focused
|
||||
- **Naming**: PascalCase for components, camelCase for variables
|
||||
- **Formatting**: 2 spaces indentation
|
||||
- **Imports**: Group by type (Astro, npm, local)
|
||||
|
||||
## URL Structure (i18n)
|
||||
|
||||
```
|
||||
/de/ # German (default)
|
||||
/en/ # English
|
||||
/it/ # Italian
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
Deployed via Netlify with `@astrojs/netlify` adapter:
|
||||
- Static pages pre-rendered
|
||||
- Dynamic routes use Netlify Functions
|
||||
- Configuration in `netlify.toml`
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- `STRIPE-INTEGRATION-README.md` - Payment setup guide
|
||||
- `readme/PlausibleCustomEventsReadMe.md` - Analytics setup
|
||||
- `readme/PossibleNextSteps.md` - Future roadmap
|
||||
70
apps/bauntown/README.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# BaunTown Website
|
||||
|
||||
Eine Community-Website für Baun.Town, eine Gemeinschaft von Entwicklern und Kreativen.
|
||||
|
||||
## Funktionen
|
||||
|
||||
- Mehrsprachige Unterstützung (Deutsch, Englisch, Italienisch)
|
||||
- Responsive Design für alle Geräte
|
||||
- News, Projekte, Tutorials und Missionen
|
||||
- Unterstützungsmöglichkeit mit Stripe und PayPal
|
||||
|
||||
## Technologie
|
||||
|
||||
- [Astro](https://astro.build)
|
||||
- [TypeScript](https://www.typescriptlang.org/)
|
||||
- [Stripe](https://stripe.com) und [PayPal](https://paypal.com) für Zahlungen
|
||||
- [Netlify](https://netlify.com) für Hosting und serverless Functions
|
||||
|
||||
## Entwicklung
|
||||
|
||||
```bash
|
||||
# Abhängigkeiten installieren
|
||||
npm install
|
||||
|
||||
# Entwicklungsserver starten
|
||||
npm run dev
|
||||
|
||||
# Für Produktion bauen
|
||||
npm run build
|
||||
|
||||
# Vorschau der Produktion
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## Deployment auf Netlify
|
||||
|
||||
### Vorbereitung
|
||||
|
||||
1. Ein Netlify-Konto erstellen
|
||||
2. Dieses Repository mit deinem Netlify-Konto verbinden
|
||||
3. Die folgenden Umgebungsvariablen in den Netlify-Einstellungen konfigurieren:
|
||||
|
||||
```
|
||||
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
|
||||
PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_stripe_publishable_key
|
||||
STRIPE_WEBHOOK_SECRET=whsec_your_stripe_webhook_secret
|
||||
|
||||
PAYPAL_CLIENT_ID=your_paypal_client_id
|
||||
PAYPAL_CLIENT_SECRET=your_paypal_client_secret
|
||||
```
|
||||
|
||||
### Stripe und PayPal konfigurieren
|
||||
|
||||
1. **Stripe**
|
||||
- Erstelle ein Konto bei [Stripe](https://stripe.com)
|
||||
- Generiere API-Schlüssel im Dashboard
|
||||
- Konfiguriere Webhook-Endpunkte für `https://deine-domain.netlify.app/.netlify/functions/process-payment-webhook?source=stripe`
|
||||
|
||||
2. **PayPal**
|
||||
- Erstelle ein Entwicklerkonto bei [PayPal Developer](https://developer.paypal.com)
|
||||
- Erstelle eine Anwendung, um Client-ID und Secret zu erhalten
|
||||
- Konfiguriere Webhook-Endpunkte für `https://deine-domain.netlify.app/.netlify/functions/process-payment-webhook?source=paypal`
|
||||
|
||||
## Internationalisierung
|
||||
|
||||
Die Website unterstützt mehrere Sprachen mit einer URL-Struktur wie `example.com/de/` für Deutsch.
|
||||
|
||||
- `de` - Deutsch (Standard)
|
||||
- `en` - Englisch
|
||||
- `it` - Italienisch
|
||||
354
apps/bauntown/STRIPE-INTEGRATION-README.md
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
# Stripe Integration für BaunTown "Buy Me a Coffee"
|
||||
|
||||
Dieses Dokument beschreibt detailliert, wie die Stripe-Integration für das "Buy Me a Coffee"-Feature auf der BaunTown-Website implementiert wurde, einschließlich der Herausforderungen und Lösungen.
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
|
||||
1. [Überblick](#überblick)
|
||||
2. [Anforderungen](#anforderungen)
|
||||
3. [Architektur](#architektur)
|
||||
4. [Implementierungsschritte](#implementierungsschritte)
|
||||
5. [Herausforderungen und Lösungen](#herausforderungen-und-lösungen)
|
||||
6. [Konfiguration in Netlify](#konfiguration-in-netlify)
|
||||
7. [Lokale Entwicklung](#lokale-entwicklung)
|
||||
8. [Tipps zur Fehlerbehebung](#tipps-zur-fehlerbehebung)
|
||||
|
||||
## Überblick
|
||||
|
||||
Die "Buy Me a Coffee"-Funktion ermöglicht es Besuchern, BaunTown durch einmalige oder wiederkehrende Spenden zu unterstützen. Benutzer können zwischen verschiedenen Kaffeegrößen wählen (3€, 5€ oder 8€) und entweder mit Stripe oder PayPal bezahlen.
|
||||
|
||||
## Anforderungen
|
||||
|
||||
- Unterstützung für einmalige und wiederkehrende Zahlungen
|
||||
- Verschiedene Preisstufen (Kaffeegrößen)
|
||||
- Integration von Stripe und PayPal als Zahlungsmethoden
|
||||
- Mehrsprachige Unterstützung (DE, EN, IT)
|
||||
- Sichere Verarbeitung von Zahlungsinformationen
|
||||
- Erfolgs- und Fehlermeldungen
|
||||
|
||||
## Architektur
|
||||
|
||||
Die Implementierung verwendet einen serverseitigen Ansatz mit Netlify Functions:
|
||||
|
||||
1. **Frontend-Komponenten**:
|
||||
- `PaymentForm.astro`: Zeigt die Zahlungsoptionen an und verarbeitet Benutzerinteraktionen
|
||||
- `support.astro`: Hauptseite für das "Buy Me a Coffee"-Feature
|
||||
|
||||
2. **Backend-Services (Netlify Functions)**:
|
||||
- `create-payment-intent.js`: Erstellt Stripe Checkout-Sessions
|
||||
- `create-paypal-order.js`: Verarbeitet PayPal-Bestellungen
|
||||
|
||||
3. **Datenfluss**:
|
||||
- Benutzer wählt Kaffeegröße und Zahlungsmethode
|
||||
- Frontend sendet Anfrage an entsprechende Netlify Function
|
||||
- Function erstellt eine Checkout-Session bei Stripe
|
||||
- Benutzer wird zur Stripe-Checkout-Seite weitergeleitet
|
||||
- Nach erfolgreicher Zahlung wird der Benutzer zur Erfolgsseite weitergeleitet
|
||||
|
||||
## Implementierungsschritte
|
||||
|
||||
### 1. Einrichtung der Umgebungsvariablen
|
||||
|
||||
```
|
||||
# .env & Netlify Environment Variables
|
||||
PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
|
||||
STRIPE_SECRET_KEY=sk_live_...
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
```
|
||||
|
||||
**WICHTIG**: Für Astro-Projekte müssen Frontend-Umgebungsvariablen mit dem Präfix `PUBLIC_` beginnen, damit sie im Browser verfügbar sind.
|
||||
|
||||
### 2. Installation der notwendigen Pakete
|
||||
|
||||
```bash
|
||||
npm install stripe @stripe/stripe-js @paypal/paypal-js
|
||||
```
|
||||
|
||||
### 3. Erstellung der Frontend-Komponenten
|
||||
|
||||
#### PaymentForm.astro
|
||||
```astro
|
||||
---
|
||||
import { useTranslations } from '../utils/i18n';
|
||||
|
||||
interface Props {
|
||||
lang: string;
|
||||
}
|
||||
|
||||
const { lang } = Astro.props;
|
||||
const t = useTranslations(lang);
|
||||
---
|
||||
|
||||
<div class="payment-container">
|
||||
<!-- Zahlungstyp-Auswahl -->
|
||||
<div class="payment-type-selector">
|
||||
<button id="one-time" class="payment-type-btn active">{t('support.onetime')}</button>
|
||||
<button id="recurring" class="payment-type-btn">{t('support.recurring')}</button>
|
||||
</div>
|
||||
|
||||
<!-- Kaffee-Größen -->
|
||||
<div class="coffee-options">
|
||||
<!-- ... -->
|
||||
</div>
|
||||
|
||||
<!-- Zahlungsmethoden -->
|
||||
<div class="payment-buttons">
|
||||
<button id="stripe-button" class="payment-method-btn">
|
||||
<!-- ... -->
|
||||
<span>{t('support.payWithStripe')}</span>
|
||||
</button>
|
||||
<button id="paypal-button" class="payment-method-btn">
|
||||
<!-- ... -->
|
||||
<span>{t('support.payWithPayPal')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Stripe-Instanz initialisieren
|
||||
const stripePromise = loadStripe(import.meta.env.PUBLIC_STRIPE_PUBLISHABLE_KEY || 'pk_test_placeholder');
|
||||
|
||||
// Event-Listener für Stripe-Button
|
||||
stripeBtn?.addEventListener('click', async () => {
|
||||
try {
|
||||
// Checkout-Session erstellen
|
||||
const response = await fetch('/.netlify/functions/create-payment-intent', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount, isRecurring, priceId, coffeeSize
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Zur Checkout-Seite weiterleiten
|
||||
if (data.url) {
|
||||
window.location.href = data.url;
|
||||
return;
|
||||
}
|
||||
|
||||
// Alternativ: redirectToCheckout verwenden
|
||||
if (data.sessionId || data.id) {
|
||||
const stripe = await stripePromise;
|
||||
await stripe.redirectToCheckout({
|
||||
sessionId: data.sessionId || data.id,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Payment error:', error);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### 4. Implementierung der Netlify Functions
|
||||
|
||||
#### create-payment-intent.js
|
||||
|
||||
```javascript
|
||||
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
|
||||
|
||||
exports.handler = async (event, context) => {
|
||||
const headers = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS"
|
||||
};
|
||||
|
||||
try {
|
||||
const { amount, isRecurring, coffeeSize } = JSON.parse(event.body || '{}');
|
||||
const amountInCents = Math.round(amount * 100);
|
||||
|
||||
// Stripe Checkout Session erstellen
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
payment_method_types: ['card'],
|
||||
line_items: [{
|
||||
price_data: {
|
||||
currency: 'eur',
|
||||
product_data: {
|
||||
name: `BaunTown Kaffee - ${coffeeSize || 'Mittlerer Kaffee'}`,
|
||||
description: isRecurring ? 'Monatliche Unterstützung' : 'Einmalige Unterstützung',
|
||||
},
|
||||
unit_amount: amountInCents,
|
||||
recurring: isRecurring ? { interval: 'month' } : undefined,
|
||||
},
|
||||
quantity: 1,
|
||||
}],
|
||||
mode: isRecurring ? 'subscription' : 'payment',
|
||||
success_url: `${process.env.URL || 'https://bauntown.com'}/support-success`,
|
||||
cancel_url: `${process.env.URL || 'https://bauntown.com'}/support-cancel`,
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
url: session.url
|
||||
})
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
statusCode: 500,
|
||||
headers,
|
||||
body: JSON.stringify({ error: error.message })
|
||||
};
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Herausforderungen und Lösungen
|
||||
|
||||
### 1. Stripe API-Schlüssel-Verfügbarkeit im Frontend
|
||||
|
||||
**Problem**: Der Stripe publishable key war im Frontend nicht verfügbar, obwohl er in den Netlify-Umgebungsvariablen konfiguriert war.
|
||||
|
||||
**Lösung**: In Astro müssen Frontend-Umgebungsvariablen mit dem Präfix `PUBLIC_` beginnen.
|
||||
|
||||
```diff
|
||||
- STRIPE_PUBLISHABLE_KEY=pk_live_...
|
||||
+ PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
|
||||
```
|
||||
|
||||
### 2. "Missing required param: payment_method_data[card]" Fehler
|
||||
|
||||
**Problem**: Der ursprüngliche Ansatz mit `confirmCardPayment` erforderte eine Karten-Input-Komponente, die nicht implementiert war.
|
||||
|
||||
**Lösung**: Verwendung von Stripe Checkout anstelle der direkten Kartenverarbeitung:
|
||||
|
||||
```diff
|
||||
- const { error } = await stripe.confirmCardPayment(data.clientSecret, {
|
||||
- payment_method: {
|
||||
- card: {
|
||||
- // Element fehlt hier
|
||||
- },
|
||||
- billing_details: {
|
||||
- name: 'BaunTown Unterstützer'
|
||||
- }
|
||||
- }
|
||||
- });
|
||||
|
||||
+ // Direkte Weiterleitung zur Stripe Checkout URL
|
||||
+ window.location.href = data.url;
|
||||
```
|
||||
|
||||
### 3. TypeErrors bei der Übersetzungsfunktion
|
||||
|
||||
**Problem**: `TypeError: Cannot read properties of undefined (reading 'home.section1.title')` in der `useTranslations` Funktion.
|
||||
|
||||
**Lösung**: Verbesserte Fehlerbehandlung in der Übersetzungsfunktion:
|
||||
|
||||
```diff
|
||||
export function useTranslations(lang: keyof typeof ui) {
|
||||
return function t(key: keyof typeof ui[typeof defaultLang]) {
|
||||
- return ui[lang][key] || ui[defaultLang][key];
|
||||
+ try {
|
||||
+ if (ui[lang] === undefined) {
|
||||
+ return ui[defaultLang]?.[key] || `[Missing: ${key}]`;
|
||||
+ }
|
||||
+ return ui[lang][key] || ui[defaultLang]?.[key] || `[Missing: ${key}]`;
|
||||
+ } catch (error) {
|
||||
+ console.error(`Translation error for key "${String(key)}" in language "${lang}":`, error);
|
||||
+ return `[Error: ${key}]`;
|
||||
+ }
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Konfiguration in Netlify
|
||||
|
||||
1. **Umgebungsvariablen**:
|
||||
- Navigieren Sie zu Ihrem Netlify-Dashboard
|
||||
- Gehen Sie zu "Site settings" > "Environment variables"
|
||||
- Fügen Sie die folgenden Umgebungsvariablen hinzu:
|
||||
- `PUBLIC_STRIPE_PUBLISHABLE_KEY` (muss mit PUBLIC_ beginnen für Frontend-Verwendung)
|
||||
- `STRIPE_SECRET_KEY`
|
||||
- `STRIPE_WEBHOOK_SECRET` (optional für Webhook-Verarbeitung)
|
||||
|
||||
2. **Netlify Functions**:
|
||||
- Die Functions sind im Verzeichnis `/netlify/functions/` definiert
|
||||
- Die Konfiguration in `netlify.toml` sorgt dafür, dass die Functions korrekt bereitgestellt werden:
|
||||
|
||||
```toml
|
||||
[build]
|
||||
command = "npm run build"
|
||||
publish = "dist"
|
||||
functions = "netlify/functions"
|
||||
|
||||
[[redirects]]
|
||||
from = "/api/*"
|
||||
to = "/.netlify/functions/:splat"
|
||||
status = 200
|
||||
```
|
||||
|
||||
## Lokale Entwicklung
|
||||
|
||||
1. **Umgebungsvariablen einrichten**:
|
||||
- Erstellen Sie eine `.env`-Datei im Hauptverzeichnis
|
||||
- Fügen Sie die Stripe-Schlüssel hinzu (verwenden Sie Testschlüssel für die Entwicklung)
|
||||
|
||||
2. **Lokaler Netlify Dev Server**:
|
||||
- Installation: `npm install netlify-cli -g`
|
||||
- Starten: `netlify dev`
|
||||
- Dadurch werden sowohl die Astro-App als auch die Netlify Functions lokal bereitgestellt
|
||||
|
||||
3. **Debugging-Tipps**:
|
||||
- Verwenden Sie `console.log` in den Functions für Debugging
|
||||
- Die Logs werden in der Netlify-Konsole angezeigt
|
||||
- In der Produktionsumgebung sind die Logs im Netlify-Dashboard unter "Functions" verfügbar
|
||||
|
||||
## Tipps zur Fehlerbehebung
|
||||
|
||||
### 1. Stripe-API-Fehler
|
||||
|
||||
- Überprüfen Sie, ob die API-Schlüssel korrekt sind
|
||||
- Stellen Sie sicher, dass Sie im Testmodus Test-Schlüssel und im Live-Modus Live-Schlüssel verwenden
|
||||
- Überprüfen Sie die Stripe-Dashboard-Logs für detaillierte Fehler
|
||||
|
||||
### 2. CORS-Probleme
|
||||
|
||||
Die Netlify-Function enthält CORS-Header für die Anfrageverarbeitung:
|
||||
|
||||
```javascript
|
||||
const headers = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS"
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Fallback für fehlende API-Schlüssel
|
||||
|
||||
Die Implementation enthält einen Fallback-Mechanismus für Entwicklungs- und Testzwecke:
|
||||
|
||||
```javascript
|
||||
if (!process.env.STRIPE_SECRET_KEY) {
|
||||
console.log("WARNUNG: STRIPE_SECRET_KEY fehlt - liefere Test-Antwort");
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
url: `${process.env.URL || 'https://bauntown.com'}/support-success?test=true`,
|
||||
message: "Test mode - no Stripe key available"
|
||||
})
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fazit
|
||||
|
||||
Die Stripe-Integration für das "Buy Me a Coffee"-Feature wurde erfolgreich implementiert und ermöglicht sowohl einmalige als auch wiederkehrende Spenden. Durch die Verwendung von Netlify Functions wird die Sicherheit erhöht, da sensible Daten wie der Stripe Secret Key nicht im Frontend verfügbar sind.
|
||||
|
||||
Die wichtigsten Punkte für eine fehlerfreie Implementierung:
|
||||
1. Verwenden Sie `PUBLIC_`-Präfix für Frontend-Umgebungsvariablen in Astro
|
||||
2. Verwenden Sie den Stripe Checkout-Flow für eine einfache und sichere Zahlungsabwicklung
|
||||
3. Implementieren Sie robuste Fehlerbehandlung für Übersetzungen und API-Aufrufe
|
||||
4. Testen Sie sowohl im Entwicklungs- als auch im Produktionsmodus
|
||||
|
||||
Bei Fragen oder Problemen konsultieren Sie die [Stripe-Dokumentation](https://stripe.com/docs/checkout) oder öffnen Sie ein Issue im BaunTown-Repository.
|
||||
8
apps/bauntown/apps/landing/.env.example
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Stripe API Keys
|
||||
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
|
||||
STRIPE_PUBLISHABLE_KEY=pk_test_your_stripe_publishable_key
|
||||
STRIPE_WEBHOOK_SECRET=whsec_your_stripe_webhook_secret
|
||||
|
||||
# PayPal API Keys
|
||||
PAYPAL_CLIENT_ID=your_paypal_client_id
|
||||
PAYPAL_CLIENT_SECRET=your_paypal_client_secret
|
||||
28
apps/bauntown/apps/landing/astro.config.mjs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
import mdx from '@astrojs/mdx';
|
||||
import netlify from '@astrojs/netlify';
|
||||
import partytown from '@astrojs/partytown';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
mdx(),
|
||||
partytown({
|
||||
config: {
|
||||
forward: ["plausible.io"]
|
||||
}
|
||||
})
|
||||
],
|
||||
// Content Collections config
|
||||
markdown: {
|
||||
shikiConfig: {
|
||||
theme: 'dracula'
|
||||
}
|
||||
},
|
||||
output: 'server',
|
||||
adapter: netlify({
|
||||
imageCDN: true,
|
||||
edgeMiddleware: true
|
||||
})
|
||||
});
|
||||
19
apps/bauntown/apps/landing/netlify.toml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[build]
|
||||
command = "npm run build"
|
||||
publish = "dist"
|
||||
functions = "netlify/functions"
|
||||
|
||||
[dev]
|
||||
command = "npm run dev"
|
||||
functions = "netlify/functions"
|
||||
publish = "dist"
|
||||
|
||||
[[redirects]]
|
||||
from = "/api/*"
|
||||
to = "/.netlify/functions/:splat"
|
||||
status = 200
|
||||
|
||||
[[redirects]]
|
||||
from = "/*"
|
||||
to = "/index.html"
|
||||
status = 200
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
const { google } = require('googleapis');
|
||||
const { JWT } = require('google-auth-library');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Überprüfe, ob alle erforderlichen Umgebungsvariablen vorhanden sind
|
||||
const requiredEnvVars = [
|
||||
'GOOGLE_SERVICE_ACCOUNT_EMAIL',
|
||||
'GOOGLE_PRIVATE_KEY',
|
||||
'GOOGLE_SHEET_ID',
|
||||
'SITE_URL'
|
||||
];
|
||||
|
||||
function checkEnvVars() {
|
||||
const missing = requiredEnvVars.filter(varName => !process.env[varName]);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Fehlende Umgebungsvariablen: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
const auth = new JWT({
|
||||
email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
|
||||
key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
|
||||
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
|
||||
});
|
||||
|
||||
const sheets = google.sheets({ version: 'v4', auth });
|
||||
|
||||
exports.handler = async function(event, context) {
|
||||
console.log('Content submission request received');
|
||||
|
||||
// CORS Headers
|
||||
const headers = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS'
|
||||
};
|
||||
|
||||
// Handle OPTIONS request
|
||||
if (event.httpMethod === 'OPTIONS') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: ''
|
||||
};
|
||||
}
|
||||
|
||||
if (event.httpMethod !== 'POST') {
|
||||
return {
|
||||
statusCode: 405,
|
||||
headers,
|
||||
body: JSON.stringify({ error: 'Method Not Allowed' })
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Überprüfe Umgebungsvariablen
|
||||
checkEnvVars();
|
||||
|
||||
// Parse request body
|
||||
const { contentType, title, description, email } = JSON.parse(event.body);
|
||||
|
||||
if (!contentType || !title || !description || !email) {
|
||||
throw new Error('Alle Felder müssen ausgefüllt werden');
|
||||
}
|
||||
|
||||
console.log('Processing submission:', { contentType, title, email });
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
// Füge neue Zeile zum Sheet hinzu
|
||||
console.log('Adding row to Google Sheet');
|
||||
try {
|
||||
// Prüfe zuerst, ob das Sheet existiert
|
||||
const sheetInfo = await sheets.spreadsheets.get({
|
||||
spreadsheetId: process.env.GOOGLE_SHEET_ID
|
||||
});
|
||||
|
||||
// Prüfe, ob das Content-Submissions-Sheet existiert, wenn nicht, erstelle es
|
||||
let sheetExists = false;
|
||||
const sheetName = 'ContentSubmissions';
|
||||
|
||||
for (const sheet of sheetInfo.data.sheets) {
|
||||
if (sheet.properties.title === sheetName) {
|
||||
sheetExists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sheetExists) {
|
||||
// Erstelle ein neues Sheet für Content-Submissions
|
||||
await sheets.spreadsheets.batchUpdate({
|
||||
spreadsheetId: process.env.GOOGLE_SHEET_ID,
|
||||
requestBody: {
|
||||
requests: [
|
||||
{
|
||||
addSheet: {
|
||||
properties: {
|
||||
title: sheetName,
|
||||
gridProperties: {
|
||||
rowCount: 1000,
|
||||
columnCount: 6
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
// Füge Überschriften hinzu
|
||||
await sheets.spreadsheets.values.update({
|
||||
spreadsheetId: process.env.GOOGLE_SHEET_ID,
|
||||
range: `${sheetName}!A1:F1`,
|
||||
valueInputOption: 'USER_ENTERED',
|
||||
requestBody: {
|
||||
values: [['Timestamp', 'ID', 'Content Type', 'Title', 'Description', 'Email']]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Füge die neue Zeile hinzu
|
||||
await sheets.spreadsheets.values.append({
|
||||
spreadsheetId: process.env.GOOGLE_SHEET_ID,
|
||||
range: `${sheetName}!A:F`,
|
||||
valueInputOption: 'USER_ENTERED',
|
||||
requestBody: {
|
||||
values: [[timestamp, id, contentType, title, description, email]]
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Content submission added to sheet');
|
||||
} catch (sheetError) {
|
||||
console.error('Google Sheets error:', sheetError);
|
||||
throw new Error(`Google Sheets Fehler: ${sheetError.message}. Bitte stellen Sie sicher, dass das Sheet existiert und der Service Account Zugriff hat.`);
|
||||
}
|
||||
|
||||
console.log('Submission completed successfully');
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
message: 'Deine Einreichung wurde erfolgreich gespeichert.'
|
||||
})
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Content submission error:', error);
|
||||
return {
|
||||
statusCode: 500,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: error.message,
|
||||
details: error.stack
|
||||
})
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
// Dieses Skript erstellt eine Stripe Checkout Session anstatt eines Payment Intents
|
||||
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
|
||||
|
||||
exports.handler = async (event, context) => {
|
||||
// CORS Headers
|
||||
const headers = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS"
|
||||
};
|
||||
|
||||
// Handle OPTIONS request (CORS preflight)
|
||||
if (event.httpMethod === "OPTIONS") {
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: ""
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse request
|
||||
const data = JSON.parse(event.body || '{}');
|
||||
const { amount, isRecurring, priceId, coffeeSize } = data;
|
||||
|
||||
// Debug-Ausgabe
|
||||
console.log("Request data:", { amount, isRecurring, priceId, coffeeSize });
|
||||
console.log("Stripe key available:", !!process.env.STRIPE_SECRET_KEY);
|
||||
|
||||
// Fallback, wenn kein Stripe-Key verfügbar
|
||||
if (!process.env.STRIPE_SECRET_KEY) {
|
||||
console.log("WARNUNG: STRIPE_SECRET_KEY fehlt - liefere Test-Antwort");
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
url: `${process.env.URL || 'https://bauntown.com'}/support-success?test=true`,
|
||||
isRecurring: isRecurring || false,
|
||||
message: "Test mode - no Stripe key available"
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
// Betrag in Cent umrechnen für Stripe
|
||||
const amountInCents = Math.round(amount * 100);
|
||||
|
||||
let sessionConfig = {
|
||||
payment_method_types: ['card'],
|
||||
line_items: [{
|
||||
price_data: {
|
||||
currency: 'eur',
|
||||
product_data: {
|
||||
name: `BaunTown Kaffee - ${coffeeSize || 'Mittlerer Kaffee'}`,
|
||||
description: isRecurring ? 'Monatliche Unterstützung' : 'Einmalige Unterstützung',
|
||||
},
|
||||
unit_amount: amountInCents,
|
||||
recurring: isRecurring ? { interval: 'month' } : undefined,
|
||||
},
|
||||
quantity: 1,
|
||||
}],
|
||||
mode: isRecurring ? 'subscription' : 'payment',
|
||||
success_url: `${process.env.URL || 'https://bauntown.com'}/support-success?amount=${amount}&type=${isRecurring ? 'recurring' : 'one-time'}&provider=stripe&coffeeSize=${encodeURIComponent(coffeeSize || 'Mittlerer Kaffee')}`,
|
||||
cancel_url: `${process.env.URL || 'https://bauntown.com'}/support-cancel`,
|
||||
metadata: {
|
||||
isRecurring: isRecurring ? 'true' : 'false',
|
||||
priceId: priceId || '',
|
||||
coffeeSize: coffeeSize || 'Mittlerer Kaffee'
|
||||
}
|
||||
};
|
||||
|
||||
console.log("Creating checkout session with config:", JSON.stringify(sessionConfig));
|
||||
|
||||
try {
|
||||
// Stripe Checkout Session erstellen
|
||||
const session = await stripe.checkout.sessions.create(sessionConfig);
|
||||
|
||||
console.log("Session created:", session.id, "URL:", session.url);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
url: session.url, // Die Redirect-URL hat Priorität
|
||||
isRecurring: isRecurring || false
|
||||
})
|
||||
};
|
||||
} catch (stripeError) {
|
||||
console.error("Stripe error:", stripeError);
|
||||
return {
|
||||
statusCode: 400,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
message: `Stripe error: ${stripeError.message}`,
|
||||
stripeCode: stripeError.code || 'unknown'
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Function error:", error);
|
||||
|
||||
return {
|
||||
statusCode: 500,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
message: error.message || "Unknown server error"
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
// Netlify Function für PayPal Order
|
||||
// Erfordert PAYPAL_CLIENT_ID und PAYPAL_CLIENT_SECRET als Umgebungsvariablen
|
||||
|
||||
const fetch = require('node-fetch');
|
||||
|
||||
// PayPal OAuth Token holen
|
||||
async function getPayPalAccessToken() {
|
||||
const auth = Buffer.from(`${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_CLIENT_SECRET}`).toString('base64');
|
||||
|
||||
const response = await fetch('https://api-m.sandbox.paypal.com/v1/oauth2/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Authorization': `Basic ${auth}`
|
||||
},
|
||||
body: 'grant_type=client_credentials'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
// PayPal Order erstellen
|
||||
async function createPayPalOrder(accessToken, amount, isRecurring, priceId, coffeeSize) {
|
||||
const url = isRecurring
|
||||
? 'https://api-m.sandbox.paypal.com/v1/billing/plans'
|
||||
: 'https://api-m.sandbox.paypal.com/v2/checkout/orders';
|
||||
|
||||
if (isRecurring) {
|
||||
// Vereinfachtes Beispiel für ein Abonnement
|
||||
// In einer echten Anwendung sollten Sie Pläne vorab erstellen
|
||||
const planData = {
|
||||
name: `BaunTown Monthly ${coffeeSize}`,
|
||||
description: `Monthly ${coffeeSize} support for BaunTown (${amount}€)`,
|
||||
type: "FIXED",
|
||||
payment_definitions: [
|
||||
{
|
||||
name: `Monthly ${coffeeSize}`,
|
||||
type: "REGULAR",
|
||||
frequency: "MONTH",
|
||||
frequency_interval: "1",
|
||||
amount: {
|
||||
value: amount,
|
||||
currency: "EUR"
|
||||
},
|
||||
cycles: "0"
|
||||
}
|
||||
],
|
||||
merchant_preferences: {
|
||||
return_url: `${process.env.URL || 'http://localhost:3000'}/support-success`,
|
||||
cancel_url: `${process.env.URL || 'http://localhost:3000'}/support-cancel`
|
||||
}
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
},
|
||||
body: JSON.stringify(planData)
|
||||
});
|
||||
|
||||
return response.json();
|
||||
} else {
|
||||
// Normale einmalige Zahlung
|
||||
const orderData = {
|
||||
intent: "CAPTURE",
|
||||
purchase_units: [
|
||||
{
|
||||
amount: {
|
||||
currency_code: "EUR",
|
||||
value: amount
|
||||
},
|
||||
description: `${coffeeSize} für BaunTown (${amount}€)`,
|
||||
custom_id: priceId
|
||||
}
|
||||
],
|
||||
application_context: {
|
||||
return_url: `${process.env.URL || 'http://localhost:3000'}/support-success?amount=${amount}&type=${isRecurring ? 'recurring' : 'one-time'}&provider=paypal&coffeeSize=${encodeURIComponent(coffeeSize)}`,
|
||||
cancel_url: `${process.env.URL || 'http://localhost:3000'}/support-cancel`
|
||||
}
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
},
|
||||
body: JSON.stringify(orderData)
|
||||
});
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
exports.handler = async (event, context) => {
|
||||
// CORS Headers
|
||||
const headers = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS"
|
||||
};
|
||||
|
||||
// Handle OPTIONS request (CORS preflight)
|
||||
if (event.httpMethod === "OPTIONS") {
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: ""
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Request-Body parsen
|
||||
const data = JSON.parse(event.body);
|
||||
const { amount, isRecurring, priceId, coffeeSize } = data;
|
||||
|
||||
// PayPal Access Token holen
|
||||
const accessToken = await getPayPalAccessToken();
|
||||
|
||||
// Order oder Plan erstellen mit zusätzlichen Metadaten
|
||||
const paypalResponse = await createPayPalOrder(accessToken, amount, isRecurring, priceId, coffeeSize);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify(paypalResponse)
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('PayPal error:', error);
|
||||
|
||||
return {
|
||||
statusCode: 500,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
message: error.message || 'Internal server error'
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
const { google } = require('googleapis');
|
||||
const { JWT } = require('google-auth-library');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Überprüfe, ob alle erforderlichen Umgebungsvariablen vorhanden sind
|
||||
const requiredEnvVars = [
|
||||
'GOOGLE_SERVICE_ACCOUNT_EMAIL',
|
||||
'GOOGLE_PRIVATE_KEY',
|
||||
'GOOGLE_SHEET_ID',
|
||||
'SITE_URL'
|
||||
];
|
||||
|
||||
function checkEnvVars() {
|
||||
const missing = requiredEnvVars.filter(varName => !process.env[varName]);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Fehlende Umgebungsvariablen: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
const auth = new JWT({
|
||||
email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
|
||||
key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
|
||||
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
|
||||
});
|
||||
|
||||
const sheets = google.sheets({ version: 'v4', auth });
|
||||
|
||||
exports.handler = async function(event, context) {
|
||||
console.log('Newsletter subscription request received');
|
||||
|
||||
// CORS Headers
|
||||
const headers = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS'
|
||||
};
|
||||
|
||||
// Handle OPTIONS request
|
||||
if (event.httpMethod === 'OPTIONS') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: ''
|
||||
};
|
||||
}
|
||||
|
||||
if (event.httpMethod !== 'POST') {
|
||||
return {
|
||||
statusCode: 405,
|
||||
headers,
|
||||
body: JSON.stringify({ error: 'Method Not Allowed' })
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Überprüfe Umgebungsvariablen
|
||||
checkEnvVars();
|
||||
|
||||
// Parse request body
|
||||
const { email } = JSON.parse(event.body);
|
||||
if (!email) {
|
||||
throw new Error('Email is required');
|
||||
}
|
||||
|
||||
console.log('Processing subscription for email:', email);
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const unsubscribeToken = crypto.randomUUID();
|
||||
|
||||
// Füge neue Zeile zum Sheet hinzu
|
||||
console.log('Adding row to Google Sheet');
|
||||
try {
|
||||
// Prüfe zuerst, ob das Sheet existiert und hole den ersten Tab-Namen
|
||||
const sheetInfo = await sheets.spreadsheets.get({
|
||||
spreadsheetId: process.env.GOOGLE_SHEET_ID
|
||||
});
|
||||
|
||||
console.log('Sheet info:', sheetInfo.data);
|
||||
|
||||
// Hole den Namen des ersten Tabs
|
||||
const firstSheetName = sheetInfo.data.sheets[0].properties.title;
|
||||
console.log('Using sheet name:', firstSheetName);
|
||||
|
||||
// Füge die Zeile hinzu
|
||||
await sheets.spreadsheets.values.append({
|
||||
spreadsheetId: process.env.GOOGLE_SHEET_ID,
|
||||
range: `${firstSheetName}!A:D`,
|
||||
valueInputOption: 'USER_ENTERED',
|
||||
requestBody: {
|
||||
values: [[timestamp, email, 'active', unsubscribeToken]]
|
||||
}
|
||||
});
|
||||
} catch (sheetError) {
|
||||
console.error('Google Sheets error:', sheetError);
|
||||
throw new Error(`Google Sheets Fehler: ${sheetError.message}. Bitte stellen Sie sicher, dass das Sheet existiert und der Service Account Zugriff hat.`);
|
||||
}
|
||||
|
||||
console.log('Subscription completed successfully');
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify({ success: true })
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Newsletter subscription error:', error);
|
||||
return {
|
||||
statusCode: 500,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: error.message,
|
||||
details: error.stack
|
||||
})
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
const { google } = require('googleapis');
|
||||
const { JWT } = require('google-auth-library');
|
||||
|
||||
const auth = new JWT({
|
||||
email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
|
||||
key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
|
||||
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
|
||||
});
|
||||
|
||||
const sheets = google.sheets({ version: 'v4', auth });
|
||||
|
||||
exports.handler = async function(event, context) {
|
||||
if (event.httpMethod !== 'GET') {
|
||||
return { statusCode: 405, body: 'Method Not Allowed' };
|
||||
}
|
||||
|
||||
const { token } = event.queryStringParameters;
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: JSON.stringify({ error: 'Token is required' })
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Finde die Zeile mit dem Token
|
||||
const response = await sheets.spreadsheets.values.get({
|
||||
spreadsheetId: process.env.GOOGLE_SHEET_ID,
|
||||
range: 'Sheet1!A:D',
|
||||
});
|
||||
|
||||
const values = response.data.values;
|
||||
const rowIndex = values.findIndex(row => row[3] === token);
|
||||
|
||||
if (rowIndex === -1) {
|
||||
return {
|
||||
statusCode: 404,
|
||||
body: JSON.stringify({ error: 'Token not found' })
|
||||
};
|
||||
}
|
||||
|
||||
// Aktualisiere den Status auf 'unsubscribed'
|
||||
await sheets.spreadsheets.values.update({
|
||||
spreadsheetId: process.env.GOOGLE_SHEET_ID,
|
||||
range: `Sheet1!C${rowIndex + 1}`,
|
||||
valueInputOption: 'USER_ENTERED',
|
||||
requestBody: {
|
||||
values: [['unsubscribed']]
|
||||
}
|
||||
});
|
||||
|
||||
// Redirect zur Bestätigungsseite
|
||||
return {
|
||||
statusCode: 302,
|
||||
headers: {
|
||||
Location: `${process.env.URL}/unsubscribe-success`
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Newsletter unsubscribe error:', error);
|
||||
return {
|
||||
statusCode: 500,
|
||||
body: JSON.stringify({ error: error.message })
|
||||
};
|
||||
}
|
||||
};
|
||||
10
apps/bauntown/apps/landing/netlify/functions/package.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"name": "bauntown-functions",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"stripe": "^12.0.0",
|
||||
"googleapis": "^128.0.0",
|
||||
"google-auth-library": "^9.0.0",
|
||||
"node-fetch": "^2.7.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
// Netlify Function für Webhooks von Stripe und PayPal
|
||||
|
||||
exports.handler = async (event, context) => {
|
||||
// CORS Headers
|
||||
const headers = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS"
|
||||
};
|
||||
|
||||
// Handle OPTIONS request (CORS preflight)
|
||||
if (event.httpMethod === "OPTIONS") {
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: ""
|
||||
};
|
||||
}
|
||||
|
||||
// Den Webhook-Source aus URL-Parameter oder Header bestimmen
|
||||
const source = event.queryStringParameters.source || 'unknown';
|
||||
|
||||
try {
|
||||
if (source === 'stripe') {
|
||||
// Stripe Webhook verarbeiten
|
||||
return processStripeWebhook(event, headers);
|
||||
} else if (source === 'paypal') {
|
||||
// PayPal Webhook verarbeiten
|
||||
return processPayPalWebhook(event, headers);
|
||||
} else {
|
||||
return {
|
||||
statusCode: 400,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
message: `Unknown webhook source: ${source}`
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Webhook error (${source}):`, error);
|
||||
|
||||
return {
|
||||
statusCode: 500,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
message: error.message || 'Internal server error'
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Stripe Webhook verarbeiten
|
||||
async function processStripeWebhook(event, headers) {
|
||||
// In einer echten Anwendung würden Sie hier die Stripe Signatur überprüfen
|
||||
// const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
|
||||
// const sig = event.headers['stripe-signature'];
|
||||
// const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.body);
|
||||
|
||||
// Verschiedene Ereignistypen verarbeiten
|
||||
switch (data.type) {
|
||||
case 'payment_intent.succeeded':
|
||||
// Zahlung erfolgreich - hier könnte eine Datenbank aktualisiert werden
|
||||
console.log('Payment succeeded:', data.data.object.id);
|
||||
break;
|
||||
|
||||
case 'payment_intent.payment_failed':
|
||||
// Zahlung fehlgeschlagen
|
||||
console.log('Payment failed:', data.data.object.id);
|
||||
break;
|
||||
|
||||
// Weitere Event-Typen hier verarbeiten
|
||||
|
||||
default:
|
||||
console.log('Unhandled Stripe event type:', data.type);
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify({ received: true })
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error processing Stripe webhook:', error);
|
||||
return {
|
||||
statusCode: 400,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
message: error.message
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// PayPal Webhook verarbeiten
|
||||
async function processPayPalWebhook(event, headers) {
|
||||
// In einer echten Anwendung würden Sie hier die PayPal-Signatur verifizieren
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.body);
|
||||
|
||||
// Verschiedene Ereignistypen verarbeiten
|
||||
switch (data.event_type) {
|
||||
case 'PAYMENT.CAPTURE.COMPLETED':
|
||||
// Zahlung erfolgreich - hier könnte eine Datenbank aktualisiert werden
|
||||
console.log('PayPal payment completed:', data.resource.id);
|
||||
break;
|
||||
|
||||
case 'PAYMENT.CAPTURE.DENIED':
|
||||
// Zahlung abgelehnt
|
||||
console.log('PayPal payment denied:', data.resource.id);
|
||||
break;
|
||||
|
||||
// Weitere Event-Typen hier verarbeiten
|
||||
|
||||
default:
|
||||
console.log('Unhandled PayPal event type:', data.event_type);
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify({ received: true })
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error processing PayPal webhook:', error);
|
||||
return {
|
||||
statusCode: 400,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
message: error.message
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
||||
31
apps/bauntown/apps/landing/package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "@bauntown/landing",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"type-check": "astro check",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^4.2.3",
|
||||
"@astrojs/netlify": "^6.2.5",
|
||||
"@astrojs/partytown": "^2.1.4",
|
||||
"@astrojs/prefetch": "^0.4.1",
|
||||
"@paypal/paypal-js": "^8.2.0",
|
||||
"@stripe/stripe-js": "^6.1.0",
|
||||
"astro": "^5.5.6",
|
||||
"astro-i18n-aut": "^0.7.3",
|
||||
"google-auth-library": "^9.15.1",
|
||||
"googleapis": "^148.0.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"stripe": "^17.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.0",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
5
apps/bauntown/apps/landing/public/favicon.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.666667" y="7.33334" width="10.6667" height="2.66667" stroke="white" stroke-width="0.8"/>
|
||||
<rect x="2" y="4.66666" width="8" height="2.66667" stroke="white" stroke-width="0.8"/>
|
||||
<rect x="3.33333" y="2" width="5.33333" height="2.66667" stroke="white" stroke-width="0.8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 383 B |
|
After Width: | Height: | Size: 616 KiB |
|
After Width: | Height: | Size: 499 KiB |
|
After Width: | Height: | Size: 304 KiB |
|
After Width: | Height: | Size: 312 KiB |
BIN
apps/bauntown/apps/landing/public/images/bauntown-codewave.png
Normal file
|
After Width: | Height: | Size: 906 KiB |
BIN
apps/bauntown/apps/landing/public/images/bauntown-laptop.png
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
apps/bauntown/apps/landing/public/images/bauntown-man-coder.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
apps/bauntown/apps/landing/public/images/bauntown-welcome.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 152 KiB |
BIN
apps/bauntown/apps/landing/public/images/members/team-hero.png
Normal file
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 314 KiB |
|
After Width: | Height: | Size: 360 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 475 KiB |
|
After Width: | Height: | Size: 375 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 473 KiB |
|
After Width: | Height: | Size: 365 KiB |
|
After Width: | Height: | Size: 428 KiB |
|
After Width: | Height: | Size: 649 KiB |
|
After Width: | Height: | Size: 453 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 609 KiB |
|
After Width: | Height: | Size: 373 KiB |
|
After Width: | Height: | Size: 458 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 327 KiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 471 KiB |
|
After Width: | Height: | Size: 561 KiB |
|
After Width: | Height: | Size: 333 KiB |
|
After Width: | Height: | Size: 315 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 297 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 906 KiB |
|
After Width: | Height: | Size: 499 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
|
@ -0,0 +1,5 @@
|
|||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.666667" y="7.33334" width="10.6667" height="2.66667" stroke="white" stroke-width="0.8"/>
|
||||
<rect x="2" y="4.66666" width="8" height="2.66667" stroke="white" stroke-width="0.8"/>
|
||||
<rect x="3.33333" y="2" width="5.33333" height="2.66667" stroke="white" stroke-width="0.8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 383 B |
1
apps/bauntown/apps/landing/src/assets/astro.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" width="115" height="48"><path fill="#17191E" d="M7.77 36.35C6.4 35.11 6 32.51 6.57 30.62c.99 1.2 2.35 1.57 3.75 1.78 2.18.33 4.31.2 6.33-.78.23-.12.44-.27.7-.42.18.55.23 1.1.17 1.67a4.56 4.56 0 0 1-1.94 3.23c-.43.32-.9.61-1.34.91-1.38.94-1.76 2.03-1.24 3.62l.05.17a3.63 3.63 0 0 1-1.6-1.38 3.87 3.87 0 0 1-.63-2.1c0-.37 0-.74-.05-1.1-.13-.9-.55-1.3-1.33-1.32a1.56 1.56 0 0 0-1.63 1.26c0 .06-.03.12-.05.2Z"/><path fill="url(#a)" d="M7.77 36.35C6.4 35.11 6 32.51 6.57 30.62c.99 1.2 2.35 1.57 3.75 1.78 2.18.33 4.31.2 6.33-.78.23-.12.44-.27.7-.42.18.55.23 1.1.17 1.67a4.56 4.56 0 0 1-1.94 3.23c-.43.32-.9.61-1.34.91-1.38.94-1.76 2.03-1.24 3.62l.05.17a3.63 3.63 0 0 1-1.6-1.38 3.87 3.87 0 0 1-.63-2.1c0-.37 0-.74-.05-1.1-.13-.9-.55-1.3-1.33-1.32a1.56 1.56 0 0 0-1.63 1.26c0 .06-.03.12-.05.2Z"/><path fill="#17191E" d="M.02 30.31s4.02-1.95 8.05-1.95l3.04-9.4c.11-.45.44-.76.82-.76.37 0 .7.31.82.76l3.04 9.4c4.77 0 8.05 1.95 8.05 1.95L17 11.71c-.2-.56-.53-.91-.98-.91H7.83c-.44 0-.76.35-.97.9L.02 30.31Zm42.37-5.97c0 1.64-2.05 2.62-4.88 2.62-1.85 0-2.5-.45-2.5-1.41 0-1 .8-1.49 2.65-1.49 1.67 0 3.09.03 4.73.23v.05Zm.03-2.04a21.37 21.37 0 0 0-4.37-.36c-5.32 0-7.82 1.25-7.82 4.18 0 3.04 1.71 4.2 5.68 4.2 3.35 0 5.63-.84 6.46-2.92h.14c-.03.5-.05 1-.05 1.4 0 1.07.18 1.16 1.06 1.16h4.15a16.9 16.9 0 0 1-.36-4c0-1.67.06-2.93.06-4.62 0-3.45-2.07-5.64-8.56-5.64-2.8 0-5.9.48-8.26 1.19.22.93.54 2.83.7 4.06 2.04-.96 4.95-1.37 7.2-1.37 3.11 0 3.97.71 3.97 2.15v.57Zm11.37 3c-.56.07-1.33.07-2.12.07-.83 0-1.6-.03-2.12-.1l-.02.58c0 2.85 1.87 4.52 8.45 4.52 6.2 0 8.2-1.64 8.2-4.55 0-2.74-1.33-4.09-7.2-4.39-4.58-.2-4.99-.7-4.99-1.28 0-.66.59-1 3.65-1 3.18 0 4.03.43 4.03 1.35v.2a46.13 46.13 0 0 1 4.24.03l.02-.55c0-3.36-2.8-4.46-8.2-4.46-6.08 0-8.13 1.49-8.13 4.39 0 2.6 1.64 4.23 7.48 4.48 4.3.14 4.77.62 4.77 1.28 0 .7-.7 1.03-3.71 1.03-3.47 0-4.35-.48-4.35-1.47v-.13Zm19.82-12.05a17.5 17.5 0 0 1-6.24 3.48c.03.84.03 2.4.03 3.24l1.5.02c-.02 1.63-.04 3.6-.04 4.9 0 3.04 1.6 5.32 6.58 5.32 2.1 0 3.5-.23 5.23-.6a43.77 43.77 0 0 1-.46-4.13c-1.03.34-2.34.53-3.78.53-2 0-2.82-.55-2.82-2.13 0-1.37 0-2.65.03-3.84 2.57.02 5.13.07 6.64.11-.02-1.18.03-2.9.1-4.04-2.2.04-4.65.07-6.68.07l.07-2.93h-.16Zm13.46 6.04a767.33 767.33 0 0 1 .07-3.18H82.6c.07 1.96.07 3.98.07 6.92 0 2.95-.03 4.99-.07 6.93h5.18c-.09-1.37-.11-3.68-.11-5.65 0-3.1 1.26-4 4.12-4 1.33 0 2.28.16 3.1.46.03-1.16.26-3.43.4-4.43-.86-.25-1.81-.41-2.96-.41-2.46-.03-4.26.98-5.1 3.38l-.17-.02Zm22.55 3.65c0 2.5-1.8 3.66-4.64 3.66-2.81 0-4.61-1.1-4.61-3.66s1.82-3.52 4.61-3.52c2.82 0 4.64 1.03 4.64 3.52Zm4.71-.11c0-4.96-3.87-7.18-9.35-7.18-5.5 0-9.23 2.22-9.23 7.18 0 4.94 3.49 7.59 9.21 7.59 5.77 0 9.37-2.65 9.37-7.6Z"/><defs><linearGradient id="a" x1="6.33" x2="19.43" y1="40.8" y2="34.6" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
1
apps/bauntown/apps/landing/src/assets/background.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="1024" fill="none"><path fill="url(#a)" fill-rule="evenodd" d="M-217.58 475.75c91.82-72.02 225.52-29.38 341.2-44.74C240 415.56 372.33 315.14 466.77 384.9c102.9 76.02 44.74 246.76 90.31 366.31 29.83 78.24 90.48 136.14 129.48 210.23 57.92 109.99 169.67 208.23 155.9 331.77-13.52 121.26-103.42 264.33-224.23 281.37-141.96 20.03-232.72-220.96-374.06-196.99-151.7 25.73-172.68 330.24-325.85 315.72-128.6-12.2-110.9-230.73-128.15-358.76-12.16-90.14 65.87-176.25 44.1-264.57-26.42-107.2-167.12-163.46-176.72-273.45-10.15-116.29 33.01-248.75 124.87-320.79Z" clip-rule="evenodd" style="opacity:.154"/><path fill="url(#b)" fill-rule="evenodd" d="M1103.43 115.43c146.42-19.45 275.33-155.84 413.5-103.59 188.09 71.13 409 212.64 407.06 413.88-1.94 201.25-259.28 278.6-414.96 405.96-130 106.35-240.24 294.39-405.6 265.3-163.7-28.8-161.93-274.12-284.34-386.66-134.95-124.06-436-101.46-445.82-284.6-9.68-180.38 247.41-246.3 413.54-316.9 101.01-42.93 207.83 21.06 316.62 6.61Z" clip-rule="evenodd" style="opacity:.154"/><defs><linearGradient id="b" x1="373" x2="1995.44" y1="1100" y2="118.03" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient><linearGradient id="a" x1="107.37" x2="1130.66" y1="1993.35" y2="1026.31" gradientUnits="userSpaceOnUse"><stop stop-color="#3245FF"/><stop offset="1" stop-color="#BC52EE"/></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
218
apps/bauntown/apps/landing/src/components/Card.astro
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
---
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
variant?: 'primary' | 'secondary' | 'tertiary';
|
||||
imageAlt?: string;
|
||||
href?: string;
|
||||
buttonText?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
variant = 'primary',
|
||||
imageAlt = '',
|
||||
href,
|
||||
buttonText,
|
||||
className = ''
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
{href ? (
|
||||
<a href={href} class="card-link">
|
||||
<article class:list={["card", `card-${variant}`, className]}>
|
||||
{image && (
|
||||
<div class="card-image-container">
|
||||
<img src={image} alt={imageAlt} class="card-image" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="card-content">
|
||||
<h3 class="card-title">{title}</h3>
|
||||
|
||||
{description && <p class="card-description">{description}</p>}
|
||||
|
||||
{buttonText && (
|
||||
<div class="card-footer">
|
||||
<span class="card-button">
|
||||
{buttonText} <span class="arrow">→</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</a>
|
||||
) : (
|
||||
<article class:list={["card", `card-${variant}`, className]}>
|
||||
{image && (
|
||||
<div class="card-image-container">
|
||||
<img src={image} alt={imageAlt} class="card-image" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="card-content">
|
||||
<h3 class="card-title">{title}</h3>
|
||||
|
||||
{description && <p class="card-description">{description}</p>}
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
|
||||
<style>
|
||||
.card-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.card-link:hover .card {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 12px -3px rgba(0, 0, 0, 0.1);
|
||||
border-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.5);
|
||||
}
|
||||
|
||||
/* Primary variant - accent background, white text */
|
||||
.card-primary {
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
box-shadow: 0 4px 6px -1px rgba(249, 115, 22, 0.2), 0 2px 4px -2px rgba(249, 115, 22, 0.1);
|
||||
}
|
||||
|
||||
.card-link:hover .card-primary {
|
||||
box-shadow: 0 8px 10px -3px rgba(249, 115, 22, 0.25), 0 4px 6px -2px rgba(249, 115, 22, 0.15);
|
||||
background-color: #ea580c; /* Slightly darker shade when hovered */
|
||||
}
|
||||
|
||||
.card-primary .card-button {
|
||||
background-color: white;
|
||||
color: var(--accent-color);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Secondary variant - white background, accent elements */
|
||||
.card-secondary {
|
||||
background-color: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.card-link:hover .card-secondary {
|
||||
box-shadow: 0 6px 12px -2px rgba(0, 0, 0, 0.08), 0 3px 5px -2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.card-secondary .card-button {
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Tertiary variant - minimal design */
|
||||
.card-tertiary {
|
||||
background-color: var(--card-bg);
|
||||
box-shadow: none;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.card-link:hover .card-tertiary {
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
|
||||
.card-tertiary .card-button {
|
||||
color: var(--accent-color);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-image-container {
|
||||
width: 100%;
|
||||
padding-top: 66.67%; /* Aspect ratio 3:2 */
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 1rem 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 1.5rem 0;
|
||||
flex-grow: 1;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.card-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.card-link:hover .arrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.card-content {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,386 @@
|
|||
---
|
||||
import { getLangFromUrl, useTranslations } from "../utils/i18n";
|
||||
|
||||
interface Props {
|
||||
defaultType?: "mission" | "tutorial" | "vision";
|
||||
showTitle?: boolean;
|
||||
customTitle?: string;
|
||||
customDescription?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
defaultType = "mission",
|
||||
showTitle = true,
|
||||
customTitle,
|
||||
customDescription,
|
||||
} = Astro.props;
|
||||
const lang = getLangFromUrl(Astro.url);
|
||||
const t = useTranslations(lang);
|
||||
---
|
||||
|
||||
<div class="content-submission">
|
||||
{showTitle && <h2>{customTitle || t("join.submissionTitle")}</h2>}
|
||||
<p class="submission-description">
|
||||
{customDescription || t("join.submissionDesc")}
|
||||
</p>
|
||||
|
||||
<form class="submission-form" id="contentSubmissionForm">
|
||||
<div class="form-group">
|
||||
<label for="contentType">{t("join.submissionType")}</label>
|
||||
<select id="contentType" name="contentType" required>
|
||||
<option value="mission" selected={defaultType === "mission"}
|
||||
>{t("join.submissionMission")}</option
|
||||
>
|
||||
<option value="tutorial" selected={defaultType === "tutorial"}
|
||||
>{t("join.submissionTutorial")}</option
|
||||
>
|
||||
<option value="vision" selected={defaultType === "vision"}
|
||||
>{t("join.submissionVision")}</option
|
||||
>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="title">{t("join.submissionTitle")}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
placeholder={t("join.submissionTitlePlaceholder")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">{t("join.submissionDesc")}</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder={t("join.submissionDescPlaceholder")}
|
||||
rows="4"
|
||||
required></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">{t("join.submissionEmail")}</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder={t("join.submissionEmailPlaceholder")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="submit-button"
|
||||
>{t("join.submissionSubmit")}</button
|
||||
>
|
||||
</form>
|
||||
|
||||
<div id="submissionSuccess" class="submission-success" style="display: none;">
|
||||
<p class="success-title">{t("join.submissionSuccessTitle")}</p>
|
||||
<p class="success-message">{t("join.submissionSuccessMessage")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.content-submission {
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 1rem;
|
||||
padding: 2.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.05);
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
/* Heller Modus */
|
||||
:root:not(.dark) .content-submission {
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
|
||||
.content-submission:hover {
|
||||
box-shadow: 0 15px 30px -5px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.75rem;
|
||||
background: linear-gradient(90deg, var(--accent-color), #fb923c);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
h2::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: -0.25rem;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--accent-color), #fb923c);
|
||||
border-radius: 1.5px;
|
||||
}
|
||||
|
||||
.submission-description {
|
||||
margin-bottom: 2rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.6;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.submission-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1.5px solid var(--border-color);
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
/* Heller Modus für Eingabefelder */
|
||||
:root:not(.dark) input,
|
||||
:root:not(.dark) select,
|
||||
:root:not(.dark) textarea {
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
input:hover,
|
||||
select:hover,
|
||||
textarea:hover {
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 3px rgba(var(--accent-color-rgb), 0.15);
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
background: linear-gradient(90deg, var(--accent-color), #fb923c);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.9rem 2rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
margin-top: 1rem;
|
||||
align-self: flex-start;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.submit-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 10px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.submit-button:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.submission-success {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
border-radius: 0.75rem;
|
||||
padding: 2rem;
|
||||
margin-top: 2rem;
|
||||
text-align: center;
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
box-shadow: 0 4px 10px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.submission-success .success-title {
|
||||
color: #10b981;
|
||||
margin: 0 0 1rem 0;
|
||||
font-weight: 700;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.submission-success .success-message {
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-submission {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.submission-description {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
declare global {
|
||||
interface Window {
|
||||
plausible: (
|
||||
eventName: string,
|
||||
options?: { props?: Record<string, string> }
|
||||
) => void;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const form = document.getElementById(
|
||||
"contentSubmissionForm"
|
||||
) as HTMLFormElement;
|
||||
const successMessage = document.getElementById("submissionSuccess");
|
||||
const contentType = document.getElementById(
|
||||
"contentType"
|
||||
) as HTMLSelectElement;
|
||||
const title = document.getElementById("title") as HTMLInputElement;
|
||||
const description = document.getElementById(
|
||||
"description"
|
||||
) as HTMLTextAreaElement;
|
||||
const email = document.getElementById("email") as HTMLInputElement;
|
||||
|
||||
if (
|
||||
!form ||
|
||||
!successMessage ||
|
||||
!contentType ||
|
||||
!title ||
|
||||
!description ||
|
||||
!email
|
||||
)
|
||||
return;
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Sammle Formulardaten
|
||||
const formData = {
|
||||
contentType: contentType.value,
|
||||
title: title.value,
|
||||
description: description.value,
|
||||
email: email.value,
|
||||
};
|
||||
|
||||
// Track content submission attempt
|
||||
if (typeof window.plausible === "function") {
|
||||
window.plausible("content-submit", {
|
||||
props: {
|
||||
type: contentType.value,
|
||||
title: title.value,
|
||||
email: email.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Sende die Daten an die Netlify-Funktion
|
||||
const response = await fetch("/.netlify/functions/content-submission", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || "Submission failed");
|
||||
}
|
||||
|
||||
// Bei erfolgreicher Übermittlung
|
||||
form.reset();
|
||||
successMessage.style.display = "block";
|
||||
|
||||
// Track successful submission
|
||||
if (typeof window.plausible === "function") {
|
||||
window.plausible("content-success", {
|
||||
props: {
|
||||
type: contentType.value,
|
||||
title: title.value,
|
||||
email: email.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Erfolgs-Nachricht nach 8 Sekunden ausblenden
|
||||
setTimeout(() => {
|
||||
successMessage.style.display = "none";
|
||||
}, 8000);
|
||||
} catch (error: unknown) {
|
||||
// Track error
|
||||
if (typeof window.plausible === "function") {
|
||||
window.plausible("content-error", {
|
||||
props: {
|
||||
type: contentType.value,
|
||||
title: title.value,
|
||||
email: email.value,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Fehlermeldung anzeigen
|
||||
alert(
|
||||
"Es gab einen Fehler bei der Übermittlung. Bitte versuche es später erneut."
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
108
apps/bauntown/apps/landing/src/components/ContentTracking.astro
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
---
|
||||
// Component for tracking content interactions
|
||||
interface Props {
|
||||
contentType: 'tutorial' | 'project' | 'tool' | 'model' | 'mission' | 'vision';
|
||||
contentId?: string;
|
||||
contentTitle?: string;
|
||||
}
|
||||
|
||||
const { contentType, contentId, contentTitle } = Astro.props;
|
||||
---
|
||||
|
||||
<script define:vars={{ contentType, contentId, contentTitle }}>
|
||||
import { trackEvent, EVENTS } from '../scripts/analytics';
|
||||
|
||||
// Track page view for content
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const eventMap = {
|
||||
'tutorial': EVENTS.TUTORIAL_VIEW,
|
||||
'project': EVENTS.PROJECT_VIEW,
|
||||
'tool': EVENTS.TOOL_VIEW,
|
||||
'model': EVENTS.MODEL_VIEW,
|
||||
'mission': EVENTS.MISSION_VIEW,
|
||||
'vision': 'vision-view'
|
||||
};
|
||||
|
||||
const eventName = eventMap[contentType];
|
||||
if (eventName) {
|
||||
trackEvent(eventName, {
|
||||
id: contentId || 'unknown',
|
||||
title: contentTitle || document.title,
|
||||
language: document.documentElement.lang
|
||||
});
|
||||
}
|
||||
|
||||
// Track video plays
|
||||
document.querySelectorAll('iframe[src*="youtube"], iframe[src*="vimeo"]').forEach(video => {
|
||||
// Create intersection observer to track when video comes into view
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const src = (entry.target as HTMLIFrameElement).src;
|
||||
trackEvent(EVENTS.VIDEO_PLAY, {
|
||||
source: src.includes('youtube') ? 'youtube' : 'vimeo',
|
||||
url: src,
|
||||
contentType,
|
||||
contentId
|
||||
});
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.5 });
|
||||
|
||||
observer.observe(video);
|
||||
});
|
||||
|
||||
// Track Figma embeds
|
||||
document.querySelectorAll('iframe[src*="figma.com"]').forEach(embed => {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
trackEvent(EVENTS.FIGMA_EMBED_VIEW, {
|
||||
url: (entry.target as HTMLIFrameElement).src,
|
||||
contentType,
|
||||
contentId
|
||||
});
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.5 });
|
||||
|
||||
observer.observe(embed);
|
||||
});
|
||||
|
||||
// Track code copy (if there are code blocks with copy buttons)
|
||||
document.querySelectorAll('.copy-code-button, [data-copy]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
trackEvent(EVENTS.CODE_COPY, {
|
||||
contentType,
|
||||
contentId,
|
||||
location: window.location.pathname
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Track card clicks on listing pages
|
||||
document.querySelectorAll('.tutorial-card, .project-card, .tool-card, .model-card, .mission-card').forEach(card => {
|
||||
card.addEventListener('click', (e) => {
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const link = target.querySelector('a');
|
||||
const title = target.querySelector('h3, h4')?.textContent?.trim() || '';
|
||||
|
||||
if (link) {
|
||||
const cardType = target.className.includes('tutorial') ? 'tutorial' :
|
||||
target.className.includes('project') ? 'project' :
|
||||
target.className.includes('tool') ? 'tool' :
|
||||
target.className.includes('model') ? 'model' :
|
||||
target.className.includes('mission') ? 'mission' : 'unknown';
|
||||
|
||||
trackEvent(`${cardType}-card-click`, {
|
||||
title,
|
||||
destination: link.href,
|
||||
fromPage: window.location.pathname
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
61
apps/bauntown/apps/landing/src/components/FigmaEmbed.astro
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
---
|
||||
interface Props {
|
||||
figmaUrl: string;
|
||||
title?: string;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
const { figmaUrl, title = "Figma Design", height = "450px" } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="figma-container">
|
||||
<h3 class="figma-title">{title}</h3>
|
||||
<a href={figmaUrl} target="_blank" rel="noopener noreferrer" class="figma-button">
|
||||
<svg width="20" height="20" viewBox="0 0 38 57" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 28.5C19 25.9804 20.0009 23.5641 21.7825 21.7825C23.5641 20.0009 25.9804 19 28.5 19C31.0196 19 33.4359 20.0009 35.2175 21.7825C36.9991 23.5641 38 25.9804 38 28.5C38 31.0196 36.9991 33.4359 35.2175 35.2175C33.4359 36.9991 31.0196 38 28.5 38C25.9804 38 23.5641 36.9991 21.7825 35.2175C20.0009 33.4359 19 31.0196 19 28.5Z" fill="#1ABCFE"/>
|
||||
<path d="M0 47.5C0 44.9804 1.00089 42.5641 2.78249 40.7825C4.56408 39.0009 6.98044 38 9.5 38H19V47.5C19 50.0196 17.9991 52.4359 16.2175 54.2175C14.4359 55.9991 12.0196 57 9.5 57C6.98044 57 4.56408 55.9991 2.78249 54.2175C1.00089 52.4359 0 50.0196 0 47.5Z" fill="#0ACF83"/>
|
||||
<path d="M19 0V19H28.5C31.0196 19 33.4359 17.9991 35.2175 16.2175C36.9991 14.4359 38 12.0196 38 9.5C38 6.98044 36.9991 4.56408 35.2175 2.78249C33.4359 1.00089 31.0196 0 28.5 0H19Z" fill="#FF7262"/>
|
||||
<path d="M0 9.5C0 12.0196 1.00089 14.4359 2.78249 16.2175C4.56408 17.9991 6.98044 19 9.5 19H19V0H9.5C6.98044 0 4.56408 1.00089 2.78249 2.78249C1.00089 4.56408 0 6.98044 0 9.5Z" fill="#F24E1E"/>
|
||||
<path d="M0 28.5C0 31.0196 1.00089 33.4359 2.78249 35.2175C4.56408 36.9991 6.98044 38 9.5 38H19V19H9.5C6.98044 19 4.56408 20.0009 2.78249 21.7825C1.00089 23.5641 0 25.9804 0 28.5Z" fill="#A259FF"/>
|
||||
</svg>
|
||||
Open in Figma
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<style define:vars={{ height }}>
|
||||
.figma-container {
|
||||
margin: 2.5rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.figma-title {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.figma-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: #0acf83;
|
||||
color: white;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: transform 0.2s, background-color 0.2s;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.figma-button:hover {
|
||||
background-color: #09b975;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.figma-button svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
</style>
|
||||
149
apps/bauntown/apps/landing/src/components/HeroSection.astro
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
---
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
imageAlt?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
imageAlt = "Hero image"
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<div class="hero-section">
|
||||
<div class="hero-text">
|
||||
<svg width="60" height="60" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg" class="hero-logo-svg" style="display:block;margin:0 auto 2rem auto;"><rect x="0.666667" y="7.33334" width="10.6667" height="2.66667" stroke="currentColor" stroke-width="0.8" fill="none"/><rect x="2" y="4.66666" width="8" height="2.66667" stroke="currentColor" stroke-width="0.8" fill="none"/><rect x="3.33333" y="2" width="5.33333" height="2.66667" stroke="currentColor" stroke-width="0.8" fill="none"/></svg>
|
||||
<h1>{title}</h1>
|
||||
{description && <p class="hero-description">{description}</p>}
|
||||
<slot name="content" />
|
||||
</div>
|
||||
|
||||
{image && (
|
||||
<div class="hero-image">
|
||||
<img src={image} alt={imageAlt} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style is:global>
|
||||
/* Hero Section Styles */
|
||||
.hero-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 120px 0 4rem;
|
||||
padding: 0.5rem 0 0;
|
||||
gap: 4rem;
|
||||
}
|
||||
|
||||
.hero-section .hero-text {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
.hero-logo {
|
||||
margin-bottom: 2rem;
|
||||
margin-top: 0;
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
transition: filter 0.2s;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.hero-logo {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
.hero-section h1 {
|
||||
font-size: 2.5rem;
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
background: linear-gradient(90deg, var(--accent-color), #fb923c);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.hero-section .hero-description {
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero-section .hero-image {
|
||||
flex: 0 0 400px;
|
||||
height: 400px;
|
||||
border-radius: 1.5rem;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.hero-section .hero-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.5s ease;
|
||||
}
|
||||
|
||||
.hero-section .hero-image img:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.hero-section {
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.hero-section .hero-image {
|
||||
flex: 0 0 250px;
|
||||
height: auto;
|
||||
aspect-ratio: 1/1;
|
||||
}
|
||||
|
||||
.hero-section h1 {
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
|
||||
.hero-section .hero-description {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero-section {
|
||||
flex-direction: column-reverse;
|
||||
margin: 100px 0 3.5rem;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.hero-section .hero-text {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hero-section .hero-text h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.hero-section .hero-description {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.hero-section .hero-image {
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
aspect-ratio: 3/2;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
356
apps/bauntown/apps/landing/src/components/LanguageSwitcher.astro
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
---
|
||||
import { languages } from '../i18n/ui';
|
||||
import { getLangFromUrl } from '../utils/i18n';
|
||||
|
||||
const lang = getLangFromUrl(Astro.url);
|
||||
const currentPath = Astro.url.pathname;
|
||||
|
||||
const getPathInLang = (targetLang: string) => {
|
||||
// Split URL path into segments
|
||||
const segments = currentPath.split('/').filter(Boolean);
|
||||
|
||||
// First segment is always the language code
|
||||
if (segments.length === 0) {
|
||||
// Homepage case
|
||||
return `/${targetLang}`;
|
||||
} else if (Object.keys(languages).includes(segments[0])) {
|
||||
// Normal case: first segment is a language code
|
||||
segments[0] = targetLang;
|
||||
} else {
|
||||
// Fallback for any non-language URLs
|
||||
segments.unshift(targetLang);
|
||||
}
|
||||
|
||||
return `/${segments.join('/')}`;
|
||||
};
|
||||
|
||||
// Props for the component
|
||||
interface Props {
|
||||
dropdownStyle?: boolean;
|
||||
}
|
||||
|
||||
const { dropdownStyle = false } = Astro.props;
|
||||
---
|
||||
|
||||
{
|
||||
dropdownStyle ? (
|
||||
<div class="language-dropdown">
|
||||
<button class="dropdown-button">
|
||||
<span class="current-lang">{languages[lang]}</span>
|
||||
<svg class="dropdown-arrow" xmlns="http://www.w3.org/2000/svg" width="12" height="6" viewBox="0 0 12 6" fill="none">
|
||||
<path d="M1 1L6 5L11 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
{Object.entries(languages).map(([code, name]) => (
|
||||
<a
|
||||
href={getPathInLang(code)}
|
||||
class={lang === code ? 'active' : ''}
|
||||
hreflang={code}
|
||||
lang={code}
|
||||
>
|
||||
{name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div class="language-selector">
|
||||
{Object.entries(languages).map(([code, name]) => (
|
||||
<a
|
||||
href={getPathInLang(code)}
|
||||
class={lang === code ? 'active' : ''}
|
||||
hreflang={code}
|
||||
lang={code}
|
||||
>
|
||||
{name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<style>
|
||||
/* Shared styles */
|
||||
.language-selector,
|
||||
.language-dropdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.language-selector a,
|
||||
.dropdown-menu a {
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.language-selector a:hover,
|
||||
.dropdown-menu a:hover {
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
.language-selector a.active,
|
||||
.dropdown-menu a.active {
|
||||
font-weight: bold;
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
/* Horizontal selector styles */
|
||||
.language-selector {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Dropdown styles */
|
||||
.language-dropdown {
|
||||
position: relative;
|
||||
z-index: 150;
|
||||
}
|
||||
|
||||
.dropdown-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: none;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
/* Fix text color in dark mode */
|
||||
:global(:root.dark) .dropdown-button {
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.dropdown-button:hover {
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.25rem);
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 120px;
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, transform 0.2s, visibility 0s 0.2s;
|
||||
z-index: 200; /* Higher z-index to ensure it appears above other elements */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Initial state for dropdowns */
|
||||
.dropdown-menu {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.dropdown-menu.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
visibility: visible;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.dropdown-menu a {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Fix link color in dark mode */
|
||||
:global(:root.dark) .dropdown-menu a {
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
/* Only show dropdown on desktop hover, not mobile */
|
||||
@media (min-width: 769px) {
|
||||
.language-dropdown:hover .dropdown-menu,
|
||||
.dropdown-button:focus-within .dropdown-menu {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
visibility: visible;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.language-dropdown:hover .dropdown-arrow,
|
||||
.dropdown-button:focus-within .dropdown-arrow,
|
||||
.dropdown-button[aria-expanded="true"] .dropdown-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Show dropdown in mobile when active */
|
||||
.dropdown-menu.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
visibility: visible;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
/* Standard animation for all dropdowns */
|
||||
.mobile-menu-controls .dropdown-menu.show {
|
||||
transform: none !important;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Mobile styles */
|
||||
@media (max-width: 768px) {
|
||||
.language-selector {
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.language-selector a {
|
||||
font-size: 1rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.language-dropdown {
|
||||
width: 100%;
|
||||
position: relative; /* Keep relative positioning for proper dropdown placement */
|
||||
}
|
||||
|
||||
.dropdown-button {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
font-size: 1.1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Important: absolute positioning for mobile dropdown */
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
top: 100%; /* Show below the button */
|
||||
z-index: 2000; /* Extra high z-index */
|
||||
max-height: calc(100vh - 200px);
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* Style for mobile menu dropdown - directly attached to button */
|
||||
.mobile-menu-controls .dropdown-menu {
|
||||
position: absolute;
|
||||
width: 100%; /* Match button width */
|
||||
left: 0;
|
||||
top: 100%; /* Position below the button */
|
||||
bottom: auto;
|
||||
margin-top: 2px; /* Small gap */
|
||||
max-height: 180px;
|
||||
border-radius: 8px;
|
||||
z-index: 2000;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
|
||||
.dropdown-menu a {
|
||||
padding: 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// For accessibility - allow keyboard navigation
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Handle all dropdown buttons in the document, not just the first one
|
||||
const dropdownButtons = document.querySelectorAll('.dropdown-button');
|
||||
|
||||
dropdownButtons.forEach(dropdownButton => {
|
||||
if (!dropdownButton) return;
|
||||
|
||||
// Open/close dropdown on click
|
||||
dropdownButton.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const isExpanded = dropdownButton.getAttribute('aria-expanded') === 'true';
|
||||
dropdownButton.setAttribute('aria-expanded', isExpanded ? 'false' : 'true');
|
||||
|
||||
const menu = dropdownButton.nextElementSibling;
|
||||
if (menu && menu.classList.contains('dropdown-menu')) {
|
||||
menu.classList.toggle('show');
|
||||
|
||||
if (!isExpanded) {
|
||||
// Focus the first menu item
|
||||
const firstItem = menu.querySelector('a');
|
||||
if (firstItem) firstItem.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
dropdownButton.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
dropdownButton.click();
|
||||
}
|
||||
|
||||
// Close when escape is pressed
|
||||
if (e.key === 'Escape') {
|
||||
const menu = dropdownButton.nextElementSibling;
|
||||
if (menu && menu.classList.contains('show')) {
|
||||
menu.classList.remove('show');
|
||||
dropdownButton.setAttribute('aria-expanded', 'false');
|
||||
dropdownButton.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize ARIA attributes
|
||||
dropdownButton.setAttribute('aria-haspopup', 'true');
|
||||
dropdownButton.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
dropdownButtons.forEach(dropdownButton => {
|
||||
if (!dropdownButton.contains(e.target)) {
|
||||
const menu = dropdownButton.nextElementSibling;
|
||||
if (menu && menu.classList.contains('show')) {
|
||||
menu.classList.remove('show');
|
||||
dropdownButton.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add click listener to all dropdown menu items
|
||||
const dropdownMenuItems = document.querySelectorAll('.dropdown-menu a');
|
||||
dropdownMenuItems.forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
const menu = item.closest('.dropdown-menu');
|
||||
if (menu) {
|
||||
menu.classList.remove('show');
|
||||
const button = menu.previousElementSibling;
|
||||
if (button) {
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
385
apps/bauntown/apps/landing/src/components/MatrixHero.astro
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
---
|
||||
interface Props {
|
||||
height?: string;
|
||||
}
|
||||
|
||||
const { height = "85vh" } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="matrix-hero" style={`height: ${height};`}>
|
||||
<div class="matrix-canvas-container">
|
||||
<canvas id="matrixCanvas"></canvas>
|
||||
<div class="fade-bottom"></div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="title-container">
|
||||
<p class="hero-subtitle">Custom build future</p>
|
||||
<button id="scroll-down" class="scroll-down-btn" aria-label="Scroll down">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="chevron-down"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.matrix-hero {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
margin-left: calc(-50vw + 50%);
|
||||
margin-top: -69px; /* Match the navigation height to extend behind it */
|
||||
padding-top: 69px; /* Add padding to ensure content starts below nav */
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
background-color: #000; /* Ensure black background */
|
||||
}
|
||||
|
||||
.matrix-canvas-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 2; /* Higher z-index to place it above content */
|
||||
pointer-events: auto; /* Allow interaction with matrix elements */
|
||||
}
|
||||
|
||||
.fade-bottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 150px;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
var(--background-color) 0%,
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
);
|
||||
z-index: 3; /* Above matrix canvas */
|
||||
}
|
||||
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 2; /* Same z-index as the matrix canvas to interleave */
|
||||
text-align: center;
|
||||
color: white;
|
||||
padding: 0 1rem;
|
||||
transform: translateY(-50px); /* Move content up */
|
||||
}
|
||||
|
||||
.title-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 5rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.2;
|
||||
max-width: 80%;
|
||||
text-shadow:
|
||||
0 0 20px rgba(255, 255, 255, 0.25),
|
||||
0 0 35px rgba(255, 165, 0, 0.18),
|
||||
0 0 60px rgba(255, 255, 255, 0.12),
|
||||
0 0 100px rgba(255, 255, 255, 0.08);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.scroll-down-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-top: 0.5rem;
|
||||
color: rgba(255, 255, 255, 1);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.scroll-down-btn:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.chevron-down {
|
||||
filter: drop-shadow(0 0 8px rgba(255, 255, 255, 0.3));
|
||||
}
|
||||
|
||||
.matrix-ball {
|
||||
display: none; /* Hide the ball element instead of removing it completely */
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.matrix-hero {
|
||||
margin-top: -57px; /* Adjust for smaller mobile nav height */
|
||||
padding-top: 57px;
|
||||
}
|
||||
|
||||
.content {
|
||||
transform: translateY(-30px); /* Less shift on mobile */
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 2.8rem;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.scroll-down-btn svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.fade-bottom {
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script defer>
|
||||
// Defer the matrix animation initialization
|
||||
function initMatrixAnimation() {
|
||||
// Scroll to first card when chevron is clicked
|
||||
const scrollBtn = document.getElementById("scroll-down");
|
||||
if (scrollBtn) {
|
||||
scrollBtn.addEventListener("click", () => {
|
||||
// Find the first card section after the hero
|
||||
const featureSection = document.querySelector(".feature-section");
|
||||
if (featureSection && window.scrollToElement) {
|
||||
// Use the global scroll helper
|
||||
window.scrollToElement(featureSection, 20); // 20px additional offset
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Matrix animation (optimized)
|
||||
const canvas = document.getElementById("matrixCanvas");
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d", { alpha: false }); // Optimize context by disabling alpha
|
||||
if (!ctx) return;
|
||||
|
||||
// Make canvas full size of container but at lower resolution for performance
|
||||
function resizeCanvas() {
|
||||
const container = canvas.parentElement;
|
||||
if (!container) return;
|
||||
|
||||
// Use a smaller canvas resolution for better performance
|
||||
const scale = window.devicePixelRatio * 0.5; // Half the device pixel ratio
|
||||
canvas.width = container.clientWidth * scale;
|
||||
canvas.height = container.clientHeight * scale;
|
||||
|
||||
// Scale the context to make it look right
|
||||
ctx.scale(scale, scale);
|
||||
|
||||
// Reset CSS dimensions
|
||||
canvas.style.width = container.clientWidth + 'px';
|
||||
canvas.style.height = container.clientHeight + 'px';
|
||||
}
|
||||
|
||||
// Initial setup
|
||||
resizeCanvas();
|
||||
const debouncedResize = debounce(resizeCanvas, 250);
|
||||
window.addEventListener("resize", debouncedResize);
|
||||
|
||||
// Clear with solid black to start
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Pre-calculate some values for optimization
|
||||
const codeSnippets = [
|
||||
"const x = 0;",
|
||||
"let y = true;",
|
||||
"if (x > 0) {",
|
||||
"function() {",
|
||||
"export const",
|
||||
"import { }",
|
||||
];
|
||||
|
||||
// Pre-calculate colors for better performance
|
||||
const colorPalette = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const hue = 20 + Math.floor(Math.random() * 20);
|
||||
const saturation = 80 + Math.floor(Math.random() * 20);
|
||||
const lightness = 50 + Math.floor(Math.random() * 20);
|
||||
colorPalette.push(`hsl(${hue}, ${saturation}%, ${lightness}%)`);
|
||||
}
|
||||
|
||||
// Matrix code effect - simplified for better performance
|
||||
class Column {
|
||||
constructor(x, fontSize, canvasHeight) {
|
||||
this.x = x;
|
||||
this.fontSize = fontSize;
|
||||
this.canvasHeight = canvasHeight;
|
||||
this.rises = [];
|
||||
this.chars = [];
|
||||
this.colorIndexes = [];
|
||||
// Even slower speed for better performance
|
||||
this.speed = 0.02 + Math.random() * 0.01;
|
||||
|
||||
// Initialize with positions from bottom - use less characters
|
||||
const snippet = codeSnippets[Math.floor(Math.random() * codeSnippets.length)];
|
||||
for (let i = 0; i < snippet.length; i++) {
|
||||
// Position strings at different positions below the canvas
|
||||
const y = canvasHeight + Math.random() * 200;
|
||||
this.rises.push(y);
|
||||
this.chars.push(snippet[i]);
|
||||
this.colorIndexes.push(Math.floor(Math.random() * colorPalette.length));
|
||||
}
|
||||
}
|
||||
|
||||
draw(ctx) {
|
||||
for (let i = 0; i < this.rises.length; i++) {
|
||||
// Calculate positions for current character
|
||||
const x = this.x + i * this.fontSize * 0.6;
|
||||
const y = this.rises[i];
|
||||
|
||||
// Draw only if in visible area (performance optimization)
|
||||
if (y > 0 && y < this.canvasHeight + 50) {
|
||||
// Draw the current character
|
||||
ctx.fillStyle = colorPalette[this.colorIndexes[i]];
|
||||
ctx.font = `${this.fontSize}px 'Courier New', monospace`;
|
||||
ctx.fillText(this.chars[i], x, y);
|
||||
}
|
||||
|
||||
// Move the rise up slower
|
||||
this.rises[i] -= this.fontSize * this.speed;
|
||||
|
||||
// Reset when off the top of the screen with random delay
|
||||
if (this.rises[i] < -50 && Math.random() > 0.99) {
|
||||
this.rises[i] = this.canvasHeight + this.fontSize * (10 + Math.random() * 20);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create columns of code - reduced number for better performance
|
||||
let columns = [];
|
||||
let animationFrameId;
|
||||
let lastFrameTime = 0;
|
||||
const targetFPS = 30; // Lower target FPS for better performance
|
||||
const frameInterval = 1000 / targetFPS;
|
||||
|
||||
function initColumns() {
|
||||
columns = [];
|
||||
const fontSize = canvas.width > 768 ? 16 : 12;
|
||||
|
||||
// Use much fewer columns for better performance
|
||||
const maxColumns = canvas.width > 768 ? 15 : 8;
|
||||
|
||||
// Create columns at optimal positions
|
||||
for (let i = 0; i < maxColumns; i++) {
|
||||
const x = (i / maxColumns) * canvas.width;
|
||||
columns.push(new Column(x, fontSize, canvas.height));
|
||||
}
|
||||
}
|
||||
|
||||
// Animation loop - optimized with frame limiting
|
||||
function animate(currentTime) {
|
||||
// Frame limiting for better performance
|
||||
const elapsed = currentTime - lastFrameTime;
|
||||
if (elapsed < frameInterval) {
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
return;
|
||||
}
|
||||
lastFrameTime = currentTime - (elapsed % frameInterval);
|
||||
|
||||
// Use alpha for trail effect
|
||||
ctx.fillStyle = "rgba(0, 0, 0, 0.08)"; // Slower fade for better effect
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw all columns
|
||||
columns.forEach((column) => column.draw(ctx));
|
||||
|
||||
// Continue animation
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
// Initialize columns after a short delay
|
||||
setTimeout(() => {
|
||||
initColumns();
|
||||
lastFrameTime = performance.now();
|
||||
animate(lastFrameTime);
|
||||
}, 500); // 500ms delay to ensure other critical resources load first
|
||||
|
||||
// Debounce function for resize event
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function() {
|
||||
const context = this;
|
||||
const args = arguments;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
func.apply(context, args);
|
||||
}, wait);
|
||||
};
|
||||
}
|
||||
|
||||
// Handle visibility change to pause animation when tab is not visible
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
// Pause animation to save resources
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
} else if (!animationFrameId) {
|
||||
// Resume animation
|
||||
lastFrameTime = performance.now();
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
}
|
||||
});
|
||||
|
||||
// Reinitialize on resize, but debounced
|
||||
window.addEventListener("resize", () => {
|
||||
// Cancel existing animation to prevent memory leaks
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
|
||||
// We already have a debounced resize for canvas dimensions
|
||||
|
||||
// Clear canvas
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Reinitialize columns
|
||||
initColumns();
|
||||
lastFrameTime = performance.now();
|
||||
animate(lastFrameTime);
|
||||
});
|
||||
}
|
||||
|
||||
// Use requestIdleCallback or setTimeout to defer initialization
|
||||
if ('requestIdleCallback' in window) {
|
||||
requestIdleCallback(() => {
|
||||
initMatrixAnimation();
|
||||
});
|
||||
} else {
|
||||
// Fallback for browsers without requestIdleCallback
|
||||
setTimeout(initMatrixAnimation, 1000); // Wait for 1 second after page load
|
||||
}
|
||||
</script>
|
||||
283
apps/bauntown/apps/landing/src/components/MissionCard.astro
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
---
|
||||
import { getLangFromUrl, useTranslations } from '../utils/i18n';
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
|
||||
interface Props {
|
||||
mission: CollectionEntry<'missions'>;
|
||||
}
|
||||
|
||||
const { mission } = Astro.props;
|
||||
const { data } = mission;
|
||||
|
||||
const lang = getLangFromUrl(Astro.url);
|
||||
const t = useTranslations(lang);
|
||||
|
||||
// Format date based on language
|
||||
const formattedDate = new Intl.DateTimeFormat(lang === 'de' ? 'de-DE' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}).format(data.pubDate);
|
||||
|
||||
// Generate mission URL - always include language segment
|
||||
let missionUrl;
|
||||
const slugParts = mission.slug.split('/');
|
||||
const fileName = slugParts[slugParts.length - 1];
|
||||
missionUrl = `/${lang}/missions/${fileName}`;
|
||||
|
||||
// Get translated difficulty and status
|
||||
const difficultyKey = `missions.difficulty.${data.difficulty}` as const;
|
||||
const statusKey = `missions.status.${data.status}` as const;
|
||||
---
|
||||
|
||||
<a href={missionUrl} class="mission-card-link">
|
||||
<article class:list={["mission-card", { "featured": data.featured }]}>
|
||||
{data.image && (
|
||||
<div class="mission-image-container">
|
||||
<img src={data.image} alt={data.title} class="mission-image" />
|
||||
<div class="status-badge" data-status={data.status}>
|
||||
{t(statusKey)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="content">
|
||||
<div class="meta">
|
||||
<span class="difficulty" data-difficulty={data.difficulty}>{t(difficultyKey)}</span>
|
||||
<span class="date">{formattedDate}</span>
|
||||
</div>
|
||||
|
||||
<h3>{data.title}</h3>
|
||||
<p>{data.description}</p>
|
||||
|
||||
<div class="details">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">{t('missions.duration')}</span>
|
||||
<span class="detail-value">{data.duration}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">{t('missions.skills')}</span>
|
||||
<div class="skills-list">
|
||||
{data.skills.map(skill => (
|
||||
<span class="skill-tag">{skill}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
{data.participants && data.participants.length > 0 && (
|
||||
<div class="participants">
|
||||
<span class="participants-count">{data.participants.length}</span>
|
||||
</div>
|
||||
)}
|
||||
<span class="read-more">{t('missions.readMore')} <span class="arrow">→</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.mission-card-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mission-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
background-color: var(--card-bg);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.03);
|
||||
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mission-card-link:hover .mission-card {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.5);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.mission-card.featured {
|
||||
border: 2px solid rgba(var(--accent-color-rgb, 249, 115, 22), 0.3);
|
||||
}
|
||||
|
||||
.mission-image-container {
|
||||
width: 100%;
|
||||
padding-top: 66.67%; /* Aspect ratio 3:2 */
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mission-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 2rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
z-index: 2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge[data-status="active"] {
|
||||
background-color: #10b981; /* Green */
|
||||
}
|
||||
|
||||
.status-badge[data-status="completed"] {
|
||||
background-color: #6366f1; /* Purple */
|
||||
}
|
||||
|
||||
.status-badge[data-status="upcoming"] {
|
||||
background-color: #f59e0b; /* Amber */
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.difficulty {
|
||||
font-weight: 600;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.difficulty[data-difficulty="beginner"] {
|
||||
background-color: #dcfce7; /* Light green */
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.difficulty[data-difficulty="intermediate"] {
|
||||
background-color: #e0f2fe; /* Light blue */
|
||||
color: #0284c7;
|
||||
}
|
||||
|
||||
.difficulty[data-difficulty="advanced"] {
|
||||
background-color: #fee2e2; /* Light red */
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.date {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.skills-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.skill-tag {
|
||||
font-size: 0.75rem;
|
||||
background-color: rgba(var(--border-color-rgb), 0.3);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.participants {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.participants-count {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.participants-count::before {
|
||||
content: '👥 ';
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.read-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.mission-card-link:hover .arrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
---
|
||||
import TableImproved from './TableImproved.astro';
|
||||
|
||||
const models = [
|
||||
{ name: "o1", provider: "OpenAI", inputCost: 15, cachedInputCost: 7.5, outputCost: 60, inputLimit: 200000, outputLimit: 100000, batchAPI: "50% for 24 hours", hoster: "OpenAI, Azure", trainingCutoff: "October 2023", totalCost: 45 },
|
||||
{ name: "gpt 4.1", provider: "OpenAI", inputCost: 2, cachedInputCost: 0.5, outputCost: 8, inputLimit: 1000000, outputLimit: 32768, batchAPI: "50% for 24 hours", hoster: "OpenAI, Azure", trainingCutoff: "June 2024", totalCost: 6 },
|
||||
{ name: "GPT-4o", provider: "OpenAI", inputCost: 2.5, cachedInputCost: 1.25, outputCost: 10, inputLimit: 128000, outputLimit: 16384, batchAPI: "50% for 24 hours", hoster: "OpenAI, Azure", trainingCutoff: "October 2023", totalCost: 7.5 },
|
||||
{ name: "gpt 4.1-mini", provider: "OpenAI", inputCost: 0.4, cachedInputCost: 0.1, outputCost: 1.6, inputLimit: 1000000, outputLimit: 32768, batchAPI: "50% for 24 hours", hoster: "OpenAI, Azure", trainingCutoff: "June 2024", totalCost: 1.2 },
|
||||
{ name: "gpt 4.1-nano", provider: "OpenAI", inputCost: 0.1, cachedInputCost: 0.025, outputCost: 0.4, inputLimit: 1000000, outputLimit: 32768, batchAPI: "50% for 24 hours", hoster: "OpenAI, Azure", trainingCutoff: "June 2024", totalCost: 0.3 },
|
||||
{ name: "o3-mini", provider: "OpenAI", inputCost: 1.1, cachedInputCost: 0.55, outputCost: 4.4, inputLimit: 200000, outputLimit: 100000, batchAPI: "50% for 24 hours", hoster: "OpenAI, Azure", trainingCutoff: "October 2023", totalCost: 3.3 },
|
||||
{ name: "Claude Sonnet 3.7", provider: "Antrophic", inputCost: 3, cachedInputCost: "Up to 90%", outputCost: 15, inputLimit: 200000, outputLimit: 8192, batchAPI: "50% for 24 hours", hoster: "Google, Amazon, Antrophic", trainingCutoff: "April 2024", totalCost: 10.5 },
|
||||
{ name: "Claude Haiku 3.5", provider: "Antrophic", inputCost: 0.8, cachedInputCost: "Up to 90%", outputCost: 4, inputLimit: 200000, outputLimit: 8192, batchAPI: "50% for 24 hours", hoster: "Google, Amazon, Antrophic", trainingCutoff: "July 2024", totalCost: 2.8 },
|
||||
{ name: "Deepseek R1", provider: "Deepseek", inputCost: 0.55, cachedInputCost: 0.14, outputCost: 2.19, inputLimit: 64000, outputLimit: 8192, batchAPI: "", hoster: "Deepseek", trainingCutoff: "July 2024", totalCost: 1.645 },
|
||||
{ name: "Deepseek V3", provider: "Deepseek", inputCost: 0.27, cachedInputCost: 0.07, outputCost: 1.1, inputLimit: 64000, outputLimit: 8192, batchAPI: "", hoster: "Deepseek", trainingCutoff: "December 2024", totalCost: 0.82 },
|
||||
{ name: "GPT-4o mini", provider: "OpenAI", inputCost: 0.15, cachedInputCost: 0.075, outputCost: 0.6, inputLimit: 128000, outputLimit: 16384, batchAPI: "50% for 24 hours", hoster: "OpenAI, Azure", trainingCutoff: "October 2023", totalCost: 0.45 },
|
||||
{ name: "Gemini 2.5 Pro", provider: "", inputCost: 1.25, cachedInputCost: "", outputCost: 10, inputLimit: 1048576, outputLimit: 65536, batchAPI: "", hoster: "Google", trainingCutoff: "January 2025", totalCost: 6.25 },
|
||||
{ name: "Gemini 2.0 Flash", provider: "Google", inputCost: 0.1, cachedInputCost: 0.025, outputCost: 0.4, inputLimit: 1048576, outputLimit: 8192, batchAPI: "", hoster: "Google", trainingCutoff: "June 2024", totalCost: 0.3 },
|
||||
{ name: "Gemini 2.0 Flash-Lite", provider: "Google", inputCost: 0.075, cachedInputCost: 0.01875, outputCost: 0.3, inputLimit: 1048576, outputLimit: 8192, batchAPI: "", hoster: "Google", trainingCutoff: "June 2024", totalCost: 0.225 },
|
||||
];
|
||||
|
||||
// Formatierungsfunktionen für verschiedene Spaltentypen
|
||||
const formatCurrency = (value: number | undefined | null): string => {
|
||||
if (value === undefined || value === null) return "-";
|
||||
return `${value}$`;
|
||||
};
|
||||
|
||||
const formatNumber = (value: number | undefined | null): string => {
|
||||
if (value === undefined || value === null) return "-";
|
||||
return value.toLocaleString();
|
||||
};
|
||||
|
||||
// Spaltendefinitionen mit festen Breiten
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Model',
|
||||
width: '150px',
|
||||
sticky: true
|
||||
},
|
||||
{
|
||||
key: 'provider',
|
||||
title: 'Provider',
|
||||
width: '120px'
|
||||
},
|
||||
{
|
||||
key: 'inputCost',
|
||||
title: 'Input Cost',
|
||||
subtitle: 'per 1M tokens',
|
||||
formatter: formatCurrency,
|
||||
align: 'right',
|
||||
width: '120px'
|
||||
},
|
||||
{
|
||||
key: 'cachedInputCost',
|
||||
title: 'Cached Input Cost',
|
||||
subtitle: 'per 1M tokens',
|
||||
formatter: (value: any): string => typeof value === 'number' ? formatCurrency(value) : value,
|
||||
align: 'right',
|
||||
width: '150px'
|
||||
},
|
||||
{
|
||||
key: 'outputCost',
|
||||
title: 'Output Cost',
|
||||
subtitle: 'per 1M tokens',
|
||||
formatter: formatCurrency,
|
||||
align: 'right',
|
||||
width: '120px'
|
||||
},
|
||||
{
|
||||
key: 'inputLimit',
|
||||
title: 'Input Token Limit',
|
||||
formatter: formatNumber,
|
||||
align: 'right',
|
||||
width: '140px'
|
||||
},
|
||||
{
|
||||
key: 'outputLimit',
|
||||
title: 'Output Token Limit',
|
||||
formatter: formatNumber,
|
||||
align: 'right',
|
||||
width: '140px'
|
||||
},
|
||||
{
|
||||
key: 'batchAPI',
|
||||
title: 'Batch API',
|
||||
width: '150px'
|
||||
},
|
||||
{
|
||||
key: 'hoster',
|
||||
title: 'Hoster',
|
||||
width: '200px'
|
||||
},
|
||||
{
|
||||
key: 'trainingCutoff',
|
||||
title: 'Training Cut-off',
|
||||
width: '140px'
|
||||
},
|
||||
{
|
||||
key: 'totalCost',
|
||||
title: 'Total Cost',
|
||||
subtitle: '1M Input + 0.5M Output',
|
||||
formatter: formatCurrency,
|
||||
align: 'right',
|
||||
width: '120px'
|
||||
}
|
||||
];
|
||||
---
|
||||
|
||||
<section class="models-comparison-table">
|
||||
<h2>AI Models Comparison</h2>
|
||||
|
||||
<TableImproved
|
||||
data={models}
|
||||
columns={columns}
|
||||
stickyHeader={true}
|
||||
stickyFirstColumn={true}
|
||||
maxHeight="70vh"
|
||||
sortable={true}
|
||||
searchable={true}
|
||||
className="models-table"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.models-comparison-table {
|
||||
margin-bottom: 2.5rem;
|
||||
background: var(--background-secondary);
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
padding: 2rem 0;
|
||||
width: calc(100vw - 2rem);
|
||||
position: relative;
|
||||
left: 50%;
|
||||
right: 50%;
|
||||
margin-left: calc(-50vw + 1rem);
|
||||
margin-right: calc(-50vw + 1rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Zusätzliche Anpassungen für die Tabelle */
|
||||
:global(.models-table) {
|
||||
--background-secondary: var(--background-secondary, #f8fafc);
|
||||
--background-tertiary: var(--background-tertiary, #edf2f7);
|
||||
--border-color: var(--border-color, #e2e8f0);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
---
|
||||
import Table from './Table.astro';
|
||||
|
||||
const models = [
|
||||
{ name: "o1", provider: "OpenAI", inputCost: 15, cachedInputCost: 7.5, outputCost: 60, inputLimit: 200000, outputLimit: 100000, batchAPI: "50% for 24 hours", hoster: "OpenAI, Azure", trainingCutoff: "October 2023", totalCost: 45 },
|
||||
{ name: "gpt 4.1", provider: "OpenAI", inputCost: 2, cachedInputCost: 0.5, outputCost: 8, inputLimit: 1000000, outputLimit: 32768, batchAPI: "50% for 24 hours", hoster: "OpenAI, Azure", trainingCutoff: "June 2024", totalCost: 6 },
|
||||
{ name: "GPT-4o", provider: "OpenAI", inputCost: 2.5, cachedInputCost: 1.25, outputCost: 10, inputLimit: 128000, outputLimit: 16384, batchAPI: "50% for 24 hours", hoster: "OpenAI, Azure", trainingCutoff: "October 2023", totalCost: 7.5 },
|
||||
{ name: "gpt 4.1-mini", provider: "OpenAI", inputCost: 0.4, cachedInputCost: 0.1, outputCost: 1.6, inputLimit: 1000000, outputLimit: 32768, batchAPI: "50% for 24 hours", hoster: "OpenAI, Azure", trainingCutoff: "June 2024", totalCost: 1.2 },
|
||||
{ name: "gpt 4.1-nano", provider: "OpenAI", inputCost: 0.1, cachedInputCost: 0.025, outputCost: 0.4, inputLimit: 1000000, outputLimit: 32768, batchAPI: "50% for 24 hours", hoster: "OpenAI, Azure", trainingCutoff: "June 2024", totalCost: 0.3 },
|
||||
{ name: "o3-mini", provider: "OpenAI", inputCost: 1.1, cachedInputCost: 0.55, outputCost: 4.4, inputLimit: 200000, outputLimit: 100000, batchAPI: "50% for 24 hours", hoster: "OpenAI, Azure", trainingCutoff: "October 2023", totalCost: 3.3 },
|
||||
{ name: "Claude Sonnet 3.7", provider: "Antrophic", inputCost: 3, cachedInputCost: "Up to 90%", outputCost: 15, inputLimit: 200000, outputLimit: 8192, batchAPI: "50% for 24 hours", hoster: "Google, Amazon, Antrophic", trainingCutoff: "April 2024", totalCost: 10.5 },
|
||||
{ name: "Claude Haiku 3.5", provider: "Antrophic", inputCost: 0.8, cachedInputCost: "Up to 90%", outputCost: 4, inputLimit: 200000, outputLimit: 8192, batchAPI: "50% for 24 hours", hoster: "Google, Amazon, Antrophic", trainingCutoff: "July 2024", totalCost: 2.8 },
|
||||
{ name: "Deepseek R1", provider: "Deepseek", inputCost: 0.55, cachedInputCost: 0.14, outputCost: 2.19, inputLimit: 64000, outputLimit: 8192, batchAPI: "", hoster: "Deepseek", trainingCutoff: "July 2024", totalCost: 1.645 },
|
||||
{ name: "Deepseek V3", provider: "Deepseek", inputCost: 0.27, cachedInputCost: 0.07, outputCost: 1.1, inputLimit: 64000, outputLimit: 8192, batchAPI: "", hoster: "Deepseek", trainingCutoff: "December 2024", totalCost: 0.82 },
|
||||
{ name: "GPT-4o mini", provider: "OpenAI", inputCost: 0.15, cachedInputCost: 0.075, outputCost: 0.6, inputLimit: 128000, outputLimit: 16384, batchAPI: "50% for 24 hours", hoster: "OpenAI, Azure", trainingCutoff: "October 2023", totalCost: 0.45 },
|
||||
{ name: "Gemini 2.5 Pro", provider: "", inputCost: 1.25, cachedInputCost: "", outputCost: 10, inputLimit: 1048576, outputLimit: 65536, batchAPI: "", hoster: "Google", trainingCutoff: "January 2025", totalCost: 6.25 },
|
||||
{ name: "Gemini 2.0 Flash", provider: "Google", inputCost: 0.1, cachedInputCost: 0.025, outputCost: 0.4, inputLimit: 1048576, outputLimit: 8192, batchAPI: "", hoster: "Google", trainingCutoff: "June 2024", totalCost: 0.3 },
|
||||
{ name: "Gemini 2.0 Flash-Lite", provider: "Google", inputCost: 0.075, cachedInputCost: 0.01875, outputCost: 0.3, inputLimit: 1048576, outputLimit: 8192, batchAPI: "", hoster: "Google", trainingCutoff: "June 2024", totalCost: 0.225 },
|
||||
];
|
||||
|
||||
// Formatierungsfunktionen für verschiedene Spaltentypen
|
||||
const formatCurrency = (value) => {
|
||||
if (value === undefined || value === null) return "-";
|
||||
return `${value}$`;
|
||||
};
|
||||
|
||||
const formatNumber = (value) => {
|
||||
if (value === undefined || value === null) return "-";
|
||||
return value.toLocaleString();
|
||||
};
|
||||
|
||||
// Spaltendefinitionen
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Model',
|
||||
width: '150px',
|
||||
sticky: true
|
||||
},
|
||||
{
|
||||
key: 'provider',
|
||||
title: 'Provider'
|
||||
},
|
||||
{
|
||||
key: 'inputCost',
|
||||
title: 'Input Cost',
|
||||
subtitle: 'per 1M tokens',
|
||||
formatter: formatCurrency,
|
||||
align: 'right'
|
||||
},
|
||||
{
|
||||
key: 'cachedInputCost',
|
||||
title: 'Cached Input Cost',
|
||||
subtitle: 'per 1M tokens',
|
||||
formatter: (value) => typeof value === 'number' ? formatCurrency(value) : value,
|
||||
align: 'right'
|
||||
},
|
||||
{
|
||||
key: 'outputCost',
|
||||
title: 'Output Cost',
|
||||
subtitle: 'per 1M tokens',
|
||||
formatter: formatCurrency,
|
||||
align: 'right'
|
||||
},
|
||||
{
|
||||
key: 'inputLimit',
|
||||
title: 'Input Token Limit',
|
||||
formatter: formatNumber,
|
||||
align: 'right'
|
||||
},
|
||||
{
|
||||
key: 'outputLimit',
|
||||
title: 'Output Token Limit',
|
||||
formatter: formatNumber,
|
||||
align: 'right'
|
||||
},
|
||||
{
|
||||
key: 'batchAPI',
|
||||
title: 'Batch API'
|
||||
},
|
||||
{
|
||||
key: 'hoster',
|
||||
title: 'Hoster'
|
||||
},
|
||||
{
|
||||
key: 'trainingCutoff',
|
||||
title: 'Training Cut-off'
|
||||
},
|
||||
{
|
||||
key: 'totalCost',
|
||||
title: 'Total Cost',
|
||||
subtitle: '1M Input + 0.5M Output',
|
||||
formatter: formatCurrency,
|
||||
align: 'right'
|
||||
}
|
||||
];
|
||||
---
|
||||
|
||||
<section class="models-comparison-page">
|
||||
<h1>AI Models Comparison</h1>
|
||||
|
||||
<Table
|
||||
data={models}
|
||||
columns={columns}
|
||||
stickyHeader={true}
|
||||
stickyFirstColumn={true}
|
||||
maxHeight="70vh"
|
||||
sortable={true}
|
||||
searchable={true}
|
||||
className="models-table"
|
||||
/>
|
||||
|
||||
<div class="table-info">
|
||||
<p>
|
||||
<strong>Hinweis:</strong> Die Preise sind in US-Dollar angegeben und können sich ändern.
|
||||
Die Gesamtkosten basieren auf 1 Million Input-Token und 0,5 Millionen Output-Token.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.models-comparison-page {
|
||||
margin: 2rem 0;
|
||||
width: calc(100vw - 2rem);
|
||||
position: relative;
|
||||
left: 50%;
|
||||
right: 50%;
|
||||
margin-left: calc(-50vw + 1rem);
|
||||
margin-right: calc(-50vw + 1rem);
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.table-info {
|
||||
margin: 1.5rem 2rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Zusätzliche Anpassungen für die Tabelle */
|
||||
:global(.models-table) {
|
||||
--background-secondary: var(--color-background-secondary, #f8fafc);
|
||||
--background-tertiary: var(--color-background-tertiary, #edf2f7);
|
||||
--border-color: var(--color-border, #e2e8f0);
|
||||
}
|
||||
</style>
|
||||
647
apps/bauntown/apps/landing/src/components/Navigation.astro
Normal file
|
|
@ -0,0 +1,647 @@
|
|||
---
|
||||
import { getLangFromUrl, useTranslations } from "../utils/i18n";
|
||||
import ThemeToggle from "./ThemeToggle.astro";
|
||||
import LanguageSwitcher from "./LanguageSwitcher.astro";
|
||||
|
||||
const lang = getLangFromUrl(Astro.url);
|
||||
const t = useTranslations(lang);
|
||||
const pathname = Astro.url.pathname;
|
||||
|
||||
// URLs with language prefix - use explicit /de/ for German too
|
||||
const homeUrl = `/${lang}`;
|
||||
const modelsUrl = `/${lang}/models`;
|
||||
const projectsUrl = `/${lang}/projects`;
|
||||
const tutorialsUrl = `/${lang}/tutorials`;
|
||||
const toolsUrl = `/${lang}/tools`;
|
||||
const visionUrl = `/${lang}/vision`;
|
||||
const joinUrl = `/${lang}/join`;
|
||||
const supportUrl = `/${lang}/support`;
|
||||
const membersUrl = `/${lang}/members`;
|
||||
|
||||
// Function to check if the current page matches a given path
|
||||
const isActive = (path: string) => {
|
||||
if (path === homeUrl) {
|
||||
// Special case for home: only match exactly
|
||||
return pathname === homeUrl || pathname === `${homeUrl}/`;
|
||||
}
|
||||
// For other pages, check if the pathname starts with the path
|
||||
return pathname.startsWith(path);
|
||||
};
|
||||
---
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<nav class="desktop-nav">
|
||||
<div class="navbar">
|
||||
<div class="logo">
|
||||
<a href={homeUrl}>
|
||||
<svg width="24" height="24" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg" class="nav-logo-svg"><rect x="0.666667" y="7.33334" width="10.6667" height="2.66667" stroke="currentColor" stroke-width="0.8" fill="none"/><rect x="2" y="4.66666" width="8" height="2.66667" stroke="currentColor" stroke-width="0.8" fill="none"/><rect x="3.33333" y="2" width="5.33333" height="2.66667" stroke="currentColor" stroke-width="0.8" fill="none"/></svg>
|
||||
<span class="logo-text">BaunTown</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-container">
|
||||
<div class="nav-links">
|
||||
<a href={tutorialsUrl} class:list={[{ active: isActive(tutorialsUrl) }]}
|
||||
>{t("nav.tutorials")}</a
|
||||
>
|
||||
<a href={toolsUrl} class:list={[{ active: isActive(toolsUrl) }]}
|
||||
>{t("nav.tools")}</a
|
||||
>
|
||||
<a href={modelsUrl} class:list={[{ active: isActive(modelsUrl) }]}
|
||||
>{t("nav.models")}</a
|
||||
>
|
||||
<a href={joinUrl} class:list={[{ active: isActive(joinUrl) }]}
|
||||
>{t("nav.join")}</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="nav-controls">
|
||||
<LanguageSwitcher dropdownStyle={true} />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile Bottom Navigation -->
|
||||
<nav class="mobile-tab-bar">
|
||||
<a href={homeUrl} class:list={[{ active: isActive(homeUrl) }]}>
|
||||
<svg width="24" height="24" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg" class="mobile-logo-svg"><rect x="0.666667" y="7.33334" width="10.6667" height="2.66667" stroke="currentColor" stroke-width="0.8" fill="none"/><rect x="2" y="4.66666" width="8" height="2.66667" stroke="currentColor" stroke-width="0.8" fill="none"/><rect x="3.33333" y="2" width="5.33333" height="2.66667" stroke="currentColor" stroke-width="0.8" fill="none"/></svg>
|
||||
<span>BaunTown</span>
|
||||
</a>
|
||||
<a href={tutorialsUrl} class:list={[{ active: isActive(tutorialsUrl) }]}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"
|
||||
></path>
|
||||
</svg>
|
||||
<span>{t("nav.tutorials")}</span>
|
||||
</a>
|
||||
<a href={toolsUrl} class:list={[{ active: isActive(toolsUrl) }]}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path>
|
||||
</svg>
|
||||
<span>{t("nav.tools")}</span>
|
||||
</a>
|
||||
<a href={modelsUrl} class:list={[{ active: isActive(modelsUrl) }]}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2"
|
||||
></path>
|
||||
<path d="M18 14h-8"></path>
|
||||
<path d="M15 18h-5"></path>
|
||||
<path d="M10 6h8v4h-8V6Z"></path>
|
||||
</svg>
|
||||
<span>{t("nav.models")}</span>
|
||||
</a>
|
||||
<button id="mobile-menu-toggle">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="4" x2="20" y1="12" y2="12"></line>
|
||||
<line x1="4" x2="20" y1="6" y2="6"></line>
|
||||
<line x1="4" x2="20" y1="18" y2="18"></line>
|
||||
</svg>
|
||||
<span>Menu</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile Side Menu -->
|
||||
<div class="mobile-menu">
|
||||
<div class="mobile-menu-header">
|
||||
<svg width="32" height="32" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg" class="mobile-logo-svg" style="display:block;margin:0 auto 1rem auto;"><rect x="0.666667" y="7.33334" width="10.6667" height="2.66667" stroke="currentColor" stroke-width="0.8" fill="none"/><rect x="2" y="4.66666" width="8" height="2.66667" stroke="currentColor" stroke-width="0.8" fill="none"/><rect x="3.33333" y="2" width="5.33333" height="2.66667" stroke="currentColor" stroke-width="0.8" fill="none"/></svg>
|
||||
<button id="mobile-menu-close">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M18 6 6 18"></path>
|
||||
<path d="m6 6 12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="mobile-logo">
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M18.5714 13.4286V18.5714H5.42857L5.42857 13.4286H2V18.5714V22H5.42857H18.5714H22V18.5714V13.4286H18.5714ZM18.5714 10.5714L22 10.5714V5.42857V2H18.5714H5.42857H2V5.42857V10.5714H5.42857L5.42857 5.42857L18.5714 5.42857V10.5714Z"
|
||||
></path>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M16.2857 13.4286L7.71428 13.4286V16.2857H11.1429H12.8571H16.2857V13.4286ZM16.2857 7.71429V10.5714L7.71428 10.5714L7.71428 7.71429H11.1429H12.8571H16.2857Z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-menu-links">
|
||||
<a href={homeUrl} class:list={[{ active: isActive(homeUrl) }]}>BaunTown</a>
|
||||
<a href={tutorialsUrl} class:list={[{ active: isActive(tutorialsUrl) }]}
|
||||
>{t("nav.tutorials")}</a
|
||||
>
|
||||
<a href={toolsUrl} class:list={[{ active: isActive(toolsUrl) }]}
|
||||
>{t("nav.tools")}</a
|
||||
>
|
||||
<a href={modelsUrl} class:list={[{ active: isActive(modelsUrl) }]}
|
||||
>{t("nav.models")}</a
|
||||
>
|
||||
<a href={projectsUrl} class:list={[{ active: isActive(projectsUrl) }]}
|
||||
>{t("nav.projects")}</a
|
||||
>
|
||||
<a href={visionUrl} class:list={[{ active: isActive(visionUrl) }]}
|
||||
>{t("nav.vision")}</a
|
||||
>
|
||||
<a href={joinUrl} class:list={[{ active: isActive(joinUrl) }]}
|
||||
>{t("nav.join")}</a
|
||||
>
|
||||
<a href={supportUrl} class:list={[{ active: isActive(supportUrl) }]}
|
||||
>{t("nav.support")}</a
|
||||
>
|
||||
</div>
|
||||
<div class="mobile-menu-controls">
|
||||
<LanguageSwitcher dropdownStyle={true} />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overlay for mobile menu -->
|
||||
<div class="overlay" id="overlay"></div>
|
||||
|
||||
<style>
|
||||
/* Common Navigation Styles */
|
||||
.nav-logo-svg {
|
||||
height: 24px;
|
||||
width: auto;
|
||||
fill: var(--text-color);
|
||||
}
|
||||
|
||||
/* Desktop Navigation */
|
||||
.desktop-nav {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: 64px; /* Fixed height for calculations */
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
background-color: rgba(var(--background-color-rgb, 255, 255, 255), 0.85);
|
||||
border-bottom: 1px solid rgba(var(--border-color-rgb, 229, 231, 235), 0.5);
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
border-color 0.3s ease;
|
||||
overflow: visible; /* Allow dropdowns to extend outside nav */
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.logo a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
overflow: visible; /* Ensure dropdowns can appear outside the container */
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
padding: 0.5rem;
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-links a:hover,
|
||||
.nav-links a.active {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-links a::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
bottom: 0;
|
||||
left: 0.5rem;
|
||||
right: 0.5rem;
|
||||
background-color: var(--accent-color);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-links a:hover::after,
|
||||
.nav-links a.active::after {
|
||||
width: calc(100% - 1rem);
|
||||
}
|
||||
|
||||
.nav-links a.active {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.nav-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
z-index: 100; /* Ensure controls appear above other elements */
|
||||
}
|
||||
|
||||
/* Mobile Tab Bar */
|
||||
.mobile-tab-bar {
|
||||
display: none;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: var(--background-color);
|
||||
padding: 0.5rem 0.25rem;
|
||||
z-index: 1000; /* Ensure it's above other elements */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mobile-tab-bar {
|
||||
display: none;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.mobile-tab-bar a,
|
||||
.mobile-tab-bar button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 0.25rem;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
border-radius: 0.5rem;
|
||||
transition: background-color 0.2s ease;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-tab-bar a.active,
|
||||
.mobile-tab-bar button.active {
|
||||
color: var(--accent-color);
|
||||
position: relative;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mobile-tab-bar a.active::after,
|
||||
.mobile-tab-bar button.active::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 60%;
|
||||
height: 2px;
|
||||
bottom: 4px;
|
||||
left: 20%;
|
||||
background-color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* For non-touch desktop, align items in center */
|
||||
@media (hover: hover) and (max-width: 768px) {
|
||||
.mobile-tab-bar a,
|
||||
.mobile-tab-bar button {
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;
|
||||
height: 48px; /* Fixed height */
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.mobile-tab-bar svg {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-tab-bar a.coffee-btn {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.mobile-tab-bar a.coffee-btn svg {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.mobile-tab-bar a.active .nav-logo-svg {
|
||||
fill: var(--accent-color);
|
||||
}
|
||||
|
||||
.mobile-tab-bar svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Mobile Side Menu */
|
||||
.mobile-menu {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 85%;
|
||||
background-color: var(--background-color);
|
||||
z-index: 1001; /* Above mobile tab bar */
|
||||
box-shadow: -5px 0 15px rgba(0, 0, 0, 0.1);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
overflow-x: visible; /* Allow language dropdown to be visible outside menu */
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.mobile-menu.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.mobile-menu-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
#mobile-menu-close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.mobile-logo svg {
|
||||
fill: var(--text-color);
|
||||
}
|
||||
|
||||
.mobile-menu-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.mobile-menu-links a {
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.mobile-menu-links a.active {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.mobile-menu-controls {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
z-index: 1100; /* Higher z-index for language switcher in mobile menu */
|
||||
position: relative;
|
||||
overflow: visible; /* Ensure dropdowns are visible */
|
||||
margin-bottom: 200px; /* Plenty of space at bottom for dropdown */
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000; /* Same level as navigation */
|
||||
}
|
||||
|
||||
.overlay.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Dark mode fixes */
|
||||
.nav-logo-svg,
|
||||
.mobile-logo-svg,
|
||||
.hero-logo-svg {
|
||||
color: #111;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
:root.dark .nav-logo-svg,
|
||||
:root.dark .mobile-logo-svg,
|
||||
:root.dark .hero-logo-svg {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Set the logo to accent color when active in both light and dark mode */
|
||||
.mobile-tab-bar a.active .nav-logo-svg {
|
||||
fill: var(--accent-color) !important;
|
||||
}
|
||||
|
||||
:global(:root.dark) .mobile-menu-links a {
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
:global(:root.dark) .mobile-menu-links a.active {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* Media Queries */
|
||||
@media (max-width: 768px) {
|
||||
.desktop-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Only show mobile tab bar on touch devices */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.mobile-tab-bar {
|
||||
display: grid;
|
||||
position: fixed;
|
||||
bottom: 0; /* Position at the bottom of screen */
|
||||
z-index: 1050; /* Make sure it's above most content but below page-header */
|
||||
}
|
||||
|
||||
/* Add bottom padding to main content to account for tab bar and filter */
|
||||
:global(body) {
|
||||
padding-bottom: 130px; /* Ensure enough space for the tab bar and filter */
|
||||
}
|
||||
}
|
||||
|
||||
/* For non-touch devices with small viewport, position at top */
|
||||
@media (hover: hover) {
|
||||
.mobile-tab-bar {
|
||||
display: grid;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: unset;
|
||||
border-top: none;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||
height: 64px;
|
||||
z-index: 1100; /* Higher than other content */
|
||||
}
|
||||
|
||||
/* Add top padding to main content to account for tab bar */
|
||||
:global(body) {
|
||||
padding-top: 120px; /* Even more top padding for small desktop viewports */
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
/* Fix placement of main content below nav bar */
|
||||
:global(main) {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
:global(h1) {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.mobile-tab-bar,
|
||||
.mobile-menu {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const mobileMenuToggle = document.getElementById("mobile-menu-toggle");
|
||||
const mobileMenuClose = document.getElementById("mobile-menu-close");
|
||||
const mobileMenu = document.querySelector(".mobile-menu");
|
||||
const overlay = document.getElementById("overlay");
|
||||
const body = document.body;
|
||||
|
||||
// Mobile menu toggle
|
||||
if (mobileMenuToggle && mobileMenu && overlay) {
|
||||
mobileMenuToggle.addEventListener("click", () => {
|
||||
if (mobileMenu.classList.contains("active")) {
|
||||
// If menu is open, close it
|
||||
mobileMenu.classList.remove("active");
|
||||
overlay.classList.remove("active");
|
||||
body.style.overflow = "";
|
||||
} else {
|
||||
// If menu is closed, open it
|
||||
mobileMenu.classList.add("active");
|
||||
overlay.classList.add("active");
|
||||
body.style.overflow = "hidden";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Mobile menu close
|
||||
if (mobileMenuClose && mobileMenu && overlay) {
|
||||
mobileMenuClose.addEventListener("click", () => {
|
||||
mobileMenu.classList.remove("active");
|
||||
overlay.classList.remove("active");
|
||||
body.style.overflow = "";
|
||||
});
|
||||
}
|
||||
|
||||
// Overlay click
|
||||
if (overlay && mobileMenu) {
|
||||
overlay.addEventListener("click", () => {
|
||||
mobileMenu.classList.remove("active");
|
||||
overlay.classList.remove("active");
|
||||
body.style.overflow = "";
|
||||
});
|
||||
}
|
||||
|
||||
// Close menu when clicking a link
|
||||
const mobileMenuLinks = document.querySelectorAll(".mobile-menu-links a");
|
||||
mobileMenuLinks.forEach((link) => {
|
||||
link.addEventListener("click", () => {
|
||||
if (mobileMenu && overlay) {
|
||||
mobileMenu.classList.remove("active");
|
||||
overlay.classList.remove("active");
|
||||
body.style.overflow = "";
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
---
|
||||
// This component adds tracking to navigation elements
|
||||
---
|
||||
|
||||
<script>
|
||||
import { trackEvent, EVENTS } from '../scripts/analytics';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Track navigation clicks
|
||||
document.querySelectorAll('.nav-links a, .mobile-tab-bar a').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
const target = e.currentTarget as HTMLAnchorElement;
|
||||
const text = target.textContent?.trim() || '';
|
||||
const href = target.getAttribute('href') || '';
|
||||
|
||||
trackEvent(EVENTS.NAV_CLICK, {
|
||||
label: text,
|
||||
destination: href
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Track footer navigation clicks
|
||||
document.querySelectorAll('footer a').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
const target = e.currentTarget as HTMLAnchorElement;
|
||||
const text = target.textContent?.trim() || '';
|
||||
const href = target.getAttribute('href') || '';
|
||||
|
||||
// Skip external links (they're tracked separately)
|
||||
if (!href.startsWith('http')) {
|
||||
trackEvent(EVENTS.FOOTER_CLICK, {
|
||||
label: text,
|
||||
destination: href
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Track language switcher
|
||||
document.querySelectorAll('.language-switcher a, .language-option').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const lang = target.getAttribute('href')?.split('/')[1] ||
|
||||
target.dataset.lang || '';
|
||||
|
||||
trackEvent(EVENTS.LANGUAGE_SWITCH, {
|
||||
from: document.documentElement.lang,
|
||||
to: lang
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Track theme toggle
|
||||
const themeToggle = document.querySelector('.theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', () => {
|
||||
const currentTheme = document.documentElement.classList.contains('dark') ? 'light' : 'dark';
|
||||
trackEvent(EVENTS.THEME_TOGGLE, {
|
||||
theme: currentTheme
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Track CTA buttons
|
||||
document.querySelectorAll('a[href*="/join"], .cta-button').forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const text = target.textContent?.trim() || '';
|
||||
const href = target.getAttribute('href') || '';
|
||||
|
||||
trackEvent(EVENTS.JOIN_CLICK, {
|
||||
label: text,
|
||||
destination: href,
|
||||
location: target.closest('section')?.id || 'unknown'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Track support/coffee buttons
|
||||
document.querySelectorAll('a[href*="/support"], .support-link').forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const text = target.textContent?.trim() || '';
|
||||
const href = target.getAttribute('href') || '';
|
||||
|
||||
trackEvent(EVENTS.SUPPORT_CLICK, {
|
||||
label: text,
|
||||
destination: href,
|
||||
location: target.closest('footer') ? 'footer' : 'page'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
205
apps/bauntown/apps/landing/src/components/NewsCard.astro
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
---
|
||||
import { getLangFromUrl, useTranslations } from "../utils/i18n";
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
|
||||
// Define allowed collections
|
||||
type AllowedCollections = "news" | "models";
|
||||
|
||||
interface Props {
|
||||
news: CollectionEntry<AllowedCollections>;
|
||||
}
|
||||
|
||||
const { news } = Astro.props;
|
||||
const { data } = news;
|
||||
|
||||
const lang = getLangFromUrl(Astro.url);
|
||||
const t = useTranslations(lang);
|
||||
|
||||
// Format date based on language
|
||||
const formattedDate = new Intl.DateTimeFormat(
|
||||
lang === "de" ? "de-DE" : lang === "it" ? "it-IT" : "en-US",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}
|
||||
).format(data.pubDate);
|
||||
|
||||
// Generate URL with language prefix
|
||||
let pageUrl;
|
||||
const slugParts = news.slug.split("/");
|
||||
const fileName = slugParts[slugParts.length - 1];
|
||||
// Determine collection for URL
|
||||
const collection = news.collection as AllowedCollections;
|
||||
pageUrl = `/${lang}/${collection}/${fileName}`;
|
||||
|
||||
// Category translation
|
||||
const categoryKey = `${collection}.categories.${data.category}` as const;
|
||||
---
|
||||
|
||||
<a href={pageUrl} class="news-card-link">
|
||||
<article class:list={["news-card", { featured: data.featured }]}>
|
||||
{
|
||||
data.image && (
|
||||
<div class="news-image-container">
|
||||
<img src={data.image} alt={data.title} class="news-image" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="content">
|
||||
<div class="meta">
|
||||
<span class="category">{t(categoryKey)}</span>
|
||||
<span class="date">{formattedDate}</span>
|
||||
</div>
|
||||
|
||||
<h3>{data.title}</h3>
|
||||
<p>{data.description}</p>
|
||||
|
||||
<div class="footer">
|
||||
<span class="author">{data.author}</span>
|
||||
<span class="read-more"
|
||||
>{t(`${collection}.readMore`)} <span class="arrow">→</span></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.news-card-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.news-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
background-color: var(--card-bg);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06),
|
||||
0 1px 2px -1px rgba(0, 0, 0, 0.03);
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.news-card-link:hover .news-card {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.5);
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.08),
|
||||
0 2px 4px -2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.news-card.featured {
|
||||
border: 2px solid rgba(var(--accent-color-rgb, 249, 115, 22), 0.3);
|
||||
}
|
||||
|
||||
.news-image-container {
|
||||
width: 100%;
|
||||
padding-top: 56.25%; /* Aspect ratio 16:9 */
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.news-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.category {
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.date {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
flex-grow: 1;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.author {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.read-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.news-card-link:hover .arrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
/* Tags */
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.1);
|
||||
color: var(--accent-color);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
156
apps/bauntown/apps/landing/src/components/Newsletter.astro
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
---
|
||||
import { getLangFromUrl, useTranslations } from "../utils/i18n";
|
||||
|
||||
interface Props {
|
||||
customTitle?: string;
|
||||
customDescription?: string;
|
||||
}
|
||||
|
||||
const { customTitle, customDescription } = Astro.props;
|
||||
const lang = getLangFromUrl(Astro.url);
|
||||
const t = useTranslations(lang);
|
||||
---
|
||||
|
||||
<div class="newsletter-container">
|
||||
<h2>{customTitle || t("join.newsletterTitle")}</h2>
|
||||
<p class="newsletter-description">
|
||||
{customDescription || t("join.newsletterDesc")}
|
||||
</p>
|
||||
|
||||
<form class="newsletter-form" id="newsletterForm">
|
||||
<input
|
||||
type="email"
|
||||
id="newsletterEmail"
|
||||
name="email"
|
||||
placeholder={t("join.emailPlaceholder")}
|
||||
required
|
||||
/>
|
||||
<button type="submit">{t("join.subscribe")}</button>
|
||||
</form>
|
||||
|
||||
<div id="subscribeSuccess" class="subscribe-success" style="display: none;">
|
||||
<p>{t("join.success")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.newsletter-container {
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 0.75rem;
|
||||
padding: 2rem;
|
||||
border: 1px solid var(--border-color);
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* Heller Modus */
|
||||
:root:not(.dark) .newsletter-container {
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.newsletter-description {
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.newsletter-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Heller Modus für Eingabefelder */
|
||||
:root:not(.dark) input {
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.subscribe-success {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
border-radius: 0.375rem;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.subscribe-success p {
|
||||
color: #10b981;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Responsive layout for larger screens */
|
||||
@media (min-width: 640px) {
|
||||
.newsletter-form {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const form = document.getElementById("newsletterForm");
|
||||
const successMessage = document.getElementById("subscribeSuccess");
|
||||
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Simulate form submission (in a real app, this would send data to a server)
|
||||
setTimeout(() => {
|
||||
form.reset();
|
||||
successMessage.style.display = "block";
|
||||
|
||||
// Hide success message after 5 seconds
|
||||
setTimeout(() => {
|
||||
successMessage.style.display = "none";
|
||||
}, 5000);
|
||||
}, 1000);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
623
apps/bauntown/apps/landing/src/components/PaymentForm.astro
Normal file
|
|
@ -0,0 +1,623 @@
|
|||
---
|
||||
import { useTranslations } from "../utils/i18n";
|
||||
|
||||
interface Props {
|
||||
lang: "de" | "en" | "it";
|
||||
}
|
||||
|
||||
const { lang } = Astro.props;
|
||||
const t = useTranslations(lang);
|
||||
---
|
||||
|
||||
<div class="payment-container">
|
||||
<div class="payment-options">
|
||||
<div class="payment-type-selector">
|
||||
<button id="one-time" class="payment-type-btn active"
|
||||
>{t("support.onetime")}</button
|
||||
>
|
||||
<button id="recurring" class="payment-type-btn"
|
||||
>{t("support.recurring")}</button
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="coffee-options">
|
||||
<h3>{t("support.buyMeACoffee")}</h3>
|
||||
<div class="coffee-selector">
|
||||
<div
|
||||
class="coffee-option"
|
||||
data-price="3"
|
||||
data-price-id-onetime="price_1R8xkmAZjQCYS0ZJk6PZtJUm"
|
||||
data-price-id-recurring="price_1R8xkmAZjQCYS0ZJSBkFXcE1"
|
||||
>
|
||||
<div class="coffee-icon small-coffee">☕</div>
|
||||
<div class="coffee-details">
|
||||
<h4>Kleiner Kaffee</h4>
|
||||
<p>3€</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="coffee-option active"
|
||||
data-price="5"
|
||||
data-price-id-onetime="price_1R8xmlAZjQCYS0ZJ4QCSZFFN"
|
||||
data-price-id-recurring="price_1R8xn3AZjQCYS0ZJIXddpl0O"
|
||||
>
|
||||
<div class="coffee-icon medium-coffee">☕</div>
|
||||
<div class="coffee-details">
|
||||
<h4>Mittlerer Kaffee</h4>
|
||||
<p>5€</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="coffee-option"
|
||||
data-price="8"
|
||||
data-price-id-onetime="price_1R8xp4AZjQCYS0ZJCkTA6gCs"
|
||||
data-price-id-recurring="price_1R8xpIAZjQCYS0ZJ93nqMjop"
|
||||
>
|
||||
<div class="coffee-icon large-coffee">☕</div>
|
||||
<div class="coffee-details">
|
||||
<h4>Großer Kaffee</h4>
|
||||
<p>8€</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="payment-methods">
|
||||
<p>{t("support.paymentMethod")}</p>
|
||||
<div class="payment-buttons">
|
||||
<button id="stripe-button" class="payment-method-btn">
|
||||
<svg
|
||||
viewBox="0 0 60 25"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="60"
|
||||
height="25"
|
||||
class="UserLogo variant--"
|
||||
>
|
||||
<title>Stripe logo</title>
|
||||
<path
|
||||
fill="var(--text-color)"
|
||||
d="M59.64 14.28h-8.06c.19 1.93 1.6 2.55 3.2 2.55 1.64 0 2.96-.37 4.05-.95v3.32a8.33 8.33 0 0 1-4.56 1.1c-4.01 0-6.83-2.5-6.83-7.48 0-4.19 2.39-7.52 6.3-7.52 3.92 0 5.96 3.28 5.96 7.5 0 .4-.04 1.26-.06 1.48zm-5.92-5.62c-1.03 0-2.17.73-2.17 2.58h4.25c0-1.85-1.07-2.58-2.08-2.58zM40.95 20.3c-1.44 0-2.32-.6-2.9-1.04l-.02 4.63-4.12.87V5.57h3.76l.08 1.02a4.7 4.7 0 0 1 3.23-1.29c2.9 0 5.62 2.6 5.62 7.4 0 5.23-2.7 7.6-5.65 7.6zM40 8.95c-.95 0-1.54.34-1.97.81l.02 6.12c.4.44.98.78 1.95.78 1.52 0 2.54-1.65 2.54-3.87 0-2.15-1.04-3.84-2.54-3.84zM28.24 5.57h4.13v14.44h-4.13V5.57zm0-4.7L32.37 0v3.36l-4.13.88V.88zm-4.32 9.35v9.79H19.8V5.57h3.7l.12 1.22c1-1.77 3.07-1.41 3.62-1.22v3.79c-.52-.17-2.29-.43-3.32.86zm-8.55 4.72c0 2.43 2.6 1.68 3.12 1.46v3.36c-.55.3-1.54.54-2.89.54a4.15 4.15 0 0 1-4.27-4.24l.02-13.17 4.02-.86v3.54h3.14V9.1h-3.14v5.85zm-4.91.7c0 2.97-2.31 4.66-5.73 4.66a11.2 11.2 0 0 1-4.46-.93v-3.93c1.38.75 3.1 1.31 4.46 1.31.92 0 1.53-.24 1.53-1C6.26 13.77 0 14.51 0 9.95 0 7.04 2.28 5.3 5.62 5.3c1.36 0 2.72.2 4.09.75v3.88a9.23 9.23 0 0 0-4.1-1.06c-.86 0-1.44.25-1.44.9 0 1.85 6.29.97 6.29 5.88z"
|
||||
fill-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span>{t("support.payWithStripe")}</span>
|
||||
</button>
|
||||
{
|
||||
/* PayPal Button wird später implementiert
|
||||
<button id="paypal-button" class="payment-method-btn">
|
||||
<svg
|
||||
width="101"
|
||||
height="32"
|
||||
viewBox="0 0 101 32"
|
||||
preserveAspectRatio="xMinYMin meet"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill="var(--text-color)"
|
||||
d="M12.237 2.8h-4.307c-.323 0-.645.241-.698.549l-2.272 14.78c0 .307.247.524.57.524h2.272c.323 0 .644-.217.698-.549l.6-3.81c.054-.307.376-.549.699-.549h1.347c3.619 0 5.703-1.78 6.25-5.114.247-1.483.011-2.654-.701-3.466-.787-.856-2.166-1.365-3.94-1.365zm.645 4.979c-.298 1.975-1.807 1.975-3.265 1.975h-.834l.576-3.712c.032-.228.268-.417.5-.417h.377c.998 0 1.942 0 2.428.58.29.347.381.87.218 1.574zM29.667 7.723h-2.318c-.23 0-.422.19-.499.416l-.124.81-.199-.294c-.624-.929-2.02-1.243-3.413-1.243-3.178 0-5.899 2.464-6.43 5.91-.277 1.72.117 3.36 1.078 4.51.887 1.054 2.15 1.497 3.656 1.497 2.586 0 4.015-1.69 4.015-1.69l-.128.818c0 .305.222.545.545.545h2.089c.323 0 .645-.24.698-.548L29.99 8.27c.055-.306-.19-.547-.323-.547zm-3.31 5.087c-.277 1.676-1.586 2.804-3.265 2.804-.842 0-1.517-.277-1.952-.801-.427-.524-.588-1.268-.454-2.096.266-1.688 1.596-2.866 3.243-2.866.823 0 1.498.277 1.953.8.455.526.631 1.292.476 2.159zM43.148 7.723h-2.337c-.222 0-.429.153-.552.371l-3.192 4.797-1.35-4.599c-.091-.304-.366-.547-.698-.547h-2.293c-.306 0-.501.305-.41.589l2.553 7.76-2.399 3.466c-.19.282.007.67.343.67h2.293c.222 0 .428-.133.552-.35l7.735-11.446c.189-.305-.008-.67-.245-.67v-.011zM48.61 2.8h-4.308c-.323 0-.644.241-.698.549l-2.337 14.78c0 .307.247.524.57.524h2.272c.33 0 .645-.217.699-.549l.6-3.81c.053-.307.375-.549.698-.549h1.348c3.618 0 5.702-1.78 6.25-5.114.248-1.483.011-2.654-.701-3.466-.788-.856-2.165-1.365-3.94-1.365h-.453zm.645 4.979c-.298 1.975-1.807 1.975-3.265 1.975h-.835l.577-3.712c.031-.228.267-.417.5-.417h.377c.998 0 1.942 0 2.428.58.289.347.38.87.218 1.574zM66.04 7.723h-2.318c-.23 0-.422.19-.5.416l-.124.81-.199-.294c-.624-.929-2.02-1.243-3.412-1.243-3.179 0-5.9 2.464-6.431 5.91-.277 1.72.117 3.36 1.078 4.51.888 1.054 2.15 1.497 3.656 1.497 2.586 0 4.015-1.69 4.015-1.69l-.128.818c0 .305.222.545.545.545h2.089c.323 0 .645-.24.698-.548l1.354-8.763c.056-.306-.189-.545-.323-.547v-.001zm-3.31 5.087c-.277 1.676-1.586 2.804-3.265 2.804-.842 0-1.517-.277-1.952-.801-.428-.524-.588-1.268-.454-2.096.265-1.688 1.596-2.866 3.242-2.866.823 0 1.499.277 1.953.8.456.526.632 1.292.477 2.159zM71.044 2.8h-2.23c-.323 0-.6.24-.652.549L66 18.11c0 .306.244.54.552.54h2.229c.322 0 .7-.234.754-.549l2.161-14.752c.107-.305-.204-.549-.652-.549z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>{t("support.payWithPayPal")}</span>
|
||||
</button>
|
||||
*/
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="payment-message"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.payment-container {
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
|
||||
padding: 2.5rem;
|
||||
width: 100%;
|
||||
max-width: 550px;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid rgba(var(--border-color-rgb), 0.1);
|
||||
}
|
||||
|
||||
.payment-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.payment-type-selector {
|
||||
display: flex;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid rgba(var(--border-color-rgb), 0.2);
|
||||
}
|
||||
|
||||
.payment-type-btn {
|
||||
flex: 1;
|
||||
padding: 1.2rem;
|
||||
border: none;
|
||||
background: var(--card-bg);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
color: var(--text-color);
|
||||
font-size: 1.1rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.payment-type-btn:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background-color: transparent;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.payment-type-btn.active {
|
||||
background-color: rgba(var(--accent-color-rgb), 0.1);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.payment-type-btn.active:after {
|
||||
background-color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* Coffee Options */
|
||||
.coffee-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.coffee-options h3 {
|
||||
text-align: center;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.3rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.coffee-selector {
|
||||
display: flex;
|
||||
gap: 1.2rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.coffee-option {
|
||||
flex: 1;
|
||||
border: 2px solid transparent;
|
||||
background-color: rgba(var(--border-color-rgb), 0.05);
|
||||
border-radius: 12px;
|
||||
padding: 1.2rem 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.coffee-option:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.coffee-option.active {
|
||||
border-color: var(--accent-color);
|
||||
background-color: rgba(var(--accent-color-rgb), 0.08);
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.coffee-icon {
|
||||
margin-bottom: 0.8rem;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.coffee-option:hover .coffee-icon {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.small-coffee {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.medium-coffee {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.large-coffee {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.coffee-details h4 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.coffee-details p {
|
||||
margin: 0.25rem 0 0 0;
|
||||
font-weight: 600;
|
||||
color: var(--accent-color);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.payment-methods {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.payment-methods p {
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.payment-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.payment-method-btn {
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(var(--border-color-rgb), 0.15);
|
||||
background-color: var(--background-color);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
color: var(--text-color);
|
||||
font-weight: 500;
|
||||
font-size: 1.1rem;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.payment-method-btn:hover {
|
||||
background-color: var(--hover-bg);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.payment-method-btn span {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#payment-message {
|
||||
color: var(--accent-color);
|
||||
text-align: center;
|
||||
padding: 1.2rem;
|
||||
margin-top: 1rem;
|
||||
border-radius: 12px;
|
||||
display: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#payment-message.success {
|
||||
background-color: rgba(var(--accent-color-rgb), 0.1);
|
||||
border: 1px solid var(--accent-color);
|
||||
display: block;
|
||||
}
|
||||
|
||||
#payment-message.error {
|
||||
background-color: rgba(255, 0, 0, 0.1);
|
||||
border: 1px solid red;
|
||||
color: red;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.payment-container {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.coffee-selector {
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.coffee-option {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.payment-type-btn {
|
||||
padding: 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Import Stripe and PayPal libraries
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
// import { loadScript } from "@paypal/paypal-js"; // Wird später benötigt
|
||||
import { trackEvent, EVENTS } from '../scripts/analytics';
|
||||
|
||||
// TypeScript-Definitionen für Plausible (legacy)
|
||||
declare global {
|
||||
interface Window {
|
||||
plausible?: (
|
||||
eventName: string,
|
||||
options?: { props?: Record<string, any> }
|
||||
) => void;
|
||||
}
|
||||
}
|
||||
|
||||
// Interface für Coffee Option Element
|
||||
interface CoffeeOptionElement extends Element {
|
||||
dataset: {
|
||||
price: string;
|
||||
priceIdOnetime: string;
|
||||
priceIdRecurring: string;
|
||||
};
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
const oneTimeBtn = document.getElementById("one-time");
|
||||
const recurringBtn = document.getElementById("recurring");
|
||||
const stripeBtn = document.getElementById("stripe-button");
|
||||
// const paypalBtn = document.getElementById("paypal-button"); // Wird später benötigt
|
||||
const coffeeOptions =
|
||||
document.querySelectorAll<CoffeeOptionElement>(".coffee-option");
|
||||
const paymentMessage = document.getElementById("payment-message");
|
||||
|
||||
if (!paymentMessage) {
|
||||
console.error("Payment message element not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Kaffeegrößen-Auswahl
|
||||
let selectedCoffeeOption = document.querySelector<CoffeeOptionElement>(
|
||||
".coffee-option.active"
|
||||
);
|
||||
let selectedAmount = Number(selectedCoffeeOption?.dataset.price || 5);
|
||||
let selectedPriceId = selectedCoffeeOption?.dataset.priceIdOnetime;
|
||||
|
||||
// Event-Listener für Kaffee-Optionen
|
||||
coffeeOptions.forEach((option) => {
|
||||
option.addEventListener("click", () => {
|
||||
// Vorherige Auswahl zurücksetzen
|
||||
coffeeOptions.forEach((opt) => opt.classList.remove("active"));
|
||||
|
||||
// Neue Auswahl aktivieren
|
||||
option.classList.add("active");
|
||||
selectedCoffeeOption = option;
|
||||
selectedAmount = Number(option.dataset.price);
|
||||
|
||||
// Preis-ID basierend auf dem Zahlungstyp setzen
|
||||
if (recurringBtn?.classList.contains("active")) {
|
||||
selectedPriceId = option.dataset.priceIdRecurring;
|
||||
} else {
|
||||
selectedPriceId = option.dataset.priceIdOnetime;
|
||||
}
|
||||
|
||||
// Plausible Event für Kaffeegrößen-Auswahl
|
||||
const coffeeTitle = option.querySelector("h4");
|
||||
if (coffeeTitle) {
|
||||
trackEvent(EVENTS.COFFEE_SELECT, {
|
||||
size: coffeeTitle.textContent,
|
||||
amount: selectedAmount,
|
||||
type: recurringBtn?.classList.contains("active") ? "recurring" : "one-time"
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Toggle zwischen Zahlungstypen
|
||||
oneTimeBtn?.addEventListener("click", () => {
|
||||
oneTimeBtn.classList.add("active");
|
||||
recurringBtn?.classList.remove("active");
|
||||
|
||||
// Preis-ID aktualisieren
|
||||
if (selectedCoffeeOption) {
|
||||
selectedPriceId = selectedCoffeeOption.dataset.priceIdOnetime;
|
||||
}
|
||||
|
||||
// Plausible Event für Einmalzahlung
|
||||
trackEvent(EVENTS.PAYMENT_TYPE_CHANGE, {
|
||||
type: "one-time",
|
||||
amount: selectedAmount
|
||||
});
|
||||
});
|
||||
|
||||
recurringBtn?.addEventListener("click", () => {
|
||||
recurringBtn.classList.add("active");
|
||||
oneTimeBtn?.classList.remove("active");
|
||||
|
||||
// Preis-ID aktualisieren
|
||||
if (selectedCoffeeOption) {
|
||||
selectedPriceId = selectedCoffeeOption.dataset.priceIdRecurring;
|
||||
}
|
||||
|
||||
// Plausible Event für wiederkehrende Zahlung
|
||||
trackEvent(EVENTS.PAYMENT_TYPE_CHANGE, {
|
||||
type: "recurring",
|
||||
amount: selectedAmount
|
||||
});
|
||||
});
|
||||
|
||||
// Dieser Stripe Key wird aus den Netlify Umgebungsvariablen geladen
|
||||
const stripePromise = loadStripe(
|
||||
import.meta.env.PUBLIC_STRIPE_PUBLISHABLE_KEY || "pk_test_placeholder"
|
||||
);
|
||||
|
||||
// Stripe payment processing
|
||||
stripeBtn?.addEventListener("click", async () => {
|
||||
const amount = selectedAmount;
|
||||
const isRecurring = recurringBtn?.classList.contains("active");
|
||||
const priceId = selectedPriceId;
|
||||
|
||||
paymentMessage.textContent = "Verarbeite Zahlung...";
|
||||
paymentMessage.classList.remove("success", "error");
|
||||
paymentMessage.style.display = "block";
|
||||
|
||||
// Plausible Event für Stripe-Zahlungsstart
|
||||
const coffeeTitle = selectedCoffeeOption?.querySelector("h4");
|
||||
trackEvent(EVENTS.CHECKOUT_START, {
|
||||
provider: "stripe",
|
||||
amount: amount,
|
||||
type: isRecurring ? "recurring" : "one-time",
|
||||
coffeeSize: coffeeTitle?.textContent || "Mittlerer Kaffee",
|
||||
priceId: selectedPriceId
|
||||
});
|
||||
|
||||
try {
|
||||
// Create a payment intent on the server using Netlify Functions
|
||||
const response = await fetch(
|
||||
"/.netlify/functions/create-payment-intent",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount,
|
||||
isRecurring,
|
||||
priceId,
|
||||
coffeeSize: coffeeTitle?.textContent || "Mittlerer Kaffee",
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Netzwerkantwort nicht ok");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
console.log("Payment response data:", data);
|
||||
|
||||
// Option 1: Direkt zur Stripe URL weiterleiten
|
||||
if (data.url) {
|
||||
console.log("Redirecting to checkout URL:", data.url);
|
||||
// Kurze Verzögerung für UX
|
||||
setTimeout(() => {
|
||||
window.location.href = data.url;
|
||||
}, 500);
|
||||
return; // Wichtig: Return hier, damit der Rest nicht ausgeführt wird
|
||||
}
|
||||
|
||||
// Wenn keine URL vorhanden ist, versuchen wir es mit redirectToCheckout
|
||||
if (data.sessionId || data.id) {
|
||||
console.log(
|
||||
"Using redirectToCheckout with session ID:",
|
||||
data.sessionId || data.id
|
||||
);
|
||||
|
||||
// Initialisiere Stripe
|
||||
const stripe = await stripePromise;
|
||||
if (!stripe) {
|
||||
throw new Error("Stripe konnte nicht initialisiert werden");
|
||||
}
|
||||
|
||||
const { error } = await stripe.redirectToCheckout({
|
||||
sessionId: data.sessionId || data.id,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("Checkout redirect error:", error);
|
||||
throw new Error(error.message);
|
||||
}
|
||||
} else {
|
||||
throw new Error("Keine Checkout-URL oder Session-ID erhalten");
|
||||
}
|
||||
|
||||
// Erfolgreiche Zahlung
|
||||
paymentMessage.textContent = `Zahlung über ${amount}€ ${isRecurring ? "(regelmäßig)" : "(einmalig)"} erfolgreich!`;
|
||||
paymentMessage.classList.add("success");
|
||||
paymentMessage.classList.remove("error");
|
||||
} catch (error: unknown) {
|
||||
console.error("Zahlungsfehler:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||
paymentMessage.textContent = `Fehler: ${errorMessage}`;
|
||||
paymentMessage.classList.add("error");
|
||||
paymentMessage.classList.remove("success");
|
||||
|
||||
// Plausible Event für Zahlungsfehler
|
||||
trackEvent('payment-error', {
|
||||
provider: "stripe",
|
||||
error: errorMessage
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// PayPal payment processing wird später implementiert
|
||||
/*
|
||||
paypalBtn?.addEventListener("click", async () => {
|
||||
const amount = selectedAmount;
|
||||
const isRecurring = recurringBtn?.classList.contains("active");
|
||||
const priceId = selectedPriceId;
|
||||
|
||||
paymentMessage.textContent = "Processing payment...";
|
||||
paymentMessage.classList.remove("success", "error");
|
||||
paymentMessage.style.display = "block";
|
||||
|
||||
// Plausible Event für PayPal-Zahlungsstart
|
||||
const coffeeTitle = selectedCoffeeOption?.querySelector("h4");
|
||||
trackEvent(EVENTS.CHECKOUT_START, {
|
||||
provider: "paypal",
|
||||
amount: amount,
|
||||
type: isRecurring ? "recurring" : "one-time",
|
||||
coffeeSize: coffeeTitle?.textContent || "Mittlerer Kaffee"
|
||||
});
|
||||
|
||||
try {
|
||||
// Create a PayPal order on the server using Netlify Functions
|
||||
const response = await fetch(
|
||||
"/.netlify/functions/create-paypal-order",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount,
|
||||
isRecurring,
|
||||
priceId,
|
||||
coffeeSize: coffeeTitle?.textContent || "Mittlerer Kaffee",
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// For demo purposes, we'll simulate a successful payment
|
||||
setTimeout(() => {
|
||||
paymentMessage.textContent = `Payment of €${amount} ${isRecurring ? "(recurring)" : "(one-time)"} successful!`;
|
||||
paymentMessage.classList.add("success");
|
||||
paymentMessage.classList.remove("error");
|
||||
}, 1500);
|
||||
} catch (error: unknown) {
|
||||
console.error("PayPal-Fehler:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||
paymentMessage.textContent = `Fehler: ${errorMessage}`;
|
||||
paymentMessage.classList.add("error");
|
||||
paymentMessage.classList.remove("success");
|
||||
|
||||
// Plausible Event für Zahlungsfehler
|
||||
trackEvent('payment-error', {
|
||||
provider: "paypal",
|
||||
error: errorMessage
|
||||
});
|
||||
}
|
||||
});
|
||||
*/
|
||||
});
|
||||
</script>
|
||||
212
apps/bauntown/apps/landing/src/components/ProjectCard.astro
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
---
|
||||
import { getLangFromUrl, useTranslations } from '../utils/i18n';
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
|
||||
interface Props {
|
||||
project: CollectionEntry<'projects'>;
|
||||
}
|
||||
|
||||
const { project } = Astro.props;
|
||||
const { data } = project;
|
||||
|
||||
const lang = getLangFromUrl(Astro.url);
|
||||
const t = useTranslations(lang);
|
||||
|
||||
// Format date based on language
|
||||
const formattedDate = new Intl.DateTimeFormat(lang === 'de' ? 'de-DE' : lang === 'it' ? 'it-IT' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}).format(data.pubDate);
|
||||
|
||||
// Generate project URL with language prefix
|
||||
let projectUrl;
|
||||
const slugParts = project.slug.split('/');
|
||||
const fileName = slugParts[slugParts.length - 1];
|
||||
projectUrl = `/${lang}/projects/${fileName}`;
|
||||
|
||||
// Category translation
|
||||
const categoryKey = `projects.categories.${data.category}` as const;
|
||||
const statusKey = `projects.status.${data.status}` as const;
|
||||
---
|
||||
|
||||
<a href={projectUrl} class="project-card-link">
|
||||
<article class:list={["project-card", { "featured": data.featured }]}>
|
||||
{data.image && (
|
||||
<div class="project-image-container">
|
||||
<img src={data.image} alt={data.title} class="project-image" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="content">
|
||||
<div class="meta">
|
||||
<span class="category">{t(categoryKey)}</span>
|
||||
<span class="status" class:list={[data.status]}>{t(statusKey)}</span>
|
||||
</div>
|
||||
|
||||
<h3>{data.title}</h3>
|
||||
<p>{data.description}</p>
|
||||
|
||||
{data.technologies && data.technologies.length > 0 && (
|
||||
<div class="technologies">
|
||||
{data.technologies.map(tech => (
|
||||
<span class="tech">{tech}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="footer">
|
||||
<span class="author">{data.author}</span>
|
||||
<span class="read-more">{t('projects.readMore')} <span class="arrow">→</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.project-card-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
background-color: var(--card-bg);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.03);
|
||||
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.project-card-link:hover .project-card {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.5);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.project-card.featured {
|
||||
border: 2px solid rgba(var(--accent-color-rgb, 249, 115, 22), 0.3);
|
||||
}
|
||||
|
||||
.project-image-container {
|
||||
width: 100%;
|
||||
padding-top: 56.25%; /* Aspect ratio 16:9 */
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.project-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.category {
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status.active {
|
||||
background-color: rgba(16, 185, 129, 0.2);
|
||||
color: rgb(16, 185, 129);
|
||||
}
|
||||
|
||||
.status.completed {
|
||||
background-color: rgba(79, 70, 229, 0.2);
|
||||
color: rgb(79, 70, 229);
|
||||
}
|
||||
|
||||
.status.archived {
|
||||
background-color: rgba(107, 114, 128, 0.2);
|
||||
color: rgb(107, 114, 128);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
flex-grow: 1;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.technologies {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.tech {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.1);
|
||||
color: var(--accent-color);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.author {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.read-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.project-card-link:hover .arrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
</style>
|
||||
795
apps/bauntown/apps/landing/src/components/Table.astro
Normal file
|
|
@ -0,0 +1,795 @@
|
|||
---
|
||||
export interface Column {
|
||||
key: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
width?: string;
|
||||
formatter?: (value: any) => string;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
sticky?: boolean;
|
||||
}
|
||||
|
||||
export interface TableProps {
|
||||
data: any[];
|
||||
columns: Column[];
|
||||
stickyHeader?: boolean;
|
||||
stickyFirstColumn?: boolean;
|
||||
maxHeight?: string;
|
||||
sortable?: boolean;
|
||||
searchable?: boolean;
|
||||
caption?: string;
|
||||
id?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
data,
|
||||
columns,
|
||||
stickyHeader = true,
|
||||
stickyFirstColumn = false,
|
||||
maxHeight = "70vh",
|
||||
sortable = false,
|
||||
searchable = false,
|
||||
caption,
|
||||
id,
|
||||
className = ""
|
||||
} = Astro.props as TableProps;
|
||||
|
||||
// Ensure first column is sticky if stickyFirstColumn is true
|
||||
const processedColumns = columns.map((col, index) => ({
|
||||
...col,
|
||||
sticky: index === 0 ? stickyFirstColumn || col.sticky : col.sticky
|
||||
}));
|
||||
|
||||
// Default formatter function
|
||||
const defaultFormatter = (value: any) => {
|
||||
if (value === undefined || value === null) return "-";
|
||||
if (typeof value === 'number') return value.toString();
|
||||
return value;
|
||||
};
|
||||
---
|
||||
|
||||
<div class={`table-component ${className}`} id={id}>
|
||||
{caption && <h2 class="table-caption">{caption}</h2>}
|
||||
|
||||
{searchable &&
|
||||
<div class="search-container">
|
||||
<input type="text" placeholder="Suchen..." class="search-input" />
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="table-outer-container" style={`max-height: ${maxHeight};`}>
|
||||
<!-- Fixed corner (top-left) if both sticky header and first column -->
|
||||
{stickyHeader && stickyFirstColumn &&
|
||||
<div class="corner-header">
|
||||
{processedColumns[0]?.title || ""}
|
||||
{processedColumns[0]?.subtitle &&
|
||||
<div class="subtitle">{processedColumns[0].subtitle}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Fixed header row (without first cell if stickyFirstColumn is true) -->
|
||||
{stickyHeader &&
|
||||
<div class="header-container">
|
||||
<table class="header-table">
|
||||
<tr>
|
||||
{processedColumns.map((column, index) => (
|
||||
(!stickyFirstColumn || index > 0) &&
|
||||
<th
|
||||
class:list={[
|
||||
{ 'sortable': sortable },
|
||||
{ 'sorted-asc': false },
|
||||
{ 'sorted-desc': false }
|
||||
]}
|
||||
data-key={column.key}
|
||||
style={column.width ? `min-width: ${column.width}; width: ${column.width}` : 'min-width: 120px; width: 120px;'}
|
||||
data-align={column.align || 'left'}
|
||||
>
|
||||
{column.title}
|
||||
{column.subtitle && <div class="subtitle">{column.subtitle}</div>}
|
||||
{sortable && <span class="sort-icon"></span>}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Fixed first column (without header cell if stickyHeader is true) -->
|
||||
{stickyFirstColumn &&
|
||||
<div class="first-col-container">
|
||||
<table class="first-col-table">
|
||||
{data.map((item, rowIndex) => (
|
||||
<tr data-row-index={rowIndex}>
|
||||
<td
|
||||
class="cell-content"
|
||||
style={processedColumns[0]?.width ? `min-width: ${processedColumns[0].width}; width: ${processedColumns[0].width}` : 'min-width: 150px; width: 150px;'}
|
||||
data-align={processedColumns[0]?.align || 'left'}
|
||||
title={processedColumns[0] ? (processedColumns[0].formatter ? processedColumns[0].formatter(item[processedColumns[0].key]) : defaultFormatter(item[processedColumns[0].key])) : ""}
|
||||
>
|
||||
{processedColumns[0] ? (
|
||||
processedColumns[0].formatter
|
||||
? processedColumns[0].formatter(item[processedColumns[0].key])
|
||||
: defaultFormatter(item[processedColumns[0].key])
|
||||
) : ""}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Main scrollable content -->
|
||||
<div class="main-container">
|
||||
<table class="main-table">
|
||||
{!stickyHeader &&
|
||||
<thead>
|
||||
<tr>
|
||||
{processedColumns.map((column) => (
|
||||
<th
|
||||
class:list={[
|
||||
{ 'sortable': sortable },
|
||||
{ 'sorted-asc': false },
|
||||
{ 'sorted-desc': false },
|
||||
{ 'sticky-col': column.sticky }
|
||||
]}
|
||||
data-key={column.key}
|
||||
style={column.width ? `min-width: ${column.width}; width: ${column.width}` : 'min-width: 120px;'}
|
||||
data-align={column.align || 'left'}
|
||||
>
|
||||
{column.title}
|
||||
{column.subtitle && <div class="subtitle">{column.subtitle}</div>}
|
||||
{sortable && <span class="sort-icon"></span>}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
}
|
||||
<tbody>
|
||||
{data.map((item, rowIndex) => (
|
||||
<tr data-row-index={rowIndex}>
|
||||
{processedColumns.map((column, colIndex) => (
|
||||
(!stickyFirstColumn || colIndex > 0) &&
|
||||
<td
|
||||
class:list={[{ 'sticky-col': column.sticky }, 'cell-content']}
|
||||
style={column.width ? `min-width: ${column.width}; width: ${column.width}` : 'min-width: 120px; width: 120px;'}
|
||||
data-align={column.align || 'left'}
|
||||
title={column.formatter ? column.formatter(item[column.key]) : defaultFormatter(item[column.key])}
|
||||
>
|
||||
{column.formatter
|
||||
? column.formatter(item[column.key])
|
||||
: defaultFormatter(item[column.key])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Container styles */
|
||||
.table-component {
|
||||
margin-bottom: 2.5rem;
|
||||
background: var(--background-secondary, #f8fafc);
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
padding: 2rem 0;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
color: var(--text-color, #1e293b);
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Dark mode styles */
|
||||
:global(.dark) .table-component {
|
||||
background: var(--card-bg, #1e293b);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.table-caption {
|
||||
text-align: center;
|
||||
margin: 0 0 1.5rem;
|
||||
padding: 0 2rem;
|
||||
color: var(--text-color, #1e293b);
|
||||
}
|
||||
|
||||
/* Search container */
|
||||
.search-container {
|
||||
padding: 0 2rem 1rem;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1rem;
|
||||
background-color: var(--background-color, #ffffff);
|
||||
color: var(--text-color, #1e293b);
|
||||
}
|
||||
|
||||
:global(.dark) .search-input {
|
||||
background-color: var(--card-bg, #1e293b);
|
||||
border-color: var(--border-color, #334155);
|
||||
}
|
||||
|
||||
/* Outer container for the table components */
|
||||
.table-outer-container {
|
||||
position: relative;
|
||||
margin: 0 1rem;
|
||||
height: 70vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Corner header (top-left cell) */
|
||||
.corner-header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 150px;
|
||||
height: 60px;
|
||||
background-color: var(--background-tertiary, #edf2f7);
|
||||
font-weight: 700;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 2px solid var(--border-color, #e2e8f0);
|
||||
border-right: 2px solid var(--border-color, #e2e8f0);
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
box-shadow: 2px 2px 4px rgba(0,0,0,0.1);
|
||||
transition: background-color 0.3s ease, border-color 0.3s ease;
|
||||
}
|
||||
|
||||
:global(.dark) .corner-header {
|
||||
background-color: var(--card-bg, #1e293b);
|
||||
border-color: var(--border-color, #334155);
|
||||
box-shadow: 2px 2px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.corner-header .subtitle {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
opacity: 0.8;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Header row container */
|
||||
.header-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 150px; /* Width of first column */
|
||||
right: 0;
|
||||
height: 60px; /* Height of header */
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
z-index: 2;
|
||||
border-bottom: 2px solid var(--border-color, #e2e8f0);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
background-color: var(--background-tertiary, #edf2f7);
|
||||
transition: background-color 0.3s ease, border-color 0.3s ease;
|
||||
}
|
||||
|
||||
:global(.dark) .header-container {
|
||||
background-color: var(--card-bg, #1e293b);
|
||||
border-color: var(--border-color, #334155);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* First column container */
|
||||
.first-col-container {
|
||||
position: absolute;
|
||||
top: 60px; /* Height of header */
|
||||
left: 0;
|
||||
width: 150px; /* Width of first column */
|
||||
bottom: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
z-index: 1;
|
||||
border-right: 2px solid var(--border-color, #e2e8f0);
|
||||
box-shadow: 2px 0 4px rgba(0,0,0,0.1);
|
||||
background-color: var(--background-tertiary, #edf2f7);
|
||||
transition: background-color 0.3s ease, border-color 0.3s ease;
|
||||
}
|
||||
|
||||
:global(.dark) .first-col-container {
|
||||
background-color: var(--card-bg, #1e293b);
|
||||
border-color: var(--border-color, #334155);
|
||||
box-shadow: 2px 0 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* Main content container */
|
||||
.main-container {
|
||||
position: absolute;
|
||||
top: 60px; /* Height of header */
|
||||
left: 150px; /* Width of first column */
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: auto;
|
||||
z-index: 0;
|
||||
background-color: var(--background-secondary, #f8fafc);
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
:global(.dark) .main-container {
|
||||
background-color: var(--background-color, #0f172a);
|
||||
}
|
||||
|
||||
/* Adjust positions when no sticky first column */
|
||||
:global(.table-component:not(.sticky-first-column)) .header-container {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
:global(.table-component:not(.sticky-first-column)) .main-container {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* Adjust positions when no sticky header */
|
||||
:global(.table-component:not(.sticky-header)) .first-col-container {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
:global(.table-component:not(.sticky-header)) .main-container {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
/* Table styles */
|
||||
.header-table,
|
||||
.first-col-table,
|
||||
.main-table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Header table specific */
|
||||
.header-table {
|
||||
table-layout: fixed;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Main table specific */
|
||||
.main-table {
|
||||
table-layout: fixed;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.header-table th,
|
||||
.main-table th {
|
||||
padding: 0.75rem 1.25rem;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
min-width: 120px;
|
||||
font-weight: 700;
|
||||
vertical-align: top;
|
||||
background-color: var(--background-tertiary, #edf2f7);
|
||||
position: relative;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
:global(.dark) .header-table th,
|
||||
:global(.dark) .main-table th {
|
||||
background-color: var(--card-bg, #1e293b);
|
||||
color: var(--text-color, #f1f5f9);
|
||||
}
|
||||
|
||||
/* Alignment */
|
||||
[data-align="center"] {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
[data-align="right"] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* First column table specific */
|
||||
.first-col-table td {
|
||||
padding: 0.75rem 1.25rem;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid var(--border-color, #e2e8f0);
|
||||
font-weight: 600;
|
||||
background-color: var(--background-tertiary, #edf2f7);
|
||||
height: 57px; /* Match height of main table rows */
|
||||
transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
:global(.dark) .first-col-table td {
|
||||
background-color: var(--card-bg, #1e293b);
|
||||
border-color: var(--border-color, #334155);
|
||||
color: var(--text-color, #f1f5f9);
|
||||
}
|
||||
|
||||
/* Main table specific */
|
||||
.main-table td {
|
||||
padding: 0.75rem 1.25rem;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid var(--border-color, #e2e8f0);
|
||||
min-width: 120px;
|
||||
transition: border-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
:global(.dark) .main-table td {
|
||||
border-color: var(--border-color, #334155);
|
||||
color: var(--text-color, #f1f5f9);
|
||||
}
|
||||
|
||||
/* Cell content with ellipsis */
|
||||
.cell-content {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cell-content:hover,
|
||||
.cell-content:focus,
|
||||
.cell-content:active {
|
||||
overflow: visible;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Tooltip for cell content on hover */
|
||||
.cell-content[title]:hover::after {
|
||||
content: attr(title);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 100%;
|
||||
background-color: var(--background-color, #ffffff);
|
||||
color: var(--text-color, #1e293b);
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
z-index: 20;
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
}
|
||||
|
||||
:global(.dark) .cell-content[title]:hover::after {
|
||||
background-color: var(--card-bg, #1e293b);
|
||||
color: var(--text-color, #f1f5f9);
|
||||
border-color: var(--border-color, #334155);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* Sticky column in main table */
|
||||
.main-table .sticky-col {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
background-color: var(--background-tertiary, #edf2f7);
|
||||
z-index: 1;
|
||||
box-shadow: 2px 0 4px rgba(0,0,0,0.1);
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
:global(.dark) .main-table .sticky-col {
|
||||
background-color: var(--card-bg, #1e293b);
|
||||
box-shadow: 2px 0 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* Subtitle in header */
|
||||
.subtitle {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
opacity: 0.8;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Sortable columns */
|
||||
.sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.sortable:hover {
|
||||
background-color: var(--hover-bg, rgba(243, 244, 246, 0.8));
|
||||
}
|
||||
|
||||
:global(.dark) .sortable:hover {
|
||||
background-color: var(--hover-bg, rgba(30, 41, 59, 0.8));
|
||||
}
|
||||
|
||||
.sort-icon {
|
||||
display: inline-block;
|
||||
width: 0.8rem;
|
||||
height: 0.8rem;
|
||||
margin-left: 0.5rem;
|
||||
position: relative;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.sort-icon::before,
|
||||
.sort-icon::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.sort-icon::before {
|
||||
top: 0;
|
||||
border-left: 0.4rem solid transparent;
|
||||
border-right: 0.4rem solid transparent;
|
||||
border-bottom: 0.4rem solid currentColor;
|
||||
}
|
||||
|
||||
.sort-icon::after {
|
||||
bottom: 0;
|
||||
border-left: 0.4rem solid transparent;
|
||||
border-right: 0.4rem solid transparent;
|
||||
border-top: 0.4rem solid currentColor;
|
||||
}
|
||||
|
||||
.sorted-asc .sort-icon::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sorted-desc .sort-icon::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
.header-container::-webkit-scrollbar,
|
||||
.first-col-container::-webkit-scrollbar,
|
||||
.main-container::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.header-container::-webkit-scrollbar-track,
|
||||
.first-col-container::-webkit-scrollbar-track,
|
||||
.main-container::-webkit-scrollbar-track {
|
||||
background: var(--background-secondary, #f8fafc);
|
||||
}
|
||||
|
||||
.header-container::-webkit-scrollbar-thumb,
|
||||
.first-col-container::-webkit-scrollbar-thumb,
|
||||
.main-container::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-color, #e2e8f0);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:global(.dark) .header-container::-webkit-scrollbar-track,
|
||||
:global(.dark) .first-col-container::-webkit-scrollbar-track,
|
||||
:global(.dark) .main-container::-webkit-scrollbar-track {
|
||||
background: var(--background-color, #0f172a);
|
||||
}
|
||||
|
||||
:global(.dark) .header-container::-webkit-scrollbar-thumb,
|
||||
:global(.dark) .first-col-container::-webkit-scrollbar-thumb,
|
||||
:global(.dark) .main-container::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-color, #334155);
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.table-component {
|
||||
width: calc(100vw - 2rem);
|
||||
margin-left: calc(-50vw + 1rem);
|
||||
margin-right: calc(-50vw + 1rem);
|
||||
}
|
||||
|
||||
.corner-header,
|
||||
.first-col-container {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.header-container,
|
||||
.main-container {
|
||||
left: 120px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Synchronize scrolling between table sections
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const tableComponents = document.querySelectorAll('.table-component');
|
||||
|
||||
tableComponents.forEach(tableComponent => {
|
||||
const mainContainer = tableComponent.querySelector('.main-container') as HTMLElement | null;
|
||||
const headerContainer = tableComponent.querySelector('.header-container') as HTMLElement | null;
|
||||
const firstColContainer = tableComponent.querySelector('.first-col-container') as HTMLElement | null;
|
||||
|
||||
if (mainContainer && headerContainer) {
|
||||
// Sync horizontal scrolling between main content and header
|
||||
mainContainer.addEventListener('scroll', () => {
|
||||
headerContainer.scrollLeft = mainContainer.scrollLeft;
|
||||
});
|
||||
|
||||
// Also allow scrolling from header
|
||||
headerContainer.addEventListener('scroll', () => {
|
||||
mainContainer.scrollLeft = headerContainer.scrollLeft;
|
||||
});
|
||||
}
|
||||
|
||||
if (mainContainer && firstColContainer) {
|
||||
// Sync vertical scrolling between main content and first column
|
||||
mainContainer.addEventListener('scroll', () => {
|
||||
firstColContainer.scrollTop = mainContainer.scrollTop;
|
||||
});
|
||||
|
||||
// Also allow scrolling from first column
|
||||
firstColContainer.addEventListener('scroll', () => {
|
||||
mainContainer.scrollTop = firstColContainer.scrollTop;
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure consistent row heights
|
||||
function matchRowHeights() {
|
||||
if (mainContainer && firstColContainer) {
|
||||
const mainRows = mainContainer.querySelectorAll('tbody tr');
|
||||
const firstColRows = firstColContainer.querySelectorAll('tr');
|
||||
|
||||
if (mainRows.length === firstColRows.length) {
|
||||
for (let i = 0; i < mainRows.length; i++) {
|
||||
const mainRow = mainRows[i] as HTMLElement;
|
||||
const firstColRow = firstColRows[i] as HTMLElement;
|
||||
const height = Math.max(mainRow.offsetHeight, firstColRow.offsetHeight);
|
||||
mainRow.style.height = `${height}px`;
|
||||
firstColRow.style.height = `${height}px`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Implement sorting if table is sortable
|
||||
if (tableComponent.querySelector('.sortable')) {
|
||||
const sortableHeaders = tableComponent.querySelectorAll('.sortable');
|
||||
|
||||
sortableHeaders.forEach(header => {
|
||||
header.addEventListener('click', () => {
|
||||
const key = (header as HTMLElement).dataset.key;
|
||||
const isAsc = header.classList.contains('sorted-asc');
|
||||
|
||||
// Remove sorted classes from all headers
|
||||
sortableHeaders.forEach(h => {
|
||||
h.classList.remove('sorted-asc', 'sorted-desc');
|
||||
});
|
||||
|
||||
// Add appropriate class to clicked header
|
||||
header.classList.add(isAsc ? 'sorted-desc' : 'sorted-asc');
|
||||
|
||||
// Sort the data
|
||||
if (mainContainer && key) {
|
||||
sortTable(tableComponent, key, !isAsc);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Implement search if table is searchable
|
||||
const searchInput = tableComponent.querySelector('.search-input') as HTMLInputElement | null;
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', () => {
|
||||
filterTable(tableComponent, searchInput.value.toLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
// Run once on load and on window resize
|
||||
setTimeout(matchRowHeights, 100);
|
||||
window.addEventListener('resize', matchRowHeights);
|
||||
});
|
||||
});
|
||||
|
||||
// Sort table function
|
||||
function sortTable(tableComponent: Element, key: string, ascending: boolean) {
|
||||
const mainContainer = tableComponent.querySelector('.main-container');
|
||||
const firstColContainer = tableComponent.querySelector('.first-col-container');
|
||||
|
||||
if (!mainContainer) return;
|
||||
|
||||
const rows = Array.from(mainContainer.querySelectorAll('tbody tr'));
|
||||
const sortedRows = rows.sort((a, b) => {
|
||||
const rowIndexA = parseInt((a as HTMLElement).dataset.rowIndex || '0');
|
||||
const rowIndexB = parseInt((b as HTMLElement).dataset.rowIndex || '0');
|
||||
|
||||
// Get the cells that contain the values to compare
|
||||
const cells = mainContainer.querySelectorAll(`tbody tr[data-row-index="${rowIndexA}"] td`);
|
||||
const cellsB = mainContainer.querySelectorAll(`tbody tr[data-row-index="${rowIndexB}"] td`);
|
||||
|
||||
// Find the index of the column with the specified key
|
||||
const headers = tableComponent.querySelectorAll('th[data-key]');
|
||||
let columnIndex = -1;
|
||||
|
||||
headers.forEach((header, index) => {
|
||||
if ((header as HTMLElement).dataset.key === key) {
|
||||
columnIndex = index;
|
||||
}
|
||||
});
|
||||
|
||||
if (columnIndex === -1) return 0;
|
||||
|
||||
// Get the values to compare
|
||||
const valueA = cells[columnIndex]?.textContent?.trim() || '';
|
||||
const valueB = cellsB[columnIndex]?.textContent?.trim() || '';
|
||||
|
||||
// Compare as numbers if possible
|
||||
const numA = parseFloat(valueA);
|
||||
const numB = parseFloat(valueB);
|
||||
|
||||
if (!isNaN(numA) && !isNaN(numB)) {
|
||||
return ascending ? numA - numB : numB - numA;
|
||||
}
|
||||
|
||||
// Otherwise compare as strings
|
||||
return ascending ?
|
||||
valueA.localeCompare(valueB) :
|
||||
valueB.localeCompare(valueA);
|
||||
});
|
||||
|
||||
// Reorder the rows in the main table
|
||||
const tbody = mainContainer.querySelector('tbody');
|
||||
if (tbody) {
|
||||
sortedRows.forEach(row => {
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// Reorder the rows in the first column table if it exists
|
||||
if (firstColContainer) {
|
||||
const firstColRows = Array.from(firstColContainer.querySelectorAll('tr'));
|
||||
const sortedFirstColRows = firstColRows.sort((a, b) => {
|
||||
const rowIndexA = parseInt((a as HTMLElement).dataset.rowIndex || '0');
|
||||
const rowIndexB = parseInt((b as HTMLElement).dataset.rowIndex || '0');
|
||||
|
||||
// Find the position of these indices in the sorted rows
|
||||
const posA = sortedRows.findIndex(row =>
|
||||
parseInt((row as HTMLElement).dataset.rowIndex || '0') === rowIndexA
|
||||
);
|
||||
const posB = sortedRows.findIndex(row =>
|
||||
parseInt((row as HTMLElement).dataset.rowIndex || '0') === rowIndexB
|
||||
);
|
||||
|
||||
return posA - posB;
|
||||
});
|
||||
|
||||
const firstColTable = firstColContainer.querySelector('table');
|
||||
if (firstColTable) {
|
||||
sortedFirstColRows.forEach(row => {
|
||||
firstColTable.appendChild(row);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter table function
|
||||
function filterTable(tableComponent: Element, searchText: string) {
|
||||
const mainContainer = tableComponent.querySelector('.main-container');
|
||||
const firstColContainer = tableComponent.querySelector('.first-col-container');
|
||||
|
||||
if (!mainContainer) return;
|
||||
|
||||
const rows = mainContainer.querySelectorAll('tbody tr');
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
const rowText = row.textContent?.toLowerCase() || '';
|
||||
const firstColRow = firstColContainer?.querySelector(`tr[data-row-index="${index}"]`);
|
||||
const firstColText = firstColRow?.textContent?.toLowerCase() || '';
|
||||
|
||||
const visible = rowText.includes(searchText) || firstColText.includes(searchText);
|
||||
|
||||
(row as HTMLElement).style.display = visible ? '' : 'none';
|
||||
if (firstColRow) {
|
||||
(firstColRow as HTMLElement).style.display = visible ? '' : 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
805
apps/bauntown/apps/landing/src/components/TableImproved.astro
Normal file
|
|
@ -0,0 +1,805 @@
|
|||
---
|
||||
export interface Column {
|
||||
key: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
width?: string;
|
||||
formatter?: (value: any) => string;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
sticky?: boolean;
|
||||
}
|
||||
|
||||
export interface TableProps {
|
||||
data: any[];
|
||||
columns: Column[];
|
||||
stickyHeader?: boolean;
|
||||
stickyFirstColumn?: boolean;
|
||||
maxHeight?: string;
|
||||
sortable?: boolean;
|
||||
searchable?: boolean;
|
||||
caption?: string;
|
||||
id?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
data,
|
||||
columns,
|
||||
stickyHeader = true,
|
||||
stickyFirstColumn = false,
|
||||
maxHeight = "70vh",
|
||||
sortable = false,
|
||||
searchable = false,
|
||||
caption,
|
||||
id,
|
||||
className = ""
|
||||
} = Astro.props as TableProps;
|
||||
|
||||
// Ensure first column is sticky if stickyFirstColumn is true
|
||||
const processedColumns = columns.map((col, index) => ({
|
||||
...col,
|
||||
sticky: index === 0 ? stickyFirstColumn || col.sticky : col.sticky,
|
||||
// Ensure every column has a width
|
||||
width: col.width || (index === 0 ? '150px' : '120px')
|
||||
}));
|
||||
|
||||
// Default formatter function
|
||||
const defaultFormatter = (value: any) => {
|
||||
if (value === undefined || value === null) return "-";
|
||||
if (typeof value === 'number') return value.toString();
|
||||
return value;
|
||||
};
|
||||
---
|
||||
|
||||
<div class={`table-component ${className}`} id={id}>
|
||||
{caption && <h2 class="table-caption">{caption}</h2>}
|
||||
|
||||
{searchable &&
|
||||
<div class="search-container">
|
||||
<input type="text" placeholder="Suchen..." class="search-input" />
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="table-outer-container" style={`max-height: ${maxHeight};`}>
|
||||
<!-- Fixed corner (top-left) if both sticky header and first column -->
|
||||
{stickyHeader && stickyFirstColumn &&
|
||||
<div class="corner-header" style={`width: ${processedColumns[0].width}`}>
|
||||
<div class="corner-content">
|
||||
{processedColumns[0]?.title || ""}
|
||||
{processedColumns[0]?.subtitle &&
|
||||
<div class="subtitle">{processedColumns[0].subtitle}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Fixed header row (without first cell if stickyFirstColumn is true) -->
|
||||
{stickyHeader &&
|
||||
<div class="header-container" style={stickyFirstColumn ? `left: ${processedColumns[0].width}` : 'left: 0'}>
|
||||
<table class="header-table">
|
||||
<tr>
|
||||
{processedColumns.map((column, index) => (
|
||||
(!stickyFirstColumn || index > 0) &&
|
||||
<th
|
||||
class:list={[
|
||||
{ 'sortable': sortable },
|
||||
{ 'sorted-asc': false },
|
||||
{ 'sorted-desc': false }
|
||||
]}
|
||||
data-key={column.key}
|
||||
style={`width: ${column.width}`}
|
||||
data-align={column.align || 'left'}
|
||||
>
|
||||
<div class="cell-content header-content">
|
||||
{column.title}
|
||||
{column.subtitle && <div class="subtitle">{column.subtitle}</div>}
|
||||
{sortable && <span class="sort-icon"></span>}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Fixed first column (without header cell if stickyHeader is true) -->
|
||||
{stickyFirstColumn &&
|
||||
<div class="first-col-container" style={`width: ${processedColumns[0].width}; top: ${stickyHeader ? '60px' : '0'}`}>
|
||||
<table class="first-col-table">
|
||||
{data.map((item, rowIndex) => (
|
||||
<tr data-row-index={rowIndex}>
|
||||
<td
|
||||
class="cell-content"
|
||||
style={`width: ${processedColumns[0].width}`}
|
||||
data-align={processedColumns[0]?.align || 'left'}
|
||||
data-full-content={processedColumns[0] ? (
|
||||
processedColumns[0].formatter
|
||||
? processedColumns[0].formatter(item[processedColumns[0].key])
|
||||
: defaultFormatter(item[processedColumns[0].key])
|
||||
) : ""}
|
||||
>
|
||||
<div class="cell-inner">
|
||||
{processedColumns[0] ? (
|
||||
processedColumns[0].formatter
|
||||
? processedColumns[0].formatter(item[processedColumns[0].key])
|
||||
: defaultFormatter(item[processedColumns[0].key])
|
||||
) : ""}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Main scrollable content -->
|
||||
<div
|
||||
class="main-container"
|
||||
style={`
|
||||
${stickyHeader ? 'top: 60px;' : 'top: 0;'}
|
||||
${stickyFirstColumn ? `left: ${processedColumns[0].width};` : 'left: 0;'}
|
||||
`}
|
||||
>
|
||||
<table class="main-table">
|
||||
{!stickyHeader &&
|
||||
<thead>
|
||||
<tr>
|
||||
{processedColumns.map((column) => (
|
||||
<th
|
||||
class:list={[
|
||||
{ 'sortable': sortable },
|
||||
{ 'sorted-asc': false },
|
||||
{ 'sorted-desc': false },
|
||||
{ 'sticky-col': column.sticky }
|
||||
]}
|
||||
data-key={column.key}
|
||||
style={`width: ${column.width}`}
|
||||
data-align={column.align || 'left'}
|
||||
>
|
||||
<div class="cell-content header-content">
|
||||
{column.title}
|
||||
{column.subtitle && <div class="subtitle">{column.subtitle}</div>}
|
||||
{sortable && <span class="sort-icon"></span>}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
}
|
||||
<tbody>
|
||||
{data.map((item, rowIndex) => (
|
||||
<tr data-row-index={rowIndex}>
|
||||
{processedColumns.map((column, colIndex) => (
|
||||
(!stickyFirstColumn || colIndex > 0) &&
|
||||
<td
|
||||
class:list={[{ 'sticky-col': column.sticky }, 'cell-content']}
|
||||
style={`width: ${column.width}`}
|
||||
data-align={column.align || 'left'}
|
||||
data-full-content={column.formatter
|
||||
? column.formatter(item[column.key])
|
||||
: defaultFormatter(item[column.key])}
|
||||
>
|
||||
<div class="cell-inner">
|
||||
{column.formatter
|
||||
? column.formatter(item[column.key])
|
||||
: defaultFormatter(item[column.key])}
|
||||
</div>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Container styles */
|
||||
.table-component {
|
||||
margin-bottom: 2.5rem;
|
||||
background: var(--background-secondary, #f8fafc);
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
padding: 2rem 0;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
color: var(--text-color, #1e293b);
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Dark mode styles */
|
||||
:global(.dark) .table-component {
|
||||
background: var(--card-bg, #1e293b);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.table-caption {
|
||||
text-align: center;
|
||||
margin: 0 0 1.5rem;
|
||||
padding: 0 2rem;
|
||||
color: var(--text-color, #1e293b);
|
||||
}
|
||||
|
||||
/* Search container */
|
||||
.search-container {
|
||||
padding: 0 2rem 1rem;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1rem;
|
||||
background-color: var(--background-color, #ffffff);
|
||||
color: var(--text-color, #1e293b);
|
||||
}
|
||||
|
||||
:global(.dark) .search-input {
|
||||
background-color: var(--card-bg, #1e293b);
|
||||
border-color: var(--border-color, #334155);
|
||||
}
|
||||
|
||||
/* Outer container for the table components */
|
||||
.table-outer-container {
|
||||
position: relative;
|
||||
margin: 0 1rem;
|
||||
height: 70vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Corner header (top-left cell) */
|
||||
.corner-header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 60px;
|
||||
background-color: var(--background-tertiary, #edf2f7);
|
||||
font-weight: 700;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 2px solid var(--border-color, #e2e8f0);
|
||||
border-right: 2px solid var(--border-color, #e2e8f0);
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
box-shadow: 2px 2px 4px rgba(0,0,0,0.1);
|
||||
transition: background-color 0.3s ease, border-color 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.corner-content {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:global(.dark) .corner-header {
|
||||
background-color: var(--card-bg, #1e293b);
|
||||
border-color: var(--border-color, #334155);
|
||||
box-shadow: 2px 2px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.corner-header .subtitle {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
opacity: 0.8;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Header row container */
|
||||
.header-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 60px; /* Height of header */
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
z-index: 2;
|
||||
border-bottom: 2px solid var(--border-color, #e2e8f0);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
background-color: var(--background-tertiary, #edf2f7);
|
||||
transition: background-color 0.3s ease, border-color 0.3s ease;
|
||||
}
|
||||
|
||||
:global(.dark) .header-container {
|
||||
background-color: var(--card-bg, #1e293b);
|
||||
border-color: var(--border-color, #334155);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* First column container */
|
||||
.first-col-container {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
z-index: 1;
|
||||
border-right: 2px solid var(--border-color, #e2e8f0);
|
||||
box-shadow: 2px 0 4px rgba(0,0,0,0.1);
|
||||
background-color: var(--background-tertiary, #edf2f7);
|
||||
transition: background-color 0.3s ease, border-color 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:global(.dark) .first-col-container {
|
||||
background-color: var(--card-bg, #1e293b);
|
||||
border-color: var(--border-color, #334155);
|
||||
box-shadow: 2px 0 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* Main content container */
|
||||
.main-container {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: auto;
|
||||
z-index: 0;
|
||||
background-color: var(--background-secondary, #f8fafc);
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
:global(.dark) .main-container {
|
||||
background-color: var(--background-color, #0f172a);
|
||||
}
|
||||
|
||||
/* Table styles */
|
||||
.header-table,
|
||||
.first-col-table,
|
||||
.main-table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.header-table th,
|
||||
.main-table th {
|
||||
padding: 0.75rem 1.25rem;
|
||||
text-align: left;
|
||||
font-weight: 700;
|
||||
vertical-align: top;
|
||||
background-color: var(--background-tertiary, #edf2f7);
|
||||
position: relative;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:global(.dark) .header-table th,
|
||||
:global(.dark) .main-table th {
|
||||
background-color: var(--card-bg, #1e293b);
|
||||
color: var(--text-color, #f1f5f9);
|
||||
}
|
||||
|
||||
/* Alignment */
|
||||
[data-align="center"] {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
[data-align="right"] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* First column table specific */
|
||||
.first-col-table td {
|
||||
padding: 0.75rem 1.25rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color, #e2e8f0);
|
||||
font-weight: 600;
|
||||
background-color: var(--background-tertiary, #edf2f7);
|
||||
height: 57px; /* Match height of main table rows */
|
||||
transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:global(.dark) .first-col-table td {
|
||||
background-color: var(--card-bg, #1e293b);
|
||||
border-color: var(--border-color, #334155);
|
||||
color: var(--text-color, #f1f5f9);
|
||||
}
|
||||
|
||||
/* Main table specific */
|
||||
.main-table td {
|
||||
padding: 0.75rem 1.25rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color, #e2e8f0);
|
||||
transition: border-color 0.3s ease, color 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:global(.dark) .main-table td {
|
||||
border-color: var(--border-color, #334155);
|
||||
color: var(--text-color, #f1f5f9);
|
||||
}
|
||||
|
||||
/* Cell content with ellipsis */
|
||||
.cell-content {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cell-inner {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
white-space: normal;
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
|
||||
/* Tooltip for cell content on hover/click */
|
||||
.cell-content:hover .cell-inner,
|
||||
.cell-content:focus .cell-inner,
|
||||
.cell-content:active .cell-inner {
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.cell-content[data-full-content]:hover::after {
|
||||
content: attr(data-full-content);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 100%;
|
||||
background-color: var(--background-color, #ffffff);
|
||||
color: var(--text-color, #1e293b);
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
z-index: 20;
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
}
|
||||
|
||||
:global(.dark) .cell-content[data-full-content]:hover::after {
|
||||
background-color: var(--card-bg, #1e293b);
|
||||
color: var(--text-color, #f1f5f9);
|
||||
border-color: var(--border-color, #334155);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* Sticky column in main table */
|
||||
.main-table .sticky-col {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
background-color: var(--background-tertiary, #edf2f7);
|
||||
z-index: 1;
|
||||
box-shadow: 2px 0 4px rgba(0,0,0,0.1);
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
:global(.dark) .main-table .sticky-col {
|
||||
background-color: var(--card-bg, #1e293b);
|
||||
box-shadow: 2px 0 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* Subtitle in header */
|
||||
.subtitle {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
opacity: 0.8;
|
||||
margin-top: 0.25rem;
|
||||
color: var(--text-muted, #64748b);
|
||||
}
|
||||
|
||||
:global(.dark) .subtitle {
|
||||
color: var(--text-muted, #94a3b8);
|
||||
}
|
||||
|
||||
/* Sortable columns */
|
||||
.sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.sortable:hover {
|
||||
background-color: var(--hover-bg, rgba(243, 244, 246, 0.8));
|
||||
}
|
||||
|
||||
:global(.dark) .sortable:hover {
|
||||
background-color: var(--hover-bg, rgba(30, 41, 59, 0.8));
|
||||
}
|
||||
|
||||
.sort-icon {
|
||||
display: inline-block;
|
||||
width: 0.8rem;
|
||||
height: 0.8rem;
|
||||
margin-left: 0.5rem;
|
||||
position: relative;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.sort-icon::before,
|
||||
.sort-icon::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.sort-icon::before {
|
||||
top: 0;
|
||||
border-left: 0.4rem solid transparent;
|
||||
border-right: 0.4rem solid transparent;
|
||||
border-bottom: 0.4rem solid currentColor;
|
||||
}
|
||||
|
||||
.sort-icon::after {
|
||||
bottom: 0;
|
||||
border-left: 0.4rem solid transparent;
|
||||
border-right: 0.4rem solid transparent;
|
||||
border-top: 0.4rem solid currentColor;
|
||||
}
|
||||
|
||||
.sorted-asc .sort-icon::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sorted-desc .sort-icon::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
.header-container::-webkit-scrollbar,
|
||||
.first-col-container::-webkit-scrollbar,
|
||||
.main-container::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.header-container::-webkit-scrollbar-track,
|
||||
.first-col-container::-webkit-scrollbar-track,
|
||||
.main-container::-webkit-scrollbar-track {
|
||||
background: var(--background-secondary, #f8fafc);
|
||||
}
|
||||
|
||||
.header-container::-webkit-scrollbar-thumb,
|
||||
.first-col-container::-webkit-scrollbar-thumb,
|
||||
.main-container::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-color, #e2e8f0);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:global(.dark) .header-container::-webkit-scrollbar-track,
|
||||
:global(.dark) .first-col-container::-webkit-scrollbar-track,
|
||||
:global(.dark) .main-container::-webkit-scrollbar-track {
|
||||
background: var(--background-color, #0f172a);
|
||||
}
|
||||
|
||||
:global(.dark) .header-container::-webkit-scrollbar-thumb,
|
||||
:global(.dark) .first-col-container::-webkit-scrollbar-thumb,
|
||||
:global(.dark) .main-container::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-color, #334155);
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.table-component {
|
||||
width: calc(100vw - 2rem);
|
||||
margin-left: calc(-50vw + 1rem);
|
||||
margin-right: calc(-50vw + 1rem);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Synchronize scrolling between table sections
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const tableComponents = document.querySelectorAll('.table-component');
|
||||
|
||||
tableComponents.forEach(tableComponent => {
|
||||
const mainContainer = tableComponent.querySelector('.main-container') as HTMLElement | null;
|
||||
const headerContainer = tableComponent.querySelector('.header-container') as HTMLElement | null;
|
||||
const firstColContainer = tableComponent.querySelector('.first-col-container') as HTMLElement | null;
|
||||
|
||||
if (mainContainer && headerContainer) {
|
||||
// Sync horizontal scrolling between main content and header
|
||||
mainContainer.addEventListener('scroll', () => {
|
||||
headerContainer.scrollLeft = mainContainer.scrollLeft;
|
||||
});
|
||||
|
||||
// Also allow scrolling from header
|
||||
headerContainer.addEventListener('scroll', () => {
|
||||
mainContainer.scrollLeft = headerContainer.scrollLeft;
|
||||
});
|
||||
}
|
||||
|
||||
if (mainContainer && firstColContainer) {
|
||||
// Sync vertical scrolling between main content and first column
|
||||
mainContainer.addEventListener('scroll', () => {
|
||||
firstColContainer.scrollTop = mainContainer.scrollTop;
|
||||
});
|
||||
|
||||
// Also allow scrolling from first column
|
||||
firstColContainer.addEventListener('scroll', () => {
|
||||
mainContainer.scrollTop = firstColContainer.scrollTop;
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure consistent row heights
|
||||
function matchRowHeights() {
|
||||
if (mainContainer && firstColContainer) {
|
||||
const mainRows = mainContainer.querySelectorAll('tbody tr');
|
||||
const firstColRows = firstColContainer.querySelectorAll('tr');
|
||||
|
||||
if (mainRows.length === firstColRows.length) {
|
||||
for (let i = 0; i < mainRows.length; i++) {
|
||||
const mainRow = mainRows[i] as HTMLElement;
|
||||
const firstColRow = firstColRows[i] as HTMLElement;
|
||||
const height = Math.max(mainRow.offsetHeight, firstColRow.offsetHeight);
|
||||
mainRow.style.height = `${height}px`;
|
||||
firstColRow.style.height = `${height}px`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Implement sorting if table is sortable
|
||||
if (tableComponent.querySelector('.sortable')) {
|
||||
const sortableHeaders = tableComponent.querySelectorAll('.sortable');
|
||||
|
||||
sortableHeaders.forEach(header => {
|
||||
header.addEventListener('click', () => {
|
||||
const key = (header as HTMLElement).dataset.key;
|
||||
const isAsc = header.classList.contains('sorted-asc');
|
||||
|
||||
// Remove sorted classes from all headers
|
||||
sortableHeaders.forEach(h => {
|
||||
h.classList.remove('sorted-asc', 'sorted-desc');
|
||||
});
|
||||
|
||||
// Add appropriate class to clicked header
|
||||
header.classList.add(isAsc ? 'sorted-desc' : 'sorted-asc');
|
||||
|
||||
// Sort the data
|
||||
if (mainContainer && key) {
|
||||
sortTable(tableComponent, key, !isAsc);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Implement search if table is searchable
|
||||
const searchInput = tableComponent.querySelector('.search-input') as HTMLInputElement | null;
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', () => {
|
||||
filterTable(tableComponent, searchInput.value.toLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
// Run once on load and on window resize
|
||||
setTimeout(matchRowHeights, 100);
|
||||
window.addEventListener('resize', matchRowHeights);
|
||||
});
|
||||
});
|
||||
|
||||
// Sort table function
|
||||
function sortTable(tableComponent: Element, key: string, ascending: boolean) {
|
||||
const mainContainer = tableComponent.querySelector('.main-container');
|
||||
const firstColContainer = tableComponent.querySelector('.first-col-container');
|
||||
|
||||
if (!mainContainer) return;
|
||||
|
||||
const rows = Array.from(mainContainer.querySelectorAll('tbody tr'));
|
||||
const sortedRows = rows.sort((a, b) => {
|
||||
const rowIndexA = parseInt((a as HTMLElement).dataset.rowIndex || '0');
|
||||
const rowIndexB = parseInt((b as HTMLElement).dataset.rowIndex || '0');
|
||||
|
||||
// Get the cells that contain the values to compare
|
||||
const cells = mainContainer.querySelectorAll(`tbody tr[data-row-index="${rowIndexA}"] td`);
|
||||
const cellsB = mainContainer.querySelectorAll(`tbody tr[data-row-index="${rowIndexB}"] td`);
|
||||
|
||||
// Find the index of the column with the specified key
|
||||
const headers = tableComponent.querySelectorAll('th[data-key]');
|
||||
let columnIndex = -1;
|
||||
|
||||
headers.forEach((header, index) => {
|
||||
if ((header as HTMLElement).dataset.key === key) {
|
||||
columnIndex = index;
|
||||
}
|
||||
});
|
||||
|
||||
if (columnIndex === -1) return 0;
|
||||
|
||||
// Get the values to compare
|
||||
const valueA = cells[columnIndex]?.getAttribute('data-full-content') || '';
|
||||
const valueB = cellsB[columnIndex]?.getAttribute('data-full-content') || '';
|
||||
|
||||
// Compare as numbers if possible
|
||||
const numA = parseFloat(valueA);
|
||||
const numB = parseFloat(valueB);
|
||||
|
||||
if (!isNaN(numA) && !isNaN(numB)) {
|
||||
return ascending ? numA - numB : numB - numA;
|
||||
}
|
||||
|
||||
// Otherwise compare as strings
|
||||
return ascending ?
|
||||
valueA.localeCompare(valueB) :
|
||||
valueB.localeCompare(valueA);
|
||||
});
|
||||
|
||||
// Reorder the rows in the main table
|
||||
const tbody = mainContainer.querySelector('tbody');
|
||||
if (tbody) {
|
||||
sortedRows.forEach(row => {
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// Reorder the rows in the first column table if it exists
|
||||
if (firstColContainer) {
|
||||
const firstColRows = Array.from(firstColContainer.querySelectorAll('tr'));
|
||||
const sortedFirstColRows = firstColRows.sort((a, b) => {
|
||||
const rowIndexA = parseInt((a as HTMLElement).dataset.rowIndex || '0');
|
||||
const rowIndexB = parseInt((b as HTMLElement).dataset.rowIndex || '0');
|
||||
|
||||
// Find the position of these indices in the sorted rows
|
||||
const posA = sortedRows.findIndex(row =>
|
||||
parseInt((row as HTMLElement).dataset.rowIndex || '0') === rowIndexA
|
||||
);
|
||||
const posB = sortedRows.findIndex(row =>
|
||||
parseInt((row as HTMLElement).dataset.rowIndex || '0') === rowIndexB
|
||||
);
|
||||
|
||||
return posA - posB;
|
||||
});
|
||||
|
||||
const firstColTable = firstColContainer.querySelector('table');
|
||||
if (firstColTable) {
|
||||
sortedFirstColRows.forEach(row => {
|
||||
firstColTable.appendChild(row);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter table function
|
||||
function filterTable(tableComponent: Element, searchText: string) {
|
||||
const mainContainer = tableComponent.querySelector('.main-container');
|
||||
const firstColContainer = tableComponent.querySelector('.first-col-container');
|
||||
|
||||
if (!mainContainer) return;
|
||||
|
||||
const rows = mainContainer.querySelectorAll('tbody tr');
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
const rowIndex = (row as HTMLElement).dataset.rowIndex;
|
||||
const cells = row.querySelectorAll('td');
|
||||
const firstColRow = firstColContainer?.querySelector(`tr[data-row-index="${rowIndex}"]`);
|
||||
|
||||
let visible = false;
|
||||
|
||||
// Check main content cells
|
||||
cells.forEach(cell => {
|
||||
const content = cell.getAttribute('data-full-content')?.toLowerCase() || '';
|
||||
if (content.includes(searchText)) {
|
||||
visible = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Check first column cell
|
||||
if (firstColRow) {
|
||||
const firstColCell = firstColRow.querySelector('td');
|
||||
const firstColContent = firstColCell?.getAttribute('data-full-content')?.toLowerCase() || '';
|
||||
if (firstColContent.includes(searchText)) {
|
||||
visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
(row as HTMLElement).style.display = visible ? '' : 'none';
|
||||
if (firstColRow) {
|
||||
(firstColRow as HTMLElement).style.display = visible ? '' : 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
203
apps/bauntown/apps/landing/src/components/TeamMembers.astro
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
---
|
||||
import { getLangFromUrl, useTranslations } from '../utils/i18n';
|
||||
import { getCollection } from 'astro:content';
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
|
||||
interface Props {
|
||||
maxMembers?: number;
|
||||
showLinks?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const { maxMembers = 5, showLinks = true, compact = false } = Astro.props;
|
||||
|
||||
// Get current language from URL
|
||||
const lang = getLangFromUrl(Astro.url);
|
||||
const t = useTranslations(lang);
|
||||
|
||||
// Get members in current language
|
||||
const allMembers = await getCollection('members', ({ id }) => {
|
||||
return id.startsWith(lang + '/');
|
||||
});
|
||||
|
||||
// Sort by order, then by name
|
||||
const sortedMembers = allMembers
|
||||
.sort((a, b) => a.data.order - b.data.order || a.data.name.localeCompare(b.data.name))
|
||||
.filter((member, index) => index < maxMembers);
|
||||
---
|
||||
|
||||
<div class:list={["team-members", { compact }]}>
|
||||
{sortedMembers.map((member) => (
|
||||
<div class="member-card">
|
||||
<div class="member-avatar">
|
||||
{member.data.image ? (
|
||||
<img src={member.data.image} alt={member.data.name} />
|
||||
) : (
|
||||
<div class="avatar-placeholder">{member.data.name.charAt(0)}</div>
|
||||
)}
|
||||
</div>
|
||||
<div class="member-info">
|
||||
<h3 class="member-name">{member.data.name}</h3>
|
||||
<p class="member-role">{member.data.role}</p>
|
||||
|
||||
{!compact && <p class="member-bio">{member.data.bio}</p>}
|
||||
|
||||
{showLinks && (
|
||||
<div class="member-links">
|
||||
{member.data.github && (
|
||||
<a href={`https://github.com/${member.data.github}`} target="_blank" rel="noopener noreferrer" class="social-link github" title="GitHub">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"></path></svg>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{member.data.twitter && (
|
||||
<a href={`https://twitter.com/${member.data.twitter}`} target="_blank" rel="noopener noreferrer" class="social-link twitter" title="Twitter">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 3a10.9 10.9 0 0 1-3.14 1.53 4.48 4.48 0 0 0-7.86 3v1A10.66 10.66 0 0 1 3 4s-4 9 5 13a11.64 11.64 0 0 1-7 2c9 5 20 0 20-11.5a4.5 4.5 0 0 0-.08-.83A7.72 7.72 0 0 0 23 3z"></path></svg>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{member.data.linkedin && (
|
||||
<a href={`https://linkedin.com/in/${member.data.linkedin}`} target="_blank" rel="noopener noreferrer" class="social-link linkedin" title="LinkedIn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"></path><rect x="2" y="9" width="4" height="12"></rect><circle cx="4" cy="4" r="2"></circle></svg>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{member.data.website && (
|
||||
<a href={member.data.website} target="_blank" rel="noopener noreferrer" class="social-link website" title="Website">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.team-members {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.team-members.compact {
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.member-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
background-color: var(--card-bg);
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
transition: transform 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.member-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.2);
|
||||
}
|
||||
|
||||
.member-avatar {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.compact .member-avatar {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
.member-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-color);
|
||||
background-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.1);
|
||||
}
|
||||
|
||||
.member-info {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0.5rem 0 0.25rem;
|
||||
}
|
||||
|
||||
.compact .member-name {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.member-role {
|
||||
font-size: 0.9rem;
|
||||
color: var(--accent-color);
|
||||
margin: 0 0 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.compact .member-role {
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.member-bio {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.member-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.social-link {
|
||||
color: var(--text-muted);
|
||||
transition: color 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.social-link:hover {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.team-members {
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
}
|
||||
|
||||
.team-members.compact {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
91
apps/bauntown/apps/landing/src/components/ThemeToggle.astro
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
---
|
||||
import { getLangFromUrl, useTranslations } from '../utils/i18n';
|
||||
|
||||
const lang = getLangFromUrl(Astro.url);
|
||||
const t = useTranslations(lang);
|
||||
---
|
||||
|
||||
<button id="themeToggle" aria-label={t('nav.darkMode')}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24">
|
||||
<path
|
||||
class="sun"
|
||||
fill-rule="evenodd"
|
||||
d="M12 17.5a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zm0 1.5a7 7 0 1 0 0-14 7 7 0 0 0 0 14zm12-7a.8.8 0 0 1-.8.8h-2.4a.8.8 0 0 1 0-1.6h2.4a.8.8 0 0 1 .8.8zM4 12a.8.8 0 0 1-.8.8H.8a.8.8 0 0 1 0-1.6h2.5a.8.8 0 0 1 .8.8zm16.5-8.5a.8.8 0 0 1 0 1l-1.8 1.8a.8.8 0 0 1-1-1l1.7-1.8a.8.8 0 0 1 1.1 0zM6.3 17.7a.8.8 0 0 1 0 1l-1.7 1.8a.8.8 0 1 1-1.1-1l1.7-1.8a.8.8 0 0 1 1.1 0zM12 0a.8.8 0 0 1 .8.8v2.5a.8.8 0 0 1-1.6 0V.8A.8.8 0 0 1 12 0zm0 20a.8.8 0 0 1 .8.8v2.4a.8.8 0 0 1-1.6 0v-2.4a.8.8 0 0 1 .8-.8zM3.5 3.5a.8.8 0 0 1 1 0l1.8 1.8a.8.8 0 1 1-1 1L3.5 4.6a.8.8 0 0 1 0-1.1zm14.2 14.2a.8.8 0 0 1 1 0l1.8 1.7a.8.8 0 0 1-1 1l-1.8-1.7a.8.8 0 0 1 0-1z"
|
||||
></path>
|
||||
<path
|
||||
class="moon"
|
||||
fill-rule="evenodd"
|
||||
d="M16.5 6A10.5 10.5 0 0 1 4.7 16.4 8.5 8.5 0 1 0 16.4 4.7l.1 1.3zm-1.7-2a9 9 0 0 1 .2 2 9 9 0 0 1-11 8.8 9.4 9.4 0 0 1-.8-.3c-.4 0-.8.3-.7.7a10 10 0 0 0 .3.8 10 10 0 0 0 9.2 6 10 10 0 0 0 4-19.2 9.7 9.7 0 0 0-.9-.3c-.3-.1-.7.3-.6.7a9 9 0 0 1 .3.8z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
#themeToggle {
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#themeToggle:hover {
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
.sun { fill: var(--text-color); }
|
||||
.moon { fill: transparent; }
|
||||
|
||||
:global(.dark) .sun { fill: transparent; }
|
||||
:global(.dark) .moon { fill: var(--text-color); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#themeToggle {
|
||||
padding: 0.75rem;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
#themeToggle::after {
|
||||
content: attr(aria-label);
|
||||
margin-left: 0.75rem;
|
||||
font-size: 1rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const theme = (() => {
|
||||
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
|
||||
return localStorage.getItem('theme');
|
||||
}
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return 'dark';
|
||||
}
|
||||
return 'light';
|
||||
})();
|
||||
|
||||
if (theme === 'light') {
|
||||
document.documentElement.classList.remove('dark');
|
||||
} else {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
|
||||
window.localStorage.setItem('theme', theme);
|
||||
|
||||
const handleToggleClick = () => {
|
||||
const element = document.documentElement;
|
||||
element.classList.toggle('dark');
|
||||
|
||||
const isDark = element.classList.contains('dark');
|
||||
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
document.getElementById('themeToggle').addEventListener('click', handleToggleClick);
|
||||
</script>
|
||||
159
apps/bauntown/apps/landing/src/components/ToolCard.astro
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
---
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import { getLangFromUrl, useTranslations } from "../utils/i18n";
|
||||
|
||||
interface Props {
|
||||
tool: CollectionEntry<"tools">;
|
||||
isFeatured?: boolean;
|
||||
}
|
||||
|
||||
const { tool, isFeatured = false } = Astro.props;
|
||||
const { data, slug } = tool;
|
||||
const lang = getLangFromUrl(Astro.url);
|
||||
const t = useTranslations(lang);
|
||||
|
||||
// Generate URL based on lang and only the filename part of the slug
|
||||
const slugParts = slug.split("/");
|
||||
const fileName = slugParts[slugParts.length - 1];
|
||||
const url = `/${lang}/tools/${fileName}`;
|
||||
|
||||
// Map category to i18n key
|
||||
const categoryKey = `tools.categories.${data.category}` as const;
|
||||
const pricingKey = `tools.pricing.${data.pricing}` as const;
|
||||
|
||||
// Default image if none is provided
|
||||
const imageSrc = data.image ?? "/images/bauntown-codespilling.png";
|
||||
---
|
||||
|
||||
<article class={`tool-card ${isFeatured ? "featured" : ""}`}>
|
||||
<a href={url} class="card-link">
|
||||
<div class="card-image-container">
|
||||
<img src={imageSrc} alt={data.title} class="card-image" loading="lazy" />
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-meta">
|
||||
<span class="card-category">{t(categoryKey)}</span>
|
||||
<span class="card-pricing">{t(pricingKey)}</span>
|
||||
</div>
|
||||
<h3 class="card-title">{data.title}</h3>
|
||||
<p class="card-description">{data.description}</p>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.tool-card {
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
background-color: var(--card-bg);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.tool-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 10px 30px -15px var(--shadow-color);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.tool-card.featured {
|
||||
border: 1px solid var(--accent-color);
|
||||
}
|
||||
|
||||
.card-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.card-image-container {
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-category, .card-pricing {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-category {
|
||||
background-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.1);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.card-pricing {
|
||||
background-color: var(--surface-bg);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
flex-grow: 1;
|
||||
line-height: 1.6;
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.read-more {
|
||||
color: var(--accent-color);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tool-card:hover .read-more {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.card-image-container {
|
||||
aspect-ratio: 3 / 2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
171
apps/bauntown/apps/landing/src/components/TutorialCard.astro
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
---
|
||||
import { getLangFromUrl, useTranslations } from '../utils/i18n';
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
|
||||
interface Props {
|
||||
tutorial: CollectionEntry<'tutorials'>;
|
||||
}
|
||||
|
||||
const { tutorial } = Astro.props;
|
||||
const { data } = tutorial;
|
||||
|
||||
const lang = getLangFromUrl(Astro.url);
|
||||
const t = useTranslations(lang);
|
||||
|
||||
// Format date based on language
|
||||
const formattedDate = new Intl.DateTimeFormat(lang === 'de' ? 'de-DE' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}).format(data.pubDate);
|
||||
|
||||
// Generate tutorial URL with language prefix
|
||||
let tutorialUrl;
|
||||
const slugParts = tutorial.slug.split('/');
|
||||
const fileName = slugParts[slugParts.length - 1];
|
||||
|
||||
tutorialUrl = `/${lang}/tutorials/${fileName}`;
|
||||
|
||||
// Category translation
|
||||
const categoryKey = `tutorials.categories.${data.category}` as const;
|
||||
---
|
||||
|
||||
<a href={tutorialUrl} class="tutorial-card-link">
|
||||
<article class:list={["tutorial-card", { "featured": data.featured }]}>
|
||||
{data.image && (
|
||||
<div class="tutorial-image-container">
|
||||
<img src={data.image} alt={data.title} class="tutorial-image" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="content">
|
||||
<div class="meta">
|
||||
<span class="category">{t(categoryKey)}</span>
|
||||
<span class="date">{formattedDate}</span>
|
||||
</div>
|
||||
|
||||
<h3>{data.title}</h3>
|
||||
<p>{data.description}</p>
|
||||
|
||||
<div class="footer">
|
||||
<span class="author">{data.author}</span>
|
||||
<span class="read-more">{t('tutorials.readMore')} <span class="arrow">→</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.tutorial-card-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tutorial-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
background-color: var(--card-bg);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.03);
|
||||
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tutorial-card-link:hover .tutorial-card {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.5);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.tutorial-card.featured {
|
||||
border: 2px solid rgba(var(--accent-color-rgb, 249, 115, 22), 0.3);
|
||||
}
|
||||
|
||||
.tutorial-image-container {
|
||||
width: 100%;
|
||||
padding-top: 66.67%; /* Aspect ratio 3:2 */
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tutorial-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.category {
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.date {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
flex-grow: 1;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.author {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.read-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.tutorial-card-link:hover .arrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
</style>
|
||||
264
apps/bauntown/apps/landing/src/components/VisionCard.astro
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
---
|
||||
import { getLangFromUrl, useTranslations } from '../utils/i18n';
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
|
||||
interface Props {
|
||||
vision: CollectionEntry<'vision'>;
|
||||
}
|
||||
|
||||
const { vision } = Astro.props;
|
||||
const { data } = vision;
|
||||
|
||||
const lang = getLangFromUrl(Astro.url);
|
||||
const t = useTranslations(lang);
|
||||
|
||||
// Format date based on language
|
||||
const formattedDate = new Intl.DateTimeFormat(lang === 'de' ? 'de-DE' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}).format(data.pubDate);
|
||||
|
||||
// Generate vision URL with language prefix
|
||||
let visionUrl;
|
||||
const slugParts = vision.slug.split('/');
|
||||
const fileName = slugParts[slugParts.length - 1];
|
||||
|
||||
visionUrl = `/${lang}/vision/${fileName}`;
|
||||
|
||||
// Get translated category and status
|
||||
const categoryKey = `vision.category.${data.category}` as const;
|
||||
const statusKey = `vision.status.${data.status}` as const;
|
||||
---
|
||||
|
||||
<a href={visionUrl} class="vision-card-link">
|
||||
<article class:list={["vision-card", { "featured": data.featured }]}>
|
||||
{data.image && (
|
||||
<div class="vision-image-container">
|
||||
<img src={data.image} alt={data.title} class="vision-image" />
|
||||
<div class="status-badge" data-status={data.status}>
|
||||
{t(statusKey)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="content">
|
||||
<div class="meta">
|
||||
<span class="category" data-category={data.category}>{t(categoryKey)}</span>
|
||||
<span class="date">{formattedDate}</span>
|
||||
</div>
|
||||
|
||||
<h3>{data.title}</h3>
|
||||
<p>{data.description}</p>
|
||||
|
||||
<div class="details">
|
||||
<div class="timeline">
|
||||
<span class="timeline-icon">⏱️</span>
|
||||
<span class="timeline-text">{data.timeline}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
{data.contributors && data.contributors.length > 0 && (
|
||||
<div class="contributors">
|
||||
<span class="contributors-count">{data.contributors.length}</span>
|
||||
</div>
|
||||
)}
|
||||
<span class="read-more">{t('vision.readMore')} <span class="arrow">→</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.vision-card-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.vision-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
background-color: var(--card-bg);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.03);
|
||||
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.vision-card-link:hover .vision-card {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(var(--accent-color-rgb, 249, 115, 22), 0.5);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.vision-card.featured {
|
||||
border: 2px solid rgba(var(--accent-color-rgb, 249, 115, 22), 0.3);
|
||||
}
|
||||
|
||||
.vision-image-container {
|
||||
width: 100%;
|
||||
padding-top: 66.67%; /* Aspect ratio 3:2 */
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vision-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 2rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
z-index: 2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge[data-status="current"] {
|
||||
background-color: #10b981; /* Green */
|
||||
}
|
||||
|
||||
.status-badge[data-status="planned"] {
|
||||
background-color: #6366f1; /* Purple */
|
||||
}
|
||||
|
||||
.status-badge[data-status="exploring"] {
|
||||
background-color: #f59e0b; /* Amber */
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.category {
|
||||
font-weight: 600;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.category[data-category="product"] {
|
||||
background-color: #dcfce7; /* Light green */
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.category[data-category="technology"] {
|
||||
background-color: #e0f2fe; /* Light blue */
|
||||
color: #0284c7;
|
||||
}
|
||||
|
||||
.category[data-category="community"] {
|
||||
background-color: #fef3c7; /* Light yellow */
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.category[data-category="future"] {
|
||||
background-color: #ede9fe; /* Light purple */
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.date {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.details {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.timeline-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.timeline-text {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.contributors {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.contributors-count {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.contributors-count::before {
|
||||
content: '👥 ';
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.read-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.vision-card-link:hover .arrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
</style>
|
||||
210
apps/bauntown/apps/landing/src/components/Welcome.astro
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
---
|
||||
import astroLogo from '../assets/astro.svg';
|
||||
import background from '../assets/background.svg';
|
||||
---
|
||||
|
||||
<div id="container">
|
||||
<img id="background" src={background.src} alt="" fetchpriority="high" />
|
||||
<main>
|
||||
<section id="hero">
|
||||
<a href="https://astro.build"
|
||||
><img src={astroLogo.src} width="115" height="48" alt="Astro Homepage" /></a
|
||||
>
|
||||
<h1>
|
||||
To get started, open the <code><pre>src/pages</pre></code> directory in your project.
|
||||
</h1>
|
||||
<section id="links">
|
||||
<a class="button" href="https://docs.astro.build">Read our docs</a>
|
||||
<a href="https://astro.build/chat"
|
||||
>Join our Discord <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 127.14 96.36"
|
||||
><path
|
||||
fill="currentColor"
|
||||
d="M107.7 8.07A105.15 105.15 0 0 0 81.47 0a72.06 72.06 0 0 0-3.36 6.83 97.68 97.68 0 0 0-29.11 0A72.37 72.37 0 0 0 45.64 0a105.89 105.89 0 0 0-26.25 8.09C2.79 32.65-1.71 56.6.54 80.21a105.73 105.73 0 0 0 32.17 16.15 77.7 77.7 0 0 0 6.89-11.11 68.42 68.42 0 0 1-10.85-5.18c.91-.66 1.8-1.34 2.66-2a75.57 75.57 0 0 0 64.32 0c.87.71 1.76 1.39 2.66 2a68.68 68.68 0 0 1-10.87 5.19 77 77 0 0 0 6.89 11.1 105.25 105.25 0 0 0 32.19-16.14c2.64-27.38-4.51-51.11-18.9-72.15ZM42.45 65.69C36.18 65.69 31 60 31 53s5-12.74 11.43-12.74S54 46 53.89 53s-5.05 12.69-11.44 12.69Zm42.24 0C78.41 65.69 73.25 60 73.25 53s5-12.74 11.44-12.74S96.23 46 96.12 53s-5.04 12.69-11.43 12.69Z"
|
||||
></path></svg
|
||||
>
|
||||
</a>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<a href="https://astro.build/blog/astro-5/" id="news" class="box">
|
||||
<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"
|
||||
><path
|
||||
d="M24.667 12c1.333 1.414 2 3.192 2 5.334 0 4.62-4.934 5.7-7.334 12C18.444 28.567 18 27.456 18 26c0-4.642 6.667-7.053 6.667-14Zm-5.334-5.333c1.6 1.65 2.4 3.43 2.4 5.333 0 6.602-8.06 7.59-6.4 17.334C13.111 27.787 12 25.564 12 22.666c0-4.434 7.333-8 7.333-16Zm-6-5.333C15.111 3.555 16 5.556 16 7.333c0 8.333-11.333 10.962-5.333 22-3.488-.774-6-4-6-8 0-8.667 8.666-10 8.666-20Z"
|
||||
fill="#111827"></path></svg
|
||||
>
|
||||
<h2>What's New in Astro 5.0?</h2>
|
||||
<p>
|
||||
From content layers to server islands, click to learn more about the new features and
|
||||
improvements in Astro 5.0
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#background {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
filter: blur(100px);
|
||||
}
|
||||
|
||||
#container {
|
||||
font-family: Inter, Roboto, 'Helvetica Neue', 'Arial Nova', 'Nimbus Sans', Arial, sans-serif;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
main {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#hero {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
|
||||
#links {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
#links a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
color: #111827;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
#links a:hover {
|
||||
color: rgb(78, 80, 86);
|
||||
}
|
||||
|
||||
#links a svg {
|
||||
height: 1em;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
#links a.button {
|
||||
color: white;
|
||||
background: linear-gradient(83.21deg, #3245ff 0%, #bc52ee 100%);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.12),
|
||||
inset 0 -2px 0 rgba(0, 0, 0, 0.24);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
#links a.button:hover {
|
||||
color: rgb(230, 230, 230);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-family:
|
||||
ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono',
|
||||
monospace;
|
||||
font-weight: normal;
|
||||
background: linear-gradient(14deg, #d83333 0%, #f041ff 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 1em;
|
||||
font-weight: normal;
|
||||
color: #111827;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #4b5563;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.006em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code {
|
||||
display: inline-block;
|
||||
background:
|
||||
linear-gradient(66.77deg, #f3cddd 0%, #f5cee7 100%) padding-box,
|
||||
linear-gradient(155deg, #d83333 0%, #f041ff 18%, #f5cee7 45%) border-box;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.box {
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 1);
|
||||
border-radius: 16px;
|
||||
border: 1px solid white;
|
||||
}
|
||||
|
||||
#news {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
max-width: 300px;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s;
|
||||
backdrop-filter: blur(50px);
|
||||
}
|
||||
|
||||
#news:hover {
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
@media screen and (max-height: 368px) {
|
||||
#news {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
#container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#hero {
|
||||
display: block;
|
||||
padding-top: 10%;
|
||||
}
|
||||
|
||||
#links {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#links a.button {
|
||||
padding: 14px 18px;
|
||||
}
|
||||
|
||||
#news {
|
||||
right: 16px;
|
||||
left: 16px;
|
||||
bottom: 2.5rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
h1 {
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
40
apps/bauntown/apps/landing/src/components/YouTubeVideo.astro
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
interface Props {
|
||||
videoId: string;
|
||||
title?: string;
|
||||
aspectRatio?: string;
|
||||
}
|
||||
|
||||
const { videoId, title = "YouTube video", aspectRatio = "16/9" } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="youtube-video-container">
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${videoId}`}
|
||||
title={title}
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen="true"
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
<style define:vars={{ aspectRatio }}>
|
||||
.youtube-video-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: var(--aspectRatio);
|
||||
margin: 2rem 0;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
181
apps/bauntown/apps/landing/src/content/config.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { defineCollection, z } from 'astro:content';
|
||||
|
||||
const toolsCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
pubDate: z.date(),
|
||||
updatedDate: z.date().optional(),
|
||||
category: z.enum(['Design', 'Development', 'Productivity', 'Hosting']),
|
||||
image: z.string().optional(),
|
||||
author: z.string().default('BaunTown'),
|
||||
featured: z.boolean().default(false),
|
||||
website: z.string().optional(),
|
||||
pricing: z.enum(['Free', 'Freemium', 'Paid']).default('Freemium'),
|
||||
tags: z.array(z.string()).optional(),
|
||||
externalLinks: z.array(z.object({
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
})).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const newsCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
pubDate: z.date(),
|
||||
updatedDate: z.date().optional(),
|
||||
category: z.enum(['AI', 'Web', 'Development', 'Design', 'Community', 'Product']),
|
||||
image: z.string().optional(),
|
||||
author: z.string().default('BaunTown'),
|
||||
featured: z.boolean().default(false),
|
||||
tags: z.array(z.string()).optional(),
|
||||
externalLinks: z.array(z.object({
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
})).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const modelsCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
pubDate: z.date(),
|
||||
updatedDate: z.date().optional(),
|
||||
category: z.enum(['Text', 'Bild']),
|
||||
image: z.string().optional(),
|
||||
author: z.string().default('BaunTown'),
|
||||
featured: z.boolean().default(false),
|
||||
tags: z.array(z.string()).optional(),
|
||||
externalLinks: z.array(z.object({
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
})).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const projectsCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
pubDate: z.date(),
|
||||
updatedDate: z.date().optional(),
|
||||
category: z.enum(['Web', 'Mobile', 'Desktop', 'IoT', 'AI', 'Design']),
|
||||
image: z.string().optional(),
|
||||
author: z.string().default('BaunTown'),
|
||||
featured: z.boolean().default(false),
|
||||
status: z.enum(['active', 'completed', 'archived']).default('active'),
|
||||
tags: z.array(z.string()).optional(),
|
||||
githubUrl: z.string().optional(),
|
||||
demoUrl: z.string().optional(),
|
||||
technologies: z.array(z.string()).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const tutorialsCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
pubDate: z.date(),
|
||||
updatedDate: z.date().optional(),
|
||||
category: z.enum(['UI & UX', 'Business', 'Users', 'Branding', 'Marketing', 'Vibecoding']),
|
||||
image: z.string().optional(),
|
||||
author: z.string().default('BaunTown'),
|
||||
featured: z.boolean().default(false),
|
||||
// Course related fields
|
||||
course: z.string().optional(),
|
||||
courseName: z.string().optional(),
|
||||
lessonNumber: z.number().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const missionsCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
pubDate: z.date(),
|
||||
updatedDate: z.date().optional(),
|
||||
difficulty: z.enum(['beginner', 'intermediate', 'advanced']),
|
||||
duration: z.string(), // e.g. "2-3 hours", "1 day"
|
||||
skills: z.array(z.string()),
|
||||
image: z.string().optional(),
|
||||
featured: z.boolean().default(false),
|
||||
category: z.enum(['UI & UX', 'Business', 'Users', 'Branding', 'Marketing', 'Vibecoding']).default('UI & UX'),
|
||||
status: z.enum(['active', 'completed', 'upcoming']).default('active'),
|
||||
participants: z.array(z.string()).optional(),
|
||||
githubRepo: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const visionCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
pubDate: z.date(),
|
||||
updatedDate: z.date().optional(),
|
||||
image: z.string().optional(),
|
||||
featured: z.boolean().default(false),
|
||||
category: z.enum(['product', 'technology', 'community', 'future']),
|
||||
timeline: z.string(), // e.g. "2025-2030", "Long-term"
|
||||
status: z.enum(['current', 'planned', 'exploring']),
|
||||
contributors: z.array(z.string()).optional(),
|
||||
relatedLinks: z.array(z.object({
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
})).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const joinCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
pubDate: z.date(),
|
||||
updatedDate: z.date().optional(),
|
||||
image: z.string().optional(),
|
||||
heroTitle: z.string().optional(),
|
||||
heroDescription: z.string().optional(),
|
||||
newsletterTitle: z.string().optional(),
|
||||
newsletterDescription: z.string().optional(),
|
||||
submissionTitle: z.string().optional(),
|
||||
submissionDescription: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const membersCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
name: z.string(),
|
||||
role: z.string(),
|
||||
bio: z.string(),
|
||||
image: z.string().optional(),
|
||||
github: z.string().optional(),
|
||||
twitter: z.string().optional(),
|
||||
linkedin: z.string().optional(),
|
||||
website: z.string().optional(),
|
||||
featured: z.boolean().default(false),
|
||||
order: z.number().default(999),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = {
|
||||
'news': newsCollection,
|
||||
'models': modelsCollection,
|
||||
'projects': projectsCollection,
|
||||
'tutorials': tutorialsCollection,
|
||||
'missions': missionsCollection,
|
||||
'vision': visionCollection,
|
||||
'join': joinCollection,
|
||||
'members': membersCollection,
|
||||
'tools': toolsCollection,
|
||||
};
|
||||
29
apps/bauntown/apps/landing/src/content/join/de/contribute.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
title: Code beitragen
|
||||
description: Hilf uns, BaunTown zu verbessern und zu erweitern.
|
||||
pubDate: 2025-04-01
|
||||
image: /images/bauntown-code.png
|
||||
category: community
|
||||
featured: true
|
||||
---
|
||||
|
||||
## Code beitragen
|
||||
|
||||
Als Open-Source-Projekt lebt BaunTown von den Beiträgen der Community. Egal ob du ein erfahrener Entwickler bist oder gerade erst anfängst, es gibt viele Möglichkeiten, wie du beitragen kannst.
|
||||
|
||||
### Wie du helfen kannst
|
||||
|
||||
- **Bug-Fixes**: Wenn du einen Fehler findest, erstelle einen Issue oder einen Pull Request.
|
||||
- **Neue Features**: Hast du eine Idee für ein neues Feature? Teile sie mit uns!
|
||||
- **Dokumentation**: Hilf uns, unsere Dokumentation zu verbessern.
|
||||
- **Code-Qualität**: Verbessere die Codequalität durch Refactoring.
|
||||
|
||||
### Erste Schritte
|
||||
|
||||
1. Fork das Repository auf GitHub
|
||||
2. Clone dein Fork auf deinen lokalen Computer
|
||||
3. Erstelle einen Branch für deine Änderungen
|
||||
4. Mache deine Änderungen und committe sie
|
||||
5. Pushe deinen Branch und erstelle einen Pull Request
|
||||
|
||||
Wir freuen uns auf deine Beiträge!
|
||||
26
apps/bauntown/apps/landing/src/content/join/de/events.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
title: Events & Meetups
|
||||
description: Treffe die BaunTown-Community persönlich bei unseren Events.
|
||||
pubDate: 2025-04-01
|
||||
image: /images/bauntown-events.png
|
||||
category: community
|
||||
featured: true
|
||||
---
|
||||
|
||||
## Events & Meetups
|
||||
|
||||
Die BaunTown-Community trifft sich regelmäßig zu verschiedenen Events, Workshops und Hackathons. Diese Veranstaltungen bieten eine großartige Möglichkeit, andere Mitglieder kennenzulernen, zusammen zu lernen und an spannenden Projekten zu arbeiten.
|
||||
|
||||
### Kommende Events
|
||||
|
||||
- **BaunTown Hackathon 2025**: Ein zweitägiger Hackathon, bei dem Teams an innovativen Projekten arbeiten.
|
||||
- **Monatliche Meetups**: Jeden letzten Donnerstag im Monat treffen wir uns online oder in verschiedenen Städten.
|
||||
- **Workshops**: Regelmäßige Workshops zu verschiedenen Technologien und Themen.
|
||||
|
||||
### Vergangene Events
|
||||
|
||||
Schau dir die Highlights und Projekte unserer vergangenen Events an und hol dir Inspiration für deine eigenen Projekte.
|
||||
|
||||
### Eigenes Event organisieren
|
||||
|
||||
Möchtest du ein lokales BaunTown-Meetup in deiner Stadt organisieren? Wir unterstützen dich gerne dabei! Kontaktiere uns für weitere Informationen und Ressourcen.
|
||||
36
apps/bauntown/apps/landing/src/content/join/de/index.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
title: Werde Teil der Community
|
||||
description: Mitmachen, Inhalte einreichen und zum BaunTown-Projekt beitragen.
|
||||
pubDate: 2025-04-01
|
||||
image: /images/bauntown-community.png
|
||||
heroTitle: Gemeinsam gestalten wir die digitale Zukunft
|
||||
heroDescription: BaunTown lebt durch seine Community. Entdecke, wie du mit deinen Fähigkeiten und Ideen einen wertvollen Beitrag leisten kannst.
|
||||
newsletterTitle: Bleibe auf dem Laufenden
|
||||
newsletterDescription: Abonniere unseren Newsletter und erhalte regelmäßige Updates zu neuen Tutorials, Missionen und Community-Events.
|
||||
submissionTitle: Deine Ideen einreichen
|
||||
submissionDescription: Hast du Ideen für Tutorials, Missionen oder Visionen? Teile sie mit uns und der Community!
|
||||
---
|
||||
|
||||
## Werde Teil der Community
|
||||
|
||||
BaunTown ist eine dynamische Gemeinschaft von Entwicklern, Designern und Visionären, die gemeinsam an innovativen Technologien arbeiten. Bei uns gibt es vielfältige Möglichkeiten, deine Fähigkeiten einzubringen und gleichzeitig wertvolle Erfahrungen zu sammeln.
|
||||
|
||||
### Newsletter abonnieren
|
||||
|
||||
Mit unserem Newsletter bleibst du stets informiert über neue Entwicklungen, Tutorials und spannende Missionen. Wir versenden regelmäßig kuratierte Inhalte und exklusive Updates für unsere Community-Mitglieder.
|
||||
|
||||
### Inhalte einreichen
|
||||
|
||||
Möchtest du dein Fachwissen teilen und anderen helfen? Du kannst Vorschläge für Tutorials, praktische Missionen oder zukunftsweisende Visionen einreichen. Unser erfahrenes Team unterstützt dich dabei, deine Ideen in hochwertige Inhalte umzusetzen, die der Community zugutekommen.
|
||||
|
||||
### An Missionen teilnehmen
|
||||
|
||||
Unsere Missionen sind praxisnahe Coding-Projekte mit echtem Mehrwert. Hier arbeitest du in einem kollaborativen Umfeld und baust dein Portfolio mit relevanten Projekten auf. Von Einsteiger-Herausforderungen bis hin zu komplexen Aufgaben für Fortgeschrittene – für jeden Kenntnisstand ist etwas dabei.
|
||||
|
||||
### Feedback geben
|
||||
|
||||
Deine Meinung ist wertvoll! Teile uns mit, was dir gefällt und wo du Verbesserungspotenzial siehst. Durch kontinuierliches Feedback können wir BaunTown gemeinsam weiterentwickeln und die Plattform noch nützlicher gestalten.
|
||||
|
||||
### Code beitragen
|
||||
|
||||
BaunTown ist ein Open-Source-Projekt, das von seinen Beitragenden lebt. Wenn du über technische Fähigkeiten verfügst, kannst du direkt zum Quellcode beitragen, Fehler beheben oder die Dokumentation verbessern. Jeder Beitrag, egal wie klein, hilft der gesamten Community.
|
||||
28
apps/bauntown/apps/landing/src/content/join/de/sponsor.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
title: BaunTown unterstützen
|
||||
description: Hilf uns, BaunTown nachhaltig weiterzuentwickeln.
|
||||
pubDate: 2025-04-01
|
||||
image: /images/bauntown-sponsor.png
|
||||
category: support
|
||||
featured: true
|
||||
---
|
||||
|
||||
## BaunTown unterstützen
|
||||
|
||||
BaunTown ist ein Projekt, das von Leidenschaft und Community-Engagement angetrieben wird. Um unsere Vision voranzutreiben, sind wir auf Unterstützung angewiesen.
|
||||
|
||||
### Warum unterstützen?
|
||||
|
||||
- **Nachhaltigkeit**: Deine Unterstützung hilft uns, das Projekt langfristig zu erhalten.
|
||||
- **Neue Features**: Ermögliche die Entwicklung neuer Funktionen und Verbesserungen.
|
||||
- **Community**: Unterstütze eine wachsende Community von Lernenden und Entwicklern.
|
||||
- **Bildung**: Hilf uns, qualitativ hochwertige Lernressourcen kostenlos anzubieten.
|
||||
|
||||
### Unterstützungsmöglichkeiten
|
||||
|
||||
- **GitHub Sponsors**: Unterstütze uns direkt über GitHub Sponsors.
|
||||
- **Open Collective**: Trage bei über unsere Open Collective Seite.
|
||||
- **Einmalige Spende**: Auch einmalige Spenden sind willkommen.
|
||||
- **Unternehmenssponsoring**: Interesse an Unternehmenssponsoring? Kontaktiere uns direkt.
|
||||
|
||||
Jeder Beitrag zählt und hilft uns, BaunTown weiterzuentwickeln!
|
||||
29
apps/bauntown/apps/landing/src/content/join/en/contribute.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
title: Contribute Code
|
||||
description: Help us improve and expand BaunTown.
|
||||
pubDate: 2025-04-01
|
||||
image: /images/bauntown-code.png
|
||||
category: community
|
||||
featured: true
|
||||
---
|
||||
|
||||
## Contribute Code
|
||||
|
||||
As an open-source project, BaunTown thrives on community contributions. Whether you're an experienced developer or just getting started, there are many ways you can contribute.
|
||||
|
||||
### How You Can Help
|
||||
|
||||
- **Bug Fixes**: If you find a bug, create an issue or submit a pull request.
|
||||
- **New Features**: Do you have an idea for a new feature? Share it with us!
|
||||
- **Documentation**: Help us improve our documentation.
|
||||
- **Code Quality**: Improve code quality through refactoring.
|
||||
|
||||
### Getting Started
|
||||
|
||||
1. Fork the repository on GitHub
|
||||
2. Clone your fork to your local machine
|
||||
3. Create a branch for your changes
|
||||
4. Make your changes and commit them
|
||||
5. Push your branch and create a pull request
|
||||
|
||||
We look forward to your contributions!
|
||||
26
apps/bauntown/apps/landing/src/content/join/en/events.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
title: Events & Meetups
|
||||
description: Meet the BaunTown community in person at our events.
|
||||
pubDate: 2025-04-01
|
||||
image: /images/bauntown-events.png
|
||||
category: community
|
||||
featured: true
|
||||
---
|
||||
|
||||
## Events & Meetups
|
||||
|
||||
The BaunTown community regularly meets for various events, workshops, and hackathons. These gatherings provide a great opportunity to meet other members, learn together, and work on exciting projects.
|
||||
|
||||
### Upcoming Events
|
||||
|
||||
- **BaunTown Hackathon 2025**: A two-day hackathon where teams work on innovative projects.
|
||||
- **Monthly Meetups**: Every last Thursday of the month, we meet online or in different cities.
|
||||
- **Workshops**: Regular workshops on various technologies and topics.
|
||||
|
||||
### Past Events
|
||||
|
||||
Check out the highlights and projects from our past events and get inspiration for your own projects.
|
||||
|
||||
### Organize Your Own Event
|
||||
|
||||
Would you like to organize a local BaunTown meetup in your city? We're happy to support you! Contact us for more information and resources.
|
||||