chore: archive inactive projects to apps-archived/

Move inactive projects out of active workspace:
- bauntown (community website)
- maerchenzauber (AI story generation)
- memoro (voice memo app)
- news (news aggregation)
- nutriphi (nutrition tracking)
- reader (reading app)
- uload (URL shortener)
- wisekeep (AI wisdom extraction)

Update CLAUDE.md documentation:
- Add presi to active projects
- Document archived projects section
- Update workspace configuration

Archived apps can be re-activated by moving back to apps/

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-29 07:03:59 +01:00
parent b97149ac12
commit 61d181fbc2
3148 changed files with 437 additions and 46640 deletions

26
apps-archived/bauntown/.gitignore vendored Normal file
View 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/

View file

@ -0,0 +1,124 @@
# 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

View 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

View file

@ -0,0 +1,363 @@
# 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.

View 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

View 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
})
});

View 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

View file

@ -0,0 +1,160 @@
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,
}),
};
}
};

View file

@ -0,0 +1,115 @@
// 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',
},
}),
};
}
};

View file

@ -0,0 +1,152 @@
// 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',
},
}),
};
}
};

View file

@ -0,0 +1,117 @@
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,
}),
};
}
};

View file

@ -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 }),
};
}
};

View 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"
}
}

View file

@ -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,
},
}),
};
}
}

View 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"
}
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 906 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 475 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 649 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 609 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 906 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

View 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

View 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

View 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

View file

@ -0,0 +1,231 @@
---
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>

View file

@ -0,0 +1,360 @@
---
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>

View file

@ -0,0 +1,122 @@
---
// 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>

View file

@ -0,0 +1,75 @@
---
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>
<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>
<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>
<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>
<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"></path>
</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>

View file

@ -0,0 +1,176 @@
---
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><rect
x="2"
y="4.66666"
width="8"
height="2.66667"
stroke="currentColor"
stroke-width="0.8"
fill="none"></rect><rect
x="3.33333"
y="2"
width="5.33333"
height="2.66667"
stroke="currentColor"
stroke-width="0.8"
fill="none"></rect></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>

View file

@ -0,0 +1,384 @@
---
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>

View file

@ -0,0 +1,381 @@
---
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>

View file

@ -0,0 +1,292 @@
---
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>

View file

@ -0,0 +1,319 @@
---
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>

View file

@ -0,0 +1,317 @@
---
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>

View file

@ -0,0 +1,717 @@
---
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><rect
x="2"
y="4.66666"
width="8"
height="2.66667"
stroke="currentColor"
stroke-width="0.8"
fill="none"></rect><rect
x="3.33333"
y="2"
width="5.33333"
height="2.66667"
stroke="currentColor"
stroke-width="0.8"
fill="none"></rect></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><rect
x="2"
y="4.66666"
width="8"
height="2.66667"
stroke="currentColor"
stroke-width="0.8"
fill="none"></rect><rect
x="3.33333"
y="2"
width="5.33333"
height="2.66667"
stroke="currentColor"
stroke-width="0.8"
fill="none"></rect></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><rect
x="2"
y="4.66666"
width="8"
height="2.66667"
stroke="currentColor"
stroke-width="0.8"
fill="none"></rect><rect
x="3.33333"
y="2"
width="5.33333"
height="2.66667"
stroke="currentColor"
stroke-width="0.8"
fill="none"></rect></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>

View file

@ -0,0 +1,94 @@
---
// 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>

View file

@ -0,0 +1,203 @@
---
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>

View 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>

View file

@ -0,0 +1,606 @@
---
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>

View file

@ -0,0 +1,226 @@
---
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>

View file

@ -0,0 +1,854 @@
---
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" />}
</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" />}
</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>

View file

@ -0,0 +1,849 @@
---
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" />}
</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" />}
</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>

View file

@ -0,0 +1,283 @@
---
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" />
</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" />
</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" />
<rect x="2" y="9" width="4" height="12" />
<circle cx="4" cy="4" r="2" />
</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" />
<line x1="2" y1="12" x2="22" y2="12" />
<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" />
</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>

View file

@ -0,0 +1,99 @@
---
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>

View file

@ -0,0 +1,162 @@
---
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>

View file

@ -0,0 +1,180 @@
---
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>

View file

@ -0,0 +1,275 @@
---
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>

View 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>

View file

@ -0,0 +1,41 @@
---
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>

View file

@ -0,0 +1,199 @@
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,
};

View 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!

View 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.

View 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.

View 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!

View 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!

View 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.

Some files were not shown because too many files have changed in this diff Show more