Merge pull request 'contribution-mechanic' (#21) from contribution-mechanic into main
Reviewed-on: mstoeck3/cc24-hub#21
This commit is contained in:
		
						commit
						86d2370976
					
				
							
								
								
									
										6
									
								
								.astro/content.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.astro/content.d.ts
									
									
									
									
										vendored
									
									
								
							@ -164,11 +164,9 @@ declare module 'astro:content' {
 | 
			
		||||
	type DataEntryMap = {
 | 
			
		||||
		"knowledgebase": Record<string, {
 | 
			
		||||
  id: string;
 | 
			
		||||
  render(): Render[".md"];
 | 
			
		||||
  slug: string;
 | 
			
		||||
  body: string;
 | 
			
		||||
  body?: string;
 | 
			
		||||
  collection: "knowledgebase";
 | 
			
		||||
  data: InferEntrySchema<"knowledgebase">;
 | 
			
		||||
  data: any;
 | 
			
		||||
  rendered?: RenderedContent;
 | 
			
		||||
  filePath?: string;
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							@ -1,5 +1,5 @@
 | 
			
		||||
{
 | 
			
		||||
	"_variables": {
 | 
			
		||||
		"lastUpdateCheck": 1752478949435
 | 
			
		||||
		"lastUpdateCheck": 1753528124767
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										47
									
								
								.env.example
									
									
									
									
									
								
							
							
						
						
									
										47
									
								
								.env.example
									
									
									
									
									
								
							@ -1,18 +1,39 @@
 | 
			
		||||
# AI Configuration
 | 
			
		||||
AI_API_ENDPOINT=https://aiendpoint.org
 | 
			
		||||
AI_API_KEY=your_apikey_here
 | 
			
		||||
AI_MODEL='ai_model_name_here'
 | 
			
		||||
# ===========================================
 | 
			
		||||
# ForensicPathways Environment Configuration
 | 
			
		||||
# ===========================================
 | 
			
		||||
 | 
			
		||||
# OIDC Configuration
 | 
			
		||||
OIDC_ENDPOINT=https://oidc-provider.org
 | 
			
		||||
OIDC_CLIENT_ID=your_oidc_client_id
 | 
			
		||||
OIDC_CLIENT_SECRET=your_oidc_client_secret
 | 
			
		||||
AUTH_SECRET=your_super_secret_jwt_key_that_should_be_at_least_32_characters_long_for_security
 | 
			
		||||
# Authentication & OIDC (Required)
 | 
			
		||||
AUTH_SECRET=change-this-to-a-strong-secret-key-in-production
 | 
			
		||||
OIDC_ENDPOINT=https://your-oidc-provider.com
 | 
			
		||||
OIDC_CLIENT_ID=your-oidc-client-id
 | 
			
		||||
OIDC_CLIENT_SECRET=your-oidc-client-secret
 | 
			
		||||
 | 
			
		||||
AUTHENTICATION_NECESSARY=false # Always set this to true in prod
 | 
			
		||||
# Auth Scopes - set to true in prod
 | 
			
		||||
AUTHENTICATION_NECESSARY_CONTRIBUTIONS=true
 | 
			
		||||
AUTHENTICATION_NECESSARY_AI=true
 | 
			
		||||
 | 
			
		||||
# Application Configuration (Required)
 | 
			
		||||
PUBLIC_BASE_URL=https://your-domain.com
 | 
			
		||||
NODE_ENV=production
 | 
			
		||||
 | 
			
		||||
# Application
 | 
			
		||||
PUBLIC_BASE_URL=http://localhost:4321
 | 
			
		||||
# AI Service Configuration (Required for AI features)
 | 
			
		||||
AI_MODEL=mistral-large-latest
 | 
			
		||||
AI_API_ENDPOINT=https://api.mistral.ai
 | 
			
		||||
AI_API_KEY=your-mistral-api-key
 | 
			
		||||
AI_RATE_LIMIT_DELAY_MS=1000
 | 
			
		||||
 | 
			
		||||
NODE_ENV=development
 | 
			
		||||
# Git Integration (Required for contributions)
 | 
			
		||||
GIT_REPO_URL=https://git.cc24.dev/mstoeck3/cc24-hub
 | 
			
		||||
GIT_PROVIDER=gitea
 | 
			
		||||
GIT_API_ENDPOINT=https://git.cc24.dev/api/v1
 | 
			
		||||
GIT_API_TOKEN=your-git-api-token
 | 
			
		||||
 | 
			
		||||
# File Upload Configuration (Optional)
 | 
			
		||||
LOCAL_UPLOAD_PATH=./public/uploads
 | 
			
		||||
 | 
			
		||||
# Nextcloud Integration (Optional)
 | 
			
		||||
NEXTCLOUD_ENDPOINT=https://your-nextcloud.com
 | 
			
		||||
NEXTCLOUD_USERNAME=your-username
 | 
			
		||||
NEXTCLOUD_PASSWORD=your-password
 | 
			
		||||
NEXTCLOUD_UPLOAD_PATH=/kb-media
 | 
			
		||||
NEXTCLOUD_PUBLIC_URL=https://your-nextcloud.com/s/
 | 
			
		||||
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@ -81,3 +81,6 @@ src/_data/config.local.yaml
 | 
			
		||||
tmp/
 | 
			
		||||
temp/
 | 
			
		||||
.astro/data-store.json
 | 
			
		||||
.astro/settings.json
 | 
			
		||||
.astro/data-store.json
 | 
			
		||||
.astro/content.d.ts
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										820
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										820
									
								
								README.md
									
									
									
									
									
								
							@ -1,34 +1,78 @@
 | 
			
		||||
# CC24-Hub
 | 
			
		||||
# ForensicPathways
 | 
			
		||||
 | 
			
		||||
Ein kuratiertes Verzeichnis für digitale Forensik- und Incident-Response-Tools mit KI-gestützten Empfehlungen, entwickelt für die Seminargruppe CC24-w1.
 | 
			
		||||
Ein kuratiertes Verzeichnis für Digital Forensics und Incident Response (DFIR) Tools, Methoden und Konzepte mit KI-gestützten Workflow-Empfehlungen.
 | 
			
		||||
 | 
			
		||||
## 🎯 Projektübersicht
 | 
			
		||||
## ✨ Funktionen
 | 
			
		||||
 | 
			
		||||
CC24-Hub bietet eine strukturierte Übersicht über bewährte DFIR-Tools, -Methoden und -Konzepte mit intelligenten Empfehlungsfunktionen. Das Projekt orientiert sich am NIST-Framework (SP 800-86) und kategorisiert nach forensischen Domänen und Untersuchungsphasen.
 | 
			
		||||
### 🎯 Hauptansichten
 | 
			
		||||
- **Kachelansicht (Grid View):** Übersichtliche Kartenansicht aller Tools/Methoden
 | 
			
		||||
- **Matrix-Ansicht:** Interaktive Matrix nach forensischen Domänen und Untersuchungsphasen (NIST Framework)
 | 
			
		||||
- **KI-Empfehlungen:** AI-gestützte Workflow-Empfehlungen basierend auf Szenario-Beschreibungen
 | 
			
		||||
 | 
			
		||||
### Hauptfunktionen
 | 
			
		||||
### 🔍 Navigation & Filterung
 | 
			
		||||
- **Tag-System:** Intelligente Filterung nach Kategorien und Eigenschaften
 | 
			
		||||
- **Volltext-Suche:** Durchsuchen von Namen, Beschreibungen und Tags
 | 
			
		||||
- **Domain/Phase-Filter:** Filterung nach forensischen Bereichen und Ermittlungsphasen
 | 
			
		||||
 | 
			
		||||
- **KI-gestützte Empfehlungen**: Workflow- und Tool-Vorschläge basierend auf forensischen Szenarien
 | 
			
		||||
- **Drei Kategorien**: Software-Tools, forensische Methoden UND Grundlagenkonzepte
 | 
			
		||||
- **Matrix-Ansicht**: Visualisierung nach Domänen × Prozess-Phasen
 | 
			
		||||
- **Erweiterte Filter**: Suche nach Name, Tags, Domäne, Phase, Lizenz
 | 
			
		||||
- **CC24-Server Integration**: Direkte SSO-Links zu gehosteten Instanzen
 | 
			
		||||
- **Knowledgebase**: Erweiterte Dokumentation mit praktischen Erkenntnissen
 | 
			
		||||
- **Konzept-Verlinkung**: Automatische Verknüpfung zwischen Tools und Grundlagenkonzepten
 | 
			
		||||
- **Status-Monitoring**: Live-Überwachung verfügbarer Services
 | 
			
		||||
- **Responsive Design**: Dark/Light Mode, Mobile-optimiert
 | 
			
		||||
### 📚 Inhaltstypen
 | 
			
		||||
- **Software/Tools:** Open Source und proprietäre forensische Software
 | 
			
		||||
- **Methoden:** Bewährte forensische Verfahren und Prozesse
 | 
			
		||||
- **Konzepte:** Grundlegendes Fachwissen und theoretische Grundlagen
 | 
			
		||||
 | 
			
		||||
## 🛠️ Technischer Stack
 | 
			
		||||
### 📖 Knowledgebase
 | 
			
		||||
- **Erweiterte Dokumentation:** Detaillierte Artikel zu Tools und Methoden
 | 
			
		||||
- **Praktische Anleitungen:** Installation, Konfiguration und Best Practices
 | 
			
		||||
- **Markdown-basiert:** Einfache Erstellung und Wartung von Inhalten
 | 
			
		||||
 | 
			
		||||
- **Framework**: [Astro](https://astro.build/) mit Server-Side Rendering
 | 
			
		||||
- **Backend**: Node.js mit API-Routen für KI und Authentifizierung
 | 
			
		||||
- **Styling**: Vanilla CSS mit CSS Custom Properties
 | 
			
		||||
- **Datenformat**: YAML für Tool-/Methoden-/Konzept-Definitionen
 | 
			
		||||
- **KI-Integration**: Mistral AI über OpenAI-kompatible API
 | 
			
		||||
- **Authentifizierung**: OIDC (OpenID Connect) mit JWT-Sessions
 | 
			
		||||
- **Node.js**: >=18.0.0
 | 
			
		||||
### 🤝 Contribution-System
 | 
			
		||||
- **Tool/Methoden-Beiträge:** Webformular für neue Einträge
 | 
			
		||||
- **Knowledgebase-Artikel:** Artikel-Editor mit Datei-Upload
 | 
			
		||||
- **Git-Integration:** Automatische Issue-Erstellung für Review-Prozess
 | 
			
		||||
- **File-Management:** Nextcloud-Integration für Medien-Uploads
 | 
			
		||||
 | 
			
		||||
## 🚀 Installation & Deployment
 | 
			
		||||
### 🔐 Authentifizierung
 | 
			
		||||
- **OIDC-Integration:** Single Sign-On mit OpenID Connect
 | 
			
		||||
- **Berechtigungssteuerung:** Schutz für AI-Features und Contribution-System
 | 
			
		||||
- **Session-Management:** Sichere JWT-basierte Sessions
 | 
			
		||||
 | 
			
		||||
## 🛠 Technische Grundlage
 | 
			
		||||
 | 
			
		||||
- **Framework:** Astro 4.x mit TypeScript
 | 
			
		||||
- **Styling:** CSS Custom Properties mit Dark/Light Mode
 | 
			
		||||
- **API:** Node.js Backend mit Astro API Routes
 | 
			
		||||
- **Datenbank:** YAML-basierte Konfiguration (tools.yaml)
 | 
			
		||||
 | 
			
		||||
## 📋 Voraussetzungen
 | 
			
		||||
 | 
			
		||||
- **Node.js:** Version 18.x oder höher
 | 
			
		||||
- **npm:** Version 8.x oder höher
 | 
			
		||||
- **Nginx:** Für Reverse Proxy (Produktion)
 | 
			
		||||
 | 
			
		||||
## 🔧 Externe Abhängigkeiten (Optional)
 | 
			
		||||
 | 
			
		||||
### OIDC Provider
 | 
			
		||||
- **Zweck:** Benutzerauthentifizierung
 | 
			
		||||
- **Beispiel:** Nextcloud, Keycloak, Auth0
 | 
			
		||||
- **Konfiguration:** `OIDC_ENDPOINT`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`
 | 
			
		||||
 | 
			
		||||
### Nextcloud
 | 
			
		||||
- **Zweck:** File-Upload für Knowledgebase-Beiträge
 | 
			
		||||
- **Features:** Medien-Management, öffentliche Links
 | 
			
		||||
- **Konfiguration:** `NEXTCLOUD_ENDPOINT`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD`
 | 
			
		||||
 | 
			
		||||
### AI Service (Mistral/OpenAI-kompatibel)
 | 
			
		||||
- **Zweck:** KI-gestützte Tool-Empfehlungen
 | 
			
		||||
- **Konfiguration:** `AI_API_ENDPOINT`, `AI_API_KEY`, `AI_MODEL`
 | 
			
		||||
 | 
			
		||||
### Uptime Kuma
 | 
			
		||||
- **Zweck:** Status-Monitoring für gehostete Services
 | 
			
		||||
- **Integration:** Status-Badges in der Service-Übersicht
 | 
			
		||||
 | 
			
		||||
### Git Provider (Gitea/GitHub/GitLab)
 | 
			
		||||
- **Zweck:** Issue-Erstellung für Contributions
 | 
			
		||||
- **Konfiguration:** `GIT_PROVIDER`, `GIT_API_ENDPOINT`, `GIT_API_TOKEN`
 | 
			
		||||
 | 
			
		||||
## 🚀 Installation
 | 
			
		||||
 | 
			
		||||
### Lokale Entwicklung
 | 
			
		||||
 | 
			
		||||
@ -40,6 +84,10 @@ cd cc24-hub
 | 
			
		||||
# Dependencies installieren
 | 
			
		||||
npm install
 | 
			
		||||
 | 
			
		||||
# Umgebungsvariablen konfigurieren
 | 
			
		||||
cp .env.example .env
 | 
			
		||||
# .env bearbeiten (siehe Konfiguration unten)
 | 
			
		||||
 | 
			
		||||
# Development Server starten
 | 
			
		||||
npm run dev
 | 
			
		||||
```
 | 
			
		||||
@ -48,150 +96,128 @@ Die Seite ist dann unter `http://localhost:4321` verfügbar.
 | 
			
		||||
 | 
			
		||||
### Produktions-Deployment
 | 
			
		||||
 | 
			
		||||
#### Voraussetzungen
 | 
			
		||||
#### 1. System vorbereiten
 | 
			
		||||
 | 
			
		||||
- Ubuntu/Debian server
 | 
			
		||||
- Node.js 18+ 
 | 
			
		||||
- Nginx
 | 
			
		||||
- Domain
 | 
			
		||||
- SSL Zertifikat
 | 
			
		||||
```bash
 | 
			
		||||
# System-Updates
 | 
			
		||||
sudo apt update && sudo apt upgrade -y
 | 
			
		||||
 | 
			
		||||
#### Installationsschritte
 | 
			
		||||
# Node.js installieren (Ubuntu/Debian)
 | 
			
		||||
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
 | 
			
		||||
sudo apt-get install -y nodejs
 | 
			
		||||
 | 
			
		||||
##### 1. Vorbereitung
 | 
			
		||||
# Nginx installieren
 | 
			
		||||
sudo apt install nginx -y
 | 
			
		||||
 | 
			
		||||
# Systemd für Service-Management
 | 
			
		||||
sudo systemctl enable nginx
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### 2. Anwendung installieren
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
# Klonen des Repositorys
 | 
			
		||||
git clone https://git.cc24.dev/mstoeck3/cc24-hub
 | 
			
		||||
cd cc24-hub
 | 
			
		||||
sudo git clone https://git.cc24.dev/mstoeck3/cc24-hub /opt/cc24-hub
 | 
			
		||||
cd /opt/cc24-hub
 | 
			
		||||
 | 
			
		||||
# Abhängigkeiten installieren
 | 
			
		||||
npm install
 | 
			
		||||
sudo npm install
 | 
			
		||||
 | 
			
		||||
# Production-Build anstoßen
 | 
			
		||||
npm run build
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
##### 2. Webroot vorbereiten
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
# Webroot erstellen und Berechtigungen setzen
 | 
			
		||||
sudo mkdir -p /var/www/cc24-hub
 | 
			
		||||
sudo chown -R $USER:$USER /var/www/cc24-hub
 | 
			
		||||
 | 
			
		||||
# Build in Webroot kopieren
 | 
			
		||||
sudo cp -r ./dist/* /var/www/cc24-hub/
 | 
			
		||||
sudo cp ./src/data/tools.yaml /var/www/cc24-hub/src/data/
 | 
			
		||||
sudo cp package.json /var/www/cc24-hub/
 | 
			
		||||
 | 
			
		||||
# Prod-Abhängigkeiten installieren
 | 
			
		||||
cd /var/www/cc24-hub
 | 
			
		||||
npm install --omit=dev
 | 
			
		||||
# Production-Build erstellen
 | 
			
		||||
sudo npm run build
 | 
			
		||||
 | 
			
		||||
# Berechtigungen setzen
 | 
			
		||||
sudo chown -R www-data:www-data /var/www/cc24-hub
 | 
			
		||||
sudo chown -R www-data:www-data /opt/cc24-hub
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
##### 3. Umgebungsvariablen setzen
 | 
			
		||||
#### 3. Umgebungsvariablen konfigurieren
 | 
			
		||||
 | 
			
		||||
Erstelle `/var/www/cc24-hub/.env`:
 | 
			
		||||
Erstelle `/opt/cc24-hub/.env`:
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
# AI Konfiguration
 | 
			
		||||
AI_API_ENDPOINT=https://llm.mikoshi.de # hier geeigneten Endpunkt setzen
 | 
			
		||||
AI_API_KEY=your_ai_api_key
 | 
			
		||||
AI_MODEL=mistral/mistral-small-latest # hier geeignetes KI-Modell wählen
 | 
			
		||||
# === GRUNDKONFIGURATION ===
 | 
			
		||||
NODE_ENV=production
 | 
			
		||||
PUBLIC_BASE_URL=https://ihre-domain.de
 | 
			
		||||
 | 
			
		||||
# Authentifizierung ("false" setzen für Tests, oder wenn kostenlose KI verwendet wird)
 | 
			
		||||
# === AI-KONFIGURATION (Optional) ===
 | 
			
		||||
AI_API_ENDPOINT=https://api.mistral.ai/v1
 | 
			
		||||
AI_API_KEY=your_mistral_api_key
 | 
			
		||||
AI_MODEL=mistral-small-latest
 | 
			
		||||
AI_RATE_LIMIT_DELAY_MS=2000
 | 
			
		||||
 | 
			
		||||
# === AUTHENTIFIZIERUNG ===
 | 
			
		||||
AUTHENTICATION_NECESSARY=true
 | 
			
		||||
OIDC_ENDPOINT=https://cloud.cc24.dev
 | 
			
		||||
OIDC_CLIENT_ID=your_oidc_client_id
 | 
			
		||||
OIDC_CLIENT_SECRET=your_oidc_client_secret
 | 
			
		||||
AUTH_SECRET=your_super_secret_jwt_key_min_32_chars
 | 
			
		||||
OIDC_ENDPOINT=https://ihr-oidc-provider.de
 | 
			
		||||
OIDC_CLIENT_ID=cc24-hub-client
 | 
			
		||||
OIDC_CLIENT_SECRET=your_super_secret_client_secret
 | 
			
		||||
AUTH_SECRET=your_jwt_secret_min_32_characters_long
 | 
			
		||||
 | 
			
		||||
# Public Configuration
 | 
			
		||||
PUBLIC_BASE_URL=https://your-domain.com # hier die URL setzen, mit der von außen zugegriffen wird
 | 
			
		||||
# === NEXTCLOUD (Optional) ===
 | 
			
		||||
NEXTCLOUD_ENDPOINT=https://ihre-nextcloud.de
 | 
			
		||||
NEXTCLOUD_USERNAME=cc24-hub-user
 | 
			
		||||
NEXTCLOUD_PASSWORD=nextcloud_app_password
 | 
			
		||||
NEXTCLOUD_UPLOAD_PATH=/kb-media
 | 
			
		||||
NEXTCLOUD_PUBLIC_URL=https://ihre-nextcloud.de/s
 | 
			
		||||
 | 
			
		||||
# === GIT-INTEGRATION (Optional) ===
 | 
			
		||||
GIT_PROVIDER=gitea
 | 
			
		||||
GIT_API_ENDPOINT=https://ihr-git-server.de/api/v1
 | 
			
		||||
GIT_API_TOKEN=your_git_api_token
 | 
			
		||||
GIT_REPO_URL=https://ihr-git-server.de/user/cc24-hub
 | 
			
		||||
 | 
			
		||||
# === UPLOAD-KONFIGURATION ===
 | 
			
		||||
LOCAL_UPLOAD_PATH=/opt/cc24-hub/public/uploads
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
# .env sichern
 | 
			
		||||
sudo chmod 600 /var/www/cc24-hub/.env
 | 
			
		||||
sudo chown www-data:www-data /var/www/cc24-hub/.env
 | 
			
		||||
# Berechtigungen sichern
 | 
			
		||||
sudo chmod 600 /opt/cc24-hub/.env
 | 
			
		||||
sudo chown www-data:www-data /opt/cc24-hub/.env
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
##### 4. Systemd-Service erstellen
 | 
			
		||||
 | 
			
		||||
Create `/etc/systemd/system/cc24-hub.service`:
 | 
			
		||||
 | 
			
		||||
```ini
 | 
			
		||||
[Unit]
 | 
			
		||||
Description=CC24-Hub DFIR Tool Directory
 | 
			
		||||
After=network.target
 | 
			
		||||
Wants=network.target
 | 
			
		||||
 | 
			
		||||
[Service]
 | 
			
		||||
Type=simple
 | 
			
		||||
User=www-data
 | 
			
		||||
Group=www-data
 | 
			
		||||
WorkingDirectory=/var/www/cc24-hub
 | 
			
		||||
ExecStart=/usr/bin/node server/entry.mjs
 | 
			
		||||
Restart=always
 | 
			
		||||
RestartSec=5
 | 
			
		||||
StartLimitInterval=60s
 | 
			
		||||
StartLimitBurst=3
 | 
			
		||||
 | 
			
		||||
# Environment
 | 
			
		||||
Environment=NODE_ENV=production
 | 
			
		||||
Environment=PORT=3000
 | 
			
		||||
 | 
			
		||||
# Security
 | 
			
		||||
NoNewPrivileges=true
 | 
			
		||||
PrivateTmp=true
 | 
			
		||||
ProtectSystem=strict
 | 
			
		||||
ReadWritePaths=/var/www/cc24-hub
 | 
			
		||||
 | 
			
		||||
# Logging
 | 
			
		||||
StandardOutput=journal
 | 
			
		||||
StandardError=journal
 | 
			
		||||
SyslogIdentifier=cc24-hub
 | 
			
		||||
 | 
			
		||||
[Install]
 | 
			
		||||
WantedBy=multi-user.target
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
##### 5. Nginx Reverse Proxy konfigurieren
 | 
			
		||||
#### 4. Nginx konfigurieren
 | 
			
		||||
 | 
			
		||||
Erstelle `/etc/nginx/sites-available/cc24-hub`:
 | 
			
		||||
 | 
			
		||||
```nginx
 | 
			
		||||
server {
 | 
			
		||||
    listen 80;
 | 
			
		||||
    server_name your-domain.com;
 | 
			
		||||
    server_name ihre-domain.de;
 | 
			
		||||
    
 | 
			
		||||
    # Redirect HTTP to HTTPS
 | 
			
		||||
    return 301 https://$server_name$request_uri;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
server {
 | 
			
		||||
    listen 443 ssl http2;
 | 
			
		||||
    server_name your-domain.com;
 | 
			
		||||
 | 
			
		||||
    # SSL Configuration (adjust paths for your certificates)
 | 
			
		||||
    ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
 | 
			
		||||
    ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
 | 
			
		||||
    server_name ihre-domain.de;
 | 
			
		||||
    
 | 
			
		||||
    # SSL Konfiguration (Let's Encrypt empfohlen)
 | 
			
		||||
    ssl_certificate /etc/letsencrypt/live/ihre-domain.de/fullchain.pem;
 | 
			
		||||
    ssl_certificate_key /etc/letsencrypt/live/ihre-domain.de/privkey.pem;
 | 
			
		||||
    
 | 
			
		||||
    # SSL Security
 | 
			
		||||
    ssl_protocols TLSv1.2 TLSv1.3;
 | 
			
		||||
    ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
 | 
			
		||||
    ssl_prefer_server_ciphers off;
 | 
			
		||||
 | 
			
		||||
    # Security Headers
 | 
			
		||||
    add_header X-Frame-Options DENY;
 | 
			
		||||
    add_header X-Content-Type-Options nosniff;
 | 
			
		||||
    add_header X-XSS-Protection "1; mode=block";
 | 
			
		||||
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
 | 
			
		||||
 | 
			
		||||
    # Proxy to Node.js application
 | 
			
		||||
    add_header Referrer-Policy "strict-origin-when-cross-origin";
 | 
			
		||||
    
 | 
			
		||||
    # Static Files
 | 
			
		||||
    location / {
 | 
			
		||||
        proxy_pass http://localhost:3000;
 | 
			
		||||
        try_files $uri $uri/ @nodejs;
 | 
			
		||||
        root /opt/cc24-hub/dist;
 | 
			
		||||
        index index.html;
 | 
			
		||||
        
 | 
			
		||||
        # Cache static assets
 | 
			
		||||
        location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?|ttf)$ {
 | 
			
		||||
            expires 1y;
 | 
			
		||||
            add_header Cache-Control "public, immutable";
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    # API Routes to Node.js
 | 
			
		||||
    location @nodejs {
 | 
			
		||||
        proxy_pass http://localhost:4321;
 | 
			
		||||
        proxy_http_version 1.1;
 | 
			
		||||
        proxy_set_header Upgrade $http_upgrade;
 | 
			
		||||
        proxy_set_header Connection 'upgrade';
 | 
			
		||||
@ -203,360 +229,248 @@ server {
 | 
			
		||||
        proxy_read_timeout 300s;
 | 
			
		||||
        proxy_connect_timeout 75s;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    # Optional: Serve static assets directly (performance optimization)
 | 
			
		||||
    location /_astro/ {
 | 
			
		||||
        proxy_pass http://localhost:3000;
 | 
			
		||||
        expires 1y;
 | 
			
		||||
        add_header Cache-Control "public, immutable";
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    # Upload limit
 | 
			
		||||
    client_max_body_size 50M;
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
##### 6. Daemon starten und Autostart setzen
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
# Enable Nginx site
 | 
			
		||||
# Site aktivieren
 | 
			
		||||
sudo ln -s /etc/nginx/sites-available/cc24-hub /etc/nginx/sites-enabled/
 | 
			
		||||
sudo nginx -t
 | 
			
		||||
sudo systemctl reload nginx
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
# Enable and start CC24-Hub service
 | 
			
		||||
#### 5. Systemd Service einrichten
 | 
			
		||||
 | 
			
		||||
Erstelle `/etc/systemd/system/cc24-hub.service`:
 | 
			
		||||
 | 
			
		||||
```ini
 | 
			
		||||
[Unit]
 | 
			
		||||
Description=ForensicPathways DFIR Guide
 | 
			
		||||
After=network.target nginx.service
 | 
			
		||||
Wants=nginx.service
 | 
			
		||||
 | 
			
		||||
[Service]
 | 
			
		||||
Type=exec
 | 
			
		||||
User=www-data
 | 
			
		||||
Group=www-data
 | 
			
		||||
WorkingDirectory=/opt/cc24-hub
 | 
			
		||||
Environment=NODE_ENV=production
 | 
			
		||||
ExecStart=/usr/bin/node ./dist/server/entry.mjs
 | 
			
		||||
Restart=always
 | 
			
		||||
RestartSec=10
 | 
			
		||||
StandardOutput=journal
 | 
			
		||||
StandardError=journal
 | 
			
		||||
 | 
			
		||||
# Security
 | 
			
		||||
NoNewPrivileges=yes
 | 
			
		||||
PrivateTmp=yes
 | 
			
		||||
ProtectSystem=strict
 | 
			
		||||
ProtectHome=yes
 | 
			
		||||
ReadWritePaths=/opt/cc24-hub
 | 
			
		||||
CapabilityBoundingSet=
 | 
			
		||||
 | 
			
		||||
# Resource Limits
 | 
			
		||||
LimitNOFILE=65536
 | 
			
		||||
MemoryMax=512M
 | 
			
		||||
 | 
			
		||||
[Install]
 | 
			
		||||
WantedBy=multi-user.target
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
# Service aktivieren und starten
 | 
			
		||||
sudo systemctl daemon-reload
 | 
			
		||||
sudo systemctl enable cc24-hub
 | 
			
		||||
sudo systemctl start cc24-hub
 | 
			
		||||
 | 
			
		||||
# Check status
 | 
			
		||||
# Status prüfen
 | 
			
		||||
sudo systemctl status cc24-hub
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
##### 7. Deployment verifizieren
 | 
			
		||||
## 🔧 Konfiguration
 | 
			
		||||
 | 
			
		||||
### Minimalkonfiguration (ohne Auth)
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
# Check application logs
 | 
			
		||||
sudo journalctl -u cc24-hub -f
 | 
			
		||||
 | 
			
		||||
# Check if app is responding
 | 
			
		||||
curl http://localhost:3000
 | 
			
		||||
 | 
			
		||||
# Check external access
 | 
			
		||||
curl https://your-domain.com
 | 
			
		||||
# Nur für Tests geeignet
 | 
			
		||||
AUTHENTICATION_NECESSARY=false
 | 
			
		||||
PUBLIC_BASE_URL=http://localhost:4321
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### OIDC Konfigurieren
 | 
			
		||||
### Tools-Datenbank
 | 
			
		||||
 | 
			
		||||
Nextcloud OIDC Einstellungen (sollte auch mit anderen OIDC-Anwendungen klappen):
 | 
			
		||||
- **Redirect URI**: `https://your-domain.com/auth/callback`
 | 
			
		||||
- **Logout URI**: `https://your-domain.com`
 | 
			
		||||
 | 
			
		||||
## 🔧 Datenformat & Kategorien
 | 
			
		||||
 | 
			
		||||
Die CC24-Hub verwaltet drei Kategorien von Einträgen in `src/data/tools.yaml`:
 | 
			
		||||
 | 
			
		||||
### 1. Software-Tools
 | 
			
		||||
Die Tools werden in `src/data/tools.yaml` verwaltet. Vollständiges Beispiel:
 | 
			
		||||
 | 
			
		||||
```yaml
 | 
			
		||||
tools:
 | 
			
		||||
  - name: "Autopsy"
 | 
			
		||||
    icon: "📦"
 | 
			
		||||
    type: "software"
 | 
			
		||||
    description: "Die führende Open-Source-Alternative zu kommerziellen Forensik-Suiten"
 | 
			
		||||
    domains: ["incident-response", "law-enforcement"]
 | 
			
		||||
    phases: ["examination", "analysis"]
 | 
			
		||||
    platforms: ["Windows", "Linux"]
 | 
			
		||||
    skillLevel: "intermediate"
 | 
			
		||||
    accessType: "download"
 | 
			
		||||
    url: "https://www.autopsy.com/"
 | 
			
		||||
    projectUrl: "https://autopsy.cc24.dev"  # CC24-Server URL (optional)
 | 
			
		||||
    license: "Apache 2.0"
 | 
			
		||||
    knowledgebase: true  # Hat erweiterte Dokumentation
 | 
			
		||||
    related_concepts: ["Hash Functions & Digital Signatures", "SQL Query Fundamentals"]  # Verknüpfung zu Konzepten
 | 
			
		||||
    tags: ["gui", "filesystem", "timeline-analysis"]
 | 
			
		||||
    statusUrl: "https://status.example.com/badge/1/status"  # Status-Badge URL (optional)
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### 2. Forensische Methoden
 | 
			
		||||
 | 
			
		||||
```yaml
 | 
			
		||||
  - name: "Live Memory Acquisition Procedure"
 | 
			
		||||
    icon: "📋"
 | 
			
		||||
    type: "method"
 | 
			
		||||
    description: "Standardisiertes Verfahren zur forensisch korrekten Akquisition des Arbeitsspeichers"
 | 
			
		||||
    domains: ["incident-response", "law-enforcement"]
 | 
			
		||||
    phases: ["data-collection"]
 | 
			
		||||
    platforms: []  # Methoden haben keine Plattformen
 | 
			
		||||
    skillLevel: "advanced"
 | 
			
		||||
    accessType: null
 | 
			
		||||
    url: "https://www.nist.gov/publications/guide-integrating-forensic-techniques"
 | 
			
		||||
    projectUrl: null
 | 
			
		||||
    license: null
 | 
			
		||||
  - name: Autopsy
 | 
			
		||||
    type: software  # software|method|concept
 | 
			
		||||
    description: >-
 | 
			
		||||
      Die führende Open-Source-Alternative zu kommerziellen Forensik-Suiten mit
 | 
			
		||||
      intuitiver grafischer Oberfläche. Besonders stark in der Timeline-Analyse,
 | 
			
		||||
      Keyword-Suche und dem Carving gelöschter Dateien. Die modulare
 | 
			
		||||
      Plugin-Architektur erlaubt Erweiterungen für spezielle
 | 
			
		||||
      Untersuchungsszenarien.
 | 
			
		||||
    icon: 📦
 | 
			
		||||
    skillLevel: intermediate  # novice|beginner|intermediate|advanced|expert
 | 
			
		||||
    url: https://www.autopsy.com/
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - malware-analysis
 | 
			
		||||
      - mobile-forensics
 | 
			
		||||
      - cloud-forensics
 | 
			
		||||
    phases:
 | 
			
		||||
      - examination
 | 
			
		||||
      - analysis
 | 
			
		||||
    platforms:
 | 
			
		||||
      - Windows
 | 
			
		||||
      - Linux
 | 
			
		||||
    related_concepts:
 | 
			
		||||
      - SQL Query Fundamentals
 | 
			
		||||
      - Hash Functions & Digital Signatures
 | 
			
		||||
    accessType: download  # download|web|api|cli|service
 | 
			
		||||
    license: Apache 2.0
 | 
			
		||||
    knowledgebase: false  # true für erweiterte Dokumentation
 | 
			
		||||
    tags:
 | 
			
		||||
      - gui
 | 
			
		||||
      - filesystem
 | 
			
		||||
      - timeline-analysis
 | 
			
		||||
      - carving
 | 
			
		||||
      - artifact-extraction
 | 
			
		||||
      - keyword-search
 | 
			
		||||
    # Optional: Für gehostete Services
 | 
			
		||||
    projectUrl: https://autopsy.ihre-domain.de
 | 
			
		||||
    statusUrl: https://status.ihre-domain.de/api/badge/1/status
 | 
			
		||||
    
 | 
			
		||||
  # Beispiel Methode
 | 
			
		||||
  - name: Live Response Methodology
 | 
			
		||||
    type: method
 | 
			
		||||
    description: >-
 | 
			
		||||
      Strukturierte Vorgehensweise zur Sammlung volatiler Daten
 | 
			
		||||
      von laufenden Systemen ohne Shutdown.
 | 
			
		||||
    icon: 📋
 | 
			
		||||
    skillLevel: advanced
 | 
			
		||||
    url: https://www.sans.org/white-papers/live-response/
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
    phases:
 | 
			
		||||
      - data-collection
 | 
			
		||||
    related_concepts:
 | 
			
		||||
      - Memory Forensics Fundamentals
 | 
			
		||||
    tags:
 | 
			
		||||
      - volatile-data
 | 
			
		||||
      - live-analysis
 | 
			
		||||
      - methodology
 | 
			
		||||
    knowledgebase: true
 | 
			
		||||
    
 | 
			
		||||
  # Beispiel Konzept
 | 
			
		||||
  - name: Hash Functions & Digital Signatures
 | 
			
		||||
    type: concept
 | 
			
		||||
    description: >-
 | 
			
		||||
      Kryptographische Grundlagen für Datenintegrität und
 | 
			
		||||
      Authentifizierung in der digitalen Forensik.
 | 
			
		||||
    icon: 🔐
 | 
			
		||||
    skillLevel: intermediate
 | 
			
		||||
    url: https://en.wikipedia.org/wiki/Cryptographic_hash_function
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - malware-analysis
 | 
			
		||||
    phases:
 | 
			
		||||
      - data-collection
 | 
			
		||||
      - examination
 | 
			
		||||
    tags:
 | 
			
		||||
      - cryptography
 | 
			
		||||
      - data-integrity
 | 
			
		||||
      - evidence-preservation
 | 
			
		||||
    knowledgebase: false
 | 
			
		||||
    related_concepts: null  # Können optional Konzepte verknüpfen
 | 
			
		||||
    tags: ["memory-acquisition", "volatile-evidence", "procedure"]
 | 
			
		||||
 | 
			
		||||
# Konfiguration der Domänen
 | 
			
		||||
domains:
 | 
			
		||||
  - id: incident-response
 | 
			
		||||
    name: Incident Response & Breach-Untersuchung
 | 
			
		||||
  - id: static-investigations
 | 
			
		||||
    name: Datenträgerforensik & Ermittlungen
 | 
			
		||||
  - id: malware-analysis
 | 
			
		||||
    name: Malware-Analyse & Reverse Engineering
 | 
			
		||||
  - id: mobile-forensics
 | 
			
		||||
    name: Mobile Geräte & App-Forensik
 | 
			
		||||
  - id: cloud-forensics
 | 
			
		||||
    name: Cloud & Virtuelle Umgebungen
 | 
			
		||||
 | 
			
		||||
# Konfiguration der Phasen (NIST Framework)
 | 
			
		||||
phases:
 | 
			
		||||
  - id: data-collection
 | 
			
		||||
    name: Datensammlung
 | 
			
		||||
    description: Imaging, Acquisition, Remote Collection Tools
 | 
			
		||||
  - id: examination
 | 
			
		||||
    name: Auswertung
 | 
			
		||||
    description: Parsing, Extraction, Initial Analysis Tools
 | 
			
		||||
  - id: analysis
 | 
			
		||||
    name: Analyse
 | 
			
		||||
    description: Deep Analysis, Correlation, Visualization Tools
 | 
			
		||||
  - id: reporting
 | 
			
		||||
    name: Bericht & Präsentation
 | 
			
		||||
    description: Documentation, Visualization, Presentation Tools
 | 
			
		||||
 | 
			
		||||
# Domänenübergreifende Kategorien
 | 
			
		||||
domain-agnostic-software:
 | 
			
		||||
  - id: collaboration-general
 | 
			
		||||
    name: Übergreifend & Kollaboration
 | 
			
		||||
    description: Cross-cutting tools and collaboration platforms
 | 
			
		||||
  - id: specific-os
 | 
			
		||||
    name: Betriebssysteme
 | 
			
		||||
    description: Operating Systems which focus on forensics
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### 3. Grundlagenkonzepte (NEU)
 | 
			
		||||
## 📦 Updates
 | 
			
		||||
 | 
			
		||||
```yaml
 | 
			
		||||
  - name: "Regular Expressions (Regex)"
 | 
			
		||||
    icon: "🔤"
 | 
			
		||||
    type: "concept"
 | 
			
		||||
    description: "Pattern matching language for searching, extracting, and manipulating text"
 | 
			
		||||
    domains: ["incident-response", "malware-analysis"]
 | 
			
		||||
    phases: ["examination", "analysis"]
 | 
			
		||||
    platforms: []  # Konzepte haben keine Plattformen
 | 
			
		||||
    skillLevel: "intermediate"
 | 
			
		||||
    accessType: null
 | 
			
		||||
    url: "https://regexr.com/"
 | 
			
		||||
    projectUrl: null
 | 
			
		||||
    license: null
 | 
			
		||||
    knowledgebase: true  # Erweiterte Erklärung in Knowledgebase
 | 
			
		||||
    related_concepts: null  # Konzepte verweisen nicht auf andere Konzepte
 | 
			
		||||
    tags: ["pattern-matching", "text-processing", "log-analysis"]
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Verfügbare Kategorien
 | 
			
		||||
 | 
			
		||||
**Domänen:**
 | 
			
		||||
- `incident-response` - Incident Response & Breach-Untersuchung
 | 
			
		||||
- `law-enforcement` - Strafverfolgung & Kriminalermittlung  
 | 
			
		||||
- `malware-analysis` - Malware-Analyse & Reverse Engineering
 | 
			
		||||
- `fraud-investigation` - Betrugs- & Finanzkriminalität
 | 
			
		||||
- `network-forensics` - Netzwerk-Forensik & Traffic-Analyse
 | 
			
		||||
- `mobile-forensics` - Mobile Geräte & App-Forensik
 | 
			
		||||
- `cloud-forensics` - Cloud & Virtuelle Umgebungen
 | 
			
		||||
- `ics-forensics` - Industrielle Kontrollsysteme (ICS/SCADA)
 | 
			
		||||
 | 
			
		||||
**Phasen (NIST SP 800-86):**
 | 
			
		||||
- `data-collection` - Datensammlung
 | 
			
		||||
- `examination` - Auswertung  
 | 
			
		||||
- `analysis` - Analyse
 | 
			
		||||
- `reporting` - Bericht & Präsentation
 | 
			
		||||
 | 
			
		||||
**Domain-agnostic Kategorien:**
 | 
			
		||||
- `collaboration-general` - Übergreifend & Kollaboration
 | 
			
		||||
- `specific-os` - Betriebssysteme
 | 
			
		||||
 | 
			
		||||
## 📚 Knowledgebase-System
 | 
			
		||||
 | 
			
		||||
### Erweiterte Dokumentation erstellen
 | 
			
		||||
 | 
			
		||||
Die Knowledgebase bietet detaillierte Artikel für Tools, Methoden und Konzepte. So erstellen Sie neue Einträge:
 | 
			
		||||
 | 
			
		||||
#### 1. Knowledgebase-Flag setzen
 | 
			
		||||
 | 
			
		||||
Setzen Sie in `src/data/tools.yaml` das Flag:
 | 
			
		||||
```yaml
 | 
			
		||||
knowledgebase: true
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### 2. Artikel-Datei erstellen
 | 
			
		||||
 | 
			
		||||
Erstellen Sie eine Markdown-Datei in `src/content/knowledgebase/`:
 | 
			
		||||
 | 
			
		||||
**Dateiname-Schema:** `[tool-name-slug].md`
 | 
			
		||||
 | 
			
		||||
**Beispiel:** `src/content/knowledgebase/autopsy.md`
 | 
			
		||||
 | 
			
		||||
```markdown
 | 
			
		||||
---
 | 
			
		||||
title: "Autopsy - Umfassende Forensik-Suite"
 | 
			
		||||
tool_name: "Autopsy"
 | 
			
		||||
description: "Detaillierte Anleitung und Best Practices für Autopsy"
 | 
			
		||||
last_updated: 2024-01-15
 | 
			
		||||
author: "CC24-Team"
 | 
			
		||||
difficulty: "intermediate"
 | 
			
		||||
categories: ["filesystem-analysis", "timeline-analysis"]
 | 
			
		||||
tags: ["gui", "windows", "linux", "open-source"]
 | 
			
		||||
sections:
 | 
			
		||||
  overview: true
 | 
			
		||||
  installation: true
 | 
			
		||||
  configuration: true
 | 
			
		||||
  usage_examples: true
 | 
			
		||||
  best_practices: true
 | 
			
		||||
  troubleshooting: true
 | 
			
		||||
  advanced_topics: false
 | 
			
		||||
review_status: "published"
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
# Übersicht
 | 
			
		||||
 | 
			
		||||
Autopsy ist eine grafische Benutzeroberfläche für The Sleuth Kit (TSK) und bietet...
 | 
			
		||||
 | 
			
		||||
## Installation
 | 
			
		||||
 | 
			
		||||
### Windows
 | 
			
		||||
1. Download der neuesten Version von [autopsy.com](https://www.autopsy.com/)
 | 
			
		||||
2. Ausführung des Installers mit Administratorrechten
 | 
			
		||||
3. ...
 | 
			
		||||
 | 
			
		||||
## Konfiguration
 | 
			
		||||
 | 
			
		||||
### Grundeinstellungen
 | 
			
		||||
- Arbeitsverzeichnis festlegen
 | 
			
		||||
- Hash-Algorithmen auswählen
 | 
			
		||||
- ...
 | 
			
		||||
 | 
			
		||||
## Verwendungsbeispiele
 | 
			
		||||
 | 
			
		||||
### Fall 1: Gelöschte Dateien wiederherstellen
 | 
			
		||||
1. Neuen Fall erstellen
 | 
			
		||||
2. Image hinzufügen
 | 
			
		||||
3. ...
 | 
			
		||||
 | 
			
		||||
## Best Practices
 | 
			
		||||
 | 
			
		||||
- Immer Hash-Verifikation durchführen
 | 
			
		||||
- Regelmäßige Backups der Case-Datenbank
 | 
			
		||||
- ...
 | 
			
		||||
 | 
			
		||||
## Troubleshooting
 | 
			
		||||
 | 
			
		||||
### Problem: Autopsy startet nicht
 | 
			
		||||
**Lösung:** Java-Version überprüfen...
 | 
			
		||||
 | 
			
		||||
### Problem: Langsame Performance
 | 
			
		||||
**Lösung:** RAM-Zuteilung erhöhen...
 | 
			
		||||
 | 
			
		||||
## Weiterführende Themen
 | 
			
		||||
 | 
			
		||||
- Integration mit externen Tools
 | 
			
		||||
- Custom Modules entwickeln
 | 
			
		||||
- ...
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
#### 3. Schema-Validierung
 | 
			
		||||
 | 
			
		||||
Das System validiert automatisch folgende Felder:
 | 
			
		||||
 | 
			
		||||
**Pflichtfelder:**
 | 
			
		||||
- `title`: Anzeigename des Artikels
 | 
			
		||||
- `tool_name`: Exakter Name aus tools.yaml
 | 
			
		||||
- `description`: Kurze Beschreibung
 | 
			
		||||
- `last_updated`: Datum der letzten Aktualisierung
 | 
			
		||||
- `difficulty`: `novice|beginner|intermediate|advanced|expert`
 | 
			
		||||
 | 
			
		||||
**Optionale Felder:**
 | 
			
		||||
- `author`: Standard "CC24-Team"
 | 
			
		||||
- `categories`: Array von Kategorien
 | 
			
		||||
- `tags`: Array von Tags
 | 
			
		||||
- `sections`: Welche Abschnitte enthalten sind
 | 
			
		||||
- `review_status`: `draft|review|published` (Standard: `published`)
 | 
			
		||||
 | 
			
		||||
#### 4. Automatische Verlinkung
 | 
			
		||||
 | 
			
		||||
- Artikel sind automatisch über `/knowledgebase/[tool-slug]` erreichbar
 | 
			
		||||
- Links werden automatisch in Tool-Details angezeigt
 | 
			
		||||
- Suchfunktion indiziert Artikel-Inhalte
 | 
			
		||||
 | 
			
		||||
### Konzept-Verlinkung
 | 
			
		||||
 | 
			
		||||
Tools können mit Grundlagenkonzepten verknüpft werden:
 | 
			
		||||
 | 
			
		||||
```yaml
 | 
			
		||||
# Tool-Definition
 | 
			
		||||
- name: "Autopsy"
 | 
			
		||||
  related_concepts: ["Hash Functions & Digital Signatures", "SQL Query Fundamentals"]
 | 
			
		||||
  
 | 
			
		||||
# Konzept-Definition  
 | 
			
		||||
- name: "Hash Functions & Digital Signatures"
 | 
			
		||||
  type: "concept"
 | 
			
		||||
  description: "Cryptographic principles for data integrity verification"
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
Die KI-Empfehlungen nutzen diese Verlinkungen für Hintergrundwissen-Empfehlungen.
 | 
			
		||||
 | 
			
		||||
## 🤖 KI-Integration
 | 
			
		||||
 | 
			
		||||
### Workflow-Empfehlungen
 | 
			
		||||
Beschreibung forensischer Szenarien für maßgeschneiderte Workflows mit phasenbasierten Tool-Empfehlungen und Prioritätsbewertung.
 | 
			
		||||
 | 
			
		||||
### Tool-spezifische Empfehlungen  
 | 
			
		||||
Konkrete Tool-Vorschläge für spezifische Probleme mit detaillierten Begründungen, Implementierungsansätzen und Vor-/Nachteilen.
 | 
			
		||||
 | 
			
		||||
### Konzept-Integration
 | 
			
		||||
Die KI berücksichtigt automatisch verknüpfte Grundlagenkonzepte und empfiehlt relevantes Hintergrundwissen.
 | 
			
		||||
 | 
			
		||||
**API-Endpunkt:** `/api/ai/query`
 | 
			
		||||
- **Rate Limiting**: 10 Anfragen pro Minute pro Benutzer
 | 
			
		||||
- **Modi**: `workflow` (Szenario-basiert) oder `tool` (Problem-spezifisch)
 | 
			
		||||
- **Authentifizierung**: Optional konfigurierbar via `AUTHENTICATION_NECESSARY`
 | 
			
		||||
 | 
			
		||||
## 🔐 Authentifizierung
 | 
			
		||||
 | 
			
		||||
OIDC-Integration mit JWT-Sessions:
 | 
			
		||||
- **6 Stunden Gültigkeit**
 | 
			
		||||
- **HTTP-Only Cookies** mit CSRF-Schutz
 | 
			
		||||
- **Nextcloud/Keycloak kompatibel**
 | 
			
		||||
 | 
			
		||||
**Relevante Dateien:**
 | 
			
		||||
- `src/utils/auth.ts` - Kern-Authentifizierungslogik
 | 
			
		||||
- `src/pages/api/auth/` - Auth-API-Endpunkte
 | 
			
		||||
 | 
			
		||||
## 📁 Datei-Referenz
 | 
			
		||||
 | 
			
		||||
### Wichtige Konfigurationsdateien
 | 
			
		||||
- `src/data/tools.yaml` - Hauptdatenbank für Tools, Methoden und Konzepte
 | 
			
		||||
- `src/content/config.ts` - Schema für Knowledgebase-Artikel
 | 
			
		||||
- `src/utils/dataService.js` - Datenverarbeitungslogik
 | 
			
		||||
- `src/styles/global.css` - Zentrale Stylesheet-Definitionen
 | 
			
		||||
 | 
			
		||||
### Content-Verzeichnisse
 | 
			
		||||
- `src/content/knowledgebase/` - Knowledgebase-Artikel (Markdown)
 | 
			
		||||
- `src/components/` - Wiederverwendbare UI-Komponenten
 | 
			
		||||
- `src/pages/api/` - Backend-API-Endpunkte
 | 
			
		||||
 | 
			
		||||
## 🤝 Beitragen
 | 
			
		||||
 | 
			
		||||
### Tool/Methode/Konzept hinzufügen
 | 
			
		||||
 | 
			
		||||
**Option 1: Direkte YAML-Bearbeitung**
 | 
			
		||||
1. Fork des Repositories erstellen
 | 
			
		||||
2. `src/data/tools.yaml` bearbeiten
 | 
			
		||||
3. Bei Bedarf Knowledgebase-Artikel erstellen
 | 
			
		||||
4. Pull Request mit Beschreibung erstellen
 | 
			
		||||
 | 
			
		||||
**Option 2: Web-Editor verwenden**
 | 
			
		||||
1. YAML-Editor öffnen (`/dfir_yaml_editor.html`)
 | 
			
		||||
2. Eintrag hinzufügen (Tool/Methode/Konzept)
 | 
			
		||||
3. YAML exportieren und in Pull Request einreichen
 | 
			
		||||
 | 
			
		||||
### Knowledgebase-Artikel erweitern
 | 
			
		||||
 | 
			
		||||
1. Tool in `tools.yaml` identifizieren
 | 
			
		||||
2. `knowledgebase: true` setzen
 | 
			
		||||
3. Artikel in `src/content/knowledgebase/[slug].md` erstellen
 | 
			
		||||
4. Schema-Validierung beachten
 | 
			
		||||
5. Pull Request einreichen
 | 
			
		||||
 | 
			
		||||
### Korrekturen & Verbesserungen
 | 
			
		||||
 | 
			
		||||
- Bug Reports und Feature Requests über Issues melden
 | 
			
		||||
- Code-Beiträge über Pull Requests willkommen
 | 
			
		||||
- Dokumentation und Übersetzungen erwünscht
 | 
			
		||||
 | 
			
		||||
## 🐛 Troubleshooting
 | 
			
		||||
 | 
			
		||||
**KI-Empfehlungen funktionieren nicht:**
 | 
			
		||||
- `.env` Datei korrekt konfiguriert?
 | 
			
		||||
- `AUTHENTICATION_NECESSARY=false` für Tests setzen
 | 
			
		||||
- API-Endpoint erreichbar?
 | 
			
		||||
 | 
			
		||||
**Authentifizierung schlägt fehl:**
 | 
			
		||||
- OIDC-Endpoints korrekt?
 | 
			
		||||
- Redirect-URIs im OIDC-Provider registriert?
 | 
			
		||||
- `AUTH_SECRET` mindestens 32 Zeichen?
 | 
			
		||||
 | 
			
		||||
**Knowledgebase-Artikel werden nicht angezeigt:**
 | 
			
		||||
- `knowledgebase: true` in tools.yaml gesetzt?
 | 
			
		||||
- Markdown-Datei existiert in `src/content/knowledgebase/`?
 | 
			
		||||
- Schema-Validierung erfolgreich?
 | 
			
		||||
 | 
			
		||||
**Logs prüfen:**
 | 
			
		||||
```bash
 | 
			
		||||
# Anwendungs-Logs
 | 
			
		||||
sudo journalctl -u cc24-hub -f
 | 
			
		||||
# Repository aktualisieren
 | 
			
		||||
cd /opt/cc24-hub
 | 
			
		||||
sudo git pull
 | 
			
		||||
 | 
			
		||||
# Development-Modus
 | 
			
		||||
npm run dev
 | 
			
		||||
# Dependencies aktualisieren
 | 
			
		||||
sudo npm install
 | 
			
		||||
 | 
			
		||||
# Rebuild
 | 
			
		||||
sudo npm run build
 | 
			
		||||
 | 
			
		||||
# Service neustarten
 | 
			
		||||
sudo systemctl restart cc24-hub
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## 💾 Backup
 | 
			
		||||
 | 
			
		||||
Wichtige Dateien für Backup:
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
/opt/cc24-hub/src/data/tools.yaml
 | 
			
		||||
/opt/cc24-hub/.env
 | 
			
		||||
/etc/nginx/sites-available/cc24-hub
 | 
			
		||||
/etc/systemd/system/cc24-hub.service
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## 🤝 Beiträge
 | 
			
		||||
 | 
			
		||||
Contributions sind willkommen! Bitte:
 | 
			
		||||
 | 
			
		||||
1. Issue im Repository erstellen
 | 
			
		||||
2. Feature-Branch erstellen
 | 
			
		||||
3. Pull Request öffnen
 | 
			
		||||
4. Tests durchführen
 | 
			
		||||
 | 
			
		||||
## 📞 Support
 | 
			
		||||
 | 
			
		||||
Bei Problemen oder Fragen:
 | 
			
		||||
 | 
			
		||||
- **Issues:** [Repository Issues](https://git.cc24.dev/mstoeck3/cc24-hub/issues)
 | 
			
		||||
- **Dokumentation:** Siehe `/knowledgebase` auf der Website
 | 
			
		||||
 | 
			
		||||
## 📄 Lizenz
 | 
			
		||||
 | 
			
		||||
Dieses Projekt steht unter der **BSD-3-Clause** Lizenz.
 | 
			
		||||
@ -14,5 +14,6 @@ export default defineConfig({
 | 
			
		||||
  server: {
 | 
			
		||||
    port: 4321,
 | 
			
		||||
    host: true
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
  },
 | 
			
		||||
  allowImportingTsExtensions: true
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@ -7,20 +7,19 @@
 | 
			
		||||
    "start": "astro dev",
 | 
			
		||||
    "build": "astro build",
 | 
			
		||||
    "preview": "astro preview",
 | 
			
		||||
    "astro": "astro",
 | 
			
		||||
    "check:health": "curl -f http://localhost:4321/health || exit 1"
 | 
			
		||||
    "astro": "astro"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@astrojs/node": "^9.3.0",
 | 
			
		||||
    "astro": "^5.12.0",
 | 
			
		||||
    "cookie": "^0.6.0",
 | 
			
		||||
    "astro": "^5.12.3",
 | 
			
		||||
    "cookie": "^1.0.2",
 | 
			
		||||
    "dotenv": "^16.4.5",
 | 
			
		||||
    "jose": "^5.2.0",
 | 
			
		||||
    "js-yaml": "^4.1.0",
 | 
			
		||||
    "jsonwebtoken": "^9.0.2",
 | 
			
		||||
    "zod": "^3.25.76"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@types/cookie": "^0.6.0",
 | 
			
		||||
    "@types/js-yaml": "^4.0.9"
 | 
			
		||||
  },
 | 
			
		||||
  "engines": {
 | 
			
		||||
 | 
			
		||||
@ -64,7 +64,7 @@ const domainAgnosticSoftware = data['domain-agnostic-software'] || [];
 | 
			
		||||
            <line x1="12" y1="8" x2="12" y2="12"/>
 | 
			
		||||
            <line x1="12" y1="16" x2="12.01" y2="16"/>
 | 
			
		||||
          </svg>
 | 
			
		||||
          Ihre Anfrage wird an mistral.ai übertragen und unterliegt deren 
 | 
			
		||||
          Ihre Anfrage wird über die kostenlose API von mistral.ai übertragen, wird für KI-Training verwendet und unterliegt deren 
 | 
			
		||||
          <a href="https://mistral.ai/privacy-policy/" target="_blank" rel="noopener noreferrer" style="color: var(--color-primary); text-decoration: underline;">Datenschutzrichtlinien</a>
 | 
			
		||||
        </p>
 | 
			
		||||
      </div>
 | 
			
		||||
@ -81,7 +81,8 @@ const domainAgnosticSoftware = data['domain-agnostic-software'] || [];
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
  <!-- This should be your loading section in AIQueryInterface.astro -->
 | 
			
		||||
  <!-- Loading State -->
 | 
			
		||||
  <div id="ai-loading" class="ai-loading" style="display: none; text-align: center; padding: 2rem;">
 | 
			
		||||
    <div style="display: inline-block; margin-bottom: 1rem;">
 | 
			
		||||
@ -92,6 +93,32 @@ const domainAgnosticSoftware = data['domain-agnostic-software'] || [];
 | 
			
		||||
      </svg>
 | 
			
		||||
    </div>
 | 
			
		||||
    <p id="loading-text" style="color: var(--color-text-secondary);">Analysiere Szenario und generiere Empfehlungen...</p>
 | 
			
		||||
    
 | 
			
		||||
    <!-- Queue Status Display - THIS SECTION SHOULD BE PRESENT -->
 | 
			
		||||
    <div id="queue-status" style="margin-top: 1rem; padding: 1rem; background-color: var(--color-bg-secondary); border-radius: 0.5rem; border: 1px solid var(--color-border); display: none;">
 | 
			
		||||
      <div style="display: flex; align-items: center; justify-content: center; gap: 1rem; margin-bottom: 0.75rem;">
 | 
			
		||||
        <div style="display: flex; align-items: center; gap: 0.5rem;">
 | 
			
		||||
          <div id="queue-position-badge" style="width: 24px; height: 24px; background-color: var(--color-primary); color: white; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 0.875rem;">1</div>
 | 
			
		||||
          <span style="font-weight: 500;">Position in Warteschlange</span>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div style="font-size: 0.875rem; color: var(--color-text-secondary); text-align: center;">
 | 
			
		||||
        <div id="queue-length-info" style="margin-bottom: 0.5rem;">
 | 
			
		||||
          <span id="queue-length">0</span> Anfrage(n) in der Warteschlange
 | 
			
		||||
        </div>
 | 
			
		||||
        <div id="estimated-time-info">
 | 
			
		||||
          Geschätzte Wartezeit: <span id="estimated-time">--</span>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div id="task-id-info" style="margin-top: 0.5rem; font-family: monospace; font-size: 0.75rem; opacity: 0.7;">
 | 
			
		||||
          Task-ID: <span id="current-task-id">--</span>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      
 | 
			
		||||
      <!-- Progress bar -->
 | 
			
		||||
      <div style="margin-top: 1rem; background-color: var(--color-bg-tertiary); border-radius: 0.25rem; height: 4px; overflow: hidden;">
 | 
			
		||||
        <div id="queue-progress" style="height: 100%; background-color: var(--color-primary); width: 0%; transition: width 0.3s ease;"></div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <!-- Error State -->
 | 
			
		||||
@ -240,86 +267,161 @@ document.addEventListener('DOMContentLoaded', () => {
 | 
			
		||||
  aiInput.addEventListener('input', updateCharacterCount);
 | 
			
		||||
  updateCharacterCount();
 | 
			
		||||
 | 
			
		||||
  // Submit handler
 | 
			
		||||
  const handleSubmit = async () => {
 | 
			
		||||
    const query = aiInput.value.trim();
 | 
			
		||||
    
 | 
			
		||||
    if (!query) {
 | 
			
		||||
      alert('Bitte geben Sie eine Beschreibung ein.');
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (query.length < 10) {
 | 
			
		||||
      alert('Bitte geben Sie eine detailliertere Beschreibung ein (mindestens 10 Zeichen).');
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Hide previous results and errors
 | 
			
		||||
    aiResults.style.display = 'none';
 | 
			
		||||
    aiError.style.display = 'none';
 | 
			
		||||
    aiLoading.style.display = 'block';
 | 
			
		||||
    
 | 
			
		||||
    // Disable submit button
 | 
			
		||||
    aiSubmitBtn.disabled = true;
 | 
			
		||||
    submitBtnText.textContent = currentMode === 'workflow' ? 'Generiere Empfehlungen...' : 'Suche passende Methode...';
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      const response = await fetch('/api/ai/query', {
 | 
			
		||||
        method: 'POST',
 | 
			
		||||
        headers: {
 | 
			
		||||
          'Content-Type': 'application/json',
 | 
			
		||||
        },
 | 
			
		||||
        body: JSON.stringify({ 
 | 
			
		||||
          query,
 | 
			
		||||
          mode: currentMode 
 | 
			
		||||
        })
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const data = await response.json();
 | 
			
		||||
 | 
			
		||||
      if (!response.ok) {
 | 
			
		||||
        throw new Error(data.error || `HTTP ${response.status}`);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (!data.success) {
 | 
			
		||||
        throw new Error(data.error || 'Unknown error');
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Store recommendation for restoration
 | 
			
		||||
      currentRecommendation = data.recommendation;
 | 
			
		||||
  // Submit handler with enhanced queue feedback
 | 
			
		||||
    const handleSubmit = async () => {
 | 
			
		||||
      const query = aiInput.value.trim();
 | 
			
		||||
      
 | 
			
		||||
      // Display results based on mode
 | 
			
		||||
      if (currentMode === 'workflow') {
 | 
			
		||||
        displayWorkflowResults(data.recommendation, query);
 | 
			
		||||
      } else {
 | 
			
		||||
        displayToolResults(data.recommendation, query);
 | 
			
		||||
      if (!query) {
 | 
			
		||||
        alert('Bitte geben Sie eine Beschreibung ein.');
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (query.length < 10) {
 | 
			
		||||
        alert('Bitte geben Sie eine detailliertere Beschreibung ein (mindestens 10 Zeichen).');
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Generate task ID for tracking
 | 
			
		||||
      const taskId = `ai_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`;
 | 
			
		||||
 | 
			
		||||
      // Hide previous results and errors
 | 
			
		||||
      aiResults.style.display = 'none';
 | 
			
		||||
      aiError.style.display = 'none';
 | 
			
		||||
      aiLoading.style.display = 'block';
 | 
			
		||||
      
 | 
			
		||||
      // Show queue status section
 | 
			
		||||
      const queueStatus = document.getElementById('queue-status');
 | 
			
		||||
      const taskIdDisplay = document.getElementById('current-task-id');
 | 
			
		||||
      if (queueStatus && taskIdDisplay) {
 | 
			
		||||
        queueStatus.style.display = 'block';
 | 
			
		||||
        taskIdDisplay.textContent = taskId;
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      aiLoading.style.display = 'none';
 | 
			
		||||
      aiResults.style.display = 'block';
 | 
			
		||||
      // Disable submit button
 | 
			
		||||
      aiSubmitBtn.disabled = true;
 | 
			
		||||
      submitBtnText.textContent = currentMode === 'workflow' ? 'Generiere Empfehlungen...' : 'Suche passende Methode...';
 | 
			
		||||
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error('AI query failed:', error);
 | 
			
		||||
      aiLoading.style.display = 'none';
 | 
			
		||||
      aiError.style.display = 'block';
 | 
			
		||||
      // Start queue status polling
 | 
			
		||||
      let statusInterval;
 | 
			
		||||
      let startTime = Date.now();
 | 
			
		||||
      
 | 
			
		||||
      // Show user-friendly error messages
 | 
			
		||||
      if (error.message.includes('429')) {
 | 
			
		||||
        aiErrorMessage.textContent = 'Zu viele Anfragen. Bitte warten Sie einen Moment und versuchen Sie es erneut.';
 | 
			
		||||
      } else if (error.message.includes('401')) {
 | 
			
		||||
        aiErrorMessage.textContent = 'Authentifizierung erforderlich. Bitte melden Sie sich an.';
 | 
			
		||||
      } else if (error.message.includes('503')) {
 | 
			
		||||
        aiErrorMessage.textContent = 'KI-Service vorübergehend nicht verfügbar. Bitte versuchen Sie es später erneut.';
 | 
			
		||||
      } else {
 | 
			
		||||
        aiErrorMessage.textContent = `Fehler: ${error.message}`;
 | 
			
		||||
      const updateQueueStatus = async () => {
 | 
			
		||||
        try {
 | 
			
		||||
          const response = await fetch(`/api/ai/queue-status?taskId=${taskId}`);
 | 
			
		||||
          const data = await response.json();
 | 
			
		||||
          
 | 
			
		||||
          if (data.success) {
 | 
			
		||||
            const queueLength = document.getElementById('queue-length');
 | 
			
		||||
            const estimatedTime = document.getElementById('estimated-time');
 | 
			
		||||
            const positionBadge = document.getElementById('queue-position-badge');
 | 
			
		||||
            const progressBar = document.getElementById('queue-progress');
 | 
			
		||||
            
 | 
			
		||||
            if (queueLength) queueLength.textContent = data.queueLength;
 | 
			
		||||
            
 | 
			
		||||
            if (estimatedTime) {
 | 
			
		||||
              if (data.estimatedWaitTime > 0) {
 | 
			
		||||
                estimatedTime.textContent = formatDuration(data.estimatedWaitTime);
 | 
			
		||||
              } else {
 | 
			
		||||
                estimatedTime.textContent = 'Verarbeitung läuft...';
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            if (positionBadge && data.currentPosition) {
 | 
			
		||||
              positionBadge.textContent = data.currentPosition;
 | 
			
		||||
              
 | 
			
		||||
              // Update progress bar (inverse of position)
 | 
			
		||||
              if (progressBar && data.queueLength > 0) {
 | 
			
		||||
                const progress = Math.max(0, ((data.queueLength - data.currentPosition + 1) / data.queueLength) * 100);
 | 
			
		||||
                progressBar.style.width = `${progress}%`;
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            // If processing and no position (request is being handled)
 | 
			
		||||
            if (data.isProcessing && !data.currentPosition) {
 | 
			
		||||
              if (positionBadge) positionBadge.textContent = '⚡';
 | 
			
		||||
              if (progressBar) progressBar.style.width = '100%';
 | 
			
		||||
              if (estimatedTime) estimatedTime.textContent = 'Verarbeitung läuft...';
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
          console.warn('Queue status update failed:', error);
 | 
			
		||||
        }
 | 
			
		||||
      };
 | 
			
		||||
      
 | 
			
		||||
      // Initial status update
 | 
			
		||||
      updateQueueStatus();
 | 
			
		||||
      
 | 
			
		||||
      // Poll every 500ms for status updates
 | 
			
		||||
      statusInterval = setInterval(updateQueueStatus, 500);
 | 
			
		||||
 | 
			
		||||
      try {
 | 
			
		||||
        const response = await fetch('/api/ai/query', {
 | 
			
		||||
          method: 'POST',
 | 
			
		||||
          headers: {
 | 
			
		||||
            'Content-Type': 'application/json',
 | 
			
		||||
          },
 | 
			
		||||
          body: JSON.stringify({ 
 | 
			
		||||
            query,
 | 
			
		||||
            mode: currentMode,
 | 
			
		||||
            taskId // Include task ID for backend tracking
 | 
			
		||||
          })
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const data = await response.json();
 | 
			
		||||
 | 
			
		||||
        // Clear status polling
 | 
			
		||||
        if (statusInterval) clearInterval(statusInterval);
 | 
			
		||||
 | 
			
		||||
        if (!response.ok) {
 | 
			
		||||
          throw new Error(data.error || `HTTP ${response.status}`);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!data.success) {
 | 
			
		||||
          throw new Error(data.error || 'Unknown error');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Store recommendation for restoration
 | 
			
		||||
        currentRecommendation = data.recommendation;
 | 
			
		||||
        
 | 
			
		||||
        // Display results based on mode
 | 
			
		||||
        if (currentMode === 'workflow') {
 | 
			
		||||
          displayWorkflowResults(data.recommendation, query);
 | 
			
		||||
        } else {
 | 
			
		||||
          displayToolResults(data.recommendation, query);
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        aiLoading.style.display = 'none';
 | 
			
		||||
        aiResults.style.display = 'block';
 | 
			
		||||
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.error('AI query failed:', error);
 | 
			
		||||
        
 | 
			
		||||
        // Clear status polling
 | 
			
		||||
        if (statusInterval) clearInterval(statusInterval);
 | 
			
		||||
        
 | 
			
		||||
        aiLoading.style.display = 'none';
 | 
			
		||||
        aiError.style.display = 'block';
 | 
			
		||||
        
 | 
			
		||||
        // Show user-friendly error messages
 | 
			
		||||
        if (error.message.includes('429')) {
 | 
			
		||||
          aiErrorMessage.textContent = 'Zu viele Anfragen. Bitte warten Sie einen Moment und versuchen Sie es erneut.';
 | 
			
		||||
        } else if (error.message.includes('401')) {
 | 
			
		||||
          aiErrorMessage.textContent = 'Authentifizierung erforderlich. Bitte melden Sie sich an.';
 | 
			
		||||
        } else if (error.message.includes('503')) {
 | 
			
		||||
          aiErrorMessage.textContent = 'KI-Service vorübergehend nicht verfügbar. Bitte versuchen Sie es später erneut.';
 | 
			
		||||
        } else {
 | 
			
		||||
          aiErrorMessage.textContent = `Fehler: ${error.message}`;
 | 
			
		||||
        }
 | 
			
		||||
      } finally {
 | 
			
		||||
        // Re-enable submit button and hide queue status
 | 
			
		||||
        aiSubmitBtn.disabled = false;
 | 
			
		||||
        const config = modeConfig[currentMode];
 | 
			
		||||
        submitBtnText.textContent = config.submitText;
 | 
			
		||||
        
 | 
			
		||||
        if (queueStatus) queueStatus.style.display = 'none';
 | 
			
		||||
        if (statusInterval) clearInterval(statusInterval);
 | 
			
		||||
      }
 | 
			
		||||
    } finally {
 | 
			
		||||
      // Re-enable submit button
 | 
			
		||||
      aiSubmitBtn.disabled = false;
 | 
			
		||||
      const config = modeConfig[currentMode];
 | 
			
		||||
      submitBtnText.textContent = config.submitText;
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
  // Event listeners
 | 
			
		||||
  aiSubmitBtn.addEventListener('click', handleSubmit);
 | 
			
		||||
@ -616,7 +718,7 @@ document.addEventListener('DOMContentLoaded', () => {
 | 
			
		||||
            <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem; vertical-align: middle;">
 | 
			
		||||
              <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"/>
 | 
			
		||||
            </svg>
 | 
			
		||||
            Passende Tool-Empfehlungen
 | 
			
		||||
            Passende Empfehlungen
 | 
			
		||||
          </h3>
 | 
			
		||||
          <p style="margin: 0; opacity: 0.9; line-height: 1.5;">
 | 
			
		||||
            Basierend auf Ihrer Anfrage: "<em>${originalQuery.slice(0, 100)}${originalQuery.length > 100 ? '...' : ''}</em>"
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										72
									
								
								src/components/ContributionButton.astro
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								src/components/ContributionButton.astro
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,72 @@
 | 
			
		||||
---
 | 
			
		||||
// src/components/ContributionButton.astro - CLEANED: Removed duplicate auth script
 | 
			
		||||
export interface Props {
 | 
			
		||||
  type: 'edit' | 'new' | 'write';
 | 
			
		||||
  toolName?: string;
 | 
			
		||||
  variant?: 'primary' | 'secondary' | 'small';
 | 
			
		||||
  text?: string;
 | 
			
		||||
  className?: string;
 | 
			
		||||
  style?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const { 
 | 
			
		||||
  type, 
 | 
			
		||||
  toolName, 
 | 
			
		||||
  variant = 'secondary', 
 | 
			
		||||
  text, 
 | 
			
		||||
  className = '', 
 | 
			
		||||
  style = '' 
 | 
			
		||||
} = Astro.props;
 | 
			
		||||
 | 
			
		||||
// Generate appropriate URLs and text based on type
 | 
			
		||||
let href: string;
 | 
			
		||||
let defaultText: string;
 | 
			
		||||
let icon: string;
 | 
			
		||||
 | 
			
		||||
switch (type) {
 | 
			
		||||
  case 'edit':
 | 
			
		||||
    href = `/contribute/tool?edit=${encodeURIComponent(toolName || '')}`;
 | 
			
		||||
    defaultText = 'Edit';
 | 
			
		||||
    icon = `<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
 | 
			
		||||
            <path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>`;
 | 
			
		||||
    break;
 | 
			
		||||
  case 'new':
 | 
			
		||||
    href = '/contribute/tool';
 | 
			
		||||
    defaultText = 'Add Tool';
 | 
			
		||||
    icon = `<line x1="12" y1="5" x2="12" y2="19"/>
 | 
			
		||||
            <line x1="5" y1="12" x2="19" y2="12"/>`;
 | 
			
		||||
    break;
 | 
			
		||||
  case 'write':
 | 
			
		||||
    href = '/contribute/knowledgebase';
 | 
			
		||||
    defaultText = 'Write Article';
 | 
			
		||||
    icon = `<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
 | 
			
		||||
            <polyline points="14 2 14 8 20 8"/>
 | 
			
		||||
            <line x1="16" y1="13" x2="8" y2="13"/>
 | 
			
		||||
            <line x1="16" y1="17" x2="8" y2="17"/>
 | 
			
		||||
            <polyline points="10 9 9 9 8 9"/>`;
 | 
			
		||||
    break;
 | 
			
		||||
  default:
 | 
			
		||||
    href = '/contribute';
 | 
			
		||||
    defaultText = 'Contribute';
 | 
			
		||||
    icon = `<line x1="12" y1="5" x2="12" y2="19"/>
 | 
			
		||||
            <line x1="5" y1="12" x2="19" y2="12"/>`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const displayText = text || defaultText;
 | 
			
		||||
const buttonClass = `btn btn-${variant} ${className}`.trim();
 | 
			
		||||
const iconSize = variant === 'small' ? '14' : '16';
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<a 
 | 
			
		||||
  href={href} 
 | 
			
		||||
  class={buttonClass}
 | 
			
		||||
  style={style}
 | 
			
		||||
  data-contribute-button={type}
 | 
			
		||||
  data-tool-name={toolName}
 | 
			
		||||
  title={`${displayText}${toolName ? `: ${toolName}` : ''}`}
 | 
			
		||||
>
 | 
			
		||||
  <svg width={iconSize} height={iconSize} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
 | 
			
		||||
    <Fragment set:html={icon} />
 | 
			
		||||
  </svg>
 | 
			
		||||
  {displayText}
 | 
			
		||||
</a>
 | 
			
		||||
@ -7,7 +7,7 @@
 | 
			
		||||
    <div class="footer-content">
 | 
			
		||||
      <div>
 | 
			
		||||
        <p class="text-muted" style="margin: 0;">
 | 
			
		||||
          © 2025 CC24-Guide - Lizensiert unter BSD-3-Clause
 | 
			
		||||
          © 2025 ForensicPathways - Lizensiert unter BSD-3-Clause
 | 
			
		||||
        </p>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div style="display: flex; gap: 2rem; align-items: center;">
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,5 @@
 | 
			
		||||
---
 | 
			
		||||
// src/components/Navigation.astro
 | 
			
		||||
import ThemeToggle from './ThemeToggle.astro';
 | 
			
		||||
 | 
			
		||||
const currentPath = Astro.url.pathname;
 | 
			
		||||
@ -8,9 +9,9 @@ const currentPath = Astro.url.pathname;
 | 
			
		||||
  <div class="container">
 | 
			
		||||
    <div class="nav-wrapper">
 | 
			
		||||
      <a href="/" class="nav-brand">
 | 
			
		||||
        <img src="/logo-dark.png" alt="CC24-Guide" class="nav-logo nav-logo-light" />
 | 
			
		||||
        <img src="/logo-white.png" alt="CC24-Guide" class="nav-logo nav-logo-dark" />
 | 
			
		||||
        <span style="font-weight: 600; font-size: 1.125rem;">CC24-Guide</span>
 | 
			
		||||
        <img src="/logo-dark.png" alt="ForensicPathways" class="nav-logo nav-logo-light" />
 | 
			
		||||
        <img src="/logo-white.png" alt="ForensicPathways" class="nav-logo nav-logo-dark" />
 | 
			
		||||
        <span style="font-weight: 600; font-size: 1.125rem;">ForensicPathways</span>
 | 
			
		||||
      </a>
 | 
			
		||||
      
 | 
			
		||||
      <ul class="nav-links">
 | 
			
		||||
@ -24,6 +25,11 @@ const currentPath = Astro.url.pathname;
 | 
			
		||||
            ~/knowledgebase
 | 
			
		||||
          </a>
 | 
			
		||||
        </li>
 | 
			
		||||
        <li>
 | 
			
		||||
          <a href="/contribute" class={`nav-link ${currentPath.startsWith('/contribute') ? 'active' : ''}`}>
 | 
			
		||||
            ~/contribute
 | 
			
		||||
          </a>
 | 
			
		||||
        </li>
 | 
			
		||||
        <li>
 | 
			
		||||
          <a href="/status" class={`nav-link ${currentPath === '/status' ? 'active' : ''}`}>
 | 
			
		||||
            ~/status
 | 
			
		||||
@ -40,35 +46,4 @@ const currentPath = Astro.url.pathname;
 | 
			
		||||
      </ul>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</nav>
 | 
			
		||||
 | 
			
		||||
<style>
 | 
			
		||||
  /* Logo theme switching */
 | 
			
		||||
  .nav-logo-light {
 | 
			
		||||
    display: block;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  .nav-logo-dark {
 | 
			
		||||
    display: none;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  [data-theme="dark"] .nav-logo-light {
 | 
			
		||||
    display: none;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  [data-theme="dark"] .nav-logo-dark {
 | 
			
		||||
    display: block;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  /* Make brand clickable */
 | 
			
		||||
  .nav-brand {
 | 
			
		||||
    text-decoration: none;
 | 
			
		||||
    color: inherit;
 | 
			
		||||
    transition: var(--transition-fast);
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  .nav-brand:hover {
 | 
			
		||||
    text-decoration: none;
 | 
			
		||||
    opacity: 0.8;
 | 
			
		||||
  }
 | 
			
		||||
</style>
 | 
			
		||||
</nav>
 | 
			
		||||
@ -1,4 +1,6 @@
 | 
			
		||||
---
 | 
			
		||||
import { createToolSlug } from '../utils/toolHelpers.js';
 | 
			
		||||
 | 
			
		||||
export interface Props {
 | 
			
		||||
  toolName: string;
 | 
			
		||||
  context: 'card' | 'modal-primary' | 'modal-secondary';
 | 
			
		||||
@ -7,12 +9,8 @@ export interface Props {
 | 
			
		||||
 | 
			
		||||
const { toolName, context, size = 'small' } = Astro.props;
 | 
			
		||||
 | 
			
		||||
// Create URL-safe slug from tool name
 | 
			
		||||
const toolSlug = toolName.toLowerCase()
 | 
			
		||||
  .replace(/[^a-z0-9\s-]/g, '') // Remove special characters
 | 
			
		||||
  .replace(/\s+/g, '-')         // Replace spaces with hyphens
 | 
			
		||||
  .replace(/-+/g, '-')          // Remove duplicate hyphens
 | 
			
		||||
  .replace(/^-|-$/g, '');       // Remove leading/trailing hyphens
 | 
			
		||||
// AFTER: Single line with centralized function
 | 
			
		||||
const toolSlug = createToolSlug(toolName);
 | 
			
		||||
 | 
			
		||||
const iconSize = size === 'small' ? '14' : '16';
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,7 @@
 | 
			
		||||
---
 | 
			
		||||
// src/components/ToolCard.astro (ENHANCED - Added data attributes for filtering)
 | 
			
		||||
import ShareButton from './ShareButton.astro';
 | 
			
		||||
 | 
			
		||||
export interface Props {
 | 
			
		||||
  tool: {
 | 
			
		||||
    name: string;
 | 
			
		||||
@ -35,99 +38,114 @@ const hasValidProjectUrl = tool.projectUrl !== undefined &&
 | 
			
		||||
const hasKnowledgebase = tool.knowledgebase === true;
 | 
			
		||||
 | 
			
		||||
// Determine card styling based on type and hosting status
 | 
			
		||||
const cardClass = isConcept ? 'card card-concept tool-card' :
 | 
			
		||||
                  isMethod ? 'card card-method tool-card' : 
 | 
			
		||||
                  hasValidProjectUrl ? 'card card-hosted tool-card' : 
 | 
			
		||||
                  (tool.license !== 'Proprietary' ? 'card card-oss tool-card' : 'card tool-card');
 | 
			
		||||
const cardClass = isConcept ? 'card card-concept tool-card cursor-pointer' :
 | 
			
		||||
                  isMethod ? 'card card-method tool-card cursor-pointer' : 
 | 
			
		||||
                  hasValidProjectUrl ? 'card card-hosted tool-card cursor-pointer' : 
 | 
			
		||||
                  (tool.license !== 'Proprietary' ? 'card card-oss tool-card cursor-pointer' : 'card tool-card cursor-pointer');
 | 
			
		||||
 | 
			
		||||
// ENHANCED: Data attributes for filtering
 | 
			
		||||
const toolDataAttributes = {
 | 
			
		||||
  'data-tool-name': tool.name.toLowerCase(),
 | 
			
		||||
  'data-tool-type': tool.type,
 | 
			
		||||
  'data-tool-domains': (tool.domains || []).join(','),
 | 
			
		||||
  'data-tool-phases': (tool.phases || []).join(','),
 | 
			
		||||
  'data-tool-tags': (tool.tags || []).join(',').toLowerCase(),
 | 
			
		||||
  'data-tool-platforms': (tool.platforms || []).join(','),
 | 
			
		||||
  'data-tool-license': tool.license || '',
 | 
			
		||||
  'data-tool-skill': tool.skillLevel,
 | 
			
		||||
  'data-tool-description': tool.description.toLowerCase()
 | 
			
		||||
};
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<div class={cardClass} onclick={`window.showToolDetails('${tool.name}')`} style="cursor: pointer; border-left: 4px solid {isMethod ? 'var(--color-method)' : hasValidProjectUrl ? 'var(--color-hosted)' : tool.license !== 'Proprietary' ? 'var(--color-oss)' : 'var(--color-border)'};">
 | 
			
		||||
  <!-- Card Header with Fixed Height -->
 | 
			
		||||
  <div class="tool-card-header">
 | 
			
		||||
    <h3>
 | 
			
		||||
      {tool.icon && <span style="margin-right: 0.5rem; font-size: 1.125rem;">{tool.icon}</span>}
 | 
			
		||||
      {tool.name}
 | 
			
		||||
    </h3>
 | 
			
		||||
    <div class="tool-card-badges">
 | 
			
		||||
      <!-- Only show CC24-Server and Knowledgebase badges -->
 | 
			
		||||
      {!isMethod && hasValidProjectUrl && <span class="badge badge-primary">CC24-Server</span>}
 | 
			
		||||
      {hasKnowledgebase && <span class="badge badge-error">📖</span>}
 | 
			
		||||
    </div>
 | 
			
		||||
<div 
 | 
			
		||||
  class={cardClass} 
 | 
			
		||||
  {...toolDataAttributes}
 | 
			
		||||
  onclick={`window.showToolDetails('${tool.name}')`}
 | 
			
		||||
>
 | 
			
		||||
<!-- Card Header with Fixed Height -->
 | 
			
		||||
<div class="tool-card-header">
 | 
			
		||||
  <h3>
 | 
			
		||||
    {tool.icon && <span class="mr-2 text-lg">{tool.icon}</span>}
 | 
			
		||||
    {tool.name}
 | 
			
		||||
  </h3>
 | 
			
		||||
  <div class="tool-card-badges">
 | 
			
		||||
    <!-- Only show CC24-Server and Knowledgebase badges -->
 | 
			
		||||
    {!isMethod && hasValidProjectUrl && <span class="badge badge-primary">CC24-Server</span>}
 | 
			
		||||
    {hasKnowledgebase && <span class="badge badge-error">📖</span>}
 | 
			
		||||
    <ShareButton toolName={tool.name} context="card" size="small" />
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<!-- Description - Truncated to 2 lines -->
 | 
			
		||||
<p class="text-muted">
 | 
			
		||||
  {tool.description}
 | 
			
		||||
</p>
 | 
			
		||||
 | 
			
		||||
<!-- Metadata - Compact Icons with Better Alignment -->
 | 
			
		||||
<div class="tool-card-metadata flex items-center gap-4 mb-3" style="line-height: 1;">
 | 
			
		||||
  <div class="metadata-item flex items-center gap-2 text-xs text-secondary flex-shrink-1 min-w-0">
 | 
			
		||||
    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="flex-shrink-0">
 | 
			
		||||
      <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
 | 
			
		||||
      <line x1="9" y1="9" x2="15" y2="9"></line>
 | 
			
		||||
      <line x1="9" y1="15" x2="15" y2="15"></line>
 | 
			
		||||
    </svg>
 | 
			
		||||
    <span class="overflow-hidden min-w-0" style="text-overflow: ellipsis; white-space: nowrap;">
 | 
			
		||||
      {tool.platforms.slice(0, 2).join(', ')}{tool.platforms.length > 2 ? '...' : ''}
 | 
			
		||||
    </span>
 | 
			
		||||
  </div>
 | 
			
		||||
  
 | 
			
		||||
  <!-- Description - Truncated to 2 lines -->
 | 
			
		||||
  <p class="text-muted">
 | 
			
		||||
    {tool.description}
 | 
			
		||||
  </p>
 | 
			
		||||
  
 | 
			
		||||
  <!-- Metadata - Compact Icons with Better Alignment -->
 | 
			
		||||
  <div class="tool-card-metadata" style="display: flex; align-items: center; gap: 1rem; margin-bottom: 0.75rem; line-height: 1;">
 | 
			
		||||
    <div class="metadata-item" style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; color: var(--color-text-secondary); flex-shrink: 1; min-width: 0;">
 | 
			
		||||
      <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink: 0;">
 | 
			
		||||
        <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
 | 
			
		||||
        <line x1="9" y1="9" x2="15" y2="9"></line>
 | 
			
		||||
        <line x1="9" y1="15" x2="15" y2="15"></line>
 | 
			
		||||
      </svg>
 | 
			
		||||
      <span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;">
 | 
			
		||||
        {tool.platforms.slice(0, 2).join(', ')}{tool.platforms.length > 2 ? '...' : ''}
 | 
			
		||||
      </span>
 | 
			
		||||
    </div>
 | 
			
		||||
    
 | 
			
		||||
    <div class="metadata-item" style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; color: var(--color-text-secondary); flex-shrink: 1; min-width: 0;">
 | 
			
		||||
      <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink: 0;">
 | 
			
		||||
        <circle cx="12" cy="12" r="10"></circle>
 | 
			
		||||
        <path d="M12 6v6l4 2"></path>
 | 
			
		||||
      </svg>
 | 
			
		||||
      <span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;">
 | 
			
		||||
        {tool.skillLevel}
 | 
			
		||||
      </span>
 | 
			
		||||
    </div>
 | 
			
		||||
    
 | 
			
		||||
    <div class="metadata-item" style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; color: var(--color-text-secondary); flex-shrink: 1; min-width: 0;">
 | 
			
		||||
      <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink: 0;">
 | 
			
		||||
        <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
 | 
			
		||||
        <polyline points="14 2 14 8 20 8"></polyline>
 | 
			
		||||
      </svg>
 | 
			
		||||
      <span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;">
 | 
			
		||||
        {isMethod ? 'Methode' : tool.license === 'Proprietary' ? 'Prop.' : tool.license?.split(' ')[0]}
 | 
			
		||||
      </span>
 | 
			
		||||
    </div>
 | 
			
		||||
  <div class="metadata-item flex items-center gap-2 text-xs text-secondary flex-shrink-1 min-w-0">
 | 
			
		||||
    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="flex-shrink-0">
 | 
			
		||||
      <circle cx="12" cy="12" r="10"></circle>
 | 
			
		||||
      <path d="M12 6v6l4 2"></path>
 | 
			
		||||
    </svg>
 | 
			
		||||
    <span class="overflow-hidden min-w-0" style="text-overflow: ellipsis; white-space: nowrap;">
 | 
			
		||||
      {tool.skillLevel}
 | 
			
		||||
    </span>
 | 
			
		||||
  </div>
 | 
			
		||||
  
 | 
			
		||||
  <!-- Tags - Two Lines with Fade -->
 | 
			
		||||
  <div class="tool-tags-container">
 | 
			
		||||
    {tool.tags.slice(0, 8).map(tag => (
 | 
			
		||||
      <span class="tag">{tag}</span>
 | 
			
		||||
    ))}
 | 
			
		||||
  <div class="metadata-item flex items-center gap-2 text-xs text-secondary flex-shrink-1 min-w-0">
 | 
			
		||||
    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="flex-shrink-0">
 | 
			
		||||
      <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
 | 
			
		||||
      <polyline points="14 2 14 8 20 8"></polyline>
 | 
			
		||||
    </svg>
 | 
			
		||||
    <span class="overflow-hidden min-w-0" style="text-overflow: ellipsis; white-space: nowrap;">
 | 
			
		||||
      {isConcept ? 'Konzept' : isMethod ? 'Methode' : tool.license === 'Proprietary' ? 'Prop.' : tool.license?.split(' ')[0]}
 | 
			
		||||
    </span>
 | 
			
		||||
  </div>
 | 
			
		||||
  
 | 
			
		||||
<!-- Buttons - Fixed at Bottom -->
 | 
			
		||||
  <div class="tool-card-buttons" onclick="event.stopPropagation();">
 | 
			
		||||
    {isConcept ? (
 | 
			
		||||
      <!-- Concept button -->
 | 
			
		||||
      <a href={tool.url} target="_blank" rel="noopener noreferrer" class="btn btn-primary single-button" style="background-color: var(--color-concept); border-color: var(--color-concept);">
 | 
			
		||||
        Mehr erfahren
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
<!-- Tags - Two Lines with Fade -->
 | 
			
		||||
<div class="tool-tags-container">
 | 
			
		||||
  {tool.tags.slice(0, 8).map(tag => (
 | 
			
		||||
    <span class="tag">{tag}</span>
 | 
			
		||||
  ))}
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<!-- Buttons - Fixed at Bottom (NO EDIT BUTTONS - Available in modals) -->
 | 
			
		||||
<div class="tool-card-buttons" onclick="event.stopPropagation();">
 | 
			
		||||
  {isConcept ? (
 | 
			
		||||
    <a href={tool.url} target="_blank" rel="noopener noreferrer" class="btn btn-primary single-button" style="background-color: var(--color-concept); border-color: var(--color-concept);">
 | 
			
		||||
      Mehr erfahren
 | 
			
		||||
    </a>
 | 
			
		||||
  ) : isMethod ? (
 | 
			
		||||
    <a href={tool.projectUrl || tool.url} target="_blank" rel="noopener noreferrer" class="btn btn-primary single-button" style="background-color: var(--color-method); border-color: var(--color-method);">
 | 
			
		||||
      Zur Methode
 | 
			
		||||
    </a>
 | 
			
		||||
  ) : hasValidProjectUrl ? (
 | 
			
		||||
    <div class="button-row">
 | 
			
		||||
      <a href={tool.url} target="_blank" rel="noopener noreferrer" class="btn btn-secondary">
 | 
			
		||||
        Homepage
 | 
			
		||||
      </a>
 | 
			
		||||
    ) : isMethod ? (
 | 
			
		||||
      <!-- Method button -->
 | 
			
		||||
      <a href={tool.projectUrl || tool.url} target="_blank" rel="noopener noreferrer" class="btn btn-primary single-button" style="background-color: var(--color-method); border-color: var(--color-method);">
 | 
			
		||||
        Zur Methode
 | 
			
		||||
      <a href={tool.projectUrl} target="_blank" rel="noopener noreferrer" class="btn btn-primary">
 | 
			
		||||
        Zugreifen
 | 
			
		||||
      </a>
 | 
			
		||||
    ) : hasValidProjectUrl ? (
 | 
			
		||||
      <!-- Two buttons for hosted tools -->
 | 
			
		||||
      <div class="button-row">
 | 
			
		||||
        <a href={tool.url} target="_blank" rel="noopener noreferrer" class="btn btn-secondary">
 | 
			
		||||
          Homepage
 | 
			
		||||
        </a>
 | 
			
		||||
        <a href={tool.projectUrl} target="_blank" rel="noopener noreferrer" class="btn btn-primary">
 | 
			
		||||
          Zugreifen
 | 
			
		||||
        </a>
 | 
			
		||||
      </div>
 | 
			
		||||
    ) : (
 | 
			
		||||
      <!-- Single button for external tools -->
 | 
			
		||||
      <a href={tool.url} target="_blank" rel="noopener noreferrer" class="btn btn-primary single-button">
 | 
			
		||||
        Software-Homepage
 | 
			
		||||
      </a>
 | 
			
		||||
    )}
 | 
			
		||||
  </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  ) : (
 | 
			
		||||
    <a href={tool.url} target="_blank" rel="noopener noreferrer" class="btn btn-primary single-button">
 | 
			
		||||
      Software-Homepage
 | 
			
		||||
    </a>
 | 
			
		||||
  )}
 | 
			
		||||
</div>
 | 
			
		||||
</div>
 | 
			
		||||
</div>
 | 
			
		||||
@ -1,11 +1,9 @@
 | 
			
		||||
---
 | 
			
		||||
import { getToolsData } from '../utils/dataService.js';
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
// Load tools data
 | 
			
		||||
const data = await getToolsData();
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
const domains = data.domains;
 | 
			
		||||
const phases = data.phases;
 | 
			
		||||
 | 
			
		||||
@ -106,7 +104,6 @@ const sortedTags = Object.entries(tagFrequency)
 | 
			
		||||
  </div>
 | 
			
		||||
  
 | 
			
		||||
  <!-- View Toggle -->
 | 
			
		||||
  <!--<div style="display: flex; gap: 1rem; margin-bottom: 1.5rem; align-items: center; flex-wrap: wrap;">-->
 | 
			
		||||
  <div style="display: flex; gap: 1rem; margin-bottom: 1.5rem; align-items: center;">
 | 
			
		||||
    <button class="btn btn-secondary view-toggle active" style="height:50px" data-view="grid">Kachelansicht</button>
 | 
			
		||||
    <button class="btn btn-secondary view-toggle" style="height:50px" data-view="matrix">Matrix-Ansicht</button>
 | 
			
		||||
@ -127,7 +124,6 @@ const sortedTags = Object.entries(tagFrequency)
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
<script define:vars={{ toolsData: data.tools, tagFrequency, sortedTags }}>
 | 
			
		||||
  // Store tools data globally for filtering
 | 
			
		||||
  window.toolsData = toolsData;
 | 
			
		||||
@ -149,26 +145,6 @@ const sortedTags = Object.entries(tagFrequency)
 | 
			
		||||
    let selectedPhase = '';
 | 
			
		||||
    let isTagCloudExpanded = false;
 | 
			
		||||
    
 | 
			
		||||
    // Check authentication status and show/hide AI button
 | 
			
		||||
    async function checkAuthAndShowAIButton() {
 | 
			
		||||
      try {
 | 
			
		||||
        const response = await fetch('/api/auth/status');
 | 
			
		||||
        const data = await response.json();
 | 
			
		||||
        
 | 
			
		||||
        // Show AI button if authentication is not required OR if user is authenticated
 | 
			
		||||
        if (!data.authRequired || data.authenticated) {
 | 
			
		||||
          if (aiViewToggle) {
 | 
			
		||||
            aiViewToggle.style.display = 'inline-flex';
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.log('Auth check failed, AI button remains hidden');
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Call auth check on page load
 | 
			
		||||
    checkAuthAndShowAIButton();
 | 
			
		||||
    
 | 
			
		||||
    // Initialize tag cloud state
 | 
			
		||||
    function initTagCloud() {
 | 
			
		||||
      const visibleCount = 22;
 | 
			
		||||
@ -298,7 +274,7 @@ const sortedTags = Object.entries(tagFrequency)
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
// Filter function
 | 
			
		||||
    // ENHANCED: Filter function with better performance for show/hide pattern
 | 
			
		||||
    function filterTools() {
 | 
			
		||||
      const searchTerm = searchInput.value.toLowerCase();
 | 
			
		||||
      const selectedDomain = domainSelect.value;
 | 
			
		||||
@ -310,7 +286,7 @@ const sortedTags = Object.entries(tagFrequency)
 | 
			
		||||
        const phases = tool.phases || [];
 | 
			
		||||
        const tags = tool.tags || [];
 | 
			
		||||
        
 | 
			
		||||
        // Search filter
 | 
			
		||||
        // Search filter - more comprehensive
 | 
			
		||||
        if (searchTerm && !(
 | 
			
		||||
          tool.name.toLowerCase().includes(searchTerm) ||
 | 
			
		||||
          tool.description.toLowerCase().includes(searchTerm) ||
 | 
			
		||||
@ -329,12 +305,12 @@ const sortedTags = Object.entries(tagFrequency)
 | 
			
		||||
          return false;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        // Proprietary filter (skip for methods since they don't have licenses)
 | 
			
		||||
        if (!includeProprietary && !isMethod(tool) && tool.license === 'Proprietary') {
 | 
			
		||||
        // Proprietary filter (skip for methods and concepts since they don't have licenses)
 | 
			
		||||
        if (!includeProprietary && !isMethod(tool) && tool.type !== 'concept' && tool.license === 'Proprietary') {
 | 
			
		||||
          return false;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        // Tag filter
 | 
			
		||||
        // Tag filter - ensure all selected tags are present
 | 
			
		||||
        if (selectedTags.size > 0 && !Array.from(selectedTags).every(tag => tags.includes(tag))) {
 | 
			
		||||
          return false;
 | 
			
		||||
        }
 | 
			
		||||
@ -344,6 +320,7 @@ const sortedTags = Object.entries(tagFrequency)
 | 
			
		||||
      
 | 
			
		||||
      // Update matrix highlighting
 | 
			
		||||
      updateMatrixHighlighting();
 | 
			
		||||
      
 | 
			
		||||
      // Emit custom event with filtered results
 | 
			
		||||
      window.dispatchEvent(new CustomEvent('toolsFiltered', { detail: filtered }));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -2,8 +2,6 @@
 | 
			
		||||
import { getToolsData } from '../utils/dataService.js';
 | 
			
		||||
import ShareButton from './ShareButton.astro';
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
// Load tools data
 | 
			
		||||
const data = await getToolsData();
 | 
			
		||||
 | 
			
		||||
@ -37,17 +35,17 @@ domains.forEach((domain: any) => {
 | 
			
		||||
<div id="matrix-container" class="matrix-wrapper" style="display: none;">
 | 
			
		||||
  <!-- Domain-Agnostic Software Sections -->
 | 
			
		||||
  {domainAgnosticTools.map((sectionData: any, index: number) => (
 | 
			
		||||
    <div id={`domain-agnostic-section-${sectionData.section.id}`} class="card collaboration-section-collapsed" style="margin-bottom: 1.5rem; border-left: 4px solid var(--color-accent);">
 | 
			
		||||
      <div class="collaboration-header" onclick={`toggleDomainAgnosticSection('${sectionData.section.id}')`} style="cursor: pointer; display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.1rem;">
 | 
			
		||||
        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" stroke-width="2" style="margin-right: 0.5rem;">
 | 
			
		||||
    <div id={`domain-agnostic-section-${sectionData.section.id}`} class="card collaboration-section-collapsed mb-6 border-l-4" style="border-left-color: var(--color-accent);">
 | 
			
		||||
      <div class="collaboration-header cursor-pointer flex items-center gap-3" onclick={`toggleDomainAgnosticSection('${sectionData.section.id}')`} style="margin-bottom: 0.1rem;">
 | 
			
		||||
        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" stroke-width="2" class="mr-2">
 | 
			
		||||
          <path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
 | 
			
		||||
          <circle cx="8.5" cy="7" r="4"/>
 | 
			
		||||
          <line x1="20" y1="8" x2="20" y2="14"/>
 | 
			
		||||
          <line x1="23" y1="11" x2="17" y2="11"/>
 | 
			
		||||
        </svg>
 | 
			
		||||
        <h3 style="margin: 0; color: var(--color-accent); font-size: 1.125rem;">
 | 
			
		||||
        <h3 class="m-0 text-lg" style="color: var(--color-accent);">
 | 
			
		||||
          {sectionData.section.name}
 | 
			
		||||
          <span id={`count-${sectionData.section.id}`} class="badge" style="background-color: var(--color-text-secondary); color: var(--color-bg); margin-left: 0.5rem; font-size: 0.75rem;">
 | 
			
		||||
          <span id={`count-${sectionData.section.id}`} class="badge text-xs" style="background-color: var(--color-text-secondary); color: var(--color-bg); margin-left: 0.5rem;">
 | 
			
		||||
            {sectionData.tools.length}
 | 
			
		||||
          </span>
 | 
			
		||||
        </h3>
 | 
			
		||||
@ -65,14 +63,14 @@ domains.forEach((domain: any) => {
 | 
			
		||||
                                      tool.projectUrl !== "" && 
 | 
			
		||||
                                      tool.projectUrl.trim() !== "";
 | 
			
		||||
            return (
 | 
			
		||||
              <div class={`collaboration-tool-compact ${hasValidProjectUrl ? 'hosted' : tool.license !== 'Proprietary' ? 'oss' : ''}`}
 | 
			
		||||
              <div class={`collaboration-tool-compact cursor-pointer ${hasValidProjectUrl ? 'hosted' : tool.license !== 'Proprietary' ? 'oss' : ''}`}
 | 
			
		||||
                   onclick={`window.showToolDetails('${tool.name}')`}>
 | 
			
		||||
                <div class="tool-compact-header">
 | 
			
		||||
                  <h4 style="margin: 0; font-size: 0.875rem; font-weight: 600;">
 | 
			
		||||
                    {tool.icon && <span style="margin-right: 0.5rem;">{tool.icon}</span>}
 | 
			
		||||
                  <h4 class="m-0 text-sm font-semibold">
 | 
			
		||||
                    {tool.icon && <span class="mr-2">{tool.icon}</span>}
 | 
			
		||||
                    {tool.name}
 | 
			
		||||
                  </h4>
 | 
			
		||||
                  <div style="display: flex; gap: 0.25rem;">
 | 
			
		||||
                  <div class="flex gap-1">
 | 
			
		||||
                    {hasValidProjectUrl && <span class="badge badge--mini badge-primary">CC24-Server</span>}
 | 
			
		||||
                    {tool.knowledgebase === true && <span class="badge badge--mini badge-error">📖</span>}
 | 
			
		||||
                  </div>
 | 
			
		||||
@ -80,7 +78,7 @@ domains.forEach((domain: any) => {
 | 
			
		||||
                <p class="text-muted">              
 | 
			
		||||
                  {tool.description}
 | 
			
		||||
                </p>
 | 
			
		||||
                <div style="display: flex; gap: 0.75rem; font-size: 0.6875rem; color: var(--color-text-secondary);">
 | 
			
		||||
                <div class="flex gap-3 text-xs" style="color: var(--color-text-secondary);">
 | 
			
		||||
                  <span>{tool.platforms.join(', ')}</span>
 | 
			
		||||
                  <span>•</span>
 | 
			
		||||
                  <span>{tool.skillLevel}</span>
 | 
			
		||||
@ -95,7 +93,7 @@ domains.forEach((domain: any) => {
 | 
			
		||||
 | 
			
		||||
  <!-- DFIR Tools Matrix -->
 | 
			
		||||
  <div id="dfir-matrix-section">
 | 
			
		||||
    <h2 style="margin-bottom: 1rem; color: var(--color-text);">MATRIX</h2>
 | 
			
		||||
    <h2 class="mb-4" style="color: var(--color-text);">MATRIX</h2>
 | 
			
		||||
    <table class="matrix-table">
 | 
			
		||||
      <thead>
 | 
			
		||||
        <tr>
 | 
			
		||||
@ -128,7 +126,7 @@ domains.forEach((domain: any) => {
 | 
			
		||||
                    title={`${tool.name}${tool.knowledgebase === true ? ' (KB verfügbar)' : ''}`}
 | 
			
		||||
                  >
 | 
			
		||||
                    {tool.name}
 | 
			
		||||
                    {tool.knowledgebase === true && <span style="margin-left: 0.25rem; font-size: 0.6875rem;">📖</span>}
 | 
			
		||||
                    {tool.knowledgebase === true && <span class="text-xs" style="margin-left: 0.25rem;">📖</span>}
 | 
			
		||||
                  </span>
 | 
			
		||||
                );
 | 
			
		||||
              })}
 | 
			
		||||
@ -146,12 +144,15 @@ domains.forEach((domain: any) => {
 | 
			
		||||
 | 
			
		||||
<!-- Primary Modal -->
 | 
			
		||||
<div class="tool-details" id="tool-details-primary">
 | 
			
		||||
  <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 1rem;">
 | 
			
		||||
    <h2 id="tool-name-primary" style="margin: 0;">Tool Name</h2>
 | 
			
		||||
    <div style="display: flex; align-items: center; gap: 0.5rem;">
 | 
			
		||||
  <div class="flex justify-between items-start mb-4">
 | 
			
		||||
    <h2 id="tool-name-primary" class="m-0">Tool Name</h2>
 | 
			
		||||
    <div class="flex items-center gap-2">
 | 
			
		||||
      <div id="share-button-primary" style="display: none;">
 | 
			
		||||
        <!-- Share button will be populated by JavaScript -->
 | 
			
		||||
      </div>
 | 
			
		||||
      <div id="contribute-button-primary" style="display: none;">
 | 
			
		||||
        <!-- Contribution button will be populated by JavaScript -->
 | 
			
		||||
      </div>
 | 
			
		||||
      <button class="btn-icon" onclick="window.hideToolDetails('primary')">
 | 
			
		||||
        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
 | 
			
		||||
          <line x1="18" y1="6" x2="6" y2="18"></line>
 | 
			
		||||
@ -163,23 +164,26 @@ domains.forEach((domain: any) => {
 | 
			
		||||
  
 | 
			
		||||
  <p id="tool-description-primary" class="text-muted"></p>
 | 
			
		||||
  
 | 
			
		||||
  <div id="tool-badges-primary" style="display: flex; gap: 0.5rem; margin-bottom: 1rem;"></div>
 | 
			
		||||
  <div id="tool-badges-primary" class="flex gap-2 mb-4"></div>
 | 
			
		||||
  
 | 
			
		||||
  <div id="tool-metadata-primary" style="margin-bottom: 1rem;"></div>
 | 
			
		||||
  <div id="tool-metadata-primary" class="mb-4"></div>
 | 
			
		||||
  
 | 
			
		||||
  <div id="tool-tags-primary" style="margin-bottom: 1rem;"></div>
 | 
			
		||||
  <div id="tool-tags-primary" class="mb-4"></div>
 | 
			
		||||
  
 | 
			
		||||
  <div id="tool-links-primary" style="display: flex; gap: 0.5rem; flex-direction: column;"></div>
 | 
			
		||||
  <div id="tool-links-primary" class="flex flex-col gap-2"></div>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<!-- Secondary Modal -->
 | 
			
		||||
<div class="tool-details" id="tool-details-secondary">
 | 
			
		||||
  <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 1rem;">
 | 
			
		||||
    <h2 id="tool-name-secondary" style="margin: 0;">Tool Name</h2>
 | 
			
		||||
    <div style="display: flex; align-items: center; gap: 0.5rem;">
 | 
			
		||||
  <div class="flex justify-between items-start mb-4">
 | 
			
		||||
    <h2 id="tool-name-secondary" class="m-0">Tool Name</h2>
 | 
			
		||||
    <div class="flex items-center gap-2">
 | 
			
		||||
      <div id="share-button-secondary" style="display: none;">
 | 
			
		||||
        <!-- Share button will be populated by JavaScript -->
 | 
			
		||||
      </div>
 | 
			
		||||
      <div id="contribute-button-secondary" style="display: none;">
 | 
			
		||||
        <!-- Contribution button will be populated by JavaScript -->
 | 
			
		||||
      </div>
 | 
			
		||||
      <button class="btn-icon" onclick="window.hideToolDetails('secondary')">
 | 
			
		||||
        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
 | 
			
		||||
          <line x1="18" y1="6" x2="6" y2="18"></line>
 | 
			
		||||
@ -191,13 +195,13 @@ domains.forEach((domain: any) => {
 | 
			
		||||
  
 | 
			
		||||
  <p id="tool-description-secondary" class="text-muted"></p>
 | 
			
		||||
  
 | 
			
		||||
  <div id="tool-badges-secondary" style="display: flex; gap: 0.5rem; margin-bottom: 1rem;"></div>
 | 
			
		||||
  <div id="tool-badges-secondary" class="flex gap-2 mb-4"></div>
 | 
			
		||||
  
 | 
			
		||||
  <div id="tool-metadata-secondary" style="margin-bottom: 1rem;"></div>
 | 
			
		||||
  <div id="tool-metadata-secondary" class="mb-4"></div>
 | 
			
		||||
  
 | 
			
		||||
  <div id="tool-tags-secondary" style="margin-bottom: 1rem;"></div>
 | 
			
		||||
  <div id="tool-tags-secondary" class="mb-4"></div>
 | 
			
		||||
  
 | 
			
		||||
  <div id="tool-links-secondary" style="display: flex; gap: 0.5rem; flex-direction: column;"></div>
 | 
			
		||||
  <div id="tool-links-secondary" class="flex flex-col gap-2"></div>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<script define:vars={{ toolsData: tools, domainAgnosticSoftware, domainAgnosticTools }}>
 | 
			
		||||
@ -278,28 +282,9 @@ domains.forEach((domain: any) => {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // ===== SHARING FUNCTIONALITY =====
 | 
			
		||||
  
 | 
			
		||||
  // Create tool slug from name (same logic as ShareButton.astro)
 | 
			
		||||
  function createToolSlug(toolName) {
 | 
			
		||||
    return toolName.toLowerCase()
 | 
			
		||||
      .replace(/[^a-z0-9\s-]/g, '') // Remove special characters
 | 
			
		||||
      .replace(/\s+/g, '-')         // Replace spaces with hyphens
 | 
			
		||||
      .replace(/-+/g, '-')          // Remove duplicate hyphens
 | 
			
		||||
      .replace(/^-|-$/g, '');       // Remove leading/trailing hyphens
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Find tool by name or slug
 | 
			
		||||
  function findTool(identifier) {
 | 
			
		||||
    return toolsData.find(tool => 
 | 
			
		||||
      tool.name === identifier || 
 | 
			
		||||
      createToolSlug(tool.name) === identifier.toLowerCase()
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Generate share URLs
 | 
			
		||||
  function generateShareURL(toolName, view, modal = null) {
 | 
			
		||||
    const toolSlug = createToolSlug(toolName);
 | 
			
		||||
    const toolSlug = window.createToolSlug(toolName);
 | 
			
		||||
    const baseUrl = window.location.origin + window.location.pathname;
 | 
			
		||||
    const params = new URLSearchParams();
 | 
			
		||||
    params.set('tool', toolSlug);
 | 
			
		||||
@ -353,10 +338,18 @@ domains.forEach((domain: any) => {
 | 
			
		||||
      backdrop = document.createElement('div');
 | 
			
		||||
      backdrop.id = 'share-modal-backdrop';
 | 
			
		||||
      backdrop.style.cssText = `
 | 
			
		||||
        position: fixed; top: 0; left: 0; right: 0; bottom: 0;
 | 
			
		||||
        background: rgba(0, 0, 0, 0.5); z-index: 9999;
 | 
			
		||||
        display: flex; align-items: center; justify-content: center;
 | 
			
		||||
        opacity: 0; transition: opacity 0.2s ease;
 | 
			
		||||
        position: fixed;
 | 
			
		||||
        top: 0;
 | 
			
		||||
        left: 0;
 | 
			
		||||
        right: 0;
 | 
			
		||||
        bottom: 0;
 | 
			
		||||
        background-color: rgba(0, 0, 0, 0.5);
 | 
			
		||||
        z-index: 1000;
 | 
			
		||||
        display: flex;
 | 
			
		||||
        align-items: center;
 | 
			
		||||
        justify-content: center;
 | 
			
		||||
        opacity: 0;
 | 
			
		||||
        transition: opacity 0.2s ease;
 | 
			
		||||
      `;
 | 
			
		||||
      document.body.appendChild(backdrop);
 | 
			
		||||
    }
 | 
			
		||||
@ -364,21 +357,27 @@ domains.forEach((domain: any) => {
 | 
			
		||||
    // Create share dialog
 | 
			
		||||
    const dialog = document.createElement('div');
 | 
			
		||||
    dialog.style.cssText = `
 | 
			
		||||
      background: var(--color-bg); border: 1px solid var(--color-border);
 | 
			
		||||
      border-radius: 0.75rem; padding: 1.5rem; max-width: 400px; width: 90%;
 | 
			
		||||
      box-shadow: var(--shadow-lg); transform: scale(0.9); transition: transform 0.2s ease;
 | 
			
		||||
      background-color: var(--color-bg);
 | 
			
		||||
      border: 1px solid var(--color-border);
 | 
			
		||||
      border-radius: 0.5rem;
 | 
			
		||||
      padding: 1.5rem;
 | 
			
		||||
      max-width: 28rem;
 | 
			
		||||
      width: 90%;
 | 
			
		||||
      box-shadow: var(--shadow-lg);
 | 
			
		||||
      transform: scale(0.9);
 | 
			
		||||
      transition: transform 0.2s ease;
 | 
			
		||||
    `;
 | 
			
		||||
    
 | 
			
		||||
    dialog.innerHTML = `
 | 
			
		||||
      <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem;">
 | 
			
		||||
        <h3 style="margin: 0; color: var(--color-primary);">
 | 
			
		||||
          <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem; vertical-align: middle;">
 | 
			
		||||
        <h3 style="margin: 0; color: var(--color-primary); display: flex; align-items: center;">
 | 
			
		||||
          <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
 | 
			
		||||
            <circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
 | 
			
		||||
            <line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
 | 
			
		||||
          </svg>
 | 
			
		||||
          ${toolName} teilen
 | 
			
		||||
        </h3>
 | 
			
		||||
        <button id="close-share-dialog" style="background: none; border: none; cursor: pointer; padding: 0.25rem;color: var(--color-text-secondary)">
 | 
			
		||||
        <button id="close-share-dialog" class="btn-icon">
 | 
			
		||||
          <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
 | 
			
		||||
            <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
 | 
			
		||||
          </svg>
 | 
			
		||||
@ -386,44 +385,54 @@ domains.forEach((domain: any) => {
 | 
			
		||||
      </div>
 | 
			
		||||
      
 | 
			
		||||
      <div style="display: flex; flex-direction: column; gap: 0.75rem;">
 | 
			
		||||
        <button class="share-option-btn" data-url="${generateShareURL(toolName, 'grid')}" 
 | 
			
		||||
                style="display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; border: 1px solid var(--color-border); border-radius: 0.5rem; background: var(--color-bg); cursor: pointer; transition: var(--transition-fast); text-align: left; width: 100%;">
 | 
			
		||||
          <div style="width: 32px; height: 32px; background: var(--color-primary); border-radius: 0.25rem; display: flex; align-items: center; justify-content: center;">
 | 
			
		||||
        <button class="share-option-btn" data-url="${generateShareURL(toolName, 'grid')}" style="display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; border: 1px solid var(--color-border); border-radius: 0.5rem; background-color: var(--color-bg); cursor: pointer; transition: var(--transition-fast); text-align: left; width: 100%;" onmouseover="this.style.backgroundColor='var(--color-bg-secondary)'" onmouseout="this.style.backgroundColor='var(--color-bg)'">
 | 
			
		||||
          <div style="width: 2rem; height: 2rem; background-color: var(--color-primary); border-radius: 0.25rem; display: flex; align-items: center; justify-content: center;">
 | 
			
		||||
            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
 | 
			
		||||
              <rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
 | 
			
		||||
              <rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
 | 
			
		||||
              <rect x="3" y="3" width="7" height="7"/>
 | 
			
		||||
              <rect x="14" y="3" width="7" height="7"/>
 | 
			
		||||
              <rect x="14" y="14" width="7" height="7"/>
 | 
			
		||||
              <rect x="3" y="14" width="7" height="7"/>
 | 
			
		||||
            </svg>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div>
 | 
			
		||||
            <div style="font-weight: 500; margin-bottom: 0.25rem;color: var(--color-text-secondary)">Kachelansicht</div>
 | 
			
		||||
            <div style="font-size: 0.8125rem; color: var(--color-text-secondary);">Scrollt zur Karte in der Übersicht</div>
 | 
			
		||||
            <div style="font-weight: 500; margin-bottom: 0.25rem; color: var(--color-text);">Kachelansicht</div>
 | 
			
		||||
            <div style="font-size: 0.875rem; color: var(--color-text-secondary);">Scrollt zur Karte in der Übersicht</div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </button>
 | 
			
		||||
        
 | 
			
		||||
        <button class="share-option-btn" data-url="${generateShareURL(toolName, 'matrix')}" 
 | 
			
		||||
                style="display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; border: 1px solid var(--color-border); border-radius: 0.5rem; background: var(--color-bg); cursor: pointer; transition: var(--transition-fast); text-align: left; width: 100%;">
 | 
			
		||||
          <div style="width: 32px; height: 32px; background: var(--color-accent); border-radius: 0.25rem; display: flex; align-items: center; justify-content: center;">
 | 
			
		||||
        <button class="share-option-btn" data-url="${generateShareURL(toolName, 'matrix')}" style="display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; border: 1px solid var(--color-border); border-radius: 0.5rem; background-color: var(--color-bg); cursor: pointer; transition: var(--transition-fast); text-align: left; width: 100%;" onmouseover="this.style.backgroundColor='var(--color-bg-secondary)'" onmouseout="this.style.backgroundColor='var(--color-bg)'">
 | 
			
		||||
          <div style="width: 2rem; height: 2rem; background-color: var(--color-accent); border-radius: 0.25rem; display: flex; align-items: center; justify-content: center;">
 | 
			
		||||
            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
 | 
			
		||||
              <path d="M3 3h7v7H3zM14 3h7v7h-7zM14 14h7v7h-7zM3 14h7v7H3z"/>
 | 
			
		||||
              <rect x="3" y="3" width="4" height="4"/>
 | 
			
		||||
              <rect x="10" y="3" width="4" height="4"/>
 | 
			
		||||
              <rect x="17" y="3" width="4" height="4"/>
 | 
			
		||||
              <rect x="3" y="10" width="4" height="4"/>
 | 
			
		||||
              <rect x="10" y="10" width="4" height="4"/>
 | 
			
		||||
              <rect x="17" y="10" width="4" height="4"/>
 | 
			
		||||
              <rect x="3" y="17" width="4" height="4"/>
 | 
			
		||||
              <rect x="10" y="17" width="4" height="4"/>
 | 
			
		||||
              <rect x="17" y="17" width="4" height="4"/>
 | 
			
		||||
            </svg>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div>
 | 
			
		||||
            <div style="font-weight: 500; margin-bottom: 0.25rem;color: var(--color-text-secondary)">Matrix-Ansicht</div>
 | 
			
		||||
            <div style="font-size: 0.8125rem; color: var(--color-text-secondary);">Zeigt Tool-Position in der Matrix</div>
 | 
			
		||||
            <div style="font-weight: 500; margin-bottom: 0.25rem; color: var(--color-text);">Matrix-Ansicht</div>
 | 
			
		||||
            <div style="font-size: 0.875rem; color: var(--color-text-secondary);">Zeigt Tool-Position in der Matrix</div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </button>
 | 
			
		||||
        
 | 
			
		||||
        <button class="share-option-btn" data-url="${generateShareURL(toolName, 'modal', 'primary')}" 
 | 
			
		||||
                style="display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; border: 1px solid var(--color-border); border-radius: 0.5rem; background: var(--color-bg); cursor: pointer; transition: var(--transition-fast); text-align: left; width: 100%;">
 | 
			
		||||
          <div style="width: 32px; height: 32px; background: var(--color-warning); border-radius: 0.25rem; display: flex; align-items: center; justify-content: center;">
 | 
			
		||||
        <button class="share-option-btn" data-url="${generateShareURL(toolName, 'modal', 'primary')}" style="display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; border: 1px solid var(--color-border); border-radius: 0.5rem; background-color: var(--color-bg); cursor: pointer; transition: var(--transition-fast); text-align: left; width: 100%;" onmouseover="this.style.backgroundColor='var(--color-bg-secondary)'" onmouseout="this.style.backgroundColor='var(--color-bg)'">
 | 
			
		||||
          <div style="width: 2rem; height: 2rem; background-color: var(--color-warning); border-radius: 0.25rem; display: flex; align-items: center; justify-content: center;">
 | 
			
		||||
            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
 | 
			
		||||
              <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
 | 
			
		||||
              <polyline points="14 2 14 8 20 8"/>
 | 
			
		||||
              <line x1="16" y1="13" x2="8" y2="13"/>
 | 
			
		||||
              <line x1="16" y1="17" x2="8" y2="17"/>
 | 
			
		||||
              <polyline points="10 9 9 9 8 9"/>
 | 
			
		||||
            </svg>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div>
 | 
			
		||||
            <div style="font-weight: 500; margin-bottom: 0.25rem;color: var(--color-text-secondary)">Tool-Details</div>
 | 
			
		||||
            <div style="font-size: 0.8125rem; color: var(--color-text-secondary);">Öffnet Detail-Fenster direkt</div>
 | 
			
		||||
            <div style="font-weight: 500; margin-bottom: 0.25rem; color: var(--color-text);">Tool-Details</div>
 | 
			
		||||
            <div style="font-size: 0.875rem; color: var(--color-text-secondary);">Öffnet Detail-Fenster direkt</div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
@ -456,16 +465,6 @@ domains.forEach((domain: any) => {
 | 
			
		||||
    
 | 
			
		||||
    // Share option handlers
 | 
			
		||||
    dialog.querySelectorAll('.share-option-btn').forEach(btn => {
 | 
			
		||||
      btn.addEventListener('mouseover', () => {
 | 
			
		||||
        btn.style.backgroundColor = 'var(--color-bg-secondary)';
 | 
			
		||||
        btn.style.borderColor = 'var(--color-primary)';
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      btn.addEventListener('mouseout', () => {
 | 
			
		||||
        btn.style.backgroundColor = 'var(--color-bg)';
 | 
			
		||||
        btn.style.borderColor = 'var(--color-border)';
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      btn.addEventListener('click', () => {
 | 
			
		||||
        const url = btn.getAttribute('data-url');
 | 
			
		||||
        copyToClipboard(url, btn);
 | 
			
		||||
@ -506,15 +505,12 @@ domains.forEach((domain: any) => {
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Update modal content
 | 
			
		||||
    const iconHtml = tool.icon ? `<span style="margin-right: 0.75rem; font-size: 1.5rem;">${tool.icon}</span>` : '';
 | 
			
		||||
    const iconHtml = tool.icon ? `<span class="mr-3 text-xl">${tool.icon}</span>` : '';
 | 
			
		||||
    elements.name.innerHTML = `${iconHtml}${tool.name}`;
 | 
			
		||||
    elements.description.textContent = tool.description;
 | 
			
		||||
 | 
			
		||||
    // Badges
 | 
			
		||||
    const hasValidProjectUrl = tool.projectUrl !== undefined && 
 | 
			
		||||
                              tool.projectUrl !== null && 
 | 
			
		||||
                              tool.projectUrl !== "" && 
 | 
			
		||||
                              tool.projectUrl.trim() !== "";
 | 
			
		||||
    const hasValidProjectUrl = window.isToolHosted(tool);
 | 
			
		||||
 | 
			
		||||
    elements.badges.innerHTML = '';
 | 
			
		||||
    if (isConcept) {
 | 
			
		||||
@ -535,7 +531,7 @@ domains.forEach((domain: any) => {
 | 
			
		||||
    const domainsText = domains.length > 0 ? domains.join(', ') : 'Domain-agnostic';
 | 
			
		||||
    const phasesText = phases.join(', ');
 | 
			
		||||
    
 | 
			
		||||
    let metadataHTML = `<div style="display: grid; gap: 0.5rem;">`;
 | 
			
		||||
    let metadataHTML = `<div class="grid gap-2">`;
 | 
			
		||||
    
 | 
			
		||||
    if (!isConcept) {
 | 
			
		||||
      metadataHTML += `
 | 
			
		||||
@ -558,7 +554,7 @@ domains.forEach((domain: any) => {
 | 
			
		||||
    // Tags and Related Concepts
 | 
			
		||||
    const tags = tool.tags || [];
 | 
			
		||||
    let tagsHTML = `
 | 
			
		||||
      <div style="display: flex; flex-wrap: wrap; gap: 0.25rem;">
 | 
			
		||||
      <div class="flex flex-wrap gap-1">
 | 
			
		||||
        ${tags.map(tag => `<span class="tag">${tag}</span>`).join('')}
 | 
			
		||||
      </div>
 | 
			
		||||
    `;
 | 
			
		||||
@ -569,14 +565,14 @@ domains.forEach((domain: any) => {
 | 
			
		||||
      const conceptLinks = relatedConcepts.map(conceptName => {
 | 
			
		||||
        const concept = toolsData.find(t => t.name === conceptName && t.type === 'concept');
 | 
			
		||||
        if (concept) {
 | 
			
		||||
          return `<button class="tag" style="cursor: pointer; background-color: var(--color-concept-bg); border: 1px solid var(--color-concept); color: var(--color-concept); transition: var(--transition-fast); margin: 0.125rem;" 
 | 
			
		||||
          return `<button class="tag cursor-pointer" style="background-color: var(--color-concept-bg); border: 1px solid var(--color-concept); color: var(--color-concept); transition: var(--transition-fast);" 
 | 
			
		||||
                    onclick="event.stopPropagation(); window.showToolDetails('${conceptName}', 'secondary')" 
 | 
			
		||||
                    onmouseover="this.style.backgroundColor='var(--color-concept)'; this.style.color='white';"
 | 
			
		||||
                    onmouseout="this.style.backgroundColor='var(--color-concept-bg)'; this.style.color='var(--color-concept)';">
 | 
			
		||||
                    ${conceptName}
 | 
			
		||||
                  </button>`;
 | 
			
		||||
        }
 | 
			
		||||
        return `<span class="tag" style="background-color: var(--color-bg-tertiary); color: var(--color-text-secondary); margin: 0.125rem;">${conceptName}</span>`;
 | 
			
		||||
        return `<span class="tag" style="background-color: var(--color-bg-tertiary); color: var(--color-text-secondary);">${conceptName}</span>`;
 | 
			
		||||
      }).join('');
 | 
			
		||||
 | 
			
		||||
      // Check if mobile device
 | 
			
		||||
@ -584,18 +580,18 @@ domains.forEach((domain: any) => {
 | 
			
		||||
      const collapseOnMobile = isMobile && relatedConcepts.length > 2;
 | 
			
		||||
 | 
			
		||||
      tagsHTML += `
 | 
			
		||||
        <div style="margin-top: 1rem;">
 | 
			
		||||
          <div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
 | 
			
		||||
        <div class="mt-4">
 | 
			
		||||
          <div class="flex items-center gap-2 mb-2">
 | 
			
		||||
            <strong style="color: var(--color-text);">Verwandte Konzepte:</strong>
 | 
			
		||||
            ${collapseOnMobile ? `
 | 
			
		||||
              <button id="concepts-toggle-${modalType}" 
 | 
			
		||||
                      onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? 'block' : 'none'; this.textContent = this.textContent === '▼' ? '▲' : '▼';"
 | 
			
		||||
                      style="background: none; border: 1px solid var(--color-border); border-radius: 0.25rem; padding: 0.25rem 0.5rem; cursor: pointer; font-size: 0.75rem;">
 | 
			
		||||
                      class="btn-icon text-xs">
 | 
			
		||||
                ▼
 | 
			
		||||
              </button>
 | 
			
		||||
            ` : ''}
 | 
			
		||||
          </div>
 | 
			
		||||
          <div ${collapseOnMobile ? 'style="display: none;"' : ''} style="display: flex; flex-wrap: wrap; gap: 0.25rem;">
 | 
			
		||||
          <div ${collapseOnMobile ? 'style="display: none;"' : ''} class="flex flex-wrap gap-1">
 | 
			
		||||
            ${conceptLinks}
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
@ -609,40 +605,40 @@ domains.forEach((domain: any) => {
 | 
			
		||||
 | 
			
		||||
    if (isConcept) {
 | 
			
		||||
      linksHTML += `
 | 
			
		||||
        <a href="${tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="width: 100%; background-color: var(--color-concept); border-color: var(--color-concept);">
 | 
			
		||||
        <a href="${tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-primary w-full" style="background-color: var(--color-concept); border-color: var(--color-concept);">
 | 
			
		||||
          Mehr erfahren
 | 
			
		||||
        </a>
 | 
			
		||||
      `;
 | 
			
		||||
    } else if (isMethod) {
 | 
			
		||||
      linksHTML += `
 | 
			
		||||
        <a href="${tool.projectUrl || tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="width: 100%; background-color: var(--color-method); border-color: var(--color-method);">
 | 
			
		||||
        <a href="${tool.projectUrl || tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-primary w-full" style="background-color: var(--color-method); border-color: var(--color-method);">
 | 
			
		||||
          Zur Methode
 | 
			
		||||
        </a>
 | 
			
		||||
      `;
 | 
			
		||||
    } else if (hasValidProjectUrl) {
 | 
			
		||||
      linksHTML += `
 | 
			
		||||
        <div style="display: flex; gap: 0.5rem;">
 | 
			
		||||
          <a href="${tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-secondary" style="flex: 1;">
 | 
			
		||||
        <div class="flex gap-2">
 | 
			
		||||
          <a href="${tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-secondary flex-1">
 | 
			
		||||
            Homepage
 | 
			
		||||
          </a>
 | 
			
		||||
          <a href="${tool.projectUrl}" target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="flex: 1;">
 | 
			
		||||
          <a href="${tool.projectUrl}" target="_blank" rel="noopener noreferrer" class="btn btn-primary flex-1">
 | 
			
		||||
            Zugreifen
 | 
			
		||||
          </a>
 | 
			
		||||
        </div>
 | 
			
		||||
      `;
 | 
			
		||||
    } else {
 | 
			
		||||
      linksHTML += `
 | 
			
		||||
        <a href="${tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="width: 100%;">
 | 
			
		||||
        <a href="${tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-primary w-full">
 | 
			
		||||
          Software-Homepage
 | 
			
		||||
        </a>
 | 
			
		||||
      `;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    if (tool.knowledgebase === true) {
 | 
			
		||||
      const kbId = tool.name.toLowerCase().replace(/\s+/g, '-');
 | 
			
		||||
      const kbId = window.createToolSlug(tool.name);
 | 
			
		||||
      linksHTML += `
 | 
			
		||||
        <a href="/knowledgebase#kb-${kbId}" class="btn btn-secondary" style="width: 100%; margin-top: 0.5rem;">
 | 
			
		||||
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
 | 
			
		||||
        <a href="/knowledgebase#kb-${kbId}" class="btn btn-secondary w-full mt-2">
 | 
			
		||||
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="mr-2">
 | 
			
		||||
            <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
 | 
			
		||||
            <polyline points="14 2 14 8 20 8"/>
 | 
			
		||||
            <line x1="16" y1="13" x2="8" y2="13"/>
 | 
			
		||||
@ -659,7 +655,7 @@ domains.forEach((domain: any) => {
 | 
			
		||||
    // ===== POPULATE SHARE BUTTON =====
 | 
			
		||||
    const shareButtonContainer = document.getElementById(`share-button-${modalType}`);
 | 
			
		||||
    if (shareButtonContainer) {
 | 
			
		||||
      const toolSlug = createToolSlug(tool.name);
 | 
			
		||||
      const toolSlug = window.createToolSlug(tool.name);
 | 
			
		||||
      shareButtonContainer.innerHTML = `
 | 
			
		||||
        <button class="share-btn share-btn--medium" 
 | 
			
		||||
                data-tool-name="${tool.name}" 
 | 
			
		||||
@ -679,6 +675,26 @@ domains.forEach((domain: any) => {
 | 
			
		||||
      `;
 | 
			
		||||
      shareButtonContainer.style.display = 'block';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // ===== POPULATE CONTRIBUTION BUTTON =====
 | 
			
		||||
    const contributeButtonContainer = document.getElementById(`contribute-button-${modalType}`);
 | 
			
		||||
    if (contributeButtonContainer) {
 | 
			
		||||
      contributeButtonContainer.innerHTML = `
 | 
			
		||||
        <a href="/contribute/tool?edit=${encodeURIComponent(tool.name)}" 
 | 
			
		||||
          class="btn-icon" 
 | 
			
		||||
          data-contribute-button="edit"
 | 
			
		||||
          data-tool-name="${tool.name}"
 | 
			
		||||
          title="Edit ${tool.name}"
 | 
			
		||||
          aria-label="Edit ${tool.name}"
 | 
			
		||||
          onclick="event.stopPropagation();">
 | 
			
		||||
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
 | 
			
		||||
            <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
 | 
			
		||||
            <path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
 | 
			
		||||
          </svg>
 | 
			
		||||
        </a>
 | 
			
		||||
      `;
 | 
			
		||||
      contributeButtonContainer.style.display = 'block';
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Show modals and update layout
 | 
			
		||||
    const overlay = document.getElementById('modal-overlay');
 | 
			
		||||
@ -703,29 +719,34 @@ domains.forEach((domain: any) => {
 | 
			
		||||
    const primaryModal = document.getElementById('tool-details-primary');
 | 
			
		||||
    const secondaryModal = document.getElementById('tool-details-secondary');
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    if (modalType === 'both' || modalType === 'all') {
 | 
			
		||||
      if (primaryModal) {
 | 
			
		||||
        primaryModal.classList.remove('active');
 | 
			
		||||
        // Hide share button
 | 
			
		||||
        const shareButtonPrimary = document.getElementById('share-button-primary');
 | 
			
		||||
        const contributeButtonPrimary = document.getElementById('contribute-button-primary');
 | 
			
		||||
        if (shareButtonPrimary) shareButtonPrimary.style.display = 'none';
 | 
			
		||||
        if (contributeButtonPrimary) contributeButtonPrimary.style.display = 'none';
 | 
			
		||||
      }
 | 
			
		||||
      if (secondaryModal) {
 | 
			
		||||
        secondaryModal.classList.remove('active');
 | 
			
		||||
        // Hide share button
 | 
			
		||||
        const shareButtonSecondary = document.getElementById('share-button-secondary');
 | 
			
		||||
        const contributeButtonSecondary = document.getElementById('contribute-button-secondary');
 | 
			
		||||
        if (shareButtonSecondary) shareButtonSecondary.style.display = 'none';
 | 
			
		||||
        if (contributeButtonSecondary) contributeButtonSecondary.style.display = 'none';
 | 
			
		||||
      }
 | 
			
		||||
      if (overlay) overlay.classList.remove('active');
 | 
			
		||||
      document.body.classList.remove('modals-side-by-side');
 | 
			
		||||
    } else if (modalType === 'primary' && primaryModal) {
 | 
			
		||||
      primaryModal.classList.remove('active');
 | 
			
		||||
      const shareButtonPrimary = document.getElementById('share-button-primary');
 | 
			
		||||
      const contributeButtonPrimary = document.getElementById('contribute-button-primary');
 | 
			
		||||
      if (shareButtonPrimary) shareButtonPrimary.style.display = 'none';
 | 
			
		||||
      if (contributeButtonPrimary) contributeButtonPrimary.style.display = 'none';
 | 
			
		||||
    } else if (modalType === 'secondary' && secondaryModal) {
 | 
			
		||||
      secondaryModal.classList.remove('active');
 | 
			
		||||
      const shareButtonSecondary = document.getElementById('share-button-secondary');
 | 
			
		||||
      const contributeButtonSecondary = document.getElementById('contribute-button-secondary');
 | 
			
		||||
      if (shareButtonSecondary) shareButtonSecondary.style.display = 'none';
 | 
			
		||||
      if (contributeButtonSecondary) contributeButtonSecondary.style.display = 'none';
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Check if any modal is still active
 | 
			
		||||
@ -790,10 +811,7 @@ domains.forEach((domain: any) => {
 | 
			
		||||
          }
 | 
			
		||||
          
 | 
			
		||||
          const isMethod = tool.type === 'method';
 | 
			
		||||
          const hasValidProjectUrl = tool.projectUrl !== undefined && 
 | 
			
		||||
                                    tool.projectUrl !== null && 
 | 
			
		||||
                                    tool.projectUrl !== "" && 
 | 
			
		||||
                                    tool.projectUrl.trim() !== "";
 | 
			
		||||
          const hasValidProjectUrl = window.isToolHosted(tool);
 | 
			
		||||
          
 | 
			
		||||
          const domains = tool.domains || [];
 | 
			
		||||
          const phases = tool.phases || [];
 | 
			
		||||
 | 
			
		||||
@ -4,26 +4,18 @@ const knowledgebaseCollection = defineCollection({
 | 
			
		||||
  type: 'content',
 | 
			
		||||
  schema: z.object({
 | 
			
		||||
    title: z.string(),
 | 
			
		||||
    tool_name: z.string(),
 | 
			
		||||
    description: z.string(),
 | 
			
		||||
    last_updated: z.date(),
 | 
			
		||||
    author: z.string().default('CC24-Team'),
 | 
			
		||||
    difficulty: z.enum(['novice', 'beginner', 'intermediate', 'advanced', 'expert']),
 | 
			
		||||
    
 | 
			
		||||
    tool_name: z.string().optional(),           
 | 
			
		||||
    related_tools: z.array(z.string()).default([]), 
 | 
			
		||||
    
 | 
			
		||||
    author: z.string().default('Anon'),
 | 
			
		||||
    difficulty: z.enum(['novice', 'beginner', 'intermediate', 'advanced', 'expert']).optional(),
 | 
			
		||||
    categories: z.array(z.string()).default([]),
 | 
			
		||||
    tags: z.array(z.string()).default([]),
 | 
			
		||||
    sections: z.object({
 | 
			
		||||
      overview: z.boolean().default(true),
 | 
			
		||||
      installation: z.boolean().default(false),
 | 
			
		||||
      configuration: z.boolean().default(false),
 | 
			
		||||
      usage_examples: z.boolean().default(true),
 | 
			
		||||
      best_practices: z.boolean().default(true),
 | 
			
		||||
      troubleshooting: z.boolean().default(false),
 | 
			
		||||
      advanced_topics: z.boolean().default(false),
 | 
			
		||||
    }).default({}),
 | 
			
		||||
    review_status: z.enum(['draft', 'review', 'published']).default('published'),
 | 
			
		||||
    
 | 
			
		||||
    published: z.boolean().default(true),
 | 
			
		||||
    
 | 
			
		||||
  })
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const collections = {
 | 
			
		||||
  knowledgebase: knowledgebaseCollection,
 | 
			
		||||
};
 | 
			
		||||
});
 | 
			
		||||
@ -3,7 +3,7 @@ title: "Kali Linux - Die Hacker-Distribution für Forensik & Penetration Testing
 | 
			
		||||
tool_name: "Kali Linux"
 | 
			
		||||
description: "Leitfaden zur Installation, Nutzung und Best Practices für Kali Linux – die All-in-One-Plattform für Security-Profis."
 | 
			
		||||
last_updated: 2025-07-20
 | 
			
		||||
author: "CC24-Team"
 | 
			
		||||
author: "Claude 4 Sonnet"
 | 
			
		||||
difficulty: "intermediate"
 | 
			
		||||
categories: ["incident-response", "forensics", "penetration-testing"]
 | 
			
		||||
tags: ["live-boot", "tool-collection", "penetration-testing", "forensics-suite", "virtualization", "arm-support"]
 | 
			
		||||
 | 
			
		||||
@ -3,9 +3,9 @@ title: "MISP - Plattform für Threat Intelligence Sharing"
 | 
			
		||||
tool_name: "MISP"
 | 
			
		||||
description: "Das Rückgrat des modernen Threat-Intelligence-Sharings mit über 40.000 aktiven Instanzen weltweit."
 | 
			
		||||
last_updated: 2025-07-20
 | 
			
		||||
author: "CC24-Team"
 | 
			
		||||
author: "Claude 4 Sonnet"
 | 
			
		||||
difficulty: "intermediate"
 | 
			
		||||
categories: ["incident-response", "law-enforcement", "malware-analysis", "network-forensics", "cloud-forensics"]
 | 
			
		||||
categories: ["incident-response", "static-investigations", "malware-analysis", "network-forensics", "cloud-forensics"]
 | 
			
		||||
tags: ["web-based", "threat-intelligence", "api", "correlation", "ioc-sharing", "automation"]
 | 
			
		||||
sections:
 | 
			
		||||
  overview: true
 | 
			
		||||
 | 
			
		||||
@ -3,7 +3,7 @@ title: "Nextcloud - Sichere Kollaborationsplattform"
 | 
			
		||||
tool_name: "Nextcloud"
 | 
			
		||||
description: "Detaillierte Anleitung und Best Practices für Nextcloud in forensischen Einsatzszenarien"
 | 
			
		||||
last_updated: 2025-07-20
 | 
			
		||||
author: "CC24-Team"
 | 
			
		||||
author: "Claude 4 Sonnet"
 | 
			
		||||
difficulty: "novice"
 | 
			
		||||
categories: ["collaboration-general"]
 | 
			
		||||
tags: ["web-based", "collaboration", "file-sharing", "api", "encryption", "document-management"]
 | 
			
		||||
 | 
			
		||||
@ -3,7 +3,7 @@ title: "Regular Expressions (Regex) – Musterbasierte Textanalyse"
 | 
			
		||||
tool_name: "Regular Expressions (Regex)"
 | 
			
		||||
description: "Pattern matching language für Suche, Extraktion und Manipulation von Text in forensischen Analysen."
 | 
			
		||||
last_updated: 2025-07-20
 | 
			
		||||
author: "CC24-Team"
 | 
			
		||||
author: "Claude 4 Sonnet"
 | 
			
		||||
difficulty: "intermediate"
 | 
			
		||||
categories: ["incident-response", "malware-analysis", "network-forensics", "fraud-investigation"]
 | 
			
		||||
tags: ["pattern-matching", "text-processing", "log-analysis", "string-manipulation", "search-algorithms"]
 | 
			
		||||
 | 
			
		||||
@ -3,7 +3,7 @@ title: "Velociraptor – Skalierbare Endpoint-Forensik mit VQL"
 | 
			
		||||
tool_name: "Velociraptor"
 | 
			
		||||
description: "Detaillierte Anleitung und Best Practices für Velociraptor – Remote-Forensik der nächsten Generation"
 | 
			
		||||
last_updated: 2025-07-20
 | 
			
		||||
author: "CC24-Team"
 | 
			
		||||
author: "Claude 4 Sonnet"
 | 
			
		||||
difficulty: "advanced"
 | 
			
		||||
categories: ["incident-response", "malware-analysis", "network-forensics"]
 | 
			
		||||
tags: ["web-based", "endpoint-monitoring", "artifact-extraction", "scripting", "live-forensics", "hunting"]
 | 
			
		||||
 | 
			
		||||
@ -20,7 +20,7 @@ tools:
 | 
			
		||||
    icon: 📦
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - malware-analysis
 | 
			
		||||
      - mobile-forensics
 | 
			
		||||
      - cloud-forensics
 | 
			
		||||
@ -49,7 +49,7 @@ tools:
 | 
			
		||||
      Formatunterstützung.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - malware-analysis
 | 
			
		||||
      - network-forensics
 | 
			
		||||
    phases:
 | 
			
		||||
@ -88,7 +88,7 @@ tools:
 | 
			
		||||
      Kollaborations-Lösungen am Markt.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - malware-analysis
 | 
			
		||||
      - network-forensics
 | 
			
		||||
    phases:
 | 
			
		||||
@ -125,7 +125,7 @@ tools:
 | 
			
		||||
      SIEMs, Firewalls und andere Sicherheitssysteme.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - malware-analysis
 | 
			
		||||
      - network-forensics
 | 
			
		||||
      - cloud-forensics
 | 
			
		||||
@ -159,7 +159,7 @@ tools:
 | 
			
		||||
      mehreren Analysten und Millionen von Zeitstempeln.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - network-forensics
 | 
			
		||||
      - cloud-forensics
 | 
			
		||||
    phases:
 | 
			
		||||
@ -235,7 +235,7 @@ tools:
 | 
			
		||||
      für Behörden und Großunternehmen interessant.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - mobile-forensics
 | 
			
		||||
      - cloud-forensics
 | 
			
		||||
    phases:
 | 
			
		||||
@ -271,7 +271,7 @@ tools:
 | 
			
		||||
      Visualisierung verständlich. Mit Preisen im sechsstelligen Bereich und
 | 
			
		||||
      ethischen Bedenken bezüglich der Käuferauswahl nicht unumstritten.
 | 
			
		||||
    domains:
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - mobile-forensics
 | 
			
		||||
    phases:
 | 
			
		||||
      - data-collection
 | 
			
		||||
@ -374,7 +374,7 @@ tools:
 | 
			
		||||
      unübertroffen.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - network-forensics
 | 
			
		||||
      - cloud-forensics
 | 
			
		||||
    phases:
 | 
			
		||||
@ -410,7 +410,7 @@ tools:
 | 
			
		||||
      für CTF-Challenges und tägliche Forensik-Aufgaben gleichermaßen.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - malware-analysis
 | 
			
		||||
      - network-forensics
 | 
			
		||||
    phases:
 | 
			
		||||
@ -445,7 +445,7 @@ tools:
 | 
			
		||||
      Effizienzgewinne bei großen Infrastrukturen sind enorm.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - malware-analysis
 | 
			
		||||
      - fraud-investigation
 | 
			
		||||
      - network-forensics
 | 
			
		||||
@ -487,7 +487,7 @@ tools:
 | 
			
		||||
      Untersuchungen.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - malware-analysis
 | 
			
		||||
      - fraud-investigation
 | 
			
		||||
    phases:
 | 
			
		||||
@ -525,7 +525,7 @@ tools:
 | 
			
		||||
      Monitoring Operations.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - network-forensics
 | 
			
		||||
      - cloud-forensics
 | 
			
		||||
    phases:
 | 
			
		||||
@ -560,7 +560,7 @@ tools:
 | 
			
		||||
      schnelle Übersichten, an Grenzen bei verschlüsseltem Traffic.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - malware-analysis
 | 
			
		||||
      - network-forensics
 | 
			
		||||
    phases:
 | 
			
		||||
@ -596,7 +596,7 @@ tools:
 | 
			
		||||
      Dokumenten-Untersuchungen.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - fraud-investigation
 | 
			
		||||
      - mobile-forensics
 | 
			
		||||
    phases:
 | 
			
		||||
@ -633,7 +633,7 @@ tools:
 | 
			
		||||
      ersten Wahl für Behörden. Lizenzkosten im sechsstelligen Bereich
 | 
			
		||||
      limitieren den Zugang auf Großorganisationen.
 | 
			
		||||
    domains:
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - fraud-investigation
 | 
			
		||||
    phases:
 | 
			
		||||
      - analysis
 | 
			
		||||
@ -666,7 +666,7 @@ tools:
 | 
			
		||||
      Organisations-Strukturen. Die Community Edition limitiert auf einen
 | 
			
		||||
      Benutzer - für Teams ist die kommerzielle Version nötig.
 | 
			
		||||
    domains:
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - malware-analysis
 | 
			
		||||
      - fraud-investigation
 | 
			
		||||
      - network-forensics
 | 
			
		||||
@ -706,7 +706,7 @@ tools:
 | 
			
		||||
      ermöglicht automatisierte Analysen großer Datensätze. Unverzichtbar wenn
 | 
			
		||||
      Fahrzeuge, Drohnen oder mobile Geräte mit Standortdaten involviert sind.
 | 
			
		||||
    domains:
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - fraud-investigation
 | 
			
		||||
      - mobile-forensics
 | 
			
		||||
    phases:
 | 
			
		||||
@ -742,7 +742,7 @@ tools:
 | 
			
		||||
      vom Raspberry Pi für kleine Teams bis zur High-Availability-Installation.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - malware-analysis
 | 
			
		||||
      - fraud-investigation
 | 
			
		||||
      - network-forensics
 | 
			
		||||
@ -847,7 +847,7 @@ tools:
 | 
			
		||||
      überall einsetzbar.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - malware-analysis
 | 
			
		||||
      - fraud-investigation
 | 
			
		||||
      - network-forensics
 | 
			
		||||
@ -906,7 +906,7 @@ tools:
 | 
			
		||||
      bedacht werden.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - malware-analysis
 | 
			
		||||
      - fraud-investigation
 | 
			
		||||
      - network-forensics
 | 
			
		||||
@ -948,7 +948,7 @@ tools:
 | 
			
		||||
      für kleine Teams. Ideal für Organisationen, die Blockchain-Analysen ohne
 | 
			
		||||
      US-Cloud-Abhängigkeit benötigen.
 | 
			
		||||
    domains:
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - fraud-investigation
 | 
			
		||||
    phases:
 | 
			
		||||
      - analysis
 | 
			
		||||
@ -981,7 +981,7 @@ tools:
 | 
			
		||||
      angestaubt in der Oberfläche, aber bewährt in tausenden Gerichtsverfahren.
 | 
			
		||||
      Freeware, aber nicht open source.
 | 
			
		||||
    domains:
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - incident-response
 | 
			
		||||
    phases:
 | 
			
		||||
      - data-collection
 | 
			
		||||
@ -1014,7 +1014,7 @@ tools:
 | 
			
		||||
      solide Technik unter der Haube hinweg.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
    phases:
 | 
			
		||||
      - data-collection
 | 
			
		||||
    platforms:
 | 
			
		||||
@ -1046,7 +1046,7 @@ tools:
 | 
			
		||||
      Updates bei neuen macOS-Versionen.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
    phases:
 | 
			
		||||
      - data-collection
 | 
			
		||||
    platforms:
 | 
			
		||||
@ -1077,7 +1077,7 @@ tools:
 | 
			
		||||
      LEAPP-Familie, ständig aktualisiert für neue Android-Versionen.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - mobile-forensics
 | 
			
		||||
    phases:
 | 
			
		||||
      - examination
 | 
			
		||||
@ -1113,7 +1113,7 @@ tools:
 | 
			
		||||
      iOS-Änderungen und neuen Artefakten.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - mobile-forensics
 | 
			
		||||
    phases:
 | 
			
		||||
      - examination
 | 
			
		||||
@ -1147,7 +1147,7 @@ tools:
 | 
			
		||||
      unverzichtbar bei Unfallrekonstruktionen und Kriminalfällen. Die
 | 
			
		||||
      Unterstützung für verschiedene Hersteller wächst mit der Community.
 | 
			
		||||
    domains:
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - ics-forensics
 | 
			
		||||
    phases:
 | 
			
		||||
      - examination
 | 
			
		||||
@ -1181,7 +1181,7 @@ tools:
 | 
			
		||||
      Tool-Sammlung auf dem neuesten Stand.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - malware-analysis
 | 
			
		||||
      - fraud-investigation
 | 
			
		||||
      - network-forensics
 | 
			
		||||
@ -1217,7 +1217,7 @@ tools:
 | 
			
		||||
      Zuverlässigkeit für Forensik-Puristen.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
    phases:
 | 
			
		||||
      - data-collection
 | 
			
		||||
    platforms:
 | 
			
		||||
@ -1249,7 +1249,7 @@ tools:
 | 
			
		||||
      langer Imaging-Vorgänge rettet Nerven und Zeit.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
    phases:
 | 
			
		||||
      - data-collection
 | 
			
		||||
    platforms:
 | 
			
		||||
@ -1281,7 +1281,7 @@ tools:
 | 
			
		||||
      Austausch mit kommerziellen Tools.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
    phases:
 | 
			
		||||
      - data-collection
 | 
			
		||||
    platforms:
 | 
			
		||||
@ -1313,7 +1313,7 @@ tools:
 | 
			
		||||
      TestDisk repariert zusätzlich beschädigte Partitionen.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - fraud-investigation
 | 
			
		||||
    phases:
 | 
			
		||||
      - examination
 | 
			
		||||
@ -1379,7 +1379,7 @@ tools:
 | 
			
		||||
      Forensik-Suite. Freeware, aber nicht open source.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
    phases:
 | 
			
		||||
      - examination
 | 
			
		||||
    platforms:
 | 
			
		||||
@ -1410,7 +1410,7 @@ tools:
 | 
			
		||||
      einem System vorhanden waren. Die einfache GUI macht es auch für weniger
 | 
			
		||||
      technische Ermittler zugänglich.
 | 
			
		||||
    domains:
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - fraud-investigation
 | 
			
		||||
    phases:
 | 
			
		||||
      - examination
 | 
			
		||||
@ -1443,7 +1443,7 @@ tools:
 | 
			
		||||
      manueller Registry-Analyse und findet oft übersehene Artefakte.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - malware-analysis
 | 
			
		||||
    phases:
 | 
			
		||||
      - examination
 | 
			
		||||
@ -1575,7 +1575,7 @@ tools:
 | 
			
		||||
      Tool-Sammlung aktuell.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - malware-analysis
 | 
			
		||||
      - network-forensics
 | 
			
		||||
      - mobile-forensics
 | 
			
		||||
@ -1609,7 +1609,7 @@ tools:
 | 
			
		||||
      Live-System-Umgebung ermöglicht. 
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - malware-analysis
 | 
			
		||||
      - mobile-forensics
 | 
			
		||||
    skillLevel: intermediate
 | 
			
		||||
@ -1639,7 +1639,7 @@ tools:
 | 
			
		||||
      Neuinstallation.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - malware-analysis
 | 
			
		||||
      - network-forensics
 | 
			
		||||
    skillLevel: intermediate
 | 
			
		||||
@ -1670,7 +1670,7 @@ tools:
 | 
			
		||||
      Ansicht. Ständige Updates für neue Windows-Versionen und Cloud-Artefakte.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
    phases:
 | 
			
		||||
      - examination
 | 
			
		||||
      - analysis
 | 
			
		||||
@ -1770,7 +1770,7 @@ tools:
 | 
			
		||||
      ab, Profis schwören darauf. Deutlich günstiger als US-Konkurrenz bei
 | 
			
		||||
      vergleichbarer Funktionalität.
 | 
			
		||||
    domains:
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - incident-response
 | 
			
		||||
    phases:
 | 
			
		||||
      - examination
 | 
			
		||||
@ -1801,7 +1801,7 @@ tools:
 | 
			
		||||
      Automatisierung. Die Zertifizierung (EnCE) ist in vielen Behörden
 | 
			
		||||
      Einstellungsvoraussetzung.
 | 
			
		||||
    domains:
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - incident-response
 | 
			
		||||
    phases:
 | 
			
		||||
      - data-collection
 | 
			
		||||
@ -1835,7 +1835,7 @@ tools:
 | 
			
		||||
      gleichzeitig. Für High-Volume-Labs die Investition wert, für
 | 
			
		||||
      Gelegenheitsnutzer Overkill.
 | 
			
		||||
    domains:
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - incident-response
 | 
			
		||||
    phases:
 | 
			
		||||
      - data-collection
 | 
			
		||||
@ -1894,7 +1894,7 @@ tools:
 | 
			
		||||
      Netzwerkverbindungen und Verschlüsselungsschlüssel.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - malware-analysis
 | 
			
		||||
    phases:
 | 
			
		||||
      - data-collection
 | 
			
		||||
@ -1928,7 +1928,7 @@ tools:
 | 
			
		||||
      Unternehmensumgebungen mit gemischten Betriebssystem-Landschaften.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - malware-analysis
 | 
			
		||||
    phases:
 | 
			
		||||
      - data-collection
 | 
			
		||||
@ -1964,7 +1964,7 @@ tools:
 | 
			
		||||
      erstellt durchsuchbare Ausgabeformate für effiziente Analyse.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - malware-analysis
 | 
			
		||||
    phases:
 | 
			
		||||
      - data-collection
 | 
			
		||||
@ -2060,7 +2060,7 @@ tools:
 | 
			
		||||
      SHA, and digital signature validation.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - malware-analysis
 | 
			
		||||
      - cloud-forensics
 | 
			
		||||
    phases:
 | 
			
		||||
@ -2084,8 +2084,8 @@ tools:
 | 
			
		||||
domains:
 | 
			
		||||
  - id: incident-response
 | 
			
		||||
    name: Incident Response & Breach-Untersuchung
 | 
			
		||||
  - id: law-enforcement
 | 
			
		||||
    name: Strafverfolgung & Kriminalermittlung
 | 
			
		||||
  - id: static-investigations
 | 
			
		||||
    name: Datenträgerforensik & Ermittlungen
 | 
			
		||||
  - id: malware-analysis
 | 
			
		||||
    name: Malware-Analyse & Reverse Engineering
 | 
			
		||||
  - id: fraud-investigation
 | 
			
		||||
 | 
			
		||||
@ -12,7 +12,7 @@
 | 
			
		||||
      Unternehmensumgebungen mit gemischten Betriebssystem-Landschaften.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - malware-analysis
 | 
			
		||||
    phases:
 | 
			
		||||
      - data-collection
 | 
			
		||||
@ -48,7 +48,7 @@
 | 
			
		||||
      erstellt durchsuchbare Ausgabeformate für effiziente Analyse.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - malware-analysis
 | 
			
		||||
    phases:
 | 
			
		||||
      - data-collection
 | 
			
		||||
@ -144,7 +144,7 @@
 | 
			
		||||
      SHA, and digital signature validation.
 | 
			
		||||
    domains:
 | 
			
		||||
      - incident-response
 | 
			
		||||
      - law-enforcement
 | 
			
		||||
      - static-investigations
 | 
			
		||||
      - malware-analysis
 | 
			
		||||
      - cloud-forensics
 | 
			
		||||
    phases:
 | 
			
		||||
@ -168,8 +168,8 @@
 | 
			
		||||
domains:
 | 
			
		||||
  - id: incident-response
 | 
			
		||||
    name: Incident Response & Breach-Untersuchung
 | 
			
		||||
  - id: law-enforcement
 | 
			
		||||
    name: Strafverfolgung & Kriminalermittlung
 | 
			
		||||
  - id: static-investigations
 | 
			
		||||
    name: Datenträgerforensik & Ermittlungen
 | 
			
		||||
  - id: malware-analysis
 | 
			
		||||
    name: Malware-Analyse & Reverse Engineering
 | 
			
		||||
  - id: fraud-investigation
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										11
									
								
								src/env.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										11
									
								
								src/env.d.ts
									
									
									
									
										vendored
									
									
								
							@ -18,6 +18,17 @@ declare global {
 | 
			
		||||
    switchToAIView?: () => void;
 | 
			
		||||
    clearTagFilters?: () => void;
 | 
			
		||||
    clearAllFilters?: () => void;
 | 
			
		||||
    
 | 
			
		||||
    // CONSOLIDATED: Tool utility functions
 | 
			
		||||
    createToolSlug: (toolName: string) => string;
 | 
			
		||||
    findToolByIdentifier: (tools: any[], identifier: string) => any | undefined;
 | 
			
		||||
    isToolHosted: (tool: any) => boolean;
 | 
			
		||||
 | 
			
		||||
    // CONSOLIDATED: Auth utility functions (now in BaseLayout)
 | 
			
		||||
    checkClientAuth: () => Promise<{authenticated: boolean; authRequired: boolean; expires?: string}>;
 | 
			
		||||
    requireClientAuth: (callback?: () => void, returnUrl?: string) => Promise<boolean>;
 | 
			
		||||
    showIfAuthenticated: (selector: string) => Promise<void>;
 | 
			
		||||
    setupAuthButtons: (selector?: string) => void;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -8,7 +8,7 @@ export interface Props {
 | 
			
		||||
  description?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const { title, description = 'CC24-Guide - A comprehensive directory of digital forensics and incident response tools' } = Astro.props;
 | 
			
		||||
const { title, description = 'ForensicPathways - A comprehensive directory of digital forensics and incident response tools' } = Astro.props;
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
@ -17,12 +17,197 @@ const { title, description = 'CC24-Guide - A comprehensive directory of digital
 | 
			
		||||
  <meta charset="UTF-8">
 | 
			
		||||
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
 | 
			
		||||
  <meta name="description" content={description}>
 | 
			
		||||
  <title>{title} - CC24-Guide</title>
 | 
			
		||||
  <title>{title} - ForensicPathways</title>
 | 
			
		||||
  <link rel="icon" type="image/x-icon" href="/favicon.ico">
 | 
			
		||||
  <script src="/src/scripts/theme.js"></script>
 | 
			
		||||
  
 | 
			
		||||
  <script>
 | 
			
		||||
    // Initialize theme immediately to prevent flash
 | 
			
		||||
    (window as any).themeUtils?.initTheme();
 | 
			
		||||
    document.addEventListener('DOMContentLoaded', () => {
 | 
			
		||||
      // Theme management (consolidated from theme.js)
 | 
			
		||||
      const THEME_KEY = 'dfir-theme';
 | 
			
		||||
 | 
			
		||||
      function getSystemTheme() {
 | 
			
		||||
        return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      function getStoredTheme() {
 | 
			
		||||
        return localStorage.getItem(THEME_KEY) || 'auto';
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      function applyTheme(theme) {
 | 
			
		||||
        const effectiveTheme = theme === 'auto' ? getSystemTheme() : theme;
 | 
			
		||||
        document.documentElement.setAttribute('data-theme', effectiveTheme);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      function updateThemeToggle(theme) {
 | 
			
		||||
        document.querySelectorAll('[data-theme-toggle]').forEach(button => {
 | 
			
		||||
          button.setAttribute('data-current-theme', theme);
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      function initTheme() {
 | 
			
		||||
        const storedTheme = getStoredTheme();
 | 
			
		||||
        applyTheme(storedTheme);
 | 
			
		||||
        updateThemeToggle(storedTheme);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      function toggleTheme() {
 | 
			
		||||
        const current = getStoredTheme();
 | 
			
		||||
        const themes = ['light', 'dark', 'auto'];
 | 
			
		||||
        const currentIndex = themes.indexOf(current);
 | 
			
		||||
        const nextIndex = (currentIndex + 1) % themes.length;
 | 
			
		||||
        const nextTheme = themes[nextIndex];
 | 
			
		||||
        
 | 
			
		||||
        localStorage.setItem(THEME_KEY, nextTheme);
 | 
			
		||||
        applyTheme(nextTheme);
 | 
			
		||||
        updateThemeToggle(nextTheme);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Listen for system theme changes
 | 
			
		||||
      window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
 | 
			
		||||
        if (getStoredTheme() === 'auto') {
 | 
			
		||||
          applyTheme('auto');
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      (window as any).themeUtils = {
 | 
			
		||||
        initTheme,
 | 
			
		||||
        toggleTheme,
 | 
			
		||||
        getStoredTheme
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      function createToolSlug(toolName) {
 | 
			
		||||
        if (!toolName || typeof toolName !== 'string') {
 | 
			
		||||
          console.warn('[toolHelpers] Invalid toolName provided to createToolSlug:', toolName);
 | 
			
		||||
          return '';
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        return toolName.toLowerCase()
 | 
			
		||||
          .replace(/[^a-z0-9\s-]/g, '')     // Remove special characters
 | 
			
		||||
          .replace(/\s+/g, '-')             // Replace spaces with hyphens
 | 
			
		||||
          .replace(/-+/g, '-')              // Remove duplicate hyphens
 | 
			
		||||
          .replace(/^-|-$/g, '');           // Remove leading/trailing hyphens
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      function findToolByIdentifier(tools, identifier) {
 | 
			
		||||
        if (!identifier || !Array.isArray(tools)) return undefined;
 | 
			
		||||
        
 | 
			
		||||
        return tools.find(tool => 
 | 
			
		||||
          tool.name === identifier || 
 | 
			
		||||
          createToolSlug(tool.name) === identifier.toLowerCase()
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      function isToolHosted(tool) {
 | 
			
		||||
        return tool.projectUrl !== undefined && 
 | 
			
		||||
               tool.projectUrl !== null && 
 | 
			
		||||
               tool.projectUrl !== "" && 
 | 
			
		||||
               tool.projectUrl.trim() !== "";
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // FIXED: Use type assertions to avoid TypeScript errors
 | 
			
		||||
      // Make functions available globally for existing code compatibility
 | 
			
		||||
      (window as any).createToolSlug = createToolSlug;
 | 
			
		||||
      (window as any).findToolByIdentifier = findToolByIdentifier;
 | 
			
		||||
      (window as any).isToolHosted = isToolHosted;
 | 
			
		||||
 | 
			
		||||
      // Client-side auth functions (consolidated from client-auth.js)
 | 
			
		||||
      async function checkClientAuth(context = 'general') {
 | 
			
		||||
        try {
 | 
			
		||||
          const response = await fetch('/api/auth/status');
 | 
			
		||||
          const data = await response.json();
 | 
			
		||||
          
 | 
			
		||||
          switch (context) {
 | 
			
		||||
            case 'contributions':
 | 
			
		||||
              return {
 | 
			
		||||
                authenticated: data.contributionAuthenticated,
 | 
			
		||||
                authRequired: data.contributionAuthRequired,
 | 
			
		||||
                expires: data.expires
 | 
			
		||||
              };
 | 
			
		||||
            case 'ai':
 | 
			
		||||
              return {
 | 
			
		||||
                authenticated: data.aiAuthenticated,
 | 
			
		||||
                authRequired: data.aiAuthRequired,
 | 
			
		||||
                expires: data.expires
 | 
			
		||||
              };
 | 
			
		||||
            default:
 | 
			
		||||
              return {
 | 
			
		||||
                authenticated: data.authenticated,
 | 
			
		||||
                authRequired: data.contributionAuthRequired || data.aiAuthRequired,
 | 
			
		||||
                expires: data.expires
 | 
			
		||||
              };
 | 
			
		||||
          }
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
          console.error('Auth check failed:', error);
 | 
			
		||||
          return {
 | 
			
		||||
            authenticated: false,
 | 
			
		||||
            authRequired: true
 | 
			
		||||
          };
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      async function requireClientAuth(callback, returnUrl, context = 'general') {
 | 
			
		||||
        const authStatus = await checkClientAuth(context);
 | 
			
		||||
        
 | 
			
		||||
        if (authStatus.authRequired && !authStatus.authenticated) {
 | 
			
		||||
          const targetUrl = returnUrl || window.location.href;
 | 
			
		||||
          window.location.href = `/api/auth/login?returnTo=${encodeURIComponent(targetUrl)}`;
 | 
			
		||||
          return false;
 | 
			
		||||
        } else {
 | 
			
		||||
          if (typeof callback === 'function') {
 | 
			
		||||
            callback();
 | 
			
		||||
          }
 | 
			
		||||
          return true;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      async function showIfAuthenticated(selector, context = 'general') {
 | 
			
		||||
        const authStatus = await checkClientAuth(context);
 | 
			
		||||
        const element = document.querySelector(selector);
 | 
			
		||||
        
 | 
			
		||||
        if (element) {
 | 
			
		||||
          element.style.display = (!authStatus.authRequired || authStatus.authenticated) 
 | 
			
		||||
            ? 'inline-flex' 
 | 
			
		||||
            : 'none';
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      function setupAuthButtons(selector = '[data-contribute-button]') {
 | 
			
		||||
        document.addEventListener('click', async (e) => {
 | 
			
		||||
          if (!e.target) return;
 | 
			
		||||
          
 | 
			
		||||
          const button = (e.target as Element).closest(selector);
 | 
			
		||||
          if (!button) return;
 | 
			
		||||
          
 | 
			
		||||
          e.preventDefault();
 | 
			
		||||
          
 | 
			
		||||
          console.log('[AUTH] Contribute button clicked:', button.getAttribute('data-contribute-button'));
 | 
			
		||||
          
 | 
			
		||||
          // ENHANCED: Use contributions context
 | 
			
		||||
          await requireClientAuth(() => {
 | 
			
		||||
            console.log('[AUTH] Navigation approved, redirecting to:', (button as HTMLAnchorElement).href);
 | 
			
		||||
            window.location.href = (button as HTMLAnchorElement).href;
 | 
			
		||||
          }, (button as HTMLAnchorElement).href, 'contributions');
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Make auth functions available globally
 | 
			
		||||
      (window as any).checkClientAuth = checkClientAuth;
 | 
			
		||||
      (window as any).requireClientAuth = requireClientAuth;
 | 
			
		||||
      (window as any).showIfAuthenticated = showIfAuthenticated;
 | 
			
		||||
      (window as any).setupAuthButtons = setupAuthButtons;
 | 
			
		||||
 | 
			
		||||
      // Initialize everything
 | 
			
		||||
      initTheme();
 | 
			
		||||
      setupAuthButtons('[data-contribute-button]');
 | 
			
		||||
      
 | 
			
		||||
      const initAIButton = async () => {
 | 
			
		||||
        await showIfAuthenticated('#ai-view-toggle', 'ai');
 | 
			
		||||
      };
 | 
			
		||||
      initAIButton();
 | 
			
		||||
      
 | 
			
		||||
      console.log('[CONSOLIDATED] All utilities loaded and initialized');
 | 
			
		||||
    });
 | 
			
		||||
  </script>
 | 
			
		||||
</head>
 | 
			
		||||
<body>
 | 
			
		||||
 | 
			
		||||
@ -2,11 +2,11 @@
 | 
			
		||||
import BaseLayout from '../layouts/BaseLayout.astro';
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<BaseLayout title="Über das Projekt" description="CC24-Guide - Ein Projekt für die Seminargruppe CC24-w1">
 | 
			
		||||
<BaseLayout title="Über das Projekt" description="ForensicPathways - Ein Projekt für die Seminargruppe CC24-w1">
 | 
			
		||||
  <section style="padding: 2rem 0; max-width: 900px; margin: 0 auto;">
 | 
			
		||||
    <!-- Hero Section -->
 | 
			
		||||
    <div style="text-align: center; margin-bottom: 3rem; padding: 2rem; background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%); border-radius: 1rem; border: 1px solid var(--color-border);">
 | 
			
		||||
      <h1 style="margin-bottom: 1rem; font-size: 2.5rem; color: var(--color-primary);">CC24-Guide</h1>
 | 
			
		||||
      <h1 style="margin-bottom: 1rem; font-size: 2.5rem; color: var(--color-primary);">ForensicPathways</h1>
 | 
			
		||||
      <p style="font-size: 1.25rem; color: var(--color-text-secondary); margin-bottom: 0.5rem;">
 | 
			
		||||
        Forensik im Dienst der Transparenz
 | 
			
		||||
      </p>
 | 
			
		||||
@ -141,7 +141,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
 | 
			
		||||
      </div>
 | 
			
		||||
      <p style="margin-bottom: 1rem; line-height: 1.7;">
 | 
			
		||||
        Falls eine Anwendung nicht wie vorgesehen funktioniert, ihr Unterstützung braucht, der Speicherplatz ausgeht 
 | 
			
		||||
        oder sonstige Probleme auftreten: <strong>Schreibt mir einfach auf Signal!</strong>
 | 
			
		||||
        oder sonstige Probleme auftreten: <strong>Schreibt mir einfach auf Signal</strong> oder an <a href="mailto:mstoeck3@hs-mittweida.de">mstoeck3@hs-mittweida.de</a>.
 | 
			
		||||
      </p>
 | 
			
		||||
      
 | 
			
		||||
      <!-- Special Note Box -->
 | 
			
		||||
@ -179,6 +179,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <!-- Contributing Section -->
 | 
			
		||||
    <!-- Contribution Section -->
 | 
			
		||||
    <div class="card" style="margin-bottom: 2rem; border-left: 4px solid var(--color-accent);">
 | 
			
		||||
      <div style="display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1rem;">
 | 
			
		||||
        <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" stroke-width="2">
 | 
			
		||||
@ -187,40 +188,43 @@ import BaseLayout from '../layouts/BaseLayout.astro';
 | 
			
		||||
          <line x1="20" y1="8" x2="20" y2="14"/>
 | 
			
		||||
          <line x1="23" y1="11" x2="17" y2="11"/>
 | 
			
		||||
        </svg>
 | 
			
		||||
        <h2 style="margin: 0; color: var(--color-accent);">Mitmachen und Beitragen</h2>
 | 
			
		||||
        <h2 style="margin: 0; color: var(--color-accent);">Mitmachen & Beitragen</h2>
 | 
			
		||||
      </div>
 | 
			
		||||
      
 | 
			
		||||
 | 
			
		||||
      <div style="display: grid; gap: 1.25rem;">
 | 
			
		||||
        <!-- Suggestions -->
 | 
			
		||||
        <div style="background-color: var(--color-bg-secondary); padding: 1.25rem; border-radius: 0.5rem;">
 | 
			
		||||
          <h4 style="margin: 0 0 0.5rem 0; color: var(--color-accent);">🔍 Vorschläge</h4>
 | 
			
		||||
          <p style="margin: 0;">
 | 
			
		||||
            Ich suche stets nach Ergänzungen für die Liste. Falls euch interessante Tools oder Methoden einfallen – 
 | 
			
		||||
            schreibt mir gerne auf Signal oder an <a href="mailto:mstoeck3@hs-mittweida.de">mstoeck3@hs-mittweida.de</a>.
 | 
			
		||||
            Du hast eine Idee, wie wir den Hub erweitern können? Reiche deinen Vorschlag unkompliziert
 | 
			
		||||
            über unsere <a href="/contribute#vorschlaege">/contribute</a>-Seite ein.
 | 
			
		||||
          </p>
 | 
			
		||||
        </div>
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
        <!-- Corrections & Updates -->
 | 
			
		||||
        <div style="background-color: var(--color-bg-secondary); padding: 1.25rem; border-radius: 0.5rem;">
 | 
			
		||||
          <h4 style="margin: 0 0 0.5rem 0; color: var(--color-accent);">🔧 Korrekturen & Updates</h4>
 | 
			
		||||
          <h4 style="margin: 0 0 0.5rem 0; color: var(--color-accent);">🔧 Korrekturen & Updates</h4>
 | 
			
		||||
          <p style="margin: 0;">
 | 
			
		||||
            Sollte eine Anwendung/Methode nicht mehr aktuell, veraltet oder falsch repräsentiert sein, 
 | 
			
		||||
            gebt mir unbedingt Bescheid.
 | 
			
		||||
            Ist eine Anwendung veraltet oder falsch dargestellt? Teile uns das bitte direkt unter
 | 
			
		||||
            <a href="/contribute#korrekturen">/contribute</a> mit.
 | 
			
		||||
          </p>
 | 
			
		||||
        </div>
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
        <!-- Code Contributions -->
 | 
			
		||||
        <div style="background-color: var(--color-bg-secondary); padding: 1.25rem; border-radius: 0.5rem;">
 | 
			
		||||
          <h4 style="margin: 0 0 0.5rem 0; color: var(--color-accent);">💻 Code-Beiträge</h4>
 | 
			
		||||
          <h4 style="margin: 0 0 0.5rem 0; color: var(--color-accent);">💻 Code‑Beiträge</h4>
 | 
			
		||||
          <p style="margin-bottom: 0.75rem;">
 | 
			
		||||
            Ihr könnt auch direkt am Sourcecode mitarbeiten:
 | 
			
		||||
            Möchtest du direkt am Sourcecode mitarbeiten? Schau dir die Anleitung unter
 | 
			
		||||
            <a href="/contribute#code">/contribute</a> an oder besuche unser Repository:
 | 
			
		||||
          </p>
 | 
			
		||||
          <a href="https://git.cc24.dev/mstoeck3/cc24-hub" target="_blank" rel="noopener noreferrer" 
 | 
			
		||||
             style="display: inline-flex; align-items: center; gap: 0.5rem; color: var(--color-accent); font-weight: 500;">
 | 
			
		||||
          <a href="https://git.cc24.dev/mstoeck3/cc24-hub" target="_blank" rel="noopener noreferrer"
 | 
			
		||||
            style="display: inline-flex; align-items: center; gap: 0.5rem; color: var(--color-accent); font-weight: 500;">
 | 
			
		||||
            <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
 | 
			
		||||
              <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
 | 
			
		||||
              <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
 | 
			
		||||
            </svg>
 | 
			
		||||
            Git-Repository besuchen
 | 
			
		||||
            Git‑Repository besuchen
 | 
			
		||||
          </a>
 | 
			
		||||
        </div>
 | 
			
		||||
        
 | 
			
		||||
        </div>        
 | 
			
		||||
        <div style="background-color: var(--color-bg-secondary); padding: 1.25rem; border-radius: 0.5rem;">
 | 
			
		||||
          <h4 style="margin: 0 0 0.5rem 0; color: var(--color-accent);">⚡ Unterstützung</h4>
 | 
			
		||||
          <p style="margin: 0;">
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,9 @@
 | 
			
		||||
// src/pages/api/ai/query.ts
 | 
			
		||||
import type { APIRoute } from 'astro';
 | 
			
		||||
import { getSessionFromRequest, verifySession } from '../../../utils/auth.js';
 | 
			
		||||
import { withAPIAuth } from '../../../utils/auth.js';
 | 
			
		||||
import { getCompressedToolsDataForAI } from '../../../utils/dataService.js';
 | 
			
		||||
import { apiError, apiServerError, createAuthErrorResponse } from '../../../utils/api.js';
 | 
			
		||||
import { enqueueApiCall } from '../../../utils/rateLimitedQueue.js';   
 | 
			
		||||
 | 
			
		||||
export const prerender = false;
 | 
			
		||||
 | 
			
		||||
@ -97,7 +99,7 @@ function createWorkflowSystemPrompt(toolsData: any): string {
 | 
			
		||||
    related_concepts: tool.related_concepts || []
 | 
			
		||||
  }));
 | 
			
		||||
 | 
			
		||||
  // NEW: Include concepts for background knowledge
 | 
			
		||||
  // Include concepts for background knowledge
 | 
			
		||||
  const conceptsList = toolsData.concepts.map((concept: any) => ({
 | 
			
		||||
    name: concept.name,
 | 
			
		||||
    description: concept.description,
 | 
			
		||||
@ -107,7 +109,7 @@ function createWorkflowSystemPrompt(toolsData: any): string {
 | 
			
		||||
    tags: concept.tags
 | 
			
		||||
  }));
 | 
			
		||||
 | 
			
		||||
  // Get regular phases (no more filtering needed)
 | 
			
		||||
  // Get regular phases
 | 
			
		||||
  const regularPhases = toolsData.phases || [];
 | 
			
		||||
  
 | 
			
		||||
  // Get domain-agnostic software phases
 | 
			
		||||
@ -159,7 +161,7 @@ FORENSISCHE DOMÄNEN:
 | 
			
		||||
${domainsDescription}
 | 
			
		||||
 | 
			
		||||
WICHTIGE REGELN:
 | 
			
		||||
1. Pro Phase 1-3 Tools/Methoden empfehlen (immer mindestens 1 wenn verfügbar)
 | 
			
		||||
1. Pro Phase 2-3 Tools/Methoden empfehlen (immer mindestens 2 wenn verfügbar)
 | 
			
		||||
2. Tools/Methoden können in MEHREREN Phasen empfohlen werden wenn sinnvoll - versuche ein Tool/Methode für jede Phase zu empfehlen, selbst wenn die Priorität "low" ist.
 | 
			
		||||
3. Für Reporting-Phase: Visualisierungs- und Dokumentationssoftware einschließen
 | 
			
		||||
4. Gib stets dem spezieller für den Fall geeigneten Werkzeug den Vorzug.
 | 
			
		||||
@ -215,7 +217,7 @@ function createToolSystemPrompt(toolsData: any): string {
 | 
			
		||||
    related_concepts: tool.related_concepts || []
 | 
			
		||||
  }));
 | 
			
		||||
 | 
			
		||||
  // NEW: Include concepts for background knowledge
 | 
			
		||||
  // Include concepts for background knowledge
 | 
			
		||||
  const conceptsList = toolsData.concepts.map((concept: any) => ({
 | 
			
		||||
    name: concept.name,
 | 
			
		||||
    description: concept.description,
 | 
			
		||||
@ -275,64 +277,36 @@ Antworte NUR mit validen JSON. Keine zusätzlichen Erklärungen außerhalb des J
 | 
			
		||||
 | 
			
		||||
export const POST: APIRoute = async ({ request }) => {
 | 
			
		||||
  try {
 | 
			
		||||
    // Check if authentication is required
 | 
			
		||||
    const authRequired = process.env.AUTHENTICATION_NECESSARY !== 'false';
 | 
			
		||||
    let userId = 'test-user';
 | 
			
		||||
 | 
			
		||||
    if (authRequired) {
 | 
			
		||||
      // Authentication check
 | 
			
		||||
      const sessionToken = getSessionFromRequest(request);
 | 
			
		||||
      if (!sessionToken) {
 | 
			
		||||
        return new Response(JSON.stringify({ error: 'Authentication required' }), {
 | 
			
		||||
          status: 401,
 | 
			
		||||
          headers: { 'Content-Type': 'application/json' }
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const session = await verifySession(sessionToken);
 | 
			
		||||
      if (!session) {
 | 
			
		||||
        return new Response(JSON.stringify({ error: 'Invalid session' }), {
 | 
			
		||||
          status: 401,
 | 
			
		||||
          headers: { 'Content-Type': 'application/json' }
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      userId = session.userId;
 | 
			
		||||
    // Authentication check
 | 
			
		||||
    const authResult = await withAPIAuth(request, 'ai');
 | 
			
		||||
    if (!authResult.authenticated) {
 | 
			
		||||
      return createAuthErrorResponse();
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    const userId = authResult.userId;
 | 
			
		||||
 | 
			
		||||
    // Rate limiting
 | 
			
		||||
    if (!checkRateLimit(userId)) {
 | 
			
		||||
      return new Response(JSON.stringify({ error: 'Rate limit exceeded' }), {
 | 
			
		||||
        status: 429,
 | 
			
		||||
        headers: { 'Content-Type': 'application/json' }
 | 
			
		||||
      });
 | 
			
		||||
      return apiError.rateLimit('Rate limit exceeded');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Parse request body
 | 
			
		||||
    const body = await request.json();
 | 
			
		||||
    const { query, mode = 'workflow' } = body;
 | 
			
		||||
    const { query, mode = 'workflow', taskId: clientTaskId } = body;
 | 
			
		||||
 | 
			
		||||
    // Validation
 | 
			
		||||
    if (!query || typeof query !== 'string') {
 | 
			
		||||
      return new Response(JSON.stringify({ error: 'Query required' }), {
 | 
			
		||||
        status: 400,
 | 
			
		||||
        headers: { 'Content-Type': 'application/json' }
 | 
			
		||||
      });
 | 
			
		||||
      return apiError.badRequest('Query required');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!['workflow', 'tool'].includes(mode)) {
 | 
			
		||||
      return new Response(JSON.stringify({ error: 'Invalid mode. Must be "workflow" or "tool"' }), {
 | 
			
		||||
        status: 400,
 | 
			
		||||
        headers: { 'Content-Type': 'application/json' }
 | 
			
		||||
      });
 | 
			
		||||
      return apiError.badRequest('Invalid mode. Must be "workflow" or "tool"');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Sanitize input
 | 
			
		||||
    const sanitizedQuery = sanitizeInput(query);
 | 
			
		||||
    if (sanitizedQuery.includes('[FILTERED]')) {
 | 
			
		||||
      return new Response(JSON.stringify({ error: 'Invalid input detected' }), {
 | 
			
		||||
        status: 400,
 | 
			
		||||
        headers: { 'Content-Type': 'application/json' }
 | 
			
		||||
      });
 | 
			
		||||
      return apiError.badRequest('Invalid input detected');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Load tools database
 | 
			
		||||
@ -343,45 +317,46 @@ export const POST: APIRoute = async ({ request }) => {
 | 
			
		||||
      ? createWorkflowSystemPrompt(toolsData)
 | 
			
		||||
      : createToolSystemPrompt(toolsData);
 | 
			
		||||
    
 | 
			
		||||
    const aiResponse = await fetch(process.env.AI_API_ENDPOINT + '/v1/chat/completions', {
 | 
			
		||||
      method: 'POST',
 | 
			
		||||
      headers: {
 | 
			
		||||
        'Content-Type': 'application/json',
 | 
			
		||||
        'Authorization': `Bearer ${process.env.AI_API_KEY}`
 | 
			
		||||
      },
 | 
			
		||||
      body: JSON.stringify({
 | 
			
		||||
        model: AI_MODEL,
 | 
			
		||||
        messages: [
 | 
			
		||||
          {
 | 
			
		||||
            role: 'system',
 | 
			
		||||
            content: systemPrompt
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            role: 'user', 
 | 
			
		||||
            content: sanitizedQuery
 | 
			
		||||
          }
 | 
			
		||||
        ],
 | 
			
		||||
        max_tokens: 2000,
 | 
			
		||||
        temperature: 0.3
 | 
			
		||||
    // Generate task ID for queue tracking (use client-provided ID if available)
 | 
			
		||||
    const taskId = clientTaskId || `ai_${userId}_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`;
 | 
			
		||||
    
 | 
			
		||||
    // Make AI API call through rate-limited queue
 | 
			
		||||
    const aiResponse = await enqueueApiCall(() =>
 | 
			
		||||
      fetch(process.env.AI_API_ENDPOINT + '/v1/chat/completions', {
 | 
			
		||||
        method: 'POST',
 | 
			
		||||
        headers: {
 | 
			
		||||
          'Content-Type': 'application/json',
 | 
			
		||||
          'Authorization': `Bearer ${process.env.AI_API_KEY}`
 | 
			
		||||
        },
 | 
			
		||||
        body: JSON.stringify({
 | 
			
		||||
          model: AI_MODEL,
 | 
			
		||||
          messages: [
 | 
			
		||||
            {
 | 
			
		||||
              role: 'system',
 | 
			
		||||
              content: systemPrompt
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
              role: 'user',
 | 
			
		||||
              content: sanitizedQuery
 | 
			
		||||
            }
 | 
			
		||||
          ],
 | 
			
		||||
          max_tokens: 2000,
 | 
			
		||||
          temperature: 0.3
 | 
			
		||||
        })
 | 
			
		||||
      })
 | 
			
		||||
    });
 | 
			
		||||
    , taskId);
 | 
			
		||||
 | 
			
		||||
    // AI response handling
 | 
			
		||||
    if (!aiResponse.ok) {
 | 
			
		||||
      console.error('AI API error:', await aiResponse.text());
 | 
			
		||||
      return new Response(JSON.stringify({ error: 'AI service unavailable' }), {
 | 
			
		||||
        status: 503,
 | 
			
		||||
        headers: { 'Content-Type': 'application/json' }
 | 
			
		||||
      });
 | 
			
		||||
      return apiServerError.unavailable('AI service unavailable');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const aiData = await aiResponse.json();
 | 
			
		||||
    const aiContent = aiData.choices?.[0]?.message?.content;
 | 
			
		||||
 | 
			
		||||
    if (!aiContent) {
 | 
			
		||||
      return new Response(JSON.stringify({ error: 'No response from AI' }), {
 | 
			
		||||
        status: 503,
 | 
			
		||||
        headers: { 'Content-Type': 'application/json' }
 | 
			
		||||
      });
 | 
			
		||||
      return apiServerError.unavailable('No response from AI');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Parse AI JSON response
 | 
			
		||||
@ -391,10 +366,7 @@ export const POST: APIRoute = async ({ request }) => {
 | 
			
		||||
      recommendation = JSON.parse(cleanedContent);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error('Failed to parse AI response:', aiContent);
 | 
			
		||||
      return new Response(JSON.stringify({ error: 'Invalid AI response format' }), {
 | 
			
		||||
        status: 503,
 | 
			
		||||
        headers: { 'Content-Type': 'application/json' }
 | 
			
		||||
      });
 | 
			
		||||
      return apiServerError.unavailable('Invalid AI response format');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Validate tool names and concept names against database
 | 
			
		||||
@ -450,9 +422,11 @@ export const POST: APIRoute = async ({ request }) => {
 | 
			
		||||
    // Log successful query
 | 
			
		||||
    console.log(`[AI Query] Mode: ${mode}, User: ${userId}, Query length: ${sanitizedQuery.length}, Tools: ${validatedRecommendation.recommended_tools.length}, Concepts: ${validatedRecommendation.background_knowledge?.length || 0}`);
 | 
			
		||||
 | 
			
		||||
    // Success response with task ID
 | 
			
		||||
    return new Response(JSON.stringify({
 | 
			
		||||
      success: true,
 | 
			
		||||
      mode,
 | 
			
		||||
      taskId,
 | 
			
		||||
      recommendation: validatedRecommendation,
 | 
			
		||||
      query: sanitizedQuery
 | 
			
		||||
    }), {
 | 
			
		||||
@ -462,9 +436,6 @@ export const POST: APIRoute = async ({ request }) => {
 | 
			
		||||
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('AI query error:', error);
 | 
			
		||||
    return new Response(JSON.stringify({ error: 'Internal server error' }), {
 | 
			
		||||
      status: 500,
 | 
			
		||||
      headers: { 'Content-Type': 'application/json' }
 | 
			
		||||
    });
 | 
			
		||||
    return apiServerError.internal('Internal server error');
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										23
									
								
								src/pages/api/ai/queue-status.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/pages/api/ai/queue-status.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,23 @@
 | 
			
		||||
// src/pages/api/ai/queue-status.ts
 | 
			
		||||
import type { APIRoute } from 'astro';
 | 
			
		||||
import { getQueueStatus } from '../../../utils/rateLimitedQueue.js';
 | 
			
		||||
import { apiResponse, apiServerError } from '../../../utils/api.js';
 | 
			
		||||
 | 
			
		||||
export const prerender = false;
 | 
			
		||||
 | 
			
		||||
export const GET: APIRoute = async ({ request }) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const url = new URL(request.url);
 | 
			
		||||
    const taskId = url.searchParams.get('taskId');
 | 
			
		||||
    
 | 
			
		||||
    const status = getQueueStatus(taskId || undefined);
 | 
			
		||||
    
 | 
			
		||||
    return apiResponse.success({
 | 
			
		||||
      ...status,
 | 
			
		||||
      timestamp: Date.now()
 | 
			
		||||
    });
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Queue status error:', error);
 | 
			
		||||
    return apiServerError.internal('Failed to get queue status');
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
@ -1,102 +0,0 @@
 | 
			
		||||
import type { APIRoute } from 'astro';
 | 
			
		||||
import { parse } from 'cookie';
 | 
			
		||||
import { 
 | 
			
		||||
  exchangeCodeForTokens, 
 | 
			
		||||
  getUserInfo, 
 | 
			
		||||
  createSession, 
 | 
			
		||||
  createSessionCookie, 
 | 
			
		||||
  logAuthEvent 
 | 
			
		||||
} from '../../../utils/auth.js';
 | 
			
		||||
 | 
			
		||||
export const GET: APIRoute = async ({ url, request }) => {
 | 
			
		||||
  try {
 | 
			
		||||
    if (process.env.NODE_ENV === 'development') {
 | 
			
		||||
      console.log('Auth callback processing...');
 | 
			
		||||
      console.log('Full URL:', url.toString());
 | 
			
		||||
      console.log('URL pathname:', url.pathname);
 | 
			
		||||
      console.log('URL search:', url.search);
 | 
			
		||||
      console.log('URL searchParams:', url.searchParams.toString());
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Try different ways to get parameters
 | 
			
		||||
    const allParams = Object.fromEntries(url.searchParams.entries());
 | 
			
		||||
    console.log('SearchParams entries:', allParams);
 | 
			
		||||
    
 | 
			
		||||
    // Also try parsing manually from the search string
 | 
			
		||||
    const manualParams = new URLSearchParams(url.search);
 | 
			
		||||
    const manualEntries = Object.fromEntries(manualParams.entries());
 | 
			
		||||
    console.log('Manual URLSearchParams:', manualEntries);
 | 
			
		||||
    
 | 
			
		||||
    // Also check request URL
 | 
			
		||||
    const requestUrl = new URL(request.url);
 | 
			
		||||
    console.log('Request URL:', requestUrl.toString());
 | 
			
		||||
    const requestParams = Object.fromEntries(requestUrl.searchParams.entries());
 | 
			
		||||
    console.log('Request URL params:', requestParams);
 | 
			
		||||
    
 | 
			
		||||
    const code = url.searchParams.get('code') || requestUrl.searchParams.get('code');
 | 
			
		||||
    const state = url.searchParams.get('state') || requestUrl.searchParams.get('state');
 | 
			
		||||
    const error = url.searchParams.get('error') || requestUrl.searchParams.get('error');
 | 
			
		||||
    
 | 
			
		||||
    console.log('Final extracted values:', { code: !!code, state: !!state, error });
 | 
			
		||||
    
 | 
			
		||||
    // Handle OIDC errors
 | 
			
		||||
    if (error) {
 | 
			
		||||
      logAuthEvent('OIDC error', { error, description: url.searchParams.get('error_description') });
 | 
			
		||||
      return new Response(null, {
 | 
			
		||||
        status: 302,
 | 
			
		||||
        headers: { 'Location': '/?auth=error' }
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    if (!code || !state) {
 | 
			
		||||
      logAuthEvent('Missing code or state parameter', { received: allParams });
 | 
			
		||||
      return new Response('Invalid callback parameters', { status: 400 });
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Verify state parameter
 | 
			
		||||
    const cookieHeader = request.headers.get('cookie');
 | 
			
		||||
    const cookies = cookieHeader ? parse(cookieHeader) : {};
 | 
			
		||||
    const storedStateData = cookies.auth_state ? JSON.parse(decodeURIComponent(cookies.auth_state)) : null;
 | 
			
		||||
    
 | 
			
		||||
    if (!storedStateData || storedStateData.state !== state) {
 | 
			
		||||
      logAuthEvent('State mismatch', { received: state, stored: storedStateData?.state });
 | 
			
		||||
      return new Response('Invalid state parameter', { status: 400 });
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Exchange code for tokens
 | 
			
		||||
    const tokens = await exchangeCodeForTokens(code);
 | 
			
		||||
    
 | 
			
		||||
    // Get user info
 | 
			
		||||
    const userInfo = await getUserInfo(tokens.access_token);
 | 
			
		||||
    
 | 
			
		||||
    // Create session
 | 
			
		||||
    const sessionToken = await createSession(userInfo.sub || userInfo.preferred_username || 'unknown');
 | 
			
		||||
    const sessionCookie = createSessionCookie(sessionToken);
 | 
			
		||||
    
 | 
			
		||||
    logAuthEvent('Authentication successful', { 
 | 
			
		||||
      userId: userInfo.sub || userInfo.preferred_username,
 | 
			
		||||
      email: userInfo.email 
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    // Clear auth state cookie and redirect to intended destination
 | 
			
		||||
    const returnTo = storedStateData.returnTo || '/';
 | 
			
		||||
    const clearStateCookie = 'auth_state=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0';
 | 
			
		||||
    
 | 
			
		||||
    const headers = new Headers();
 | 
			
		||||
    headers.append('Location', returnTo);
 | 
			
		||||
    headers.append('Set-Cookie', sessionCookie);
 | 
			
		||||
    headers.append('Set-Cookie', clearStateCookie);
 | 
			
		||||
    
 | 
			
		||||
    return new Response(null, {
 | 
			
		||||
      status: 302,
 | 
			
		||||
      headers: headers
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    logAuthEvent('Callback failed', { error: error.message });
 | 
			
		||||
    return new Response(null, {
 | 
			
		||||
      status: 302,
 | 
			
		||||
      headers: { 'Location': '/?auth=error' }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
@ -28,7 +28,7 @@ export const GET: APIRoute = async ({ url, redirect }) => {
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    logAuthEvent('Login failed', { error: error.message });
 | 
			
		||||
    logAuthEvent('Login failed', { error: error instanceof Error ? error.message : 'Unknown error' });
 | 
			
		||||
    return new Response('Authentication error', { status: 500 });
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
@ -1,104 +1,67 @@
 | 
			
		||||
// src/pages/api/auth/process.ts (FIXED - Proper cookie handling)
 | 
			
		||||
import type { APIRoute } from 'astro';
 | 
			
		||||
import { parse } from 'cookie';
 | 
			
		||||
import { 
 | 
			
		||||
  verifyAuthState,
 | 
			
		||||
  exchangeCodeForTokens, 
 | 
			
		||||
  getUserInfo, 
 | 
			
		||||
  createSession, 
 | 
			
		||||
  createSessionCookie, 
 | 
			
		||||
  logAuthEvent 
 | 
			
		||||
  createSessionWithCookie,
 | 
			
		||||
  logAuthEvent
 | 
			
		||||
} from '../../../utils/auth.js';
 | 
			
		||||
import { apiError, apiSpecial, apiWithHeaders, handleAPIRequest } from '../../../utils/api.js';
 | 
			
		||||
 | 
			
		||||
// Mark as server-rendered
 | 
			
		||||
export const prerender = false;
 | 
			
		||||
 | 
			
		||||
export const POST: APIRoute = async ({ request }) => {
 | 
			
		||||
  try {
 | 
			
		||||
    // Check if there's a body to parse
 | 
			
		||||
    const contentType = request.headers.get('content-type');
 | 
			
		||||
    console.log('Request content-type:', contentType);
 | 
			
		||||
    
 | 
			
		||||
  return await handleAPIRequest(async () => {
 | 
			
		||||
    // Parse request body
 | 
			
		||||
    let body;
 | 
			
		||||
    try {
 | 
			
		||||
      body = await request.json();
 | 
			
		||||
    } catch (parseError) {
 | 
			
		||||
      console.error('JSON parse error:', parseError);
 | 
			
		||||
      return new Response(JSON.stringify({ success: false, error: 'Invalid JSON' }), {
 | 
			
		||||
        status: 400,
 | 
			
		||||
        headers: { 'Content-Type': 'application/json' }
 | 
			
		||||
      });
 | 
			
		||||
      return apiSpecial.invalidJSON();
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    const { code, state } = body || {};
 | 
			
		||||
    
 | 
			
		||||
    console.log('Processing authentication:', { code: !!code, state: !!state });
 | 
			
		||||
    
 | 
			
		||||
    if (!code || !state) {
 | 
			
		||||
      logAuthEvent('Missing code or state parameter in process request');
 | 
			
		||||
      return new Response(JSON.stringify({ success: false }), {
 | 
			
		||||
        status: 400,
 | 
			
		||||
        headers: { 'Content-Type': 'application/json' }
 | 
			
		||||
      });
 | 
			
		||||
      return apiSpecial.missingRequired(['code', 'state']);
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Verify state parameter
 | 
			
		||||
    const cookieHeader = request.headers.get('cookie');
 | 
			
		||||
    const cookies = cookieHeader ? parse(cookieHeader) : {};
 | 
			
		||||
    const storedStateData = cookies.auth_state ? JSON.parse(decodeURIComponent(cookies.auth_state)) : null;
 | 
			
		||||
    
 | 
			
		||||
    console.log('State verification:', { 
 | 
			
		||||
      received: state, 
 | 
			
		||||
      stored: storedStateData?.state,
 | 
			
		||||
      match: storedStateData?.state === state 
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    if (!storedStateData || storedStateData.state !== state) {
 | 
			
		||||
      logAuthEvent('State mismatch', { received: state, stored: storedStateData?.state });
 | 
			
		||||
      return new Response(JSON.stringify({ success: false }), {
 | 
			
		||||
        status: 400,
 | 
			
		||||
        headers: { 'Content-Type': 'application/json' }
 | 
			
		||||
      });
 | 
			
		||||
    // CONSOLIDATED: Single function call replaces 15+ lines of duplicated state verification
 | 
			
		||||
    const stateVerification = verifyAuthState(request, state);
 | 
			
		||||
    if (!stateVerification.isValid || !stateVerification.stateData) {
 | 
			
		||||
      return apiError.badRequest(stateVerification.error || 'Invalid state parameter');
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Exchange code for tokens
 | 
			
		||||
    console.log('Exchanging code for tokens...');
 | 
			
		||||
    // Exchange code for tokens and get user info
 | 
			
		||||
    const tokens = await exchangeCodeForTokens(code);
 | 
			
		||||
    
 | 
			
		||||
    // Get user info
 | 
			
		||||
    console.log('Getting user info...');
 | 
			
		||||
    const userInfo = await getUserInfo(tokens.access_token);
 | 
			
		||||
    
 | 
			
		||||
    // Create session
 | 
			
		||||
    const sessionToken = await createSession(userInfo.sub || userInfo.preferred_username || 'unknown');
 | 
			
		||||
    const sessionCookie = createSessionCookie(sessionToken);
 | 
			
		||||
    // CONSOLIDATED: Single function call replaces 10+ lines of session creation
 | 
			
		||||
    const sessionResult = await createSessionWithCookie(userInfo);
 | 
			
		||||
    
 | 
			
		||||
    logAuthEvent('Authentication successful', { 
 | 
			
		||||
      userId: userInfo.sub || userInfo.preferred_username,
 | 
			
		||||
      email: userInfo.email 
 | 
			
		||||
      userId: sessionResult.userId,
 | 
			
		||||
      email: sessionResult.userEmail 
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    // Clear auth state cookie
 | 
			
		||||
    const clearStateCookie = 'auth_state=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0';
 | 
			
		||||
    const returnTo = storedStateData.returnTo || '/';
 | 
			
		||||
    // FIXED: Create response with multiple Set-Cookie headers
 | 
			
		||||
    const responseHeaders = new Headers();
 | 
			
		||||
    responseHeaders.set('Content-Type', 'application/json');
 | 
			
		||||
    
 | 
			
		||||
    const headers = new Headers();
 | 
			
		||||
    headers.append('Content-Type', 'application/json');
 | 
			
		||||
    headers.append('Set-Cookie', sessionCookie);
 | 
			
		||||
    headers.append('Set-Cookie', clearStateCookie);
 | 
			
		||||
    // Each cookie needs its own Set-Cookie header
 | 
			
		||||
    responseHeaders.append('Set-Cookie', sessionResult.sessionCookie);
 | 
			
		||||
    responseHeaders.append('Set-Cookie', sessionResult.clearStateCookie);
 | 
			
		||||
    
 | 
			
		||||
    return new Response(JSON.stringify({ 
 | 
			
		||||
      success: true, 
 | 
			
		||||
      redirectTo: returnTo 
 | 
			
		||||
      redirectTo: stateVerification.stateData.returnTo 
 | 
			
		||||
    }), {
 | 
			
		||||
      status: 200,
 | 
			
		||||
      headers: headers
 | 
			
		||||
      headers: responseHeaders
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Authentication processing failed:', error);
 | 
			
		||||
    logAuthEvent('Authentication processing failed', { error: error.message });
 | 
			
		||||
    return new Response(JSON.stringify({ success: false }), {
 | 
			
		||||
      status: 500,
 | 
			
		||||
      headers: { 'Content-Type': 'application/json' }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
  }, 'Authentication processing failed');
 | 
			
		||||
};
 | 
			
		||||
@ -1,56 +1,22 @@
 | 
			
		||||
// src/pages/api/auth/status.ts
 | 
			
		||||
// src/pages/api/auth/status.ts 
 | 
			
		||||
import type { APIRoute } from 'astro';
 | 
			
		||||
import { getSessionFromRequest, verifySession } from '../../../utils/auth.js';
 | 
			
		||||
import { withAPIAuth } from '../../../utils/auth.js';
 | 
			
		||||
import { apiResponse, handleAPIRequest } from '../../../utils/api.js';
 | 
			
		||||
 | 
			
		||||
export const prerender = false;
 | 
			
		||||
 | 
			
		||||
export const GET: APIRoute = async ({ request }) => {
 | 
			
		||||
  try {
 | 
			
		||||
    // Check if authentication is required
 | 
			
		||||
    const authRequired = process.env.AUTHENTICATION_NECESSARY !== 'false';
 | 
			
		||||
  return await handleAPIRequest(async () => {
 | 
			
		||||
    const contributionAuth = await withAPIAuth(request, 'contributions');
 | 
			
		||||
    const aiAuth = await withAPIAuth(request, 'ai');
 | 
			
		||||
    
 | 
			
		||||
    if (!authRequired) {
 | 
			
		||||
      // If authentication is not required, always return authenticated
 | 
			
		||||
      return new Response(JSON.stringify({ 
 | 
			
		||||
        authenticated: true,
 | 
			
		||||
        authRequired: false
 | 
			
		||||
      }), {
 | 
			
		||||
        status: 200,
 | 
			
		||||
        headers: { 'Content-Type': 'application/json' }
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const sessionToken = getSessionFromRequest(request);
 | 
			
		||||
    
 | 
			
		||||
    if (!sessionToken) {
 | 
			
		||||
      return new Response(JSON.stringify({ 
 | 
			
		||||
        authenticated: false,
 | 
			
		||||
        authRequired: true
 | 
			
		||||
      }), {
 | 
			
		||||
        status: 200,
 | 
			
		||||
        headers: { 'Content-Type': 'application/json' }
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    const session = await verifySession(sessionToken);
 | 
			
		||||
    
 | 
			
		||||
    return new Response(JSON.stringify({ 
 | 
			
		||||
      authenticated: session !== null,
 | 
			
		||||
      authRequired: true,
 | 
			
		||||
      expires: session?.exp ? new Date(session.exp * 1000).toISOString() : null
 | 
			
		||||
    }), {
 | 
			
		||||
      status: 200,
 | 
			
		||||
      headers: { 'Content-Type': 'application/json' }
 | 
			
		||||
    return apiResponse.success({
 | 
			
		||||
      authenticated: contributionAuth.authenticated || aiAuth.authenticated,
 | 
			
		||||
      contributionAuthRequired: contributionAuth.authRequired,
 | 
			
		||||
      aiAuthRequired: aiAuth.authRequired,
 | 
			
		||||
      contributionAuthenticated: contributionAuth.authenticated,
 | 
			
		||||
      aiAuthenticated: aiAuth.authenticated,
 | 
			
		||||
      expires: contributionAuth.session?.exp ? new Date(contributionAuth.session.exp * 1000).toISOString() : null
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    return new Response(JSON.stringify({ 
 | 
			
		||||
      authenticated: false,
 | 
			
		||||
      authRequired: process.env.AUTHENTICATION_NECESSARY !== 'false',
 | 
			
		||||
      error: 'Session verification failed'
 | 
			
		||||
    }), {
 | 
			
		||||
      status: 200,
 | 
			
		||||
      headers: { 'Content-Type': 'application/json' }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
  }, 'Status check failed');
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										160
									
								
								src/pages/api/contribute/knowledgebase.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										160
									
								
								src/pages/api/contribute/knowledgebase.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,160 @@
 | 
			
		||||
// src/pages/api/contribute/knowledgebase.ts - SIMPLIFIED: Issues only, minimal validation
 | 
			
		||||
import type { APIRoute } from 'astro';
 | 
			
		||||
import { withAPIAuth } from '../../../utils/auth.js';
 | 
			
		||||
import { apiResponse, apiError, apiServerError, handleAPIRequest } from '../../../utils/api.js';
 | 
			
		||||
import { GitContributionManager } from '../../../utils/gitContributions.js';
 | 
			
		||||
import { z } from 'zod';
 | 
			
		||||
 | 
			
		||||
export const prerender = false;
 | 
			
		||||
 | 
			
		||||
// Simple schema - all fields optional except for having some content
 | 
			
		||||
const KnowledgebaseContributionSchema = z.object({
 | 
			
		||||
  toolName: z.string().optional().nullable().transform(val => val || undefined),
 | 
			
		||||
  title: z.string().optional().nullable().transform(val => val || undefined),
 | 
			
		||||
  description: z.string().optional().nullable().transform(val => val || undefined),
 | 
			
		||||
  content: z.string().optional().nullable().transform(val => val || undefined),
 | 
			
		||||
  externalLink: z.string().url().optional().nullable().catch(undefined),
 | 
			
		||||
  difficulty: z.enum(['novice', 'beginner', 'intermediate', 'advanced', 'expert']).optional().nullable().catch(undefined),
 | 
			
		||||
  categories: z.string().transform(str => {
 | 
			
		||||
    try { return JSON.parse(str); } catch { return []; }
 | 
			
		||||
  }).pipe(z.array(z.string()).default([])),
 | 
			
		||||
  tags: z.string().transform(str => {
 | 
			
		||||
    try { return JSON.parse(str); } catch { return []; }
 | 
			
		||||
  }).pipe(z.array(z.string()).default([])),
 | 
			
		||||
  uploadedFiles: z.string().transform(str => {
 | 
			
		||||
    try { return JSON.parse(str); } catch { return []; }
 | 
			
		||||
  }).pipe(z.array(z.any()).default([])),
 | 
			
		||||
  reason: z.string().optional().nullable().transform(val => val || undefined)
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
interface KnowledgebaseContributionData {
 | 
			
		||||
  toolName?: string;
 | 
			
		||||
  title?: string;
 | 
			
		||||
  description?: string;
 | 
			
		||||
  content?: string;
 | 
			
		||||
  externalLink?: string;
 | 
			
		||||
  difficulty?: string;
 | 
			
		||||
  categories: string[];
 | 
			
		||||
  tags: string[];
 | 
			
		||||
  uploadedFiles: any[];
 | 
			
		||||
  reason?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Rate limiting
 | 
			
		||||
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
 | 
			
		||||
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour
 | 
			
		||||
const RATE_LIMIT_MAX = 3; // Max 3 submissions per hour per user
 | 
			
		||||
 | 
			
		||||
function checkRateLimit(userEmail: string): boolean {
 | 
			
		||||
  const now = Date.now();
 | 
			
		||||
  const userLimit = rateLimitStore.get(userEmail);
 | 
			
		||||
  
 | 
			
		||||
  if (!userLimit || now > userLimit.resetTime) {
 | 
			
		||||
    rateLimitStore.set(userEmail, { count: 1, resetTime: now + RATE_LIMIT_WINDOW });
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  if (userLimit.count >= RATE_LIMIT_MAX) {
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  userLimit.count++;
 | 
			
		||||
  return true;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function validateKnowledgebaseData(data: KnowledgebaseContributionData): { valid: boolean; errors?: string[] } {
 | 
			
		||||
  // Very minimal validation - just check that SOMETHING was provided
 | 
			
		||||
  // Use nullish coalescing to avoid “possibly undefined” errors in strict mode
 | 
			
		||||
  const hasContent = (data.content ?? '').trim().length > 0;
 | 
			
		||||
  const hasLink = (data.externalLink ?? '').trim().length > 0;
 | 
			
		||||
  const hasFiles = Array.isArray(data.uploadedFiles) && data.uploadedFiles.length > 0;
 | 
			
		||||
  const hasTitle = (data.title ?? '').trim().length > 0;
 | 
			
		||||
  const hasDescription = (data.description ?? '').trim().length > 0;
 | 
			
		||||
  
 | 
			
		||||
  if (!hasContent && !hasLink && !hasFiles && !hasTitle && !hasDescription) {
 | 
			
		||||
    return { 
 | 
			
		||||
      valid: false, 
 | 
			
		||||
      errors: ['Please provide at least a title, description, content, external link, or upload files'] 
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  return { valid: true };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const POST: APIRoute = async ({ request }) => {
 | 
			
		||||
  return await handleAPIRequest(async () => {
 | 
			
		||||
    // Check authentication
 | 
			
		||||
    const authResult = await withAPIAuth(request, 'contributions');
 | 
			
		||||
    if (authResult.authRequired && !authResult.authenticated) {
 | 
			
		||||
      return apiError.unauthorized();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const userEmail = authResult.session?.email || 'anon@anon.anon';
 | 
			
		||||
 | 
			
		||||
    // Rate limiting
 | 
			
		||||
    if (!checkRateLimit(userEmail)) {
 | 
			
		||||
      return apiError.rateLimit('Rate limit exceeded. Please wait before submitting again.');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Parse form data
 | 
			
		||||
    let formData;
 | 
			
		||||
    try {
 | 
			
		||||
      formData = await request.formData();
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      return apiError.badRequest('Invalid form data');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const rawData = Object.fromEntries(formData);
 | 
			
		||||
 | 
			
		||||
    // Validate and sanitize data
 | 
			
		||||
    let validatedData;
 | 
			
		||||
    try {
 | 
			
		||||
      validatedData = KnowledgebaseContributionSchema.parse(rawData);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      if (error instanceof z.ZodError) {
 | 
			
		||||
        const errorMessages = error.errors.map(err => 
 | 
			
		||||
          `${err.path.join('.')}: ${err.message}`
 | 
			
		||||
        );
 | 
			
		||||
        return apiError.validation('Validation failed', errorMessages);
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      return apiError.badRequest('Invalid request data');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Basic content validation
 | 
			
		||||
    const contentValidation = validateKnowledgebaseData(validatedData);
 | 
			
		||||
    if (!contentValidation.valid) {
 | 
			
		||||
      return apiError.validation('Content validation failed', contentValidation.errors);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Submit as issue via Git
 | 
			
		||||
    try {
 | 
			
		||||
      const gitManager = new GitContributionManager();
 | 
			
		||||
      const result = await gitManager.submitKnowledgebaseContribution({
 | 
			
		||||
        ...validatedData,
 | 
			
		||||
        submitter: userEmail
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      if (result.success) {
 | 
			
		||||
        console.log(`[KB CONTRIBUTION] "${validatedData.title || 'Article'}" by ${userEmail} - Issue: ${result.issueUrl}`);
 | 
			
		||||
        
 | 
			
		||||
        return apiResponse.created({
 | 
			
		||||
          success: true,
 | 
			
		||||
          message: result.message,
 | 
			
		||||
          issueUrl: result.issueUrl,
 | 
			
		||||
          issueNumber: result.issueNumber
 | 
			
		||||
        });
 | 
			
		||||
      } else {
 | 
			
		||||
        console.error(`[KB CONTRIBUTION FAILED] "${validatedData.title || 'Article'}" by ${userEmail}: ${result.message}`);
 | 
			
		||||
        
 | 
			
		||||
        return apiServerError.internal(`Contribution failed: ${result.message}`);
 | 
			
		||||
      }
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error(`[KB GIT ERROR] "${validatedData.title || 'Article'}" by ${userEmail}:`, error);
 | 
			
		||||
      
 | 
			
		||||
      const errorMessage = error instanceof Error ? error.message : 'Git operation failed';
 | 
			
		||||
      return apiServerError.internal(`Submission failed: ${errorMessage}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
  }, 'Knowledgebase contribution processing failed');
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										219
									
								
								src/pages/api/contribute/tool.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										219
									
								
								src/pages/api/contribute/tool.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,219 @@
 | 
			
		||||
// src/pages/api/contribute/tool.ts (UPDATED - Using consolidated API responses)
 | 
			
		||||
import type { APIRoute } from 'astro';
 | 
			
		||||
import { withAPIAuth } from '../../../utils/auth.js';
 | 
			
		||||
import { apiResponse, apiError, apiServerError, apiSpecial, handleAPIRequest } from '../../../utils/api.js';
 | 
			
		||||
import { GitContributionManager, type ContributionData } from '../../../utils/gitContributions.js';
 | 
			
		||||
import { z } from 'zod';
 | 
			
		||||
 | 
			
		||||
export const prerender = false;
 | 
			
		||||
 | 
			
		||||
// Enhanced tool schema for contributions (stricter validation)
 | 
			
		||||
const ContributionToolSchema = z.object({
 | 
			
		||||
  name: z.string().min(1, 'Tool name is required').max(100, 'Tool name too long'),
 | 
			
		||||
  icon: z.string().optional().nullable(),
 | 
			
		||||
  type: z.enum(['software', 'method', 'concept'], {
 | 
			
		||||
    errorMap: () => ({ message: 'Type must be software, method, or concept' })
 | 
			
		||||
  }),
 | 
			
		||||
  description: z.string().min(10, 'Description must be at least 10 characters').max(1000, 'Description too long'),
 | 
			
		||||
  domains: z.array(z.string()).default([]),
 | 
			
		||||
  phases: z.array(z.string()).default([]),
 | 
			
		||||
  platforms: z.array(z.string()).default([]),
 | 
			
		||||
  skillLevel: z.enum(['novice', 'beginner', 'intermediate', 'advanced', 'expert'], {
 | 
			
		||||
    errorMap: () => ({ message: 'Invalid skill level' })
 | 
			
		||||
  }),
 | 
			
		||||
  accessType: z.string().optional().nullable(),
 | 
			
		||||
  url: z.string().url('Must be a valid URL'),
 | 
			
		||||
  projectUrl: z.string().url('Must be a valid URL').optional().nullable(),
 | 
			
		||||
  license: z.string().optional().nullable(),
 | 
			
		||||
  knowledgebase: z.boolean().optional().nullable(),
 | 
			
		||||
  'domain-agnostic-software': z.array(z.string()).optional().nullable(),
 | 
			
		||||
  related_concepts: z.array(z.string()).optional().nullable(),
 | 
			
		||||
  tags: z.array(z.string()).default([]),
 | 
			
		||||
  statusUrl: z.string().url('Must be a valid URL').optional().nullable()
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const ContributionRequestSchema = z.object({
 | 
			
		||||
  action: z.enum(['add', 'edit'], {
 | 
			
		||||
    errorMap: () => ({ message: 'Action must be add or edit' })
 | 
			
		||||
  }),
 | 
			
		||||
  tool: ContributionToolSchema,
 | 
			
		||||
  metadata: z.object({
 | 
			
		||||
    reason: z.string().transform(val => val.trim() === '' ? undefined : val).optional()
 | 
			
		||||
  }).optional()
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Rate limiting
 | 
			
		||||
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
 | 
			
		||||
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour
 | 
			
		||||
const RATE_LIMIT_MAX = 15; // 15 contributions per hour per user
 | 
			
		||||
 | 
			
		||||
function checkRateLimit(userId: string): boolean {
 | 
			
		||||
  const now = Date.now();
 | 
			
		||||
  const userLimit = rateLimitStore.get(userId);
 | 
			
		||||
  
 | 
			
		||||
  if (!userLimit || now > userLimit.resetTime) {
 | 
			
		||||
    rateLimitStore.set(userId, { count: 1, resetTime: now + RATE_LIMIT_WINDOW });
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  if (userLimit.count >= RATE_LIMIT_MAX) {
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  userLimit.count++;
 | 
			
		||||
  return true;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Input sanitization
 | 
			
		||||
function sanitizeInput(obj: any): any {
 | 
			
		||||
  if (typeof obj === 'string') {
 | 
			
		||||
    return obj.trim().slice(0, 1000);
 | 
			
		||||
  }
 | 
			
		||||
  if (Array.isArray(obj)) {
 | 
			
		||||
    return obj.map(sanitizeInput);
 | 
			
		||||
  }
 | 
			
		||||
  if (obj && typeof obj === 'object') {
 | 
			
		||||
    const sanitized: any = {};
 | 
			
		||||
    for (const [key, value] of Object.entries(obj)) {
 | 
			
		||||
      sanitized[key] = sanitizeInput(value);
 | 
			
		||||
    }
 | 
			
		||||
    return sanitized;
 | 
			
		||||
  }
 | 
			
		||||
  return obj;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Tool validation function
 | 
			
		||||
async function validateToolData(tool: any, action: string): Promise<{ valid: boolean; errors: string[] }> {
 | 
			
		||||
  const errors: string[] = [];
 | 
			
		||||
  
 | 
			
		||||
  try {
 | 
			
		||||
    // Load existing data for validation
 | 
			
		||||
    const existingData = { tools: [] }; // Replace with actual data loading
 | 
			
		||||
    
 | 
			
		||||
    // Check for duplicate names (on add)
 | 
			
		||||
    if (action === 'add') {
 | 
			
		||||
      const existingNames = new Set(existingData.tools.map((t: any) => t.name.toLowerCase()));
 | 
			
		||||
      if (existingNames.has(tool.name.toLowerCase())) {
 | 
			
		||||
        errors.push('A tool with this name already exists');
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Type-specific validation
 | 
			
		||||
    if (tool.type === 'method') {
 | 
			
		||||
      if (tool.platforms && tool.platforms.length > 0) {
 | 
			
		||||
        errors.push('Methods should not have platform information');
 | 
			
		||||
      }
 | 
			
		||||
      if (tool.license && tool.license !== null) {
 | 
			
		||||
        errors.push('Methods should not have license information');
 | 
			
		||||
      }
 | 
			
		||||
    } else if (tool.type === 'software') {
 | 
			
		||||
      if (!tool.platforms || tool.platforms.length === 0) {
 | 
			
		||||
        errors.push('Software tools must specify at least one platform');
 | 
			
		||||
      }
 | 
			
		||||
      if (!tool.license) {
 | 
			
		||||
        errors.push('Software tools must specify a license');
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    return { valid: errors.length === 0, errors };
 | 
			
		||||
    
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Tool validation failed:', error);
 | 
			
		||||
    errors.push('Validation failed due to system error');
 | 
			
		||||
    return { valid: false, errors };
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const POST: APIRoute = async ({ request }) => {
 | 
			
		||||
  return await handleAPIRequest(async () => {
 | 
			
		||||
    // Authentication check
 | 
			
		||||
    const authResult = await withAPIAuth(request, 'contributions');
 | 
			
		||||
    if (authResult.authRequired && !authResult.authenticated) {
 | 
			
		||||
      return apiError.unauthorized();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const userId = authResult.session?.userId || 'anonymous';
 | 
			
		||||
    const userEmail = authResult.session?.email || 'anon@anon.anon';
 | 
			
		||||
 | 
			
		||||
    // Rate limiting
 | 
			
		||||
    if (!checkRateLimit(userId)) {
 | 
			
		||||
      return apiError.rateLimit('Rate limit exceeded. Please wait before submitting another contribution.');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Parse and sanitize request body
 | 
			
		||||
    let body;
 | 
			
		||||
    try {
 | 
			
		||||
      const rawBody = await request.text();
 | 
			
		||||
      if (!rawBody.trim()) {
 | 
			
		||||
        return apiSpecial.emptyBody();
 | 
			
		||||
      }
 | 
			
		||||
      body = JSON.parse(rawBody);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      return apiSpecial.invalidJSON();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Sanitize input
 | 
			
		||||
    const sanitizedBody = sanitizeInput(body);
 | 
			
		||||
 | 
			
		||||
    // Validate request structure
 | 
			
		||||
    let validatedData;
 | 
			
		||||
    try {
 | 
			
		||||
      validatedData = ContributionRequestSchema.parse(sanitizedBody);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      if (error instanceof z.ZodError) {
 | 
			
		||||
        const errorMessages = error.errors.map(err => 
 | 
			
		||||
          `${err.path.join('.')}: ${err.message}`
 | 
			
		||||
        );
 | 
			
		||||
        return apiError.validation('Validation failed', errorMessages);
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      return apiError.badRequest('Invalid request data');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Additional tool-specific validation
 | 
			
		||||
    const toolValidation = await validateToolData(validatedData.tool, validatedData.action);
 | 
			
		||||
    if (!toolValidation.valid) {
 | 
			
		||||
      return apiError.validation('Tool validation failed', toolValidation.errors);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Prepare contribution data
 | 
			
		||||
    const contributionData: ContributionData = {
 | 
			
		||||
      type: validatedData.action,
 | 
			
		||||
      tool: validatedData.tool,
 | 
			
		||||
      metadata: {
 | 
			
		||||
        submitter: userEmail,
 | 
			
		||||
        reason: validatedData.metadata?.reason
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // CRITICAL FIX: Enhanced error handling for Git operations
 | 
			
		||||
    try {
 | 
			
		||||
      // Submit contribution via Git (now creates issue instead of PR)
 | 
			
		||||
      const gitManager = new GitContributionManager();
 | 
			
		||||
      const result = await gitManager.submitContribution(contributionData);
 | 
			
		||||
 | 
			
		||||
      if (result.success) {
 | 
			
		||||
        console.log(`[CONTRIBUTION] Issue created for "${validatedData.tool.name}" by ${userEmail} - Issue: ${result.issueUrl}`);
 | 
			
		||||
        
 | 
			
		||||
        return apiResponse.created({
 | 
			
		||||
          success: true,
 | 
			
		||||
          message: result.message,
 | 
			
		||||
          issueUrl: result.issueUrl,
 | 
			
		||||
          issueNumber: result.issueNumber
 | 
			
		||||
        });
 | 
			
		||||
      } else {
 | 
			
		||||
        console.error(`[CONTRIBUTION FAILED] "${validatedData.tool.name}" by ${userEmail}: ${result.message}`);
 | 
			
		||||
        
 | 
			
		||||
        return apiServerError.internal(`Contribution failed: ${result.message}`);
 | 
			
		||||
      }
 | 
			
		||||
    } catch (gitError) {
 | 
			
		||||
      // CRITICAL: Handle Git operation errors properly
 | 
			
		||||
      console.error(`[GIT ERROR] ${validatedData.action} "${validatedData.tool.name}" by ${userEmail}:`, gitError);
 | 
			
		||||
      
 | 
			
		||||
      // Return proper error response
 | 
			
		||||
      const errorMessage = gitError instanceof Error ? gitError.message : 'Git operation failed';
 | 
			
		||||
      return apiServerError.internal(`Git operation failed: ${errorMessage}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
  }, 'Contribution processing failed');
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										280
									
								
								src/pages/api/upload/media.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										280
									
								
								src/pages/api/upload/media.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,280 @@
 | 
			
		||||
// src/pages/api/upload/media.ts (UPDATED - Using consolidated API responses)
 | 
			
		||||
import type { APIRoute } from 'astro';
 | 
			
		||||
import { withAPIAuth } from '../../../utils/auth.js';
 | 
			
		||||
import { apiResponse, apiError, apiServerError, apiSpecial, handleAPIRequest } from '../../../utils/api.js';
 | 
			
		||||
import { NextcloudUploader, isNextcloudConfigured } from '../../../utils/nextcloud.js';
 | 
			
		||||
import { promises as fs } from 'fs';
 | 
			
		||||
import path from 'path';
 | 
			
		||||
import crypto from 'crypto';
 | 
			
		||||
 | 
			
		||||
export const prerender = false;
 | 
			
		||||
 | 
			
		||||
interface UploadResult {
 | 
			
		||||
  success: boolean;
 | 
			
		||||
  url?: string;
 | 
			
		||||
  filename?: string;
 | 
			
		||||
  size?: number;
 | 
			
		||||
  error?: string;
 | 
			
		||||
  storage?: 'nextcloud' | 'local';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Configuration
 | 
			
		||||
const UPLOAD_CONFIG = {
 | 
			
		||||
  maxFileSize: 50 * 1024 * 1024, // 50MB
 | 
			
		||||
  allowedTypes: new Set([
 | 
			
		||||
    // Images
 | 
			
		||||
    'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
 | 
			
		||||
    // Videos
 | 
			
		||||
    'video/mp4', 'video/webm', 'video/ogg', 'video/avi', 'video/mov',
 | 
			
		||||
    // Documents
 | 
			
		||||
    'application/pdf',
 | 
			
		||||
    'application/msword',
 | 
			
		||||
    'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
 | 
			
		||||
    'application/vnd.ms-excel',
 | 
			
		||||
    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
 | 
			
		||||
    'application/vnd.ms-powerpoint',
 | 
			
		||||
    'application/vnd.openxmlformats-officedocument.presentationml.presentation',
 | 
			
		||||
    'text/plain', 'text/csv', 'application/json'
 | 
			
		||||
  ]),
 | 
			
		||||
  localUploadPath: process.env.LOCAL_UPLOAD_PATH || './public/uploads',
 | 
			
		||||
  publicBaseUrl: process.env.PUBLIC_BASE_URL || 'http://localhost:4321'
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Rate limiting for uploads
 | 
			
		||||
const uploadRateLimit = new Map<string, { count: number; resetTime: number }>();
 | 
			
		||||
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour
 | 
			
		||||
const RATE_LIMIT_MAX = 100; // Max 100 uploads per hour per user
 | 
			
		||||
 | 
			
		||||
function checkUploadRateLimit(userEmail: string): boolean {
 | 
			
		||||
  const now = Date.now();
 | 
			
		||||
  const userLimit = uploadRateLimit.get(userEmail);
 | 
			
		||||
  
 | 
			
		||||
  if (!userLimit || now > userLimit.resetTime) {
 | 
			
		||||
    uploadRateLimit.set(userEmail, { count: 1, resetTime: now + RATE_LIMIT_WINDOW });
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  if (userLimit.count >= RATE_LIMIT_MAX) {
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  userLimit.count++;
 | 
			
		||||
  return true;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function validateFile(file: File): { valid: boolean; error?: string } {
 | 
			
		||||
  // File size check
 | 
			
		||||
  if (file.size > UPLOAD_CONFIG.maxFileSize) {
 | 
			
		||||
    return { 
 | 
			
		||||
      valid: false, 
 | 
			
		||||
      error: `File too large. Maximum size is ${Math.round(UPLOAD_CONFIG.maxFileSize / 1024 / 1024)}MB` 
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  // File type check
 | 
			
		||||
  if (!UPLOAD_CONFIG.allowedTypes.has(file.type)) {
 | 
			
		||||
    return { 
 | 
			
		||||
      valid: false, 
 | 
			
		||||
      error: `File type ${file.type} not allowed` 
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  return { valid: true };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function uploadToNextcloud(file: File, userEmail: string): Promise<UploadResult> {
 | 
			
		||||
  try {
 | 
			
		||||
    const uploader = new NextcloudUploader();
 | 
			
		||||
    const result = await uploader.uploadFile(file, userEmail);
 | 
			
		||||
    return {
 | 
			
		||||
      success: true,
 | 
			
		||||
      url: result.url,
 | 
			
		||||
      filename: result.filename,
 | 
			
		||||
      size: file.size,
 | 
			
		||||
      storage: 'nextcloud'
 | 
			
		||||
    };
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Nextcloud upload failed:', error);
 | 
			
		||||
    return {
 | 
			
		||||
      success: false,
 | 
			
		||||
      error: error instanceof Error ? error.message : 'Nextcloud upload failed',
 | 
			
		||||
      storage: 'nextcloud'
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function uploadToLocal(file: File, userType: string): Promise<UploadResult> {
 | 
			
		||||
  try {
 | 
			
		||||
    // Ensure upload directory exists
 | 
			
		||||
    await fs.mkdir(UPLOAD_CONFIG.localUploadPath, { recursive: true });
 | 
			
		||||
    
 | 
			
		||||
    // Generate unique filename
 | 
			
		||||
    const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
 | 
			
		||||
    const randomString = crypto.randomBytes(8).toString('hex');
 | 
			
		||||
    const extension = path.extname(file.name);
 | 
			
		||||
    const filename = `${timestamp}-${randomString}${extension}`;
 | 
			
		||||
    
 | 
			
		||||
    // Save file
 | 
			
		||||
    const filepath = path.join(UPLOAD_CONFIG.localUploadPath, filename);
 | 
			
		||||
    const buffer = Buffer.from(await file.arrayBuffer());
 | 
			
		||||
    await fs.writeFile(filepath, buffer);
 | 
			
		||||
    
 | 
			
		||||
    // Generate public URL
 | 
			
		||||
    const publicUrl = `${UPLOAD_CONFIG.publicBaseUrl}/uploads/${filename}`;
 | 
			
		||||
    
 | 
			
		||||
    return {
 | 
			
		||||
      success: true,
 | 
			
		||||
      url: publicUrl,
 | 
			
		||||
      filename: filename,
 | 
			
		||||
      size: file.size,
 | 
			
		||||
      storage: 'local'
 | 
			
		||||
    };
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Local upload failed:', error);
 | 
			
		||||
    return {
 | 
			
		||||
      success: false,
 | 
			
		||||
      error: error instanceof Error ? error.message : 'Local upload failed',
 | 
			
		||||
      storage: 'local'
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const POST: APIRoute = async ({ request }) => {
 | 
			
		||||
  return await handleAPIRequest(async () => {
 | 
			
		||||
    const authResult = await withAPIAuth(request, 'contributions');
 | 
			
		||||
    if (authResult.authRequired && !authResult.authenticated) {
 | 
			
		||||
      return apiError.unauthorized();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const userEmail = authResult.session?.email || 'anon@anon.anon';
 | 
			
		||||
 | 
			
		||||
    if (!checkUploadRateLimit(userEmail)) {
 | 
			
		||||
      return apiError.rateLimit('Upload rate limit exceeded. Please wait before uploading again.');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let formData;
 | 
			
		||||
    try {
 | 
			
		||||
      formData = await request.formData();
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      return apiError.badRequest('Invalid form data');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const file = formData.get('file') as File;
 | 
			
		||||
    const type = formData.get('type') as string;
 | 
			
		||||
 | 
			
		||||
    if (!file) {
 | 
			
		||||
      return apiSpecial.missingRequired(['file']);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Validate file
 | 
			
		||||
    const validation = validateFile(file);
 | 
			
		||||
    if (!validation.valid) {
 | 
			
		||||
      return apiError.badRequest(validation.error!);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Attempt upload (Nextcloud first, then local fallback)
 | 
			
		||||
    let result: UploadResult;
 | 
			
		||||
    
 | 
			
		||||
    if (isNextcloudConfigured()) {
 | 
			
		||||
      result = await uploadToNextcloud(file, userEmail);
 | 
			
		||||
      
 | 
			
		||||
      // If Nextcloud fails, try local fallback
 | 
			
		||||
      if (!result.success) {
 | 
			
		||||
        console.warn('Nextcloud upload failed, trying local fallback:', result.error);
 | 
			
		||||
        result = await uploadToLocal(file, type);
 | 
			
		||||
      }
 | 
			
		||||
    } else {
 | 
			
		||||
      result = await uploadToLocal(file, type);
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    if (result.success) {
 | 
			
		||||
      // Log successful upload
 | 
			
		||||
      console.log(`[MEDIA UPLOAD] ${file.name} (${file.size} bytes) by ${userEmail} -> ${result.storage}: ${result.url}`);
 | 
			
		||||
      
 | 
			
		||||
      // BEFORE: Manual success response (5 lines)
 | 
			
		||||
      // return new Response(JSON.stringify(result), {
 | 
			
		||||
      //   status: 200,
 | 
			
		||||
      //   headers: { 'Content-Type': 'application/json' }
 | 
			
		||||
      // });
 | 
			
		||||
 | 
			
		||||
      // AFTER: Single line with specialized helper
 | 
			
		||||
      return apiSpecial.uploadSuccess({
 | 
			
		||||
        url: result.url!,
 | 
			
		||||
        filename: result.filename!,
 | 
			
		||||
        size: result.size!,
 | 
			
		||||
        storage: result.storage!
 | 
			
		||||
      });
 | 
			
		||||
    } else {
 | 
			
		||||
      // Log failed upload
 | 
			
		||||
      console.error(`[MEDIA UPLOAD FAILED] ${file.name} by ${userEmail}: ${result.error}`);
 | 
			
		||||
      
 | 
			
		||||
      // BEFORE: Manual error response (5 lines)
 | 
			
		||||
      // return new Response(JSON.stringify(result), {
 | 
			
		||||
      //   status: 500,
 | 
			
		||||
      //   headers: { 'Content-Type': 'application/json' }
 | 
			
		||||
      // });
 | 
			
		||||
 | 
			
		||||
      // AFTER: Single line with specialized helper
 | 
			
		||||
      return apiSpecial.uploadFailed(result.error!);
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
  }, 'Media upload processing failed');
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// GET endpoint for upload status/info
 | 
			
		||||
export const GET: APIRoute = async ({ request }) => {
 | 
			
		||||
  return await handleAPIRequest(async () => {
 | 
			
		||||
    // Authentication check
 | 
			
		||||
    const authResult = await withAPIAuth(request);
 | 
			
		||||
    if (authResult.authRequired && !authResult.authenticated) {
 | 
			
		||||
      return apiError.unauthorized();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Return upload configuration and status
 | 
			
		||||
    const nextcloudConfigured = isNextcloudConfigured();
 | 
			
		||||
 | 
			
		||||
    // Check local upload directory
 | 
			
		||||
    let localStorageAvailable = false;
 | 
			
		||||
    try {
 | 
			
		||||
      await fs.access(UPLOAD_CONFIG.localUploadPath);
 | 
			
		||||
      localStorageAvailable = true;
 | 
			
		||||
    } catch {
 | 
			
		||||
      try {
 | 
			
		||||
        await fs.mkdir(UPLOAD_CONFIG.localUploadPath, { recursive: true });
 | 
			
		||||
        localStorageAvailable = true;
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.warn('Local upload directory not accessible:', error);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    const status = {
 | 
			
		||||
      storage: {
 | 
			
		||||
        nextcloud: {
 | 
			
		||||
          configured: nextcloudConfigured,
 | 
			
		||||
          primary: nextcloudConfigured
 | 
			
		||||
        },
 | 
			
		||||
        local: {
 | 
			
		||||
          available: localStorageAvailable,
 | 
			
		||||
          fallback: nextcloudConfigured,
 | 
			
		||||
          primary: !nextcloudConfigured
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      limits: {
 | 
			
		||||
        maxFileSize: UPLOAD_CONFIG.maxFileSize,
 | 
			
		||||
        maxFileSizeMB: Math.round(UPLOAD_CONFIG.maxFileSize / 1024 / 1024),
 | 
			
		||||
        allowedTypes: Array.from(UPLOAD_CONFIG.allowedTypes),
 | 
			
		||||
        rateLimit: {
 | 
			
		||||
          maxPerHour: RATE_LIMIT_MAX,
 | 
			
		||||
          windowMs: RATE_LIMIT_WINDOW
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      paths: {
 | 
			
		||||
        uploadEndpoint: '/api/upload/media',
 | 
			
		||||
        localPath: localStorageAvailable ? '/uploads' : null
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
    
 | 
			
		||||
    return apiResponse.success(status);
 | 
			
		||||
    
 | 
			
		||||
  }, 'Upload status retrieval failed');
 | 
			
		||||
};
 | 
			
		||||
@ -1,4 +1,5 @@
 | 
			
		||||
---
 | 
			
		||||
// src/pages/auth/callback.astro - Fixed with Email
 | 
			
		||||
// Since server-side URL parameters aren't working, 
 | 
			
		||||
// we'll handle this client-side and POST to the API
 | 
			
		||||
---
 | 
			
		||||
@ -6,49 +7,118 @@
 | 
			
		||||
<html>
 | 
			
		||||
<head>
 | 
			
		||||
  <title>Processing Authentication...</title>
 | 
			
		||||
  <style>
 | 
			
		||||
    body {
 | 
			
		||||
      font-family: system-ui, -apple-system, sans-serif;
 | 
			
		||||
      background: var(--color-bg, #ffffff);
 | 
			
		||||
      color: var(--color-text, #000000);
 | 
			
		||||
      margin: 0;
 | 
			
		||||
      padding: 0;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    .container {
 | 
			
		||||
      text-align: center;
 | 
			
		||||
      padding: 4rem 2rem;
 | 
			
		||||
      max-width: 500px;
 | 
			
		||||
      margin: 0 auto;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    .spinner {
 | 
			
		||||
      width: 40px;
 | 
			
		||||
      height: 40px;
 | 
			
		||||
      border: 4px solid #f3f3f3;
 | 
			
		||||
      border-top: 4px solid #3498db;
 | 
			
		||||
      border-radius: 50%;
 | 
			
		||||
      animation: spin 1s linear infinite;
 | 
			
		||||
      margin: 0 auto 1rem;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    @keyframes spin {
 | 
			
		||||
      0% { transform: rotate(0deg); }
 | 
			
		||||
      100% { transform: rotate(360deg); }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    .error {
 | 
			
		||||
      color: #e74c3c;
 | 
			
		||||
      background: #fdf2f2;
 | 
			
		||||
      padding: 1rem;
 | 
			
		||||
      border-radius: 0.5rem;
 | 
			
		||||
      border: 1px solid #e74c3c;
 | 
			
		||||
      margin-top: 1rem;
 | 
			
		||||
    }
 | 
			
		||||
  </style>
 | 
			
		||||
</head>
 | 
			
		||||
<body>
 | 
			
		||||
  <div style="text-align: center; padding: 4rem; font-family: sans-serif;">
 | 
			
		||||
  <div class="container">
 | 
			
		||||
    <div class="spinner"></div>
 | 
			
		||||
    <h2>Processing authentication...</h2>
 | 
			
		||||
    <p>Please wait while we complete your login.</p>
 | 
			
		||||
    <div id="error-message" style="display: none;" class="error"></div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <script>
 | 
			
		||||
    // Get URL parameters from client-side
 | 
			
		||||
    const urlParams = new URLSearchParams(window.location.search);
 | 
			
		||||
    const code = urlParams.get('code');
 | 
			
		||||
    const state = urlParams.get('state');
 | 
			
		||||
    const error = urlParams.get('error');
 | 
			
		||||
    
 | 
			
		||||
    console.log('Client-side callback params:', { code: !!code, state: !!state, error });
 | 
			
		||||
    
 | 
			
		||||
    if (error) {
 | 
			
		||||
      window.location.href = '/?auth=error';
 | 
			
		||||
    } else if (code && state) {
 | 
			
		||||
      // Send the parameters to our API endpoint
 | 
			
		||||
      fetch('/api/auth/process', {
 | 
			
		||||
        method: 'POST',
 | 
			
		||||
        headers: {
 | 
			
		||||
          'Content-Type': 'application/json',
 | 
			
		||||
        },
 | 
			
		||||
        body: JSON.stringify({ code, state })
 | 
			
		||||
      })
 | 
			
		||||
      .then(response => response.json())
 | 
			
		||||
      .then(data => {
 | 
			
		||||
        if (data.success) {
 | 
			
		||||
          window.location.href = data.redirectTo || '/';
 | 
			
		||||
        } else {
 | 
			
		||||
          window.location.href = '/?auth=error';
 | 
			
		||||
    (function() {
 | 
			
		||||
      // Get URL parameters from client-side
 | 
			
		||||
      const urlParams = new URLSearchParams(window.location.search);
 | 
			
		||||
      const code = urlParams.get('code');
 | 
			
		||||
      const state = urlParams.get('state');
 | 
			
		||||
      const error = urlParams.get('error');
 | 
			
		||||
      
 | 
			
		||||
      console.log('Client-side callback params:', { code: !!code, state: !!state, error });
 | 
			
		||||
      
 | 
			
		||||
      const errorDiv = document.getElementById('error-message') as HTMLElement;
 | 
			
		||||
      
 | 
			
		||||
      if (error) {
 | 
			
		||||
        if (errorDiv) {
 | 
			
		||||
          errorDiv.textContent = `Authentication error: ${error}`;
 | 
			
		||||
          errorDiv.style.display = 'block';
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
      .catch(error => {
 | 
			
		||||
        console.error('Authentication processing failed:', error);
 | 
			
		||||
        window.location.href = '/?auth=error';
 | 
			
		||||
      });
 | 
			
		||||
    } else {
 | 
			
		||||
      console.error('Missing code or state parameters');
 | 
			
		||||
      window.location.href = '/?auth=error';
 | 
			
		||||
    }
 | 
			
		||||
        setTimeout(() => {
 | 
			
		||||
          window.location.href = '/?auth=error';
 | 
			
		||||
        }, 3000);
 | 
			
		||||
      } else if (code && state) {
 | 
			
		||||
        // Send the parameters to our API endpoint
 | 
			
		||||
        fetch('/api/auth/process', {
 | 
			
		||||
          method: 'POST',
 | 
			
		||||
          headers: {
 | 
			
		||||
            'Content-Type': 'application/json',
 | 
			
		||||
          },
 | 
			
		||||
          body: JSON.stringify({ code, state })
 | 
			
		||||
        })
 | 
			
		||||
        .then(response => {
 | 
			
		||||
          if (!response.ok) {
 | 
			
		||||
            throw new Error(`HTTP ${response.status}`);
 | 
			
		||||
          }
 | 
			
		||||
          return response.json();
 | 
			
		||||
        })
 | 
			
		||||
        .then(data => {
 | 
			
		||||
          if (data.success) {
 | 
			
		||||
            window.location.href = data.redirectTo || '/';
 | 
			
		||||
          } else {
 | 
			
		||||
            throw new Error(data.error || 'Authentication failed');
 | 
			
		||||
          }
 | 
			
		||||
        })
 | 
			
		||||
        .catch(error => {
 | 
			
		||||
          console.error('Authentication processing failed:', error);
 | 
			
		||||
          if (errorDiv) {
 | 
			
		||||
            errorDiv.textContent = `Authentication failed: ${error.message}`;
 | 
			
		||||
            errorDiv.style.display = 'block';
 | 
			
		||||
          }
 | 
			
		||||
          setTimeout(() => {
 | 
			
		||||
            window.location.href = '/?auth=error';
 | 
			
		||||
          }, 3000);
 | 
			
		||||
        });
 | 
			
		||||
      } else {
 | 
			
		||||
        console.error('Missing code or state parameters');
 | 
			
		||||
        if (errorDiv) {
 | 
			
		||||
          errorDiv.textContent = 'Missing authentication parameters';
 | 
			
		||||
          errorDiv.style.display = 'block';
 | 
			
		||||
        }
 | 
			
		||||
        setTimeout(() => {
 | 
			
		||||
          window.location.href = '/?auth=error';
 | 
			
		||||
        }, 3000);
 | 
			
		||||
      }
 | 
			
		||||
    })();
 | 
			
		||||
  </script>
 | 
			
		||||
</body>
 | 
			
		||||
</html>
 | 
			
		||||
							
								
								
									
										253
									
								
								src/pages/contribute/index.astro
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										253
									
								
								src/pages/contribute/index.astro
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,253 @@
 | 
			
		||||
---
 | 
			
		||||
// src/pages/contribute/index.astro - Consolidated Auth
 | 
			
		||||
import BaseLayout from '../../layouts/BaseLayout.astro';
 | 
			
		||||
import { withAuth } from '../../utils/auth.js'; // Note: .js extension!
 | 
			
		||||
 | 
			
		||||
export const prerender = false;
 | 
			
		||||
 | 
			
		||||
// CONSOLIDATED: Replace 15+ lines with single function call
 | 
			
		||||
const authResult = await withAuth(Astro, 'contributions');
 | 
			
		||||
if (authResult instanceof Response) {
 | 
			
		||||
  return authResult; // Redirect to login
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const { authenticated, userEmail, userId } = authResult;
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<BaseLayout title="Contribute" description="Inhalte zum ForensicPathways beitragen">
 | 
			
		||||
  <section style="padding: 2rem 0;">
 | 
			
		||||
    <!-- Header -->
 | 
			
		||||
    <div style="text-align: center; margin-bottom: 3rem; padding: 2rem; background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%); color: white; border-radius: 1rem; border: 1px solid var(--color-border);">
 | 
			
		||||
      <h1 style="margin-bottom: 1rem; font-size: 2.5rem;">
 | 
			
		||||
        <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.75rem; vertical-align: middle;">
 | 
			
		||||
          <path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
 | 
			
		||||
          <circle cx="8.5" cy="7" r="4"/>
 | 
			
		||||
          <line x1="20" y1="8" x2="20" y2="14"/>
 | 
			
		||||
          <line x1="23" y1="11" x2="17" y2="11"/>
 | 
			
		||||
        </svg>
 | 
			
		||||
        Zum ForensicPathways beitragen
 | 
			
		||||
      </h1>
 | 
			
		||||
      <p style="margin: 0; opacity: 0.9; line-height: 1.6; font-size: 1.125rem;">
 | 
			
		||||
        Habt ihr Ideen/Ergänzungen zu den dargestellten Tools/Methoden/Konzepten? Oder habt ihr einen umfangreicheren Eintrag für unsere Knowledgebase?
 | 
			
		||||
        Hier habt ihr die Möglichkeit, direkt beizutragen!
 | 
			
		||||
      </p>
 | 
			
		||||
      {userEmail && (
 | 
			
		||||
        <p style="margin-top: 1rem; opacity: 0.8; font-size: 0.9rem;">
 | 
			
		||||
          Angemeldet als: <strong>{userEmail}</strong>
 | 
			
		||||
        </p>
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <!-- Contribution Options -->
 | 
			
		||||
<!-- WRAPPER -->
 | 
			
		||||
<div
 | 
			
		||||
  style="
 | 
			
		||||
    display:grid;
 | 
			
		||||
    grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
 | 
			
		||||
    gap:2rem;
 | 
			
		||||
    align-items:stretch;
 | 
			
		||||
    margin-bottom: 2rem;
 | 
			
		||||
  "
 | 
			
		||||
>
 | 
			
		||||
 | 
			
		||||
  <!-- src/pages/contribute/index.astro - Replace the Tools/Methods/Concepts card -->
 | 
			
		||||
 | 
			
		||||
<!-- Tools, Methods & Concepts - IMPROVED UX -->
 | 
			
		||||
<div class="card" 
 | 
			
		||||
     style="padding: 2rem; border-left: 4px solid var(--color-primary); transition: var(--transition-fast);
 | 
			
		||||
            display:flex; flex-direction:column;">
 | 
			
		||||
  <div style="display: flex; align-items: center; gap: 1rem; margin-bottom: 1.5rem;">
 | 
			
		||||
    <div style="width: 48px; height: 48px; background-color: var(--color-primary); border-radius: 0.5rem; display: flex; align-items: center; justify-content: center;">
 | 
			
		||||
      <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
 | 
			
		||||
        <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"/>
 | 
			
		||||
      </svg>
 | 
			
		||||
    </div>
 | 
			
		||||
    <h3 style="margin: 0; color: var(--color-primary); font-size: 1.25rem;">Software, Methoden oder Konzepte</h3>
 | 
			
		||||
  </div>
 | 
			
		||||
  
 | 
			
		||||
  <p style="margin-bottom: 1.5rem; line-height: 1.6;">
 | 
			
		||||
    Ergänzt Software/Tools, forensische Methoden und relevante Konzepte zu unserer Datenbank.
 | 
			
		||||
    Füllt einfach ein kurzes Formular aus!
 | 
			
		||||
  </p>
 | 
			
		||||
  
 | 
			
		||||
  <div style="display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1.5rem;">
 | 
			
		||||
    <span class="badge" style="background-color: var(--color-primary); color: white;">Software/Tools</span>
 | 
			
		||||
    <span class="badge" style="background-color: var(--color-method); color: white;">Methoden</span>
 | 
			
		||||
    <span class="badge" style="background-color: var(--color-concept); color: white;">Konzepte</span>
 | 
			
		||||
  </div>
 | 
			
		||||
  
 | 
			
		||||
  <div style="margin-top:auto; display:flex; flex-direction: column; gap:1rem;">
 | 
			
		||||
    <a href="/contribute/tool" class="btn btn-primary" style="width: 100%;">
 | 
			
		||||
      <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
 | 
			
		||||
        <line x1="12" y1="5" x2="12" y2="19"/>
 | 
			
		||||
        <line x1="5" y1="12" x2="19" y2="12"/>
 | 
			
		||||
      </svg>
 | 
			
		||||
      Neuer Eintrag
 | 
			
		||||
    </a>
 | 
			
		||||
    
 | 
			
		||||
    <!-- IMPROVED: Clear guidance instead of confusing button -->
 | 
			
		||||
    <div style="background-color: var(--color-bg-secondary); padding: 1.25rem; border-radius: 0.5rem; border-left: 3px solid var(--color-accent);">
 | 
			
		||||
      <div style="display: flex; align-items: start; gap: 0.75rem;">
 | 
			
		||||
        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" stroke-width="2" style="flex-shrink: 0; margin-top: 0.125rem;">
 | 
			
		||||
          <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
 | 
			
		||||
          <path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
 | 
			
		||||
        </svg>
 | 
			
		||||
        <div>
 | 
			
		||||
          <h4 style="margin: 0 0 0.5rem 0; color: var(--color-accent); font-size: 0.9375rem;">Existierenden Eintrag bearbeiten</h4>
 | 
			
		||||
          <p style="margin: 0; font-size: 0.875rem; line-height: 1.5; color: var(--color-text-secondary);">
 | 
			
		||||
            Suchen Sie das Tool/Methode/Konzept auf der <a href="/" style="color: var(--color-primary); text-decoration: underline;">Hauptseite</a>, 
 | 
			
		||||
            öffnen Sie die Details und klicken Sie den <strong style="color: var(--color-text);">Edit</strong>-Button.
 | 
			
		||||
          </p>
 | 
			
		||||
          <div style="margin-top: 0.75rem;">
 | 
			
		||||
            <a href="/" class="btn btn-secondary" style="font-size: 0.8125rem; padding: 0.5rem 1rem;">
 | 
			
		||||
              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.375rem;">
 | 
			
		||||
                <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
 | 
			
		||||
                <polyline points="9,22 9,12 15,12 15,22"/>
 | 
			
		||||
              </svg>
 | 
			
		||||
              Zur Hauptseite
 | 
			
		||||
            </a>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
  <!-- Knowledgebase Articles -->
 | 
			
		||||
  <div class="card" 
 | 
			
		||||
       style="padding: 2rem; border-left: 4px solid var(--color-accent); cursor: pointer; transition: var(--transition-fast);
 | 
			
		||||
              display:flex; flex-direction:column;" 
 | 
			
		||||
       onclick="window.location.href='/contribute/knowledgebase'">
 | 
			
		||||
    <div style="display: flex; align-items: center; gap: 1rem; margin-bottom: 1.5rem;">
 | 
			
		||||
      <div style="width: 48px; height: 48px; background-color: var(--color-accent); border-radius: 0.5rem; display: flex; align-items: center; justify-content: center;">
 | 
			
		||||
        <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
 | 
			
		||||
          <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
 | 
			
		||||
          <polyline points="14 2 14 8 20 8"/>
 | 
			
		||||
          <line x1="16" y1="13" x2="8" y2="13"/>
 | 
			
		||||
          <line x1="16" y1="17" x2="8" y2="17"/>
 | 
			
		||||
          <polyline points="10 9 9 9 8 9"/>
 | 
			
		||||
        </svg>
 | 
			
		||||
      </div>
 | 
			
		||||
      <h3 style="margin: 0; color: var(--color-accent); font-size: 1.25rem;">Knowledgebase-Artikel</h3>
 | 
			
		||||
    </div>
 | 
			
		||||
    
 | 
			
		||||
    <p style="margin-bottom: 1.5rem; line-height: 1.6;">
 | 
			
		||||
      Wenn ihr einen umfangreicheren Beitrag zu einem Tool, einer Methode oder einem Kozept habt, könnt ihr ihn hier einreichen.
 | 
			
		||||
      Der Upload von beliebigen Dateien und Unterlagen ist hier möglich, und wird manuell von mir integriert.
 | 
			
		||||
    </p>
 | 
			
		||||
    
 | 
			
		||||
    <div style="display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1.5rem;">
 | 
			
		||||
      <span class="badge badge-secondary">Installationsanleitungen</span>
 | 
			
		||||
      <span class="badge badge-secondary">Tutorials</span>
 | 
			
		||||
      <span class="badge badge-secondary">Best Practices</span>
 | 
			
		||||
      <span class="badge badge-secondary">Fallstudien</span>
 | 
			
		||||
    </div>
 | 
			
		||||
    
 | 
			
		||||
    <div style="margin-top:auto; display:flex; gap:1rem;">
 | 
			
		||||
      <a href="/contribute/knowledgebase" class="btn btn-accent" style="flex: 1;">Beitrag einreichen</a>
 | 
			
		||||
      <a href="/knowledgebase" class="btn btn-secondary" style="flex: 1;">Beiträge ansehen</a>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <!-- Issues & Improvements -->
 | 
			
		||||
  <div class="card" 
 | 
			
		||||
       style="padding: 2rem; border-left: 4px solid var(--color-accent); cursor: pointer; transition: var(--transition-fast);
 | 
			
		||||
              display:flex; flex-direction:column;">
 | 
			
		||||
    <div style="display: flex; align-items: center; gap: 1rem; margin-bottom: 1.5rem;">
 | 
			
		||||
      <div style="width: 48px; height: 48px; background-color: var(--color-warning); border-radius: 0.5rem; display: flex; align-items: center; justify-content: center;">
 | 
			
		||||
        <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
 | 
			
		||||
          <circle cx="12" cy="12" r="10"/>
 | 
			
		||||
          <line x1="12" y1="8" x2="12" y2="12"/>
 | 
			
		||||
          <line x1="12" y1="16" x2="12.01" y2="16"/>
 | 
			
		||||
        </svg>
 | 
			
		||||
      </div>
 | 
			
		||||
      <h3 style="margin: 0; color: var(--color-warning); font-size: 1.25rem;">Probleme & Verbesserungen</h3>
 | 
			
		||||
    </div>
 | 
			
		||||
    
 | 
			
		||||
    <div style="display: grid; grid-template-columns: 2fr 1fr; gap: 2rem; align-items: center;">
 | 
			
		||||
      <div style="display:flex; flex-direction:column;">
 | 
			
		||||
        <p style="margin-bottom: 1rem; line-height: 1.6;">
 | 
			
		||||
          Ist euch ein Bug oder eine fehlerhafte Information aufgefallen? Auch wenn es nur Kleinigkeiten sind - hier könnt ihr sie einreichen.
 | 
			
		||||
          Erstellt direkt einen Issue in unserem Git.
 | 
			
		||||
        </p>
 | 
			
		||||
        <div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
 | 
			
		||||
          <span class="badge" style="background-color: var(--color-warning); color: white;">Bug Reports</span>
 | 
			
		||||
          <span class="badge" style="background-color: var(--color-warning); color: white;">Korrekturen</span>
 | 
			
		||||
          <span class="badge" style="background-color: var(--color-warning); color: white;">Vorschläge</span>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div style="display: flex; flex-direction: column; gap: 1rem;">
 | 
			
		||||
        <a href="https://git.cc24.dev/mstoeck3/cc24-hub/issues/new" target="_blank" rel="noopener noreferrer" class="btn" style="background-color: var(--color-warning); color: white; border-color: var(--color-warning);">
 | 
			
		||||
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
 | 
			
		||||
            <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
 | 
			
		||||
            <polyline points="15 3 21 3 21 9"/>
 | 
			
		||||
            <line x1="10" y1="14" x2="21" y2="3"/>
 | 
			
		||||
          </svg>
 | 
			
		||||
          Problem melden
 | 
			
		||||
        </a>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <!-- Push this actions block down if you add more later -->
 | 
			
		||||
    <div style="margin-top:auto;"></div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    <!-- Guidelines -->
 | 
			
		||||
    <div class="card" style="margin-bottom: 2rem;">
 | 
			
		||||
      <h3 style="margin-bottom: 1.5rem; color: var(--color-text);">Richtlinien</h3>
 | 
			
		||||
      
 | 
			
		||||
      <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem;">
 | 
			
		||||
        <div>
 | 
			
		||||
          <h4 style="margin-bottom: 0.75rem; color: var(--color-primary);">Empfehlungen</h4>
 | 
			
		||||
          <ul style="margin: 0; padding-left: 1.5rem; line-height: 1.6;">
 | 
			
		||||
            <li>Informationen sollten stets korrekt und up-to-date sein</li>
 | 
			
		||||
            <li>Nutzt klare, verständliche Sprache</li>
 | 
			
		||||
            <li>Nutzt passende Tags und Kategorisierungen</li>
 | 
			
		||||
            <li>Verifiziert, ob alle Links funktionieren</li>
 | 
			
		||||
            <li>Testet die Tools/Methoden oder Installationsanleitungen nach Möglichkeit vorher aus</li>
 | 
			
		||||
            <li>Stellt auf keinen Fall Informationen ein, die nicht öffentlich sein dürfen. Alles wird unter BSD-3-Clause-Veröffentlicht.</li>
 | 
			
		||||
          </ul>
 | 
			
		||||
        </div>
 | 
			
		||||
        
 | 
			
		||||
        <div>
 | 
			
		||||
          <h4 style="margin-bottom: 0.75rem; color: var(--color-accent);">Review</h4>
 | 
			
		||||
          <ul style="margin: 0; padding-left: 1.5rem; line-height: 1.6;">
 | 
			
		||||
            <li>Alle Beiträge werden transparent als Pull Requests in unserem Git veröffentlicht</li>
 | 
			
		||||
            <li>Die Inforamtionen werden teilweise automatisiert validiert</li>
 | 
			
		||||
            <li>Manuelle Prüfung innerhalb des Git-Review-Prozesses durch Maintainer</li>
 | 
			
		||||
            <li>Feedback durch PR-Kommentare, ggf. auch direkt</li>
 | 
			
		||||
            <li>Der PR wird dann zeitnah veröffentlicht und ist beim nächsten Serverupdate verfügbar</li>
 | 
			
		||||
          </ul>
 | 
			
		||||
        </div>
 | 
			
		||||
        
 | 
			
		||||
        <div>
 | 
			
		||||
          <h4 style="margin-bottom: 0.75rem; color: var(--color-warning);">Best Practices</h4>
 | 
			
		||||
          <ul style="margin: 0; padding-left: 1.5rem; line-height: 1.6;">
 | 
			
		||||
            <li>Vermeidet Duplokate</li>
 | 
			
		||||
            <li>Versucht, konsistent bei der Benennung, Kategorisierung und Tags zu sein</li>
 | 
			
		||||
            <li>Schreibt detaillierte Beschreibungen</li>
 | 
			
		||||
            <li>Inkludiert Screenshots bei komplizierten Guides</li>
 | 
			
		||||
            <li>Nennt eure Primärquellen</li>
 | 
			
		||||
          </ul>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
  <script>
 | 
			
		||||
    // Add hover effects for cards
 | 
			
		||||
    document.querySelectorAll('.card[onclick]').forEach((card) => {
 | 
			
		||||
      const cardEl = card as HTMLElement;
 | 
			
		||||
      cardEl.addEventListener('mouseenter', function() {
 | 
			
		||||
        this.style.transform = 'translateY(-2px)';
 | 
			
		||||
        this.style.boxShadow = '0 8px 32px rgba(0, 0, 0, 0.12)';
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      cardEl.addEventListener('mouseleave', function() {
 | 
			
		||||
        this.style.transform = 'translateY(0)';
 | 
			
		||||
        this.style.boxShadow = '';
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  </script>
 | 
			
		||||
</BaseLayout>
 | 
			
		||||
							
								
								
									
										481
									
								
								src/pages/contribute/knowledgebase.astro
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										481
									
								
								src/pages/contribute/knowledgebase.astro
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,481 @@
 | 
			
		||||
---
 | 
			
		||||
// src/pages/contribute/knowledgebase.astro - SIMPLIFIED: Issues only, minimal validation
 | 
			
		||||
import BaseLayout from '../../layouts/BaseLayout.astro';
 | 
			
		||||
import { withAuth } from '../../utils/auth.js';
 | 
			
		||||
import { getToolsData } from '../../utils/dataService.js';
 | 
			
		||||
 | 
			
		||||
export const prerender = false;
 | 
			
		||||
 | 
			
		||||
// Check authentication
 | 
			
		||||
const authResult = await withAuth(Astro, 'contributions');
 | 
			
		||||
if (authResult instanceof Response) {
 | 
			
		||||
  return authResult;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const { authenticated, userEmail, userId } = authResult;
 | 
			
		||||
 | 
			
		||||
// Load tools for reference (optional dropdown)
 | 
			
		||||
const data = await getToolsData();
 | 
			
		||||
const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.name));
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<BaseLayout title="Contribute Knowledge Base Article">
 | 
			
		||||
  <div class="container" style="max-width: 900px; margin: 0 auto; padding: 2rem 1rem;">
 | 
			
		||||
    
 | 
			
		||||
    <!-- Header -->
 | 
			
		||||
    <div style="text-align: center; margin-bottom: 2rem; padding: 2rem; background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%); color: white; border-radius: 1rem;">
 | 
			
		||||
      <h1 style="margin-bottom: 1rem; font-size: 2rem;">Knowledgebase-Artikel</h1>
 | 
			
		||||
      <p style="margin: 0.5rem 0; opacity: 0.9;">Danke für deinen Beitrag!</p>
 | 
			
		||||
      {userEmail && <p style="margin: 0.5rem 0; opacity: 0.8;"><strong>Eingeloggt als:</strong> {userEmail}</p>}
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <!-- Main Form -->
 | 
			
		||||
    <div class="card">
 | 
			
		||||
      <form id="kb-form" novalidate>
 | 
			
		||||
        
 | 
			
		||||
        <!-- Basic Information -->
 | 
			
		||||
        <div class="form-section">
 | 
			
		||||
          <h3 class="section-title">Grundinformationen</h3>
 | 
			
		||||
          
 | 
			
		||||
          <div class="form-grid-2">
 | 
			
		||||
            <div class="form-group">
 | 
			
		||||
              <label for="tool-name" class="form-label">Zusammenhang zu Tool / Methode / Konzept (Optional)</label>
 | 
			
		||||
              <select id="tool-name" name="toolName" class="form-input">
 | 
			
		||||
                <option value="">Auswählen...</option>
 | 
			
		||||
                {sortedTools.map(tool => (
 | 
			
		||||
                  <option value={tool.name}>{tool.name} ({tool.type})</option>
 | 
			
		||||
                ))}
 | 
			
		||||
              </select>
 | 
			
		||||
            </div>
 | 
			
		||||
            
 | 
			
		||||
            <div class="form-group">
 | 
			
		||||
              <label for="difficulty" class="form-label">Schwierigkeitsniveau (Optional)</label>
 | 
			
		||||
              <select id="difficulty" name="difficulty" class="form-input">
 | 
			
		||||
                <option value="">Niveau wählen...</option>
 | 
			
		||||
                <option value="novice">Novice</option>
 | 
			
		||||
                <option value="beginner">Beginner</option>
 | 
			
		||||
                <option value="intermediate">Intermediate</option>
 | 
			
		||||
                <option value="advanced">Advanced</option>
 | 
			
		||||
                <option value="expert">Expert</option>
 | 
			
		||||
              </select>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
          <br>
 | 
			
		||||
 | 
			
		||||
          <div class="form-group">
 | 
			
		||||
            <label for="title" class="form-label">Titel des Artikels (Optional)</label>
 | 
			
		||||
            <input 
 | 
			
		||||
              type="text" 
 | 
			
		||||
              id="title" 
 | 
			
		||||
              name="title"  
 | 
			
		||||
              maxlength="100"
 | 
			
		||||
              placeholder="Klarer, deskriptiver Titel"
 | 
			
		||||
              class="form-input"
 | 
			
		||||
            />
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div class="form-group">
 | 
			
		||||
            <label for="description" class="form-label">Kurzbeschreibung (Optional)</label>
 | 
			
		||||
            <textarea 
 | 
			
		||||
              id="description" 
 | 
			
		||||
              name="description"  
 | 
			
		||||
              maxlength="300"
 | 
			
		||||
              rows="3"
 | 
			
		||||
              placeholder="Kurze Zusammenfassung, worum es in dem Artikel geht"
 | 
			
		||||
              class="form-input"
 | 
			
		||||
            ></textarea>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <!-- Content -->
 | 
			
		||||
        <div class="form-section">
 | 
			
		||||
          <h3 class="section-title">Inhalt</h3>
 | 
			
		||||
          
 | 
			
		||||
          <div class="form-group">
 | 
			
		||||
            <label for="content" class="form-label">Inhalt (Optional)</label>
 | 
			
		||||
            <textarea 
 | 
			
		||||
              id="content" 
 | 
			
		||||
              name="content" 
 | 
			
		||||
              rows="8"
 | 
			
		||||
              placeholder="Schreibt hier so viel Text, wie ihr wollt. Die Formatierung wird später redaktionell angepasst."
 | 
			
		||||
              class="form-input"
 | 
			
		||||
            ></textarea>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div class="form-group">
 | 
			
		||||
            <label for="external-link" class="form-label">Link (z.B. Primärquelle) (Optional)</label>
 | 
			
		||||
            <input 
 | 
			
		||||
              type="url" 
 | 
			
		||||
              id="external-link" 
 | 
			
		||||
              name="externalLink" 
 | 
			
		||||
              placeholder="https://example.com/documentation"
 | 
			
		||||
              class="form-input"
 | 
			
		||||
            />
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <!-- File Upload -->
 | 
			
		||||
        <div class="form-section">
 | 
			
		||||
          <h3 class="section-title">Dateien hochladen</h3>
 | 
			
		||||
          
 | 
			
		||||
          <div class="form-group">
 | 
			
		||||
            <label class="form-label">Dokumente, Bilder, Videos (Optional)</label>
 | 
			
		||||
            <div class="upload-area" id="upload-area">
 | 
			
		||||
              <input type="file" id="file-input" multiple accept=".pdf,.doc,.docx,.txt,.md,.zip,.png,.jpg,.jpeg,.gif,.mp4,.webm" style="display: none;">
 | 
			
		||||
              <div class="upload-placeholder">
 | 
			
		||||
                <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
 | 
			
		||||
                  <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
 | 
			
		||||
                  <polyline points="7 10 12 15 17 10"/>
 | 
			
		||||
                  <line x1="12" y1="15" x2="12" y2="3"/>
 | 
			
		||||
                </svg>
 | 
			
		||||
                <p>Klicken, um Dateien auszuwählen oder Drag&Drop</p>
 | 
			
		||||
                <small>Die Dateien landen in der CC24-Cloud. Keine Malware.</small>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div id="file-list" class="file-list" style="display: none;">
 | 
			
		||||
              <h5>Ausgewählte Dateien</h5>
 | 
			
		||||
              <div id="files-container"></div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <!-- Additional Information -->
 | 
			
		||||
        <div class="form-section">
 | 
			
		||||
          <h3 class="section-title">Zusatzinformation</h3>
 | 
			
		||||
          
 | 
			
		||||
          <div class="form-grid-2">
 | 
			
		||||
            <div class="form-group">
 | 
			
		||||
              <label for="categories" class="form-label">Kategorien (Optional)</label>
 | 
			
		||||
              <input 
 | 
			
		||||
                type="text" 
 | 
			
		||||
                id="categories" 
 | 
			
		||||
                name="categories" 
 | 
			
		||||
                placeholder="setup, configuration, troubleshooting"
 | 
			
		||||
                class="form-input"
 | 
			
		||||
              />
 | 
			
		||||
              <small class="form-help">Komma-getrennte Kategorien</small>
 | 
			
		||||
            </div>
 | 
			
		||||
            
 | 
			
		||||
            <div class="form-group">
 | 
			
		||||
              <label for="tags" class="form-label">Tags (Optional)</label>
 | 
			
		||||
              <input 
 | 
			
		||||
                type="text" 
 | 
			
		||||
                id="tags" 
 | 
			
		||||
                name="tags" 
 | 
			
		||||
                placeholder="installation, docker, linux, windows"
 | 
			
		||||
                class="form-input"
 | 
			
		||||
              />
 | 
			
		||||
              <small class="form-help">Komma-getrennte Tags</small>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div class="form-group">
 | 
			
		||||
            <label for="reason" class="form-label">Grund für den Beitrag (Optional)</label>
 | 
			
		||||
            <textarea 
 | 
			
		||||
              id="reason" 
 | 
			
		||||
              name="reason" 
 | 
			
		||||
              rows="3"
 | 
			
		||||
              placeholder="Möchtest du sonst noch etwas mitteilen? Welches Problem wurde für dich gelöst??"
 | 
			
		||||
              class="form-input"
 | 
			
		||||
            ></textarea>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <!-- Submit Button -->
 | 
			
		||||
        <div class="form-actions">
 | 
			
		||||
          <a href="/" class="btn btn-secondary">Abbruch</a>
 | 
			
		||||
          <button type="submit" id="submit-btn" class="btn btn-accent">
 | 
			
		||||
            <span id="submit-text">Abschicken</span>
 | 
			
		||||
            <span id="submit-spinner" style="display: none;">⏳</span>
 | 
			
		||||
          </button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </form>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <!-- Success Modal -->
 | 
			
		||||
    <div id="success-modal"
 | 
			
		||||
        style="display:none; position:fixed; top:0; left:0; width:100%; height:100%;
 | 
			
		||||
                background:rgba(0,0,0,.5); z-index:1000; align-items:center; justify-content:center;">
 | 
			
		||||
      <div class="card" style="max-width:500px; width:90%; margin:2rem; text-align:center;">
 | 
			
		||||
        <div style="font-size:3rem; margin-bottom:1rem;">✅</div>
 | 
			
		||||
        <h3 style="margin-bottom:1rem;">Article Submitted!</h3>
 | 
			
		||||
        <p id="success-message" style="margin-bottom:1.5rem;">
 | 
			
		||||
          Your knowledge‑base article has been submitted as an issue for review by maintainers.
 | 
			
		||||
        </p>
 | 
			
		||||
        <div style="display:flex; gap:1rem; justify-content:center;">
 | 
			
		||||
          <a id="issue-link" href="#" target="_blank" class="btn btn-primary" style="display:none;">View Issue</a>
 | 
			
		||||
          <a href="/" class="btn btn-secondary">Back to Home</a>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <!-- Message Container -->
 | 
			
		||||
    <div id="message-container" class="message-container"></div>
 | 
			
		||||
  </div>
 | 
			
		||||
</BaseLayout>
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
interface UploadedFile {
 | 
			
		||||
  id: string;
 | 
			
		||||
  file: File;
 | 
			
		||||
  name: string;
 | 
			
		||||
  uploaded: boolean;
 | 
			
		||||
  url?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
declare global {
 | 
			
		||||
  interface Window {
 | 
			
		||||
    removeFile: (fileId: string) => void;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class KnowledgebaseForm {
 | 
			
		||||
  private uploadedFiles: UploadedFile[] = [];
 | 
			
		||||
  private isSubmitting = false;
 | 
			
		||||
  private elements: Record<string, HTMLElement | null> = {};
 | 
			
		||||
 | 
			
		||||
  constructor() {
 | 
			
		||||
    this.init();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private init() {
 | 
			
		||||
    // Get elements
 | 
			
		||||
    this.elements = {
 | 
			
		||||
      form: document.getElementById('kb-form'),
 | 
			
		||||
      submitBtn: document.getElementById('submit-btn'),
 | 
			
		||||
      submitText: document.getElementById('submit-text'),
 | 
			
		||||
      submitSpinner: document.getElementById('submit-spinner'),
 | 
			
		||||
      fileInput: document.getElementById('file-input'),
 | 
			
		||||
      uploadArea: document.getElementById('upload-area'),
 | 
			
		||||
      fileList: document.getElementById('file-list'),
 | 
			
		||||
      filesContainer: document.getElementById('files-container'),
 | 
			
		||||
      successModal: document.getElementById('success-modal')
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    if (!this.elements.form || !this.elements.submitBtn) {
 | 
			
		||||
      console.error('[KB FORM] Critical elements missing');
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.setupEventListeners();
 | 
			
		||||
    this.setupFileUpload();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private setupEventListeners() {
 | 
			
		||||
    // Form submission
 | 
			
		||||
    this.elements.form?.addEventListener('submit', (e) => {
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      if (!this.isSubmitting) {
 | 
			
		||||
        this.handleSubmit();
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private setupFileUpload() {
 | 
			
		||||
    if (!this.elements.fileInput || !this.elements.uploadArea) return;
 | 
			
		||||
 | 
			
		||||
    this.elements.uploadArea.addEventListener('click', () => {
 | 
			
		||||
      (this.elements.fileInput as HTMLInputElement)?.click();
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    this.elements.uploadArea.addEventListener('dragover', (e) => {
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      this.elements.uploadArea?.classList.add('drag-over');
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    this.elements.uploadArea.addEventListener('dragleave', () => {
 | 
			
		||||
      this.elements.uploadArea?.classList.remove('drag-over');
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    this.elements.uploadArea.addEventListener('drop', (e) => {
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      this.elements.uploadArea?.classList.remove('drag-over');
 | 
			
		||||
      if (e.dataTransfer?.files) {
 | 
			
		||||
        this.handleFiles(Array.from(e.dataTransfer.files));
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    this.elements.fileInput.addEventListener('change', (e) => {
 | 
			
		||||
      const target = e.target as HTMLInputElement;
 | 
			
		||||
      if (target?.files) {
 | 
			
		||||
        this.handleFiles(Array.from(target.files));
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private handleFiles(files: File[]) {
 | 
			
		||||
    files.forEach(file => {
 | 
			
		||||
      const fileId = Date.now() + '-' + Math.random().toString(36).substr(2, 9);
 | 
			
		||||
      const newFile: UploadedFile = {
 | 
			
		||||
        id: fileId,
 | 
			
		||||
        file,
 | 
			
		||||
        name: file.name,
 | 
			
		||||
        uploaded: false
 | 
			
		||||
      };
 | 
			
		||||
      this.uploadedFiles.push(newFile);
 | 
			
		||||
      this.uploadFile(fileId);
 | 
			
		||||
    });
 | 
			
		||||
    this.renderFileList();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async uploadFile(fileId: string) {
 | 
			
		||||
    const fileItem = this.uploadedFiles.find(f => f.id === fileId);
 | 
			
		||||
    if (!fileItem) return;
 | 
			
		||||
 | 
			
		||||
    const formData = new FormData();
 | 
			
		||||
    formData.append('file', fileItem.file);
 | 
			
		||||
    formData.append('type', 'knowledgebase');
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      const response = await fetch('/api/upload/media', {
 | 
			
		||||
        method: 'POST',
 | 
			
		||||
        body: formData
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      if (response.ok) {
 | 
			
		||||
        const result = await response.json();
 | 
			
		||||
        fileItem.uploaded = true;
 | 
			
		||||
        fileItem.url = result.url;
 | 
			
		||||
        this.renderFileList();
 | 
			
		||||
      } else {
 | 
			
		||||
        throw new Error('Upload failed');
 | 
			
		||||
      }
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      this.showMessage('error', `Failed to upload ${fileItem.name}`);
 | 
			
		||||
      this.removeFile(fileId);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private removeFile(fileId: string) {
 | 
			
		||||
    this.uploadedFiles = this.uploadedFiles.filter(f => f.id !== fileId);
 | 
			
		||||
    this.renderFileList();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private renderFileList() {
 | 
			
		||||
    if (!this.elements.filesContainer || !this.elements.fileList) return;
 | 
			
		||||
 | 
			
		||||
    if (this.uploadedFiles.length > 0) {
 | 
			
		||||
      (this.elements.fileList as HTMLElement).style.display = 'block';
 | 
			
		||||
      (this.elements.filesContainer as HTMLElement).innerHTML = this.uploadedFiles.map(file => `
 | 
			
		||||
        <div class="file-item">
 | 
			
		||||
          <div class="file-info">
 | 
			
		||||
            <strong>${file.name}</strong>
 | 
			
		||||
            <div class="file-meta">
 | 
			
		||||
              ${(file.file.size / 1024 / 1024).toFixed(2)} MB
 | 
			
		||||
              ${file.uploaded ? 
 | 
			
		||||
                '<span class="file-status success">✓ Uploaded</span>' : 
 | 
			
		||||
                '<span class="file-status pending">⏳ Uploading...</span>'
 | 
			
		||||
              }
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
          <button type="button" onclick="window.removeFile('${file.id}')" class="btn btn-danger btn-small">Remove</button>
 | 
			
		||||
        </div>
 | 
			
		||||
      `).join('');
 | 
			
		||||
    } else {
 | 
			
		||||
      (this.elements.fileList as HTMLElement).style.display = 'none';
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async handleSubmit() {
 | 
			
		||||
    if (this.isSubmitting) return;
 | 
			
		||||
 | 
			
		||||
    this.isSubmitting = true;
 | 
			
		||||
    
 | 
			
		||||
    // Update UI
 | 
			
		||||
    (this.elements.submitBtn as HTMLButtonElement).disabled = true;
 | 
			
		||||
    (this.elements.submitText as HTMLElement).textContent = 'Submitting...';
 | 
			
		||||
    (this.elements.submitSpinner as HTMLElement).style.display = 'inline';
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      const formData = new FormData(this.elements.form as HTMLFormElement);
 | 
			
		||||
      
 | 
			
		||||
      // Process categories and tags
 | 
			
		||||
      const categoriesValue = (formData.get('categories') as string) || '';
 | 
			
		||||
      const tagsValue = (formData.get('tags') as string) || '';
 | 
			
		||||
      
 | 
			
		||||
      const categories = categoriesValue.split(',').map(s => s.trim()).filter(s => s);
 | 
			
		||||
      const tags = tagsValue.split(',').map(s => s.trim()).filter(s => s);
 | 
			
		||||
      formData.set('categories', JSON.stringify(categories));
 | 
			
		||||
      formData.set('tags', JSON.stringify(tags));
 | 
			
		||||
 | 
			
		||||
      // Add uploaded files
 | 
			
		||||
      formData.set('uploadedFiles', JSON.stringify(this.uploadedFiles.filter(f => f.uploaded)));
 | 
			
		||||
 | 
			
		||||
      const response = await fetch('/api/contribute/knowledgebase', {
 | 
			
		||||
        method: 'POST',
 | 
			
		||||
        body: formData
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const result = await response.json();
 | 
			
		||||
 | 
			
		||||
      if (result.success) {
 | 
			
		||||
        this.showSuccess(result);
 | 
			
		||||
      } else {
 | 
			
		||||
        throw new Error(result.error || 'Submission failed');
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error('[KB FORM] Submission error:', error);
 | 
			
		||||
      this.showMessage('error', 'Submission failed. Please try again.');
 | 
			
		||||
    } finally {
 | 
			
		||||
      this.isSubmitting = false;
 | 
			
		||||
      (this.elements.submitBtn as HTMLButtonElement).disabled = false;
 | 
			
		||||
      (this.elements.submitText as HTMLElement).textContent = 'Submit Article';
 | 
			
		||||
      (this.elements.submitSpinner as HTMLElement).style.display = 'none';
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private showSuccess(result: any) {
 | 
			
		||||
    const successMessage = document.getElementById('success-message');
 | 
			
		||||
    const issueLink = document.getElementById('issue-link') as HTMLAnchorElement;
 | 
			
		||||
    
 | 
			
		||||
    if (successMessage) {
 | 
			
		||||
      successMessage.textContent = 'Your knowledge base article has been submitted as an issue for review by maintainers.';
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    if (result.issueUrl && issueLink) {
 | 
			
		||||
      issueLink.href = result.issueUrl;
 | 
			
		||||
      issueLink.style.display = 'inline-flex';
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    (this.elements.successModal as HTMLElement).style.display = 'flex';
 | 
			
		||||
    
 | 
			
		||||
    // Reset form
 | 
			
		||||
    (this.elements.form as HTMLFormElement).reset();
 | 
			
		||||
    this.uploadedFiles = [];
 | 
			
		||||
    this.renderFileList();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private showMessage(type: 'success' | 'error' | 'warning', message: string) {
 | 
			
		||||
    const container = document.getElementById('message-container');
 | 
			
		||||
    if (!container) return;
 | 
			
		||||
 | 
			
		||||
    const messageEl = document.createElement('div');
 | 
			
		||||
    messageEl.className = `message message-${type}`;
 | 
			
		||||
    messageEl.textContent = message;
 | 
			
		||||
 | 
			
		||||
    container.appendChild(messageEl);
 | 
			
		||||
    setTimeout(() => messageEl.remove(), 5000);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Public method for file removal
 | 
			
		||||
  public removeFileById(fileId: string) {
 | 
			
		||||
    this.removeFile(fileId);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Global instance
 | 
			
		||||
let formInstance: KnowledgebaseForm;
 | 
			
		||||
 | 
			
		||||
// Global function for file removal
 | 
			
		||||
window.removeFile = (fileId: string) => {
 | 
			
		||||
  if (formInstance) {
 | 
			
		||||
    formInstance.removeFileById(fileId);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Initialize form
 | 
			
		||||
document.addEventListener('DOMContentLoaded', () => {
 | 
			
		||||
  formInstance = new KnowledgebaseForm();
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										751
									
								
								src/pages/contribute/tool.astro
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										751
									
								
								src/pages/contribute/tool.astro
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,751 @@
 | 
			
		||||
---
 | 
			
		||||
// src/pages/contribute/tool.astro - COMPLETE REWRITE
 | 
			
		||||
import BaseLayout from '../../layouts/BaseLayout.astro';
 | 
			
		||||
import { withAuth } from '../../utils/auth.js';
 | 
			
		||||
import { getToolsData } from '../../utils/dataService.js';
 | 
			
		||||
 | 
			
		||||
export const prerender = false;
 | 
			
		||||
 | 
			
		||||
// Check authentication
 | 
			
		||||
const authResult = await withAuth(Astro, 'contributions');
 | 
			
		||||
if (authResult instanceof Response) {
 | 
			
		||||
  return authResult;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const { authenticated, userEmail, userId } = authResult;
 | 
			
		||||
 | 
			
		||||
// Load existing data
 | 
			
		||||
const data = await getToolsData();
 | 
			
		||||
const domains = data.domains;
 | 
			
		||||
const phases = data.phases;
 | 
			
		||||
const domainAgnosticSoftware = data['domain-agnostic-software'] || [];
 | 
			
		||||
const existingTools = data.tools;
 | 
			
		||||
 | 
			
		||||
// Check if this is an edit operation
 | 
			
		||||
const editToolName = Astro.url.searchParams.get('edit');
 | 
			
		||||
const editTool = editToolName ? existingTools.find(tool => tool.name === editToolName) : null;
 | 
			
		||||
const isEdit = !!editTool;
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<BaseLayout title={isEdit ? `Edit ${editTool?.name}` : 'Contribute Tool'}>
 | 
			
		||||
  <div class="container" style="max-width: 900px; margin: 0 auto; padding: 2rem 1rem;">
 | 
			
		||||
    
 | 
			
		||||
    <!-- Header -->
 | 
			
		||||
    <div style="text-align: center; margin-bottom: 2rem; padding: 2rem; background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%); color: white; border-radius: 1rem;">
 | 
			
		||||
      <h1 style="margin-bottom: 1rem; font-size: 2rem;">{isEdit ? `Edit: ${editTool?.name}` : 'Tool / Methode / Konzept beitragen'}</h1>
 | 
			
		||||
      <p style="margin: 0.5rem 0; opacity: 0.9;">
 | 
			
		||||
        {isEdit 
 | 
			
		||||
          ? 'Passt die Informationen für dieses Tool/Methode/Konzept an. Dein Beitrag wird als Git-Issue veröffentlicht.'
 | 
			
		||||
          : 'Füge ein neues Tool, Methode oder Konzept in unsere Datenbank hinzu. Dein Beitrag wird als Git-Issue veröffentlicht.'
 | 
			
		||||
        }
 | 
			
		||||
      </p>
 | 
			
		||||
      {userEmail && <p style="margin: 0.5rem 0; opacity: 0.8;"><strong>Eingeloggt als:</strong> {userEmail}</p>}
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <!-- Validation Error Display -->
 | 
			
		||||
    <div id="validation-errors" class="card" style="display: none; background-color: var(--color-error); color: white; margin-bottom: 2rem;">
 | 
			
		||||
      <h3 style="margin: 0 0 1rem 0;">⚠️ Please fix the following issues:</h3>
 | 
			
		||||
      <ul id="error-list" style="margin: 0; padding-left: 1.5rem;"></ul>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <!-- Main Form -->
 | 
			
		||||
    <div class="card">
 | 
			
		||||
      <form id="contribution-form" novalidate style="padding: 2rem;">
 | 
			
		||||
        
 | 
			
		||||
        <!-- Basic Information -->
 | 
			
		||||
        <div style="border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 2rem;">
 | 
			
		||||
          <h3 style="margin: 0 0 1.5rem 0; color: var(--color-primary); border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem;">Grundlegende Infos</h3>
 | 
			
		||||
          
 | 
			
		||||
          <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1.5rem;">
 | 
			
		||||
            <div>
 | 
			
		||||
              <label for="type" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">Typ <span style="color: var(--color-error);">*</span></label>
 | 
			
		||||
              <select id="type" name="type" required>
 | 
			
		||||
                <option value="">Auswählen...</option>
 | 
			
		||||
                <option value="software" selected={editTool?.type === 'software'}>Software/Tool</option>
 | 
			
		||||
                <option value="method" selected={editTool?.type === 'method'}>Methode/Prozess</option>
 | 
			
		||||
                <option value="concept" selected={editTool?.type === 'concept'}>Konzept/Wissen</option>
 | 
			
		||||
              </select>
 | 
			
		||||
            </div>
 | 
			
		||||
            
 | 
			
		||||
            <div>
 | 
			
		||||
              <label for="skillLevel" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">Skill Level <span style="color: var(--color-error);">*</span></label>
 | 
			
		||||
              <select id="skillLevel" name="skillLevel" required>
 | 
			
		||||
                <option value="">Auswählen...</option>
 | 
			
		||||
                <option value="novice" selected={editTool?.skillLevel === 'novice'}>Novice</option>
 | 
			
		||||
                <option value="beginner" selected={editTool?.skillLevel === 'beginner'}>Beginner</option>
 | 
			
		||||
                <option value="intermediate" selected={editTool?.skillLevel === 'intermediate'}>Intermediate</option>
 | 
			
		||||
                <option value="advanced" selected={editTool?.skillLevel === 'advanced'}>Advanced</option>
 | 
			
		||||
                <option value="expert" selected={editTool?.skillLevel === 'expert'}>Expert</option>
 | 
			
		||||
              </select>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div style="margin-bottom: 1.5rem;">
 | 
			
		||||
            <label for="name" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">Name <span style="color: var(--color-error);">*</span></label>
 | 
			
		||||
            <input type="text" id="name" name="name" value={editTool?.name || ''} 
 | 
			
		||||
                   placeholder="Tool/Method/Concept name" maxlength="100" required />
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div style="margin-bottom: 1.5rem;">
 | 
			
		||||
            <label for="icon" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">Icon (Emoji)</label>
 | 
			
		||||
            <input type="text" id="icon" name="icon" value={editTool?.icon || ''} 
 | 
			
		||||
                   placeholder="📦 (optional)" maxlength="10" />
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div style="margin-bottom: 1.5rem;">
 | 
			
		||||
            <label for="description" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">Beschreibung <span style="color: var(--color-error);">*</span></label>
 | 
			
		||||
            <textarea id="description" name="description" rows="4" maxlength="1000" required
 | 
			
		||||
                      placeholder="Klare, kurze Beschreibung, was dein Tool/Methode/Konzept tut, was sein Zweck ist und was es einzigartig macht.">{editTool?.description || ''}</textarea>
 | 
			
		||||
            <div style="text-align: right; font-size: 0.75rem; color: var(--color-text-secondary); margin-top: 0.25rem;">
 | 
			
		||||
              <span id="description-count">0</span>/1000
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div style="margin-bottom: 1.5rem;">
 | 
			
		||||
            <label for="url" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">Primary URL <span style="color: var(--color-error);">*</span></label>
 | 
			
		||||
            <input type="url" id="url" name="url" value={editTool?.url || ''} 
 | 
			
		||||
                   placeholder="https://example.com" required />
 | 
			
		||||
            <small style="display: block; margin-top: 0.25rem; color: var(--color-text-secondary); font-size: 0.8125rem;">Homepage, Dokumentation, oder Primärquelle</small>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <!-- Categories -->
 | 
			
		||||
        <div style="border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 2rem;">
 | 
			
		||||
          <h3 style="margin: 0 0 1.5rem 0; color: var(--color-primary); border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem;">Kategorien</h3>
 | 
			
		||||
          
 | 
			
		||||
          <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 2rem;">
 | 
			
		||||
            <div>
 | 
			
		||||
              <label style="display: block; margin-bottom: 0.75rem; font-weight: 600;">Forensische Domänen</label>
 | 
			
		||||
              <div style="display: grid; gap: 0.5rem;">
 | 
			
		||||
                {domains.map(domain => (
 | 
			
		||||
                  <label class="checkbox-wrapper">
 | 
			
		||||
                    <input type="checkbox" name="domains" value={domain.id} 
 | 
			
		||||
                           checked={editTool?.domains?.includes(domain.id)} />
 | 
			
		||||
                    <span>{domain.name}</span>
 | 
			
		||||
                  </label>
 | 
			
		||||
                ))}
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
            
 | 
			
		||||
            <div>
 | 
			
		||||
              <label style="display: block; margin-bottom: 0.75rem; font-weight: 600;">Phasen der Ermittlung</label>
 | 
			
		||||
              <div style="display: grid; gap: 0.5rem;">
 | 
			
		||||
                {phases.map(phase => (
 | 
			
		||||
                  <label class="checkbox-wrapper">
 | 
			
		||||
                    <input type="checkbox" name="phases" value={phase.id} 
 | 
			
		||||
                           checked={editTool?.phases?.includes(phase.id)} />
 | 
			
		||||
                    <span>{phase.name}</span>
 | 
			
		||||
                  </label>
 | 
			
		||||
                ))}
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <!-- Software-Specific Fields -->
 | 
			
		||||
        <div id="software-fields" style="border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 2rem; display: none;">
 | 
			
		||||
          <h3 style="margin: 0 0 1.5rem 0; color: var(--color-primary); border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem;">Software Details</h3>
 | 
			
		||||
          
 | 
			
		||||
          <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; margin-bottom: 1.5rem;">
 | 
			
		||||
            <div>
 | 
			
		||||
              <label style="display: block; margin-bottom: 0.75rem; font-weight: 600;">Betrieb auf: <span id="platforms-required" style="color: var(--color-error);">*</span></label>
 | 
			
		||||
              <div style="display: grid; gap: 0.5rem;">
 | 
			
		||||
                {['Windows', 'macOS', 'Linux', 'Web', 'Mobile', 'Cross-platform'].map(platform => (
 | 
			
		||||
                  <label class="checkbox-wrapper">
 | 
			
		||||
                    <input type="checkbox" name="platforms" value={platform} 
 | 
			
		||||
                           checked={editTool?.platforms?.includes(platform)} />
 | 
			
		||||
                    <span>{platform}</span>
 | 
			
		||||
                  </label>
 | 
			
		||||
                ))}
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
            
 | 
			
		||||
            <div>
 | 
			
		||||
              <label for="license" style="display: block; margin-bottom: 0.75rem; font-weight: 600;">Lizenzmodell <span id="license-required" style="color: var(--color-error);">*</span></label>
 | 
			
		||||
              <input type="text" id="license" name="license" value={editTool?.license || ''} 
 | 
			
		||||
                     placeholder="MIT, Apache 2.0, GPL v3, Proprietary" list="license-options" />
 | 
			
		||||
              <datalist id="license-options">
 | 
			
		||||
                <option value="MIT" />
 | 
			
		||||
                <option value="Apache 2.0" />
 | 
			
		||||
                <option value="GPL v3" />
 | 
			
		||||
                <option value="BSD-3-Clause" />
 | 
			
		||||
                <option value="Proprietary" />
 | 
			
		||||
                <option value="Open Source" />
 | 
			
		||||
              </datalist>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 2rem;">
 | 
			
		||||
            <div>
 | 
			
		||||
              <label for="accessType" style="display: block; margin-bottom: 0.75rem; font-weight: 600;">Zugriff über:</label>
 | 
			
		||||
              <select id="accessType" name="accessType">
 | 
			
		||||
                <option value="">Select access type...</option>
 | 
			
		||||
                <option value="download" selected={editTool?.accessType === 'download'}>Download</option>
 | 
			
		||||
                <option value="web" selected={editTool?.accessType === 'web'}>Web Application</option>
 | 
			
		||||
                <option value="api" selected={editTool?.accessType === 'api'}>API</option>
 | 
			
		||||
                <option value="cli" selected={editTool?.accessType === 'cli'}>Command Line</option>
 | 
			
		||||
                <option value="service" selected={editTool?.accessType === 'service'}>Service</option>
 | 
			
		||||
              </select>
 | 
			
		||||
            </div>
 | 
			
		||||
            
 | 
			
		||||
            <div>
 | 
			
		||||
              <label style="display: block; margin-bottom: 0.75rem; font-weight: 600;">Domänenübergreifende Kategorien</label>
 | 
			
		||||
              <div style="display: grid; gap: 0.5rem;">
 | 
			
		||||
                {domainAgnosticSoftware.map(cat => (
 | 
			
		||||
                  <label class="checkbox-wrapper">
 | 
			
		||||
                    <input type="checkbox" name="domainAgnostic" value={cat.id} 
 | 
			
		||||
                           checked={editTool?.['domain-agnostic-software']?.includes(cat.id)} />
 | 
			
		||||
                    <span>{cat.name}</span>
 | 
			
		||||
                  </label>
 | 
			
		||||
                ))}
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <!-- Related Concepts -->
 | 
			
		||||
        <div id="concepts-fields" style="border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 2rem; display: none;">
 | 
			
		||||
          <h3 style="margin: 0 0 1.5rem 0; color: var(--color-primary); border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem;">Konzepte im Zusammenhang</h3>
 | 
			
		||||
          <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0.5rem;">
 | 
			
		||||
            {existingTools.filter(tool => tool.type === 'concept').map(concept => (
 | 
			
		||||
              <label class="checkbox-wrapper">
 | 
			
		||||
                <input type="checkbox" name="relatedConcepts" value={concept.name} 
 | 
			
		||||
                       checked={editTool?.related_concepts?.includes(concept.name)} />
 | 
			
		||||
                <span>{concept.name}</span>
 | 
			
		||||
              </label>
 | 
			
		||||
            ))}
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <!-- Additional Information -->
 | 
			
		||||
        <div style="border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 2rem;">
 | 
			
		||||
          <h3 style="margin: 0 0 1.5rem 0; color: var(--color-primary); border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem;">Zusatzinfos</h3>
 | 
			
		||||
          
 | 
			
		||||
          <div style="margin-bottom: 1.5rem;">
 | 
			
		||||
            <label for="tags" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">Tags</label>
 | 
			
		||||
            <input type="text" id="tags" name="tags" value={editTool?.tags?.join(', ') || ''} 
 | 
			
		||||
                   placeholder="Komma-getrennt: Passende Begriffe, nach denen ihr suchen würdet." />
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div style="margin-bottom: 1.5rem;">
 | 
			
		||||
            <label class="checkbox-wrapper">
 | 
			
		||||
              <input type="checkbox" id="knowledgebase" name="knowledgebase" 
 | 
			
		||||
                     checked={editTool?.knowledgebase} />
 | 
			
		||||
              <span>Der Beitrag soll später einen Knowledgebase-Artikel haben</span>
 | 
			
		||||
            </label>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div>
 | 
			
		||||
            <label for="reason" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">Grund für den Beitrag (Optional)</label>
 | 
			
		||||
            <textarea id="reason" name="reason" rows="3" maxlength="500"
 | 
			
		||||
                      placeholder="Hier kannst du noch deine Motivation und sonstige Infos beschreiben."></textarea>
 | 
			
		||||
            <div style="text-align: right; font-size: 0.75rem; color: var(--color-text-secondary); margin-top: 0.25rem;">
 | 
			
		||||
              <span id="reason-count">0</span>/500
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <!-- YAML Preview -->
 | 
			
		||||
        <div style="border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 2rem;">
 | 
			
		||||
          <h3 style="margin: 0 0 1.5rem 0; color: var(--color-primary); border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem;">Preview</h3>
 | 
			
		||||
          <div style="border: 1px solid var(--color-border); border-radius: 0.375rem; overflow: hidden;">
 | 
			
		||||
            <pre id="yaml-preview" style="background: var(--color-bg-secondary); color: var(--color-text); padding: 1rem; margin: 0; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.8125rem; line-height: 1.4; overflow-x: auto; max-height: 300px;"># YAML preview will appear here</pre>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <!-- Submit -->
 | 
			
		||||
        <div style="display: flex; gap: 1rem; justify-content: flex-end; margin-top: 2rem; padding-top: 2rem; border-top: 1px solid var(--color-border);">
 | 
			
		||||
          <a href="/" class="btn btn-secondary">Cancel</a>
 | 
			
		||||
          <button type="submit" id="submit-btn" class="btn btn-primary">
 | 
			
		||||
            <span id="submit-text">{isEdit ? 'Update Tool' : 'Submit Contribution'}</span>
 | 
			
		||||
            <span id="submit-spinner" style="display: none; margin-left: 0.5rem;">⏳</span>
 | 
			
		||||
          </button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </form>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <!-- Success Modal -->
 | 
			
		||||
    <div id="success-modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); z-index: 1000; align-items: center; justify-content: center;">
 | 
			
		||||
      <div class="card" style="max-width: 500px; width: 90%; margin: 2rem; text-align: center;">
 | 
			
		||||
        <div style="font-size: 3rem; margin-bottom: 1rem;">✅</div>
 | 
			
		||||
        <h3 style="margin-bottom: 1rem;">Contribution Submitted!</h3>
 | 
			
		||||
        <p id="success-message" style="margin-bottom: 1.5rem;">Your contribution has been submitted successfully.</p>
 | 
			
		||||
        <div style="display: flex; gap: 1rem; justify-content: center;">
 | 
			
		||||
          <a id="pr-link" href="#" target="_blank" class="btn btn-primary" style="display: none;">View Pull Request</a>
 | 
			
		||||
          <a href="/" class="btn btn-secondary">Back to Home</a>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</BaseLayout>
 | 
			
		||||
 | 
			
		||||
<script define:vars={{ isEdit, editTool, domains, phases, domainAgnosticSoftware }}>
 | 
			
		||||
// FIXED: Prevent duplicate form submissions
 | 
			
		||||
console.log('[FORM] Script loaded, initializing...');
 | 
			
		||||
 | 
			
		||||
class ContributionForm {
 | 
			
		||||
  constructor() {
 | 
			
		||||
    this.isEdit = isEdit;
 | 
			
		||||
    this.editTool = editTool;
 | 
			
		||||
    this.elements = {};
 | 
			
		||||
    this.isSubmitting = false; // NEW: Prevent concurrent submissions
 | 
			
		||||
    this.init();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  init() {
 | 
			
		||||
    console.log('[FORM] Starting initialization...');
 | 
			
		||||
    
 | 
			
		||||
    // Get all form elements
 | 
			
		||||
    this.elements = {
 | 
			
		||||
      form: document.getElementById('contribution-form'),
 | 
			
		||||
      submitBtn: document.getElementById('submit-btn'),
 | 
			
		||||
      submitText: document.getElementById('submit-text'),
 | 
			
		||||
      submitSpinner: document.getElementById('submit-spinner'),
 | 
			
		||||
      typeSelect: document.getElementById('type'),
 | 
			
		||||
      nameInput: document.getElementById('name'),
 | 
			
		||||
      descriptionTextarea: document.getElementById('description'),
 | 
			
		||||
      reasonTextarea: document.getElementById('reason'),
 | 
			
		||||
      skillLevelSelect: document.getElementById('skillLevel'),
 | 
			
		||||
      urlInput: document.getElementById('url'),
 | 
			
		||||
      yamlPreview: document.getElementById('yaml-preview'),
 | 
			
		||||
      successModal: document.getElementById('success-modal'),
 | 
			
		||||
      softwareFields: document.getElementById('software-fields'),
 | 
			
		||||
      conceptsFields: document.getElementById('concepts-fields'),
 | 
			
		||||
      descriptionCount: document.getElementById('description-count'),
 | 
			
		||||
      reasonCount: document.getElementById('reason-count'),
 | 
			
		||||
      validationErrors: document.getElementById('validation-errors'),
 | 
			
		||||
      errorList: document.getElementById('error-list'),
 | 
			
		||||
      platformsRequired: document.getElementById('platforms-required'),
 | 
			
		||||
      licenseRequired: document.getElementById('license-required'),
 | 
			
		||||
      licenseInput: document.getElementById('license')
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // Verify critical elements
 | 
			
		||||
    if (!this.elements.form || !this.elements.submitBtn) {
 | 
			
		||||
      console.error('[FORM] Critical elements missing!');
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // FIXED: Check if already initialized
 | 
			
		||||
    if (this.elements.form.hasAttribute('data-form-initialized')) {
 | 
			
		||||
      console.log('[FORM] Form already initialized, skipping...');
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Mark as initialized
 | 
			
		||||
    this.elements.form.setAttribute('data-form-initialized', 'true');
 | 
			
		||||
 | 
			
		||||
    console.log('[FORM] Setting up handlers...');
 | 
			
		||||
    this.setupEventListeners();
 | 
			
		||||
    this.updateFieldVisibility();
 | 
			
		||||
    this.setupCharacterCounters();
 | 
			
		||||
    this.updateYAMLPreview();
 | 
			
		||||
 | 
			
		||||
    console.log('[FORM] Initialization complete!');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setupEventListeners() {
 | 
			
		||||
    // Type change handler
 | 
			
		||||
    this.elements.typeSelect.addEventListener('change', () => {
 | 
			
		||||
      this.updateFieldVisibility();
 | 
			
		||||
      this.updateYAMLPreview();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Form input handlers
 | 
			
		||||
    this.elements.form.addEventListener('input', () => {
 | 
			
		||||
      this.debounce(() => this.updateYAMLPreview(), 300);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    this.elements.form.addEventListener('change', () => {
 | 
			
		||||
      this.updateYAMLPreview();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // FIXED: Single submit handler with double-submission prevention
 | 
			
		||||
    this.elements.form.addEventListener('submit', (e) => {
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      
 | 
			
		||||
      // Prevent double submission
 | 
			
		||||
      if (this.isSubmitting) {
 | 
			
		||||
        console.log('[FORM] Submission already in progress, ignoring...');
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      this.handleSubmit();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    console.log('[FORM] Event listeners attached');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
updateFieldVisibility() {
 | 
			
		||||
  const type = this.elements.typeSelect.value;
 | 
			
		||||
  
 | 
			
		||||
  // Hide all conditional fields
 | 
			
		||||
  this.elements.softwareFields.style.display = 'none';
 | 
			
		||||
  this.elements.conceptsFields.style.display = 'none';
 | 
			
		||||
  
 | 
			
		||||
  // Hide required indicators
 | 
			
		||||
  if (this.elements.platformsRequired) this.elements.platformsRequired.style.display = 'none';
 | 
			
		||||
  if (this.elements.licenseRequired) this.elements.licenseRequired.style.display = 'none';
 | 
			
		||||
 | 
			
		||||
  // Show relevant fields based on type
 | 
			
		||||
  if (type === 'software') {
 | 
			
		||||
    this.elements.softwareFields.style.display = 'block';
 | 
			
		||||
    this.elements.conceptsFields.style.display = 'block';
 | 
			
		||||
    if (this.elements.platformsRequired) this.elements.platformsRequired.style.display = 'inline';
 | 
			
		||||
    if (this.elements.licenseRequired) this.elements.licenseRequired.style.display = 'inline';
 | 
			
		||||
  } else if (type === 'method') {
 | 
			
		||||
    this.elements.conceptsFields.style.display = 'block';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  console.log('[FORM] Field visibility updated for type:', type);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
setupCharacterCounters() {
 | 
			
		||||
  const counters = [
 | 
			
		||||
    { element: this.elements.descriptionTextarea, counter: this.elements.descriptionCount, max: 1000 },
 | 
			
		||||
    { element: this.elements.reasonTextarea, counter: this.elements.reasonCount, max: 500 }
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  counters.forEach(({ element, counter, max }) => {
 | 
			
		||||
    if (element && counter) {
 | 
			
		||||
      const updateCounter = () => {
 | 
			
		||||
        const count = element.value.length;
 | 
			
		||||
        counter.textContent = count;
 | 
			
		||||
        counter.style.color = count > max * 0.9 ? 'var(--color-warning)' : 'var(--color-text-secondary)';
 | 
			
		||||
      };
 | 
			
		||||
      
 | 
			
		||||
      element.addEventListener('input', updateCounter);
 | 
			
		||||
      updateCounter();
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
updateYAMLPreview() {
 | 
			
		||||
  if (!this.elements.yamlPreview) return;
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    const formData = new FormData(this.elements.form);
 | 
			
		||||
    
 | 
			
		||||
    const tool = {
 | 
			
		||||
      name: formData.get('name') || 'Tool Name',
 | 
			
		||||
      type: formData.get('type') || 'software',
 | 
			
		||||
      description: formData.get('description') || 'Tool description',
 | 
			
		||||
      domains: formData.getAll('domains'),
 | 
			
		||||
      phases: formData.getAll('phases'),
 | 
			
		||||
      skillLevel: formData.get('skillLevel') || 'intermediate',
 | 
			
		||||
      url: formData.get('url') || 'https://example.com'
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // Add icon if provided
 | 
			
		||||
    if (formData.get('icon')) {
 | 
			
		||||
      tool.icon = formData.get('icon');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Add software-specific fields
 | 
			
		||||
    if (tool.type === 'software') {
 | 
			
		||||
      tool.platforms = formData.getAll('platforms');
 | 
			
		||||
      tool.license = formData.get('license') || 'Unknown';
 | 
			
		||||
      if (formData.get('accessType')) {
 | 
			
		||||
        tool.accessType = formData.get('accessType');
 | 
			
		||||
      }
 | 
			
		||||
      const domainAgnostic = formData.getAll('domainAgnostic');
 | 
			
		||||
      if (domainAgnostic.length > 0) {
 | 
			
		||||
        tool['domain-agnostic-software'] = domainAgnostic;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Add optional fields
 | 
			
		||||
    if (formData.has('knowledgebase')) {
 | 
			
		||||
      tool.knowledgebase = true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const tags = formData.get('tags');
 | 
			
		||||
    if (tags) {
 | 
			
		||||
      tool.tags = tags.split(',').map(t => t.trim()).filter(Boolean);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const relatedConcepts = formData.getAll('relatedConcepts');
 | 
			
		||||
    if (relatedConcepts.length > 0) {
 | 
			
		||||
      tool.related_concepts = relatedConcepts;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Generate YAML
 | 
			
		||||
    const yaml = this.generateYAML(tool);
 | 
			
		||||
    this.elements.yamlPreview.textContent = yaml;
 | 
			
		||||
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('[FORM] YAML preview error:', error);
 | 
			
		||||
    this.elements.yamlPreview.textContent = '# Error generating preview';
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
generateYAML(tool) {
 | 
			
		||||
  const lines = [];
 | 
			
		||||
  
 | 
			
		||||
  lines.push(`name: "${tool.name}"`);
 | 
			
		||||
  if (tool.icon) lines.push(`icon: "${tool.icon}"`);
 | 
			
		||||
  lines.push(`type: ${tool.type}`);
 | 
			
		||||
  lines.push(`description: "${tool.description}"`);
 | 
			
		||||
  lines.push(`domains: [${tool.domains.map(d => `"${d}"`).join(', ')}]`);
 | 
			
		||||
  lines.push(`phases: [${tool.phases.map(p => `"${p}"`).join(', ')}]`);
 | 
			
		||||
  lines.push(`skillLevel: ${tool.skillLevel}`);
 | 
			
		||||
  lines.push(`url: "${tool.url}"`);
 | 
			
		||||
  
 | 
			
		||||
  if (tool.platforms && tool.platforms.length > 0) {
 | 
			
		||||
    lines.push(`platforms: [${tool.platforms.map(p => `"${p}"`).join(', ')}]`);
 | 
			
		||||
  }
 | 
			
		||||
  if (tool.license) lines.push(`license: "${tool.license}"`);
 | 
			
		||||
  if (tool.accessType) lines.push(`accessType: ${tool.accessType}`);
 | 
			
		||||
  if (tool['domain-agnostic-software']) {
 | 
			
		||||
    lines.push(`domain-agnostic-software: [${tool['domain-agnostic-software'].map(c => `"${c}"`).join(', ')}]`);
 | 
			
		||||
  }
 | 
			
		||||
  if (tool.knowledgebase) lines.push(`knowledgebase: true`);
 | 
			
		||||
  if (tool.tags && tool.tags.length > 0) {
 | 
			
		||||
    lines.push(`tags: [${tool.tags.map(t => `"${t}"`).join(', ')}]`);
 | 
			
		||||
  }
 | 
			
		||||
  if (tool.related_concepts && tool.related_concepts.length > 0) {
 | 
			
		||||
    lines.push(`related_concepts: [${tool.related_concepts.map(c => `"${c}"`).join(', ')}]`);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return lines.join('\n');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
validateForm() {
 | 
			
		||||
  const errors = [];
 | 
			
		||||
  const formData = new FormData(this.elements.form);
 | 
			
		||||
 | 
			
		||||
  // Required field validation
 | 
			
		||||
  const name = formData.get('name')?.trim();
 | 
			
		||||
  if (!name) {
 | 
			
		||||
    errors.push('Tool name is required');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const description = formData.get('description')?.trim();
 | 
			
		||||
  if (!description) {
 | 
			
		||||
    errors.push('Description is required');
 | 
			
		||||
  } else if (description.length < 10) {
 | 
			
		||||
    errors.push('Description must be at least 10 characters long');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const skillLevel = formData.get('skillLevel');
 | 
			
		||||
  if (!skillLevel) {
 | 
			
		||||
    errors.push('Skill level is required');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const type = formData.get('type');
 | 
			
		||||
  if (!type) {
 | 
			
		||||
    errors.push('Type is required');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const url = formData.get('url')?.trim();
 | 
			
		||||
  if (!url) {
 | 
			
		||||
    errors.push('Primary URL is required');
 | 
			
		||||
  } else {
 | 
			
		||||
    try {
 | 
			
		||||
      new URL(url);
 | 
			
		||||
    } catch {
 | 
			
		||||
      errors.push('Primary URL must be a valid URL');
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Software-specific validation
 | 
			
		||||
  if (type === 'software') {
 | 
			
		||||
    const platforms = formData.getAll('platforms');
 | 
			
		||||
    if (platforms.length === 0) {
 | 
			
		||||
      errors.push('At least one platform is required for software');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const license = formData.get('license')?.trim();
 | 
			
		||||
    if (!license) {
 | 
			
		||||
      errors.push('License is required for software');
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return errors;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
showValidationErrors(errors) {
 | 
			
		||||
  if (errors.length === 0) {
 | 
			
		||||
    this.elements.validationErrors.style.display = 'none';
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Clear previous errors
 | 
			
		||||
  this.elements.errorList.innerHTML = '';
 | 
			
		||||
  
 | 
			
		||||
  // Add each error as list item
 | 
			
		||||
  errors.forEach(error => {
 | 
			
		||||
    const li = document.createElement('li');
 | 
			
		||||
    li.textContent = error;
 | 
			
		||||
    this.elements.errorList.appendChild(li);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  // Show error container
 | 
			
		||||
  this.elements.validationErrors.style.display = 'block';
 | 
			
		||||
  
 | 
			
		||||
  // Scroll to top to show errors
 | 
			
		||||
  this.elements.validationErrors.scrollIntoView({ behavior: 'smooth', block: 'center' });
 | 
			
		||||
}
 | 
			
		||||
  async handleSubmit() {
 | 
			
		||||
    console.log('[FORM] Submit handler called!');
 | 
			
		||||
 | 
			
		||||
    // FIXED: Immediate submission lock
 | 
			
		||||
    if (this.isSubmitting) {
 | 
			
		||||
      console.log('[FORM] Already submitting, aborting...');
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.isSubmitting = true;
 | 
			
		||||
 | 
			
		||||
    // Validate before submitting
 | 
			
		||||
    const validationErrors = this.validateForm();
 | 
			
		||||
    if (validationErrors.length > 0) {
 | 
			
		||||
      console.log('[FORM] Validation failed:', validationErrors);
 | 
			
		||||
      this.showValidationErrors(validationErrors);
 | 
			
		||||
      this.isSubmitting = false; // Reset lock
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Hide validation errors
 | 
			
		||||
    this.elements.validationErrors.style.display = 'none';
 | 
			
		||||
 | 
			
		||||
    // Immediate UI feedback
 | 
			
		||||
    this.elements.submitBtn.disabled = true;
 | 
			
		||||
    this.elements.submitText.textContent = this.isEdit ? 'Updating...' : 'Submitting...';
 | 
			
		||||
    this.elements.submitSpinner.style.display = 'inline';
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      const formData = new FormData(this.elements.form);
 | 
			
		||||
      
 | 
			
		||||
      // Build submission object
 | 
			
		||||
      const submission = {
 | 
			
		||||
        action: this.isEdit ? 'edit' : 'add',
 | 
			
		||||
        tool: {
 | 
			
		||||
          name: formData.get('name'),
 | 
			
		||||
          type: formData.get('type'),
 | 
			
		||||
          description: formData.get('description'),
 | 
			
		||||
          domains: formData.getAll('domains'),
 | 
			
		||||
          phases: formData.getAll('phases'),
 | 
			
		||||
          skillLevel: formData.get('skillLevel'),
 | 
			
		||||
          url: formData.get('url'),
 | 
			
		||||
          tags: formData.get('tags') ? 
 | 
			
		||||
            formData.get('tags').split(',').map(t => t.trim()).filter(Boolean) : []
 | 
			
		||||
        },
 | 
			
		||||
        metadata: {
 | 
			
		||||
          reason: formData.get('reason') || ''
 | 
			
		||||
        }
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      // Add optional fields
 | 
			
		||||
      if (formData.get('icon')) submission.tool.icon = formData.get('icon');
 | 
			
		||||
      if (formData.has('knowledgebase')) submission.tool.knowledgebase = true;
 | 
			
		||||
 | 
			
		||||
      // Add software-specific fields
 | 
			
		||||
      if (submission.tool.type === 'software') {
 | 
			
		||||
        submission.tool.platforms = formData.getAll('platforms');
 | 
			
		||||
        submission.tool.license = formData.get('license');
 | 
			
		||||
        if (formData.get('accessType')) {
 | 
			
		||||
          submission.tool.accessType = formData.get('accessType');
 | 
			
		||||
        }
 | 
			
		||||
        const domainAgnostic = formData.getAll('domainAgnostic');
 | 
			
		||||
        if (domainAgnostic.length > 0) {
 | 
			
		||||
          submission.tool['domain-agnostic-software'] = domainAgnostic;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Add related concepts
 | 
			
		||||
      if (submission.tool.type !== 'concept') {
 | 
			
		||||
        const related = formData.getAll('relatedConcepts');
 | 
			
		||||
        if (related.length > 0) {
 | 
			
		||||
          submission.tool.related_concepts = related;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      console.log('[FORM] Sending submission:', submission);
 | 
			
		||||
 | 
			
		||||
      // Submit to API
 | 
			
		||||
      const response = await fetch('/api/contribute/tool', {
 | 
			
		||||
        method: 'POST',
 | 
			
		||||
        headers: { 'Content-Type': 'application/json' },
 | 
			
		||||
        body: JSON.stringify(submission)
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      console.log('[FORM] Response status:', response.status);
 | 
			
		||||
      const result = await response.json();
 | 
			
		||||
      console.log('[FORM] Response data:', result);
 | 
			
		||||
 | 
			
		||||
      if (result.success) {
 | 
			
		||||
        this.showSuccess(result);
 | 
			
		||||
      } else {
 | 
			
		||||
        throw new Error(result.error || 'Submission failed');
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error('[FORM] Submission error:', error);
 | 
			
		||||
      alert(`Submission failed: ${error.message}\n\nPlease try again or contact support if the problem persists.`);
 | 
			
		||||
    } finally {
 | 
			
		||||
      // FIXED: Always reset submission state
 | 
			
		||||
      this.isSubmitting = false;
 | 
			
		||||
      this.elements.submitBtn.disabled = false;
 | 
			
		||||
      this.elements.submitText.textContent = this.isEdit ? 'Update Tool' : 'Submit Contribution';
 | 
			
		||||
      this.elements.submitSpinner.style.display = 'none';
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  showSuccess(result) {
 | 
			
		||||
    // Update success message
 | 
			
		||||
    const successMessage = document.getElementById('success-message');
 | 
			
		||||
    if (successMessage) {
 | 
			
		||||
      successMessage.textContent = `Your ${this.isEdit ? 'update' : 'contribution'} has been submitted as an issue and will be reviewed by maintainers.`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Show issue link if available
 | 
			
		||||
    if (result.issueUrl) {
 | 
			
		||||
      const prLink = document.getElementById('pr-link');
 | 
			
		||||
      if (prLink) {
 | 
			
		||||
        prLink.href = result.issueUrl;
 | 
			
		||||
        prLink.textContent = 'View Issue';
 | 
			
		||||
        prLink.style.display = 'inline-flex';
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Show modal
 | 
			
		||||
    this.elements.successModal.style.display = 'flex';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  debounce(func, wait) {
 | 
			
		||||
    let timeout;
 | 
			
		||||
    return (...args) => {
 | 
			
		||||
      clearTimeout(timeout);
 | 
			
		||||
      timeout = setTimeout(() => func.apply(this, args), wait);
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// FIXED: Single initialization only
 | 
			
		||||
function initializeForm() {
 | 
			
		||||
  const form = document.getElementById('contribution-form');
 | 
			
		||||
  if (!form) {
 | 
			
		||||
    console.error('[FORM] Form element not found!');
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (form.hasAttribute('data-form-initialized')) {
 | 
			
		||||
    console.log('[FORM] Form already initialized');
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  console.log('[FORM] Initializing form...');
 | 
			
		||||
  new ContributionForm();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// FIXED: Simple initialization
 | 
			
		||||
if (document.readyState === 'loading') {
 | 
			
		||||
  document.addEventListener('DOMContentLoaded', initializeForm);
 | 
			
		||||
} else {
 | 
			
		||||
  initializeForm();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
console.log('[FORM] Script loaded successfully');
 | 
			
		||||
</script>
 | 
			
		||||
@ -2,7 +2,7 @@
 | 
			
		||||
import BaseLayout from '../layouts/BaseLayout.astro';
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<BaseLayout title="Impressum" description="CC24-Guide - Impressum">
 | 
			
		||||
<BaseLayout title="Impressum" description="ForensicPathways - Impressum">
 | 
			
		||||
  <section style="padding: 2rem 0; max-width: 900px; margin: 0 auto;">
 | 
			
		||||
    <!-- Hero Section -->
 | 
			
		||||
    <div style="text-align: center; margin-bottom: 3rem; padding: 2rem; background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%); border-radius: 1rem; border: 1px solid var(--color-border);">
 | 
			
		||||
 | 
			
		||||
@ -6,7 +6,6 @@ import ToolMatrix from '../components/ToolMatrix.astro';
 | 
			
		||||
import AIQueryInterface from '../components/AIQueryInterface.astro';
 | 
			
		||||
import { getToolsData } from '../utils/dataService.js';
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
// Load tools data
 | 
			
		||||
const data = await getToolsData();
 | 
			
		||||
const tools = data.tools;
 | 
			
		||||
@ -16,7 +15,7 @@ const tools = data.tools;
 | 
			
		||||
<!-- Hero Section -->
 | 
			
		||||
<section style="padding: 2rem 0 1rem; border-bottom: 1px solid var(--color-border);">
 | 
			
		||||
  <div style="text-align: center; margin-bottom: 2rem; padding: 2rem; background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%); border-radius: 1rem; border: 1px solid var(--color-border);">
 | 
			
		||||
    <h1 style="margin-bottom: 1rem; font-size: 1.5rem; color: var(--color-primary);">CC24 // DFIR - Guide</h1>
 | 
			
		||||
    <h1 style="margin-bottom: 1rem; font-size: 1.5rem; color: var(--color-primary);">ForensicPathways</h1>
 | 
			
		||||
    
 | 
			
		||||
    <p style="font-size: 1.25rem; margin-bottom: 1.125rem; color: var(--color-text);">
 | 
			
		||||
      <strong>Das richtige Werkzeug zur richtigen Zeit</strong> – in der digitalen Forensik entscheidet oft die Wahl der passenden Methode oder Software über Erfolg oder Misserfolg einer Untersuchung.
 | 
			
		||||
@ -53,13 +52,24 @@ const tools = data.tools;
 | 
			
		||||
        KI befragen
 | 
			
		||||
      </button>
 | 
			
		||||
      
 | 
			
		||||
      <!-- Contribution Button -->
 | 
			
		||||
      <a href="/contribute" class="btn" style="padding: 0.75rem 1.5rem; background-color: var(--color-warning); color: white; border-color: var(--color-warning);" data-contribute-button="new">
 | 
			
		||||
        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
 | 
			
		||||
          <path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
 | 
			
		||||
          <circle cx="8.5" cy="7" r="4"/>
 | 
			
		||||
          <line x1="20" y1="8" x2="20" y2="14"/>
 | 
			
		||||
          <line x1="23" y1="11" x2="17" y2="11"/>
 | 
			
		||||
        </svg>
 | 
			
		||||
        Beitragen
 | 
			
		||||
      </a>
 | 
			
		||||
      
 | 
			
		||||
      <a href="#filters-section" class="btn btn-secondary" style="padding: 0.75rem 1.5rem;">
 | 
			
		||||
        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
 | 
			
		||||
          <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
 | 
			
		||||
          <polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
 | 
			
		||||
          <line x1="12" y1="22.08" x2="12" y2="12"></line>
 | 
			
		||||
        </svg>
 | 
			
		||||
         Entdecken
 | 
			
		||||
        Entdecken
 | 
			
		||||
      </a>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
@ -67,13 +77,12 @@ const tools = data.tools;
 | 
			
		||||
  
 | 
			
		||||
  <!-- Filters Section -->
 | 
			
		||||
  <section id="filters-section" style="padding: 2rem 0;">
 | 
			
		||||
    <ToolFilters />
 | 
			
		||||
    <ToolFilters data={data} />
 | 
			
		||||
  </section>
 | 
			
		||||
 | 
			
		||||
  <!-- AI Query Interface -->
 | 
			
		||||
  <AIQueryInterface />
 | 
			
		||||
 | 
			
		||||
  
 | 
			
		||||
  <!-- Tools Grid -->
 | 
			
		||||
  <section id="tools-grid" style="padding-bottom: 2rem;">
 | 
			
		||||
    <div class="grid-auto-fit" id="tools-container">
 | 
			
		||||
@ -89,35 +98,22 @@ const tools = data.tools;
 | 
			
		||||
  </section>
 | 
			
		||||
  
 | 
			
		||||
  <!-- Matrix View -->
 | 
			
		||||
  <ToolMatrix />
 | 
			
		||||
  <ToolMatrix data={data} />
 | 
			
		||||
</BaseLayout>
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
  // Extend Window interface for custom properties
 | 
			
		||||
  declare global {
 | 
			
		||||
    interface Window {
 | 
			
		||||
      toolsData: any[];
 | 
			
		||||
      showToolDetails: (toolName: string, modalType?: string) => void;
 | 
			
		||||
      hideToolDetails: (modalType?: string) => void;
 | 
			
		||||
      hideAllToolDetails: () => void;
 | 
			
		||||
      clearAllFilters?: () => void;
 | 
			
		||||
      restoreAIResults?: () => void;
 | 
			
		||||
      switchToAIView?: () => void;
 | 
			
		||||
      showShareDialog: (shareButton: HTMLElement) => void;
 | 
			
		||||
      navigateToGrid: (toolName: string) => void;
 | 
			
		||||
      navigateToMatrix: (toolName: string) => void;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
<script define:vars={{ toolsData: data.tools }}>
 | 
			
		||||
  // Store tools data globally
 | 
			
		||||
  window.toolsData = toolsData;
 | 
			
		||||
  
 | 
			
		||||
  // Handle view changes and filtering
 | 
			
		||||
  document.addEventListener('DOMContentLoaded', () => {
 | 
			
		||||
    const toolsContainer = document.getElementById('tools-container') as HTMLElement;
 | 
			
		||||
    const toolsGrid = document.getElementById('tools-grid') as HTMLElement;
 | 
			
		||||
    const matrixContainer = document.getElementById('matrix-container') as HTMLElement;
 | 
			
		||||
    const aiInterface = document.getElementById('ai-interface') as HTMLElement;
 | 
			
		||||
    const filtersSection = document.getElementById('filters-section') as HTMLElement;
 | 
			
		||||
    const noResults = document.getElementById('no-results') as HTMLElement;
 | 
			
		||||
    const aiQueryBtn = document.getElementById('ai-query-btn') as HTMLButtonElement;
 | 
			
		||||
    const toolsContainer = document.getElementById('tools-container');
 | 
			
		||||
    const toolsGrid = document.getElementById('tools-grid');
 | 
			
		||||
    const matrixContainer = document.getElementById('matrix-container');
 | 
			
		||||
    const aiInterface = document.getElementById('ai-interface');
 | 
			
		||||
    const filtersSection = document.getElementById('filters-section');
 | 
			
		||||
    const noResults = document.getElementById('no-results');
 | 
			
		||||
    const aiQueryBtn = document.getElementById('ai-query-btn');
 | 
			
		||||
    
 | 
			
		||||
    // Guard against null elements
 | 
			
		||||
    if (!toolsContainer || !toolsGrid || !matrixContainer || !noResults || !aiInterface || !filtersSection) {
 | 
			
		||||
@ -125,63 +121,20 @@ const tools = data.tools;
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Simple sorting function
 | 
			
		||||
    function sortTools(tools: any[], sortBy = 'default') {
 | 
			
		||||
      const sorted = [...tools];
 | 
			
		||||
      
 | 
			
		||||
      switch (sortBy) {
 | 
			
		||||
        case 'alphabetical':
 | 
			
		||||
          return sorted.sort((a, b) => a.name.localeCompare(b.name));
 | 
			
		||||
        case 'difficulty':
 | 
			
		||||
          const difficultyOrder = { 'novice': 0, 'beginner': 1, 'intermediate': 2, 'advanced': 3, 'expert': 4 };
 | 
			
		||||
          return sorted.sort((a, b) => 
 | 
			
		||||
            (difficultyOrder[a.skillLevel as keyof typeof difficultyOrder] || 999) - (difficultyOrder[b.skillLevel as keyof typeof difficultyOrder] || 999)
 | 
			
		||||
          );
 | 
			
		||||
        case 'type':
 | 
			
		||||
          const typeOrder = { 'concept': 0, 'method': 1, 'software': 2 };
 | 
			
		||||
          return sorted.sort((a, b) => 
 | 
			
		||||
            (typeOrder[a.type as keyof typeof typeOrder] || 999) - (typeOrder[b.type as keyof typeof typeOrder] || 999)
 | 
			
		||||
          );
 | 
			
		||||
        case 'default':
 | 
			
		||||
        default:
 | 
			
		||||
          return sorted;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Authentication check function
 | 
			
		||||
    async function checkAuthentication() {
 | 
			
		||||
      try {
 | 
			
		||||
        const response = await fetch('/api/auth/status');
 | 
			
		||||
        const data = await response.json();
 | 
			
		||||
        return {
 | 
			
		||||
          authenticated: data.authenticated,
 | 
			
		||||
          authRequired: data.authRequired
 | 
			
		||||
        };
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.error('Auth check failed:', error);
 | 
			
		||||
        return {
 | 
			
		||||
          authenticated: false,
 | 
			
		||||
          authRequired: true
 | 
			
		||||
        };
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // AI Query Button Handler
 | 
			
		||||
    if (aiQueryBtn) {
 | 
			
		||||
      aiQueryBtn.addEventListener('click', async () => {
 | 
			
		||||
        const authStatus = await checkAuthentication();
 | 
			
		||||
        
 | 
			
		||||
        if (authStatus.authRequired && !authStatus.authenticated) {
 | 
			
		||||
          const returnUrl = `${window.location.pathname}?view=ai`;
 | 
			
		||||
          window.location.href = `/api/auth/login?returnTo=${encodeURIComponent(returnUrl)}`;
 | 
			
		||||
        if (typeof window.requireClientAuth === 'function') {
 | 
			
		||||
          // ENHANCED: Use AI-specific authentication
 | 
			
		||||
          await window.requireClientAuth(() => switchToView('ai'), `${window.location.pathname}?view=ai`, 'ai');
 | 
			
		||||
        } else {
 | 
			
		||||
          console.warn('[AUTH] requireClientAuth not available');
 | 
			
		||||
          switchToView('ai');
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Function to switch between different views
 | 
			
		||||
    function switchToView(view: string) {
 | 
			
		||||
    function switchToView(view) {
 | 
			
		||||
      // Hide all views first
 | 
			
		||||
      toolsGrid.style.display = 'none';
 | 
			
		||||
      matrixContainer.style.display = 'none';
 | 
			
		||||
@ -203,7 +156,7 @@ const tools = data.tools;
 | 
			
		||||
          if (window.restoreAIResults) {
 | 
			
		||||
            window.restoreAIResults();
 | 
			
		||||
          }
 | 
			
		||||
          const aiInput = document.getElementById('ai-query-input') as HTMLTextAreaElement;
 | 
			
		||||
          const aiInput = document.getElementById('ai-query-input');
 | 
			
		||||
          if (aiInput) {
 | 
			
		||||
            setTimeout(() => aiInput.focus(), 100);
 | 
			
		||||
          }
 | 
			
		||||
@ -237,19 +190,19 @@ const tools = data.tools;
 | 
			
		||||
      ];
 | 
			
		||||
      
 | 
			
		||||
      elements.forEach(selector => {
 | 
			
		||||
        const element = document.querySelector(selector) as HTMLElement;
 | 
			
		||||
        const element = document.querySelector(selector);
 | 
			
		||||
        if (element) element.style.display = 'none';
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      const allInputs = filtersSection.querySelectorAll('input, select, textarea');
 | 
			
		||||
      allInputs.forEach(input => (input as HTMLElement).style.display = 'none');
 | 
			
		||||
      allInputs.forEach(input => input.style.display = 'none');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function showFilterControls() {
 | 
			
		||||
      const domainPhaseContainer = document.querySelector('.domain-phase-container') as HTMLElement;
 | 
			
		||||
      const searchInput = document.getElementById('search-input') as HTMLElement;
 | 
			
		||||
      const tagCloud = document.querySelector('.tag-cloud') as HTMLElement;
 | 
			
		||||
      const tagHeader = document.querySelector('.tag-header') as HTMLElement;
 | 
			
		||||
      const domainPhaseContainer = document.querySelector('.domain-phase-container');
 | 
			
		||||
      const searchInput = document.getElementById('search-input');
 | 
			
		||||
      const tagCloud = document.querySelector('.tag-cloud');
 | 
			
		||||
      const tagHeader = document.querySelector('.tag-header');
 | 
			
		||||
      const checkboxWrappers = document.querySelectorAll('.checkbox-wrapper');
 | 
			
		||||
      const allInputs = filtersSection.querySelectorAll('input, select, textarea');
 | 
			
		||||
      
 | 
			
		||||
@ -258,29 +211,15 @@ const tools = data.tools;
 | 
			
		||||
      if (tagCloud) tagCloud.style.display = 'flex';
 | 
			
		||||
      if (tagHeader) tagHeader.style.display = 'flex';
 | 
			
		||||
      
 | 
			
		||||
      allInputs.forEach(input => (input as HTMLElement).style.display = 'block');
 | 
			
		||||
      checkboxWrappers.forEach(wrapper => (wrapper as HTMLElement).style.display = 'flex');
 | 
			
		||||
      allInputs.forEach(input => input.style.display = 'block');
 | 
			
		||||
      checkboxWrappers.forEach(wrapper => wrapper.style.display = 'flex');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Create tool slug from name
 | 
			
		||||
    function createToolSlug(toolName: string): string {
 | 
			
		||||
      return toolName.toLowerCase()
 | 
			
		||||
        .replace(/[^a-z0-9\s-]/g, '')
 | 
			
		||||
        .replace(/\s+/g, '-')
 | 
			
		||||
        .replace(/-+/g, '-')
 | 
			
		||||
        .replace(/^-|-$/g, '');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Find tool by name or slug
 | 
			
		||||
    function findTool(identifier: string) {
 | 
			
		||||
      return window.toolsData.find(tool => 
 | 
			
		||||
        tool.name === identifier || 
 | 
			
		||||
        createToolSlug(tool.name) === identifier.toLowerCase()
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
    // REMOVED: createToolSlug function - now using window.createToolSlug
 | 
			
		||||
    // REMOVED: findTool function - now using window.findToolByIdentifier
 | 
			
		||||
 | 
			
		||||
    // Navigation functions for sharing
 | 
			
		||||
    window.navigateToGrid = function(toolName: string) {
 | 
			
		||||
    window.navigateToGrid = function(toolName) {
 | 
			
		||||
      console.log('Navigating to grid for tool:', toolName);
 | 
			
		||||
      
 | 
			
		||||
      // Switch to grid view first
 | 
			
		||||
@ -296,7 +235,7 @@ const tools = data.tools;
 | 
			
		||||
        // Wait for filters to clear and re-render
 | 
			
		||||
        setTimeout(() => {
 | 
			
		||||
          const toolCards = document.querySelectorAll('.tool-card');
 | 
			
		||||
          let targetCard: Element | null = null;
 | 
			
		||||
          let targetCard = null;
 | 
			
		||||
          
 | 
			
		||||
          toolCards.forEach(card => {
 | 
			
		||||
            const cardTitle = card.querySelector('h3');
 | 
			
		||||
@ -311,13 +250,12 @@ const tools = data.tools;
 | 
			
		||||
          
 | 
			
		||||
          if (targetCard) {
 | 
			
		||||
            console.log('Found target card, scrolling...');
 | 
			
		||||
            // Cast to Element to fix TypeScript issue
 | 
			
		||||
            (targetCard as Element).scrollIntoView({ behavior: 'smooth', block: 'center' });
 | 
			
		||||
            (targetCard as HTMLElement).style.animation = 'highlight-flash 2s ease-out';
 | 
			
		||||
            targetCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
 | 
			
		||||
            targetCard.style.animation = 'highlight-flash 2s ease-out';
 | 
			
		||||
            
 | 
			
		||||
            setTimeout(() => {
 | 
			
		||||
              if (targetCard) {
 | 
			
		||||
                (targetCard as HTMLElement).style.animation = '';
 | 
			
		||||
                targetCard.style.animation = '';
 | 
			
		||||
              }
 | 
			
		||||
            }, 2000);
 | 
			
		||||
          } else {
 | 
			
		||||
@ -327,7 +265,7 @@ const tools = data.tools;
 | 
			
		||||
      }, 200);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    window.navigateToMatrix = function(toolName: string) {
 | 
			
		||||
    window.navigateToMatrix = function(toolName) {
 | 
			
		||||
      console.log('Navigating to matrix for tool:', toolName);
 | 
			
		||||
      
 | 
			
		||||
      // Switch to matrix view
 | 
			
		||||
@ -336,7 +274,7 @@ const tools = data.tools;
 | 
			
		||||
      // Wait for view switch and matrix to render
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        const toolChips = document.querySelectorAll('.tool-chip');
 | 
			
		||||
        let firstMatch: Element | null = null;
 | 
			
		||||
        let firstMatch = null;
 | 
			
		||||
        let matchCount = 0;
 | 
			
		||||
        
 | 
			
		||||
        toolChips.forEach(chip => {
 | 
			
		||||
@ -344,7 +282,7 @@ const tools = data.tools;
 | 
			
		||||
          const chipText = chip.textContent?.replace(/📖/g, '').replace(/[^\w\s\-\.]/g, '').trim();
 | 
			
		||||
          if (chipText === toolName) {
 | 
			
		||||
            // Highlight this occurrence
 | 
			
		||||
            (chip as HTMLElement).style.animation = 'highlight-flash 2s ease-out';
 | 
			
		||||
            chip.style.animation = 'highlight-flash 2s ease-out';
 | 
			
		||||
            matchCount++;
 | 
			
		||||
            
 | 
			
		||||
            // Remember the first match for scrolling
 | 
			
		||||
@ -354,15 +292,14 @@ const tools = data.tools;
 | 
			
		||||
            
 | 
			
		||||
            // Clean up animation after it completes
 | 
			
		||||
            setTimeout(() => {
 | 
			
		||||
              (chip as HTMLElement).style.animation = '';
 | 
			
		||||
              chip.style.animation = '';
 | 
			
		||||
            }, 8000);
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
        
 | 
			
		||||
        if (firstMatch) {
 | 
			
		||||
          console.log(`Found ${matchCount} occurrences of tool, highlighting all and scrolling to first`);
 | 
			
		||||
          // Cast to Element to fix TypeScript issue
 | 
			
		||||
          (firstMatch as Element).scrollIntoView({ behavior: 'smooth', block: 'center' });
 | 
			
		||||
          firstMatch.scrollIntoView({ behavior: 'smooth', block: 'center' });
 | 
			
		||||
        } else {
 | 
			
		||||
          console.warn('Tool chip not found in matrix:', toolName);
 | 
			
		||||
        }
 | 
			
		||||
@ -384,8 +321,8 @@ const tools = data.tools;
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      // Find the tool by name or slug
 | 
			
		||||
      const tool = findTool(toolParam);
 | 
			
		||||
      // Find the tool by name or slug using global function
 | 
			
		||||
      const tool = window.findToolByIdentifier(window.toolsData, toolParam);
 | 
			
		||||
      if (!tool) {
 | 
			
		||||
        console.warn('Shared tool not found:', toolParam);
 | 
			
		||||
        return;
 | 
			
		||||
@ -417,160 +354,48 @@ const tools = data.tools;
 | 
			
		||||
      }, 100);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Handle filtered results
 | 
			
		||||
    // ENHANCED: New filtering logic using show/hide pattern
 | 
			
		||||
    window.addEventListener('toolsFiltered', (event) => {
 | 
			
		||||
      const customEvent = event as CustomEvent;
 | 
			
		||||
      const filtered = customEvent.detail;
 | 
			
		||||
      const filtered = event.detail;
 | 
			
		||||
      const currentView = document.querySelector('.view-toggle.active')?.getAttribute('data-view');
 | 
			
		||||
      
 | 
			
		||||
      if (currentView === 'matrix' || currentView === 'ai') {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      // Clear container
 | 
			
		||||
      toolsContainer.innerHTML = '';
 | 
			
		||||
      // Get all existing tool cards
 | 
			
		||||
      const allToolCards = document.querySelectorAll('.tool-card');
 | 
			
		||||
      const filteredNames = new Set(filtered.map(tool => tool.name.toLowerCase()));
 | 
			
		||||
      
 | 
			
		||||
      if (filtered.length === 0) {
 | 
			
		||||
      let visibleCount = 0;
 | 
			
		||||
      
 | 
			
		||||
      allToolCards.forEach(card => {
 | 
			
		||||
        const toolName = card.getAttribute('data-tool-name');
 | 
			
		||||
        if (filteredNames.has(toolName)) {
 | 
			
		||||
          card.style.display = 'block';
 | 
			
		||||
          visibleCount++;
 | 
			
		||||
        } else {
 | 
			
		||||
          card.style.display = 'none';
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      // Show/hide no results message
 | 
			
		||||
      if (visibleCount === 0) {
 | 
			
		||||
        noResults.style.display = 'block';
 | 
			
		||||
      } else {
 | 
			
		||||
        noResults.style.display = 'none';
 | 
			
		||||
        
 | 
			
		||||
        const sortedTools = sortTools(filtered, 'default');
 | 
			
		||||
        
 | 
			
		||||
        sortedTools.forEach((tool: any) => {
 | 
			
		||||
          const toolCard = createToolCard(tool);
 | 
			
		||||
          toolsContainer.appendChild(toolCard);
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    // Handle view changes
 | 
			
		||||
    window.addEventListener('viewChanged', (event) => {
 | 
			
		||||
      const customEvent = event as CustomEvent;
 | 
			
		||||
      const view = customEvent.detail;
 | 
			
		||||
      const view = event.detail;
 | 
			
		||||
      switchToView(view);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Make switchToView available globally
 | 
			
		||||
    window.switchToAIView = () => switchToView('ai');
 | 
			
		||||
 | 
			
		||||
    // Tool card creation function
 | 
			
		||||
    function createToolCard(tool: any): HTMLElement {
 | 
			
		||||
      const isMethod = tool.type === 'method';
 | 
			
		||||
      const isConcept = tool.type === 'concept';
 | 
			
		||||
      const hasValidProjectUrl = tool.projectUrl !== undefined && 
 | 
			
		||||
                                tool.projectUrl !== null && 
 | 
			
		||||
                                tool.projectUrl !== "" && 
 | 
			
		||||
                                tool.projectUrl.trim() !== "";
 | 
			
		||||
      
 | 
			
		||||
      const hasKnowledgebase = tool.knowledgebase === true;
 | 
			
		||||
      
 | 
			
		||||
      const cardDiv = document.createElement('div');
 | 
			
		||||
      const cardClass = isConcept ? 'card card-concept tool-card' :
 | 
			
		||||
                        isMethod ? 'card card-method tool-card' : 
 | 
			
		||||
                        hasValidProjectUrl ? 'card card-hosted tool-card' : 
 | 
			
		||||
                        (tool.license !== 'Proprietary' ? 'card card-oss tool-card' : 'card tool-card');
 | 
			
		||||
      cardDiv.className = cardClass;
 | 
			
		||||
      cardDiv.style.cursor = 'pointer';
 | 
			
		||||
      cardDiv.onclick = () => window.showToolDetails(tool.name);
 | 
			
		||||
      
 | 
			
		||||
      // Create tool slug for share button
 | 
			
		||||
      const toolSlug = createToolSlug(tool.name);
 | 
			
		||||
 | 
			
		||||
      cardDiv.innerHTML = `
 | 
			
		||||
        <div class="tool-card-header">
 | 
			
		||||
          <h3>${tool.icon ? `<span style="margin-right: 0.5rem; font-size: 1.125rem;">${tool.icon}</span>` : ''}${tool.name}</h3>
 | 
			
		||||
          <div class="tool-card-badges">
 | 
			
		||||
            ${!isMethod && !isConcept && hasValidProjectUrl ? '<span class="badge badge-primary">CC24-Server</span>' : ''}
 | 
			
		||||
            ${hasKnowledgebase ? '<span class="badge badge-error">📖</span>' : ''}
 | 
			
		||||
            <button class="share-btn share-btn--small" 
 | 
			
		||||
                    data-tool-name="${tool.name}" 
 | 
			
		||||
                    data-tool-slug="${toolSlug}" 
 | 
			
		||||
                    data-context="card"
 | 
			
		||||
                    onclick="event.stopPropagation(); window.showShareDialog(this)"
 | 
			
		||||
                    title="${tool.name} teilen"
 | 
			
		||||
                    aria-label="${tool.name} teilen">
 | 
			
		||||
              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
 | 
			
		||||
                <circle cx="18" cy="5" r="3"/>
 | 
			
		||||
                <circle cx="6" cy="12" r="3"/>
 | 
			
		||||
                <circle cx="18" cy="19" r="3"/>
 | 
			
		||||
                <line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/>
 | 
			
		||||
                <line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
 | 
			
		||||
              </svg>
 | 
			
		||||
            </button>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        
 | 
			
		||||
        <p class="text-muted">
 | 
			
		||||
          ${tool.description}
 | 
			
		||||
        </p>
 | 
			
		||||
        
 | 
			
		||||
        <div class="tool-card-metadata" style="display: flex; align-items: center; gap: 1rem; margin-bottom: 0.75rem; line-height: 1;">
 | 
			
		||||
          <div class="metadata-item" style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; color: var(--color-text-secondary); flex-shrink: 1; min-width: 0;">
 | 
			
		||||
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink: 0;">
 | 
			
		||||
              <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
 | 
			
		||||
              <line x1="9" y1="9" x2="15" y2="9"></line>
 | 
			
		||||
              <line x1="9" y1="15" x2="15" y2="15"></line>
 | 
			
		||||
            </svg>
 | 
			
		||||
            <span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;">
 | 
			
		||||
              ${(tool.platforms || []).slice(0, 2).join(', ')}${tool.platforms && tool.platforms.length > 2 ? '...' : ''}
 | 
			
		||||
            </span>
 | 
			
		||||
          </div>
 | 
			
		||||
          
 | 
			
		||||
          <div class="metadata-item" style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; color: var(--color-text-secondary); flex-shrink: 1; min-width: 0;">
 | 
			
		||||
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink: 0;">
 | 
			
		||||
              <circle cx="12" cy="12" r="10"></circle>
 | 
			
		||||
              <path d="M12 6v6l4 2"></path>
 | 
			
		||||
            </svg>
 | 
			
		||||
            <span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;">
 | 
			
		||||
              ${tool.skillLevel}
 | 
			
		||||
            </span>
 | 
			
		||||
          </div>
 | 
			
		||||
          
 | 
			
		||||
          <div class="metadata-item" style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; color: var(--color-text-secondary); flex-shrink: 1; min-width: 0;">
 | 
			
		||||
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink: 0;">
 | 
			
		||||
              <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
 | 
			
		||||
              <polyline points="14 2 14 8 20 8"></polyline>
 | 
			
		||||
            </svg>
 | 
			
		||||
            <span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;">
 | 
			
		||||
              ${isConcept ? 'Konzept' : isMethod ? 'Methode' : tool.license === 'Proprietary' ? 'Prop.' : tool.license?.split(' ')[0] || 'N/A'}
 | 
			
		||||
            </span>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        
 | 
			
		||||
        <div class="tool-tags-container">
 | 
			
		||||
          ${(tool.tags || []).slice(0, 8).map((tag: string) => `<span class="tag">${tag}</span>`).join('')}
 | 
			
		||||
        </div>
 | 
			
		||||
        
 | 
			
		||||
        <div class="tool-card-buttons" onclick="event.stopPropagation();">
 | 
			
		||||
          ${isConcept ? `
 | 
			
		||||
            <a href="${tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-primary single-button" style="background-color: var(--color-concept); border-color: var(--color-concept);">
 | 
			
		||||
              Mehr erfahren
 | 
			
		||||
            </a>
 | 
			
		||||
          ` : isMethod ? `
 | 
			
		||||
            <a href="${tool.projectUrl || tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-primary single-button" style="background-color: var(--color-method); border-color: var(--color-method);">
 | 
			
		||||
              Zur Methode
 | 
			
		||||
            </a>
 | 
			
		||||
          ` : hasValidProjectUrl ? `
 | 
			
		||||
            <div class="button-row">
 | 
			
		||||
              <a href="${tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-secondary">
 | 
			
		||||
                Homepage
 | 
			
		||||
              </a>
 | 
			
		||||
              <a href="${tool.projectUrl}" target="_blank" rel="noopener noreferrer" class="btn btn-primary">
 | 
			
		||||
                Zugreifen
 | 
			
		||||
              </a>
 | 
			
		||||
            </div>
 | 
			
		||||
          ` : `
 | 
			
		||||
            <a href="${tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-primary single-button">
 | 
			
		||||
              Software-Homepage
 | 
			
		||||
            </a>
 | 
			
		||||
          `}
 | 
			
		||||
        </div>
 | 
			
		||||
      `;
 | 
			
		||||
      
 | 
			
		||||
      return cardDiv;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Initialize URL handling
 | 
			
		||||
    handleSharedURL();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
@ -1,15 +1,51 @@
 | 
			
		||||
---
 | 
			
		||||
import BaseLayout from '../layouts/BaseLayout.astro';
 | 
			
		||||
import { getCollection } from 'astro:content';
 | 
			
		||||
import { getToolsData } from '../utils/dataService.js';
 | 
			
		||||
import ContributionButton from '../components/ContributionButton.astro';
 | 
			
		||||
 | 
			
		||||
// Load tools data
 | 
			
		||||
// Load tools data and knowledgebase articles
 | 
			
		||||
const data = await getToolsData();
 | 
			
		||||
const allKnowledgebaseEntries = await getCollection('knowledgebase', (entry) => {
 | 
			
		||||
  // Only include published articles
 | 
			
		||||
  return entry.data.published !== false;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Filter tools that have knowledgebase flag set to true
 | 
			
		||||
const knowledgebaseTools = data.tools.filter((tool: any) => tool.knowledgebase === true);
 | 
			
		||||
// Create unified knowledgebase entries with optional tool association
 | 
			
		||||
const knowledgebaseEntries = allKnowledgebaseEntries.map((entry) => {
 | 
			
		||||
  const associatedTool = entry.data.tool_name 
 | 
			
		||||
    ? data.tools.find((tool: any) => tool.name === entry.data.tool_name)
 | 
			
		||||
    : null;
 | 
			
		||||
  
 | 
			
		||||
  return {
 | 
			
		||||
    // Article metadata
 | 
			
		||||
    slug: entry.slug,
 | 
			
		||||
    title: entry.data.title,
 | 
			
		||||
    description: entry.data.description,
 | 
			
		||||
    author: entry.data.author,
 | 
			
		||||
    last_updated: entry.data.last_updated,
 | 
			
		||||
    difficulty: entry.data.difficulty,
 | 
			
		||||
    categories: entry.data.categories || [],
 | 
			
		||||
    tags: entry.data.tags || [],
 | 
			
		||||
    
 | 
			
		||||
    // Tool association (optional)
 | 
			
		||||
    tool_name: entry.data.tool_name,
 | 
			
		||||
    related_tools: entry.data.related_tools || [],
 | 
			
		||||
    associatedTool,
 | 
			
		||||
    
 | 
			
		||||
    // Derived properties for consistency with existing UI
 | 
			
		||||
    name: entry.data.title, // For search compatibility
 | 
			
		||||
    type: associatedTool?.type || 'article',
 | 
			
		||||
    icon: associatedTool?.icon || '📖',
 | 
			
		||||
    platforms: associatedTool?.platforms || [],
 | 
			
		||||
    skillLevel: entry.data.difficulty || associatedTool?.skillLevel || 'intermediate',
 | 
			
		||||
    phases: associatedTool?.phases || [],
 | 
			
		||||
    license: associatedTool?.license
 | 
			
		||||
  };
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Sort alphabetically by name
 | 
			
		||||
knowledgebaseTools.sort((a: any, b: any) => a.name.localeCompare(b.name));
 | 
			
		||||
// Sort alphabetically by title
 | 
			
		||||
knowledgebaseEntries.sort((a: any, b: any) => a.title.localeCompare(b.title));
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<BaseLayout title="Knowledgebase" description="Extended documentation and insights for DFIR tools">
 | 
			
		||||
@ -17,12 +53,24 @@ knowledgebaseTools.sort((a: any, b: any) => a.name.localeCompare(b.name));
 | 
			
		||||
    <!-- Header -->
 | 
			
		||||
    <div style="text-align: center; margin-bottom: 3rem; padding: 2rem; background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%); border-radius: 1rem; border: 1px solid var(--color-border);">
 | 
			
		||||
      <h1 style="margin-bottom: 1rem; font-size: 2.5rem; color: var(--color-primary);">Knowledgebase</h1>
 | 
			
		||||
      <p style="font-size: 1.25rem; color: var(--color-text-secondary); margin-bottom: 0.5rem;">
 | 
			
		||||
      <p style="font-size: 1.25rem; color: var(--color-text-secondary); margin-bottom: 1.125rem;">
 | 
			
		||||
        Erweiterte Dokumentation und Erkenntnisse
 | 
			
		||||
      </p>
 | 
			
		||||
      <p style="font-size: 1rem; color: var(--color-text-secondary);">
 | 
			
		||||
      <p style="font-size: 1rem; color: var(--color-text-secondary); margin-bottom: 1.5rem;">
 | 
			
		||||
        Praktische Erfahrungen, Konfigurationshinweise und Lektionen aus der Praxis
 | 
			
		||||
      </p>
 | 
			
		||||
      
 | 
			
		||||
      <!--contribution button -->
 | 
			
		||||
      <div style="display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap;">
 | 
			
		||||
        <ContributionButton type="write" variant="primary" text="Artikel schreiben" style="padding: 0.75rem 1.5rem;" />
 | 
			
		||||
        <a href="#kb-entries" class="btn btn-secondary" style="padding: 0.75rem 1.5rem;">
 | 
			
		||||
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
 | 
			
		||||
            <circle cx="11" cy="11" r="8"/>
 | 
			
		||||
            <line x1="21" y1="21" x2="16.65" y2="16.65"/>
 | 
			
		||||
          </svg>
 | 
			
		||||
          Artikel durchsuchen
 | 
			
		||||
        </a>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <!-- Search -->
 | 
			
		||||
@ -35,16 +83,16 @@ knowledgebaseTools.sort((a: any, b: any) => a.name.localeCompare(b.name));
 | 
			
		||||
      />
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <!-- Tools Count -->
 | 
			
		||||
    <!-- Articles Count -->
 | 
			
		||||
    <div style="text-align: center; margin-bottom: 2rem;">
 | 
			
		||||
      <p class="text-muted" style="font-size: 0.875rem;">
 | 
			
		||||
        <span id="visible-count">{knowledgebaseTools.length}</span> von {knowledgebaseTools.length} Einträgen
 | 
			
		||||
        <span id="visible-count">{knowledgebaseEntries.length}</span> von {knowledgebaseEntries.length} Einträgen
 | 
			
		||||
      </p>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <!-- Knowledgebase Entries -->
 | 
			
		||||
    <div style="max-width: 1000px; margin: 0 auto;">
 | 
			
		||||
      {knowledgebaseTools.length === 0 ? (
 | 
			
		||||
      {knowledgebaseEntries.length === 0 ? (
 | 
			
		||||
        <div class="card" style="text-align: center; padding: 3rem;">
 | 
			
		||||
          <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-secondary)" stroke-width="1.5" style="margin: 0 auto 1rem;">
 | 
			
		||||
            <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
 | 
			
		||||
@ -55,88 +103,121 @@ knowledgebaseTools.sort((a: any, b: any) => a.name.localeCompare(b.name));
 | 
			
		||||
          </svg>
 | 
			
		||||
          <h3 style="color: var(--color-text-secondary); margin-bottom: 0.5rem;">Noch keine Knowledgebase-Einträge</h3>
 | 
			
		||||
          <p class="text-muted">
 | 
			
		||||
            Knowledgebase-Einträge werden automatisch angezeigt, sobald Datenbankeinträge das Attribut "knowledgebase: true" haben.
 | 
			
		||||
            Knowledgebase-Einträge werden automatisch angezeigt, sobald Artikel veröffentlicht werden.
 | 
			
		||||
          </p>
 | 
			
		||||
        </div>
 | 
			
		||||
      ) : (
 | 
			
		||||
        <div id="kb-entries">
 | 
			
		||||
          {knowledgebaseTools.map((tool: any, index: number) => {
 | 
			
		||||
            const hasValidProjectUrl = tool.projectUrl !== undefined && 
 | 
			
		||||
                                      tool.projectUrl !== null && 
 | 
			
		||||
                                      tool.projectUrl !== "" && 
 | 
			
		||||
                                      tool.projectUrl.trim() !== "";
 | 
			
		||||
          {knowledgebaseEntries.map((entry: any, index: number) => {
 | 
			
		||||
            const hasAssociatedTool = !!entry.associatedTool;
 | 
			
		||||
            const hasValidProjectUrl = hasAssociatedTool && 
 | 
			
		||||
                                      entry.associatedTool.projectUrl !== undefined && 
 | 
			
		||||
                                      entry.associatedTool.projectUrl !== null && 
 | 
			
		||||
                                      entry.associatedTool.projectUrl !== "" && 
 | 
			
		||||
                                      entry.associatedTool.projectUrl.trim() !== "";
 | 
			
		||||
            
 | 
			
		||||
            const toolSlug = tool.name.toLowerCase()
 | 
			
		||||
              .replace(/[^a-z0-9\s-]/g, '') // Remove special characters
 | 
			
		||||
              .replace(/\s+/g, '-')         // Replace spaces with hyphens
 | 
			
		||||
              .replace(/-+/g, '-')          // Remove duplicate hyphens
 | 
			
		||||
              .replace(/^-|-$/g, '');       // Remove leading/trailing hyphens
 | 
			
		||||
            const isMethod = hasAssociatedTool && entry.associatedTool.type === 'method';
 | 
			
		||||
            const isConcept = hasAssociatedTool && entry.associatedTool.type === 'concept';
 | 
			
		||||
            const isStandalone = !hasAssociatedTool;
 | 
			
		||||
            
 | 
			
		||||
            return (
 | 
			
		||||
              <article 
 | 
			
		||||
                class="kb-entry card" 
 | 
			
		||||
                id={`kb-${toolSlug}`} 
 | 
			
		||||
                data-tool-name={tool.name.toLowerCase()}
 | 
			
		||||
                id={`kb-${entry.slug}`} 
 | 
			
		||||
                data-tool-name={entry.title.toLowerCase()}
 | 
			
		||||
                data-article-type={isStandalone ? 'standalone' : 'tool-associated'}
 | 
			
		||||
              >
 | 
			
		||||
                <div style="display: flex; justify-content: space-between; align-items: center;">
 | 
			
		||||
                  <div style="display: flex; align-items: center; gap: 1rem;">
 | 
			
		||||
                    <h3 style="margin: 0; color: var(--color-primary);">
 | 
			
		||||
                      {tool.icon && <span style="margin-right: 0.5rem;">{tool.icon}</span>}
 | 
			
		||||
                      {tool.name}
 | 
			
		||||
                      <span style="margin-right: 0.5rem;">{entry.icon}</span>
 | 
			
		||||
                      {entry.title}
 | 
			
		||||
                    </h3>
 | 
			
		||||
                    <div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
 | 
			
		||||
                      <!-- Type indicator badges -->
 | 
			
		||||
                      {tool.type === 'concept' && <span class="badge" style="background-color: var(--color-concept); color: white;">Konzept</span>}
 | 
			
		||||
                      {tool.type === 'method' && <span class="badge" style="background-color: var(--color-method); color: white;">Methode</span>}
 | 
			
		||||
                      {tool.type === 'software' && <span class="badge" style="background-color: var(--color-primary); color: white;">Software</span>}
 | 
			
		||||
                      {isStandalone && <span class="badge" style="background-color: var(--color-accent); color: white;">Artikel</span>}
 | 
			
		||||
                      {isConcept && <span class="badge" style="background-color: var(--color-concept); color: white;">Konzept</span>}
 | 
			
		||||
                      {isMethod && <span class="badge" style="background-color: var(--color-method); color: white;">Methode</span>}
 | 
			
		||||
                      {hasAssociatedTool && !isMethod && !isConcept && <span class="badge" style="background-color: var(--color-primary); color: white;">Software</span>}
 | 
			
		||||
                      
 | 
			
		||||
                      {hasValidProjectUrl && <span class="badge badge-primary">CC24-Server</span>}
 | 
			
		||||
                      {tool.license !== 'Proprietary' && tool.type !== 'concept' && tool.type !== 'method' && <span class="badge badge-success">Open Source</span>}
 | 
			
		||||
                      {hasAssociatedTool && entry.associatedTool.license !== 'Proprietary' && !isMethod && !isConcept && <span class="badge badge-success">Open Source</span>}
 | 
			
		||||
                      
 | 
			
		||||
                      <!-- Difficulty indicator -->
 | 
			
		||||
                      <span class="badge" style="background-color: var(--color-text-secondary); color: white; font-size: 0.75rem;">
 | 
			
		||||
                        {tool.skillLevel || 'intermediate'}
 | 
			
		||||
                      </span>
 | 
			
		||||
                      {entry.difficulty && (
 | 
			
		||||
                        <span class="badge" style="background-color: var(--color-text-secondary); color: white; font-size: 0.75rem;">
 | 
			
		||||
                          {entry.difficulty}
 | 
			
		||||
                        </span>
 | 
			
		||||
                      )}
 | 
			
		||||
                      
 | 
			
		||||
                      <!-- Knowledge Base indicator -->
 | 
			
		||||
                      <span class="badge badge-error">📖</span>
 | 
			
		||||
                    </div>
 | 
			
		||||
                  </div>
 | 
			
		||||
                  
 | 
			
		||||
                  <!-- Action button -->
 | 
			
		||||
                  <a href={`/knowledgebase/${toolSlug}`} class="btn btn-primary" style="font-size: 0.8125rem;">
 | 
			
		||||
                    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
 | 
			
		||||
                      <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
 | 
			
		||||
                      <polyline points="15 3 21 3 21 9"/>
 | 
			
		||||
                      <line x1="10" y1="14" x2="21" y2="3"/>
 | 
			
		||||
                    </svg>
 | 
			
		||||
                    Artikel öffnen
 | 
			
		||||
                  </a>
 | 
			
		||||
                  <!-- Action buttons -->
 | 
			
		||||
                  <div style="display: flex; gap: 0.5rem; align-items: center; flex-shrink: 0;">
 | 
			
		||||
                    <a href={`/knowledgebase/${entry.slug}`} class="btn btn-primary" style="font-size: 0.8125rem;">
 | 
			
		||||
                      <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
 | 
			
		||||
                        <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
 | 
			
		||||
                        <polyline points="15 3 21 3 21 9"/>
 | 
			
		||||
                        <line x1="10" y1="14" x2="21" y2="3"/>
 | 
			
		||||
                      </svg>
 | 
			
		||||
                      Artikel öffnen
 | 
			
		||||
                    </a>
 | 
			
		||||
                    
 | 
			
		||||
                    <!-- Edit button for knowledgebase articles -->
 | 
			
		||||
                    <ContributionButton type="edit" toolName={entry.tool_name || entry.title} variant="secondary" text="Edit" style="font-size: 0.8125rem; padding: 0.5rem 0.75rem;" />
 | 
			
		||||
                  </div>
 | 
			
		||||
                </div>
 | 
			
		||||
 | 
			
		||||
                <!-- Description -->
 | 
			
		||||
                <p style="margin: 1rem 0; color: var(--color-text-secondary); line-height: 1.5;">
 | 
			
		||||
                  {tool.description}
 | 
			
		||||
                  {entry.description}
 | 
			
		||||
                </p>
 | 
			
		||||
 | 
			
		||||
                <!-- Tags and Metadata -->
 | 
			
		||||
                <!-- Metadata and Tags -->
 | 
			
		||||
                <div style="display: flex; gap: 1rem; flex-wrap: wrap; align-items: center; margin-top: 1rem;">
 | 
			
		||||
                  {tool.tags && tool.tags.length > 0 && (
 | 
			
		||||
                  <!-- Tags -->
 | 
			
		||||
                  {entry.tags && entry.tags.length > 0 && (
 | 
			
		||||
                    <div style="display: flex; flex-wrap: wrap; gap: 0.25rem;">
 | 
			
		||||
                      {tool.tags.map((tag: string) => (
 | 
			
		||||
                      {entry.tags.map((tag: string) => (
 | 
			
		||||
                        <span class="tag" style="font-size: 0.75rem;">{tag}</span>
 | 
			
		||||
                      ))}
 | 
			
		||||
                    </div>
 | 
			
		||||
                  )}
 | 
			
		||||
                  
 | 
			
		||||
                  {tool.phases && tool.phases.length > 0 && (
 | 
			
		||||
                  <!-- Categories -->
 | 
			
		||||
                  {entry.categories && entry.categories.length > 0 && (
 | 
			
		||||
                    <div style="font-size: 0.8125rem; color: var(--color-text-secondary);">
 | 
			
		||||
                      <strong>Phasen:</strong> {tool.phases.join(', ')}
 | 
			
		||||
                      <strong>Kategorien:</strong> {entry.categories.join(', ')}
 | 
			
		||||
                    </div>
 | 
			
		||||
                  )}
 | 
			
		||||
                  
 | 
			
		||||
                  {tool.platforms && tool.platforms.length > 0 && tool.type !== 'concept' && tool.type !== 'method' && (
 | 
			
		||||
                  <!-- Tool-specific metadata (only if associated with tool) -->
 | 
			
		||||
                  {hasAssociatedTool && entry.phases && entry.phases.length > 0 && (
 | 
			
		||||
                    <div style="font-size: 0.8125rem; color: var(--color-text-secondary);">
 | 
			
		||||
                      <strong>Plattformen:</strong> {tool.platforms.join(', ')}
 | 
			
		||||
                      <strong>Phasen:</strong> {entry.phases.join(', ')}
 | 
			
		||||
                    </div>
 | 
			
		||||
                  )}
 | 
			
		||||
                  
 | 
			
		||||
                  {hasAssociatedTool && entry.platforms && entry.platforms.length > 0 && !isMethod && !isConcept && (
 | 
			
		||||
                    <div style="font-size: 0.8125rem; color: var(--color-text-secondary);">
 | 
			
		||||
                      <strong>Plattformen:</strong> {entry.platforms.join(', ')}
 | 
			
		||||
                    </div>
 | 
			
		||||
                  )}
 | 
			
		||||
 | 
			
		||||
                  <!-- Related tools for standalone articles -->
 | 
			
		||||
                  {isStandalone && entry.related_tools && entry.related_tools.length > 0 && (
 | 
			
		||||
                    <div style="font-size: 0.8125rem; color: var(--color-text-secondary);">
 | 
			
		||||
                      <strong>Verwandte Tools:</strong> {entry.related_tools.join(', ')}
 | 
			
		||||
                    </div>
 | 
			
		||||
                  )}
 | 
			
		||||
 | 
			
		||||
                  <!-- Author and date -->
 | 
			
		||||
                  <div style="font-size: 0.8125rem; color: var(--color-text-secondary); margin-left: auto;">
 | 
			
		||||
                    <strong>Autor:</strong> {entry.author} • <strong>Aktualisiert:</strong> {entry.last_updated.toLocaleDateString('de-DE')}
 | 
			
		||||
                  </div>
 | 
			
		||||
                </div>
 | 
			
		||||
              </article>
 | 
			
		||||
            );
 | 
			
		||||
@ -151,26 +232,19 @@ knowledgebaseTools.sort((a: any, b: any) => a.name.localeCompare(b.name));
 | 
			
		||||
      <p class="text-muted">Versuchen Sie es mit anderen Suchbegriffen.</p>
 | 
			
		||||
    </div>
 | 
			
		||||
  </section>
 | 
			
		||||
  
 | 
			
		||||
  <!-- Floating Action Button -->
 | 
			
		||||
  <div id="fab-container" style="position: fixed; bottom: 2rem; right: 2rem; z-index: 100; display: none;">
 | 
			
		||||
    <ContributionButton 
 | 
			
		||||
      type="write" 
 | 
			
		||||
      variant="primary" 
 | 
			
		||||
      text="✍️" 
 | 
			
		||||
      style="border-radius: 50%; width: 56px; height: 56px; display: flex; align-items: center; justify-content: center; box-shadow: var(--shadow-lg); font-size: 1.5rem; padding: 0;"
 | 
			
		||||
      className="fab-button"
 | 
			
		||||
    />
 | 
			
		||||
  </div>
 | 
			
		||||
</BaseLayout>
 | 
			
		||||
 | 
			
		||||
<style>
 | 
			
		||||
  /* Simplified knowledgebase styles */
 | 
			
		||||
  .kb-entry {
 | 
			
		||||
    margin-bottom: 1.5rem;
 | 
			
		||||
    border-left: 4px solid var(--color-accent);
 | 
			
		||||
    transition: var(--transition-fast);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .kb-entry:hover {
 | 
			
		||||
    transform: translateY(-2px);
 | 
			
		||||
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .dark .kb-entry:hover {
 | 
			
		||||
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
 | 
			
		||||
  }
 | 
			
		||||
</style>
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
  // Enhanced knowledgebase functionality with search
 | 
			
		||||
  document.addEventListener('DOMContentLoaded', () => {
 | 
			
		||||
@ -217,5 +291,22 @@ knowledgebaseTools.sort((a: any, b: any) => a.name.localeCompare(b.name));
 | 
			
		||||
        filterEntries(target.value);
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Show floating action button on scroll (optional enhancement)
 | 
			
		||||
    let lastScrollY = window.scrollY;
 | 
			
		||||
    const fabContainer = document.getElementById('fab-container');
 | 
			
		||||
    
 | 
			
		||||
    window.addEventListener('scroll', () => {
 | 
			
		||||
      if (fabContainer) {
 | 
			
		||||
        if (window.scrollY > 200 && window.scrollY < lastScrollY) {
 | 
			
		||||
          // Scrolling up and past threshold
 | 
			
		||||
          fabContainer.style.display = 'block';
 | 
			
		||||
        } else {
 | 
			
		||||
          // Scrolling down or at top
 | 
			
		||||
          fabContainer.style.display = 'none';
 | 
			
		||||
        }
 | 
			
		||||
        lastScrollY = window.scrollY;
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
</script>
 | 
			
		||||
@ -26,20 +26,35 @@ const { Content } = await entry.render();
 | 
			
		||||
 | 
			
		||||
// Load tools data to get the tool details
 | 
			
		||||
const data = await getToolsData();
 | 
			
		||||
const tool = data.tools.find((t: any) => t.name === entry.data.tool_name);
 | 
			
		||||
 | 
			
		||||
if (!tool) {
 | 
			
		||||
  console.warn(`Tool not found for knowledgebase entry: ${entry.data.tool_name}`);
 | 
			
		||||
  return Astro.redirect('/knowledgebase');
 | 
			
		||||
// UPGRADED: Handle optional tool association
 | 
			
		||||
const primaryTool = entry.data.tool_name 
 | 
			
		||||
  ? data.tools.find((t: any) => t.name === entry.data.tool_name)
 | 
			
		||||
  : null;
 | 
			
		||||
 | 
			
		||||
// UPGRADED: Handle multiple related tools
 | 
			
		||||
const relatedTools = entry.data.related_tools 
 | 
			
		||||
  ? entry.data.related_tools.map((toolName: string) => 
 | 
			
		||||
      data.tools.find((t: any) => t.name === toolName)
 | 
			
		||||
    ).filter(Boolean)
 | 
			
		||||
  : [];
 | 
			
		||||
 | 
			
		||||
// UPGRADED: Use primary tool or first related tool for styling, fallback to generic
 | 
			
		||||
const displayTool = primaryTool || relatedTools[0];
 | 
			
		||||
 | 
			
		||||
// UPGRADED: Don't redirect - show article even without tool association
 | 
			
		||||
if (!displayTool && !entry.data.tool_name && relatedTools.length === 0) {
 | 
			
		||||
  console.log(`Standalone knowledgebase article: ${entry.slug}`);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Determine tool type for styling
 | 
			
		||||
const isMethod = tool.type === 'method';
 | 
			
		||||
const isConcept = tool.type === 'concept';
 | 
			
		||||
const hasValidProjectUrl = tool.projectUrl !== undefined && 
 | 
			
		||||
                          tool.projectUrl !== null && 
 | 
			
		||||
                          tool.projectUrl !== "" && 
 | 
			
		||||
                          tool.projectUrl.trim() !== "";
 | 
			
		||||
// Determine styling based on tool type or fallback to generic
 | 
			
		||||
const isMethod = displayTool?.type === 'method';
 | 
			
		||||
const isConcept = displayTool?.type === 'concept';
 | 
			
		||||
const isStandalone = !displayTool;
 | 
			
		||||
const hasValidProjectUrl = displayTool && displayTool.projectUrl !== undefined && 
 | 
			
		||||
                          displayTool.projectUrl !== null && 
 | 
			
		||||
                          displayTool.projectUrl !== "" && 
 | 
			
		||||
                          displayTool.projectUrl.trim() !== "";
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<BaseLayout title={entry.data.title} description={entry.data.description}>
 | 
			
		||||
@ -49,7 +64,7 @@ const hasValidProjectUrl = tool.projectUrl !== undefined &&
 | 
			
		||||
      <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 1rem;">
 | 
			
		||||
        <div style="flex: 1;">
 | 
			
		||||
          <h1 style="margin: 0 0 0.5rem 0; color: var(--color-primary);">
 | 
			
		||||
            {tool.icon && <span style="margin-right: 0.75rem; font-size: 1.5rem;">{tool.icon}</span>}
 | 
			
		||||
            {displayTool?.icon && <span style="margin-right: 0.75rem; font-size: 1.5rem;">{displayTool.icon}</span>}
 | 
			
		||||
            {entry.data.title}
 | 
			
		||||
          </h1>
 | 
			
		||||
          <p style="margin: 0; color: var(--color-text-secondary); font-size: 1.125rem;">
 | 
			
		||||
@ -58,32 +73,59 @@ const hasValidProjectUrl = tool.projectUrl !== undefined &&
 | 
			
		||||
        </div>
 | 
			
		||||
        <div style="display: flex; flex-direction: column; gap: 0.5rem; align-items: end;">
 | 
			
		||||
          <div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
 | 
			
		||||
            {isConcept && <span class="badge" style="background-color: var(--color-concept); color: white;">Konzept</span>}
 | 
			
		||||
            {isMethod && <span class="badge" style="background-color: var(--color-method); color: white;">Methode</span>}
 | 
			
		||||
            {!isMethod && !isConcept && <span class="badge" style="background-color: var(--color-primary); color: white;">Software</span>}
 | 
			
		||||
            {!isMethod && !isConcept && hasValidProjectUrl && <span class="badge badge-primary">CC24-Server</span>}
 | 
			
		||||
            {!isMethod && !isConcept && tool.license !== 'Proprietary' && <span class="badge badge-success">Open Source</span>}
 | 
			
		||||
            <!-- UPGRADED: Conditional badges based on tool type or standalone -->
 | 
			
		||||
            {isStandalone ? (
 | 
			
		||||
              <span class="badge" style="background-color: var(--color-accent); color: white;">Artikel</span>
 | 
			
		||||
            ) : (
 | 
			
		||||
              <>
 | 
			
		||||
                {isConcept && <span class="badge" style="background-color: var(--color-concept); color: white;">Konzept</span>}
 | 
			
		||||
                {isMethod && <span class="badge" style="background-color: var(--color-method); color: white;">Methode</span>}
 | 
			
		||||
                {!isMethod && !isConcept && !isStandalone && <span class="badge" style="background-color: var(--color-primary); color: white;">Software</span>}
 | 
			
		||||
                {!isMethod && !isConcept && hasValidProjectUrl && <span class="badge badge-primary">CC24-Server</span>}
 | 
			
		||||
                {!isMethod && !isConcept && !isStandalone && displayTool?.license !== 'Proprietary' && <span class="badge badge-success">Open Source</span>}
 | 
			
		||||
              </>
 | 
			
		||||
            )}
 | 
			
		||||
            <span class="badge badge-error">📖</span>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      
 | 
			
		||||
      <!-- Metadata -->
 | 
			
		||||
      <!-- UPGRADED: Flexible metadata section -->
 | 
			
		||||
      <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--color-border);">
 | 
			
		||||
        <div>
 | 
			
		||||
          <strong style="font-size: 0.875rem; color: var(--color-text-secondary);">Schwierigkeit</strong>
 | 
			
		||||
          <p style="margin: 0; font-size: 0.9375rem;">{entry.data.difficulty}</p>
 | 
			
		||||
        </div>
 | 
			
		||||
        <!-- Difficulty (always shown if present) -->
 | 
			
		||||
        {entry.data.difficulty && (
 | 
			
		||||
          <div>
 | 
			
		||||
            <strong style="font-size: 0.875rem; color: var(--color-text-secondary);">Schwierigkeit</strong>
 | 
			
		||||
            <p style="margin: 0; font-size: 0.9375rem;">{entry.data.difficulty}</p>
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
        
 | 
			
		||||
        <!-- Last Updated (always shown) -->
 | 
			
		||||
        <div>
 | 
			
		||||
          <strong style="font-size: 0.875rem; color: var(--color-text-secondary);">Letztes Update</strong>
 | 
			
		||||
          <p style="margin: 0; font-size: 0.9375rem;">{entry.data.last_updated.toLocaleDateString('de-DE')}</p>
 | 
			
		||||
        </div>
 | 
			
		||||
        
 | 
			
		||||
        <!-- Author (always shown) -->
 | 
			
		||||
        <div>
 | 
			
		||||
          <strong style="font-size: 0.875rem; color: var(--color-text-secondary);">Autor</strong>
 | 
			
		||||
          <p style="margin: 0; font-size: 0.9375rem;">{entry.data.author}</p>
 | 
			
		||||
        </div>
 | 
			
		||||
        
 | 
			
		||||
        <!-- UPGRADED: Show article type -->
 | 
			
		||||
        <div>
 | 
			
		||||
          <strong style="font-size: 0.875rem; color: var(--color-text-secondary);">Typ</strong>
 | 
			
		||||
          <p style="margin: 0; font-size: 0.9375rem;">
 | 
			
		||||
            {isStandalone ? 'Allgemeiner Artikel' : 
 | 
			
		||||
             isConcept ? 'Konzept-Artikel' : 
 | 
			
		||||
             isMethod ? 'Methoden-Artikel' : 
 | 
			
		||||
             'Software-Artikel'}
 | 
			
		||||
          </p>
 | 
			
		||||
        </div>
 | 
			
		||||
        
 | 
			
		||||
        <!-- UPGRADED: Categories (if present) -->
 | 
			
		||||
        {entry.data.categories && entry.data.categories.length > 0 && (
 | 
			
		||||
          <div>
 | 
			
		||||
          <div style="grid-column: 1 / -1;">
 | 
			
		||||
            <strong style="font-size: 0.875rem; color: var(--color-text-secondary);">Kategorien</strong>
 | 
			
		||||
            <div style="display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.25rem;">
 | 
			
		||||
              {entry.data.categories.map((cat: string) => (
 | 
			
		||||
@ -105,57 +147,107 @@ const hasValidProjectUrl = tool.projectUrl !== undefined &&
 | 
			
		||||
      </a>
 | 
			
		||||
    </nav>
 | 
			
		||||
 | 
			
		||||
  <!-- Content -->
 | 
			
		||||
  <div class="card" style="padding: 2rem;">
 | 
			
		||||
    <div class="kb-content markdown-content" style="line-height: 1.7;">
 | 
			
		||||
      <Content />
 | 
			
		||||
    <!-- Content -->
 | 
			
		||||
    <div class="card" style="padding: 2rem;">
 | 
			
		||||
      <div class="kb-content markdown-content" style="line-height: 1.7;">
 | 
			
		||||
        <Content />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
    <!-- Tool Actions -->
 | 
			
		||||
    <!-- UPGRADED: Flexible Tool Actions Section -->
 | 
			
		||||
    <div class="card" style="margin-top: 2rem; background-color: var(--color-bg-secondary);">
 | 
			
		||||
      <h3 style="margin: 0 0 1rem 0; color: var(--color-text);">Tool-Aktionen</h3>
 | 
			
		||||
      <h3 style="margin: 0 0 1rem 0; color: var(--color-text);">
 | 
			
		||||
        {isStandalone ? 'Verwandte Aktionen' : 'Tool-Aktionen'}
 | 
			
		||||
      </h3>
 | 
			
		||||
      
 | 
			
		||||
      <div style="display: flex; gap: 1rem; flex-wrap: wrap;">
 | 
			
		||||
        {isConcept ? (
 | 
			
		||||
          <a href={tool.url} target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="background-color: var(--color-concept); border-color: var(--color-concept);">
 | 
			
		||||
        {isStandalone ? (
 | 
			
		||||
          <!-- UPGRADED: Standalone article actions -->
 | 
			
		||||
          <a href="/knowledgebase" class="btn btn-primary">
 | 
			
		||||
            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
 | 
			
		||||
              <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
 | 
			
		||||
              <polyline points="15 3 21 3 21 9"/>
 | 
			
		||||
              <line x1="10" y1="14" x2="21" y2="3"/>
 | 
			
		||||
              <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
 | 
			
		||||
              <polyline points="14 2 14 8 20 8"/>
 | 
			
		||||
              <line x1="16" y1="13" x2="8" y2="13"/>
 | 
			
		||||
              <line x1="16" y1="17" x2="8" y2="17"/>
 | 
			
		||||
              <polyline points="10 9 9 9 8 9"/>
 | 
			
		||||
            </svg>
 | 
			
		||||
            Mehr erfahren
 | 
			
		||||
          </a>
 | 
			
		||||
        ) : isMethod ? (
 | 
			
		||||
          <a href={tool.projectUrl || tool.url} target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="background-color: var(--color-method); border-color: var(--color-method);">
 | 
			
		||||
            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
 | 
			
		||||
              <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
 | 
			
		||||
              <polyline points="15 3 21 3 21 9"/>
 | 
			
		||||
              <line x1="10" y1="14" x2="21" y2="3"/>
 | 
			
		||||
            </svg>
 | 
			
		||||
            Zur Methode
 | 
			
		||||
            Weitere Artikel
 | 
			
		||||
          </a>
 | 
			
		||||
        ) : (
 | 
			
		||||
          <!-- UPGRADED: Tool-specific actions (existing logic) -->
 | 
			
		||||
          <>
 | 
			
		||||
            <a href={tool.url} target="_blank" rel="noopener noreferrer" class="btn btn-secondary">
 | 
			
		||||
              <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
 | 
			
		||||
                <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
 | 
			
		||||
                <polyline points="15 3 21 3 21 9"/>
 | 
			
		||||
                <line x1="10" y1="14" x2="21" y2="3"/>
 | 
			
		||||
              </svg>
 | 
			
		||||
              Software-Homepage
 | 
			
		||||
            </a>
 | 
			
		||||
            {hasValidProjectUrl && (
 | 
			
		||||
              <a href={tool.projectUrl} target="_blank" rel="noopener noreferrer" class="btn btn-primary">
 | 
			
		||||
            {isConcept ? (
 | 
			
		||||
              <a href={displayTool.url} target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="background-color: var(--color-concept); border-color: var(--color-concept);">
 | 
			
		||||
                <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
 | 
			
		||||
                  <circle cx="12" cy="12" r="10"/>
 | 
			
		||||
                  <path d="M12 16l4-4-4-4"/>
 | 
			
		||||
                  <path d="M8 12h8"/>
 | 
			
		||||
                  <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
 | 
			
		||||
                  <polyline points="15 3 21 3 21 9"/>
 | 
			
		||||
                  <line x1="10" y1="14" x2="21" y2="3"/>
 | 
			
		||||
                </svg>
 | 
			
		||||
                Zugreifen
 | 
			
		||||
                Mehr erfahren
 | 
			
		||||
              </a>
 | 
			
		||||
            ) : isMethod ? (
 | 
			
		||||
              <a href={displayTool.projectUrl || displayTool.url} target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="background-color: var(--color-method); border-color: var(--color-method);">
 | 
			
		||||
                <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
 | 
			
		||||
                  <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
 | 
			
		||||
                  <polyline points="15 3 21 3 21 9"/>
 | 
			
		||||
                  <line x1="10" y1="14" x2="21" y2="3"/>
 | 
			
		||||
                </svg>
 | 
			
		||||
                Zur Methode
 | 
			
		||||
              </a>
 | 
			
		||||
            ) : (
 | 
			
		||||
              <>
 | 
			
		||||
                <a href={displayTool.url} target="_blank" rel="noopener noreferrer" class="btn btn-secondary">
 | 
			
		||||
                  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
 | 
			
		||||
                    <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
 | 
			
		||||
                    <polyline points="15 3 21 3 21 9"/>
 | 
			
		||||
                    <line x1="10" y1="14" x2="21" y2="3"/>
 | 
			
		||||
                  </svg>
 | 
			
		||||
                  Software-Homepage
 | 
			
		||||
                </a>
 | 
			
		||||
                {hasValidProjectUrl && (
 | 
			
		||||
                  <a href={displayTool.projectUrl} target="_blank" rel="noopener noreferrer" class="btn btn-primary">
 | 
			
		||||
                    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
 | 
			
		||||
                      <circle cx="12" cy="12" r="10"/>
 | 
			
		||||
                      <path d="M12 16l4-4-4-4"/>
 | 
			
		||||
                      <path d="M8 12h8"/>
 | 
			
		||||
                    </svg>
 | 
			
		||||
                    Zugreifen
 | 
			
		||||
                  </a>
 | 
			
		||||
                )}
 | 
			
		||||
              </>
 | 
			
		||||
            )}
 | 
			
		||||
          </>
 | 
			
		||||
        )}
 | 
			
		||||
        
 | 
			
		||||
        <!-- UPGRADED: Show related tools if present -->
 | 
			
		||||
        {relatedTools.length > 0 && relatedTools.length > (primaryTool ? 1 : 0) && (
 | 
			
		||||
          <div style="margin-left: auto;">
 | 
			
		||||
            <details style="position: relative;">
 | 
			
		||||
              <summary class="btn btn-secondary" style="cursor: pointer; list-style: none;">
 | 
			
		||||
                <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
 | 
			
		||||
                  <path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
 | 
			
		||||
                  <circle cx="8.5" cy="7" r="4"/>
 | 
			
		||||
                  <line x1="20" y1="8" x2="20" y2="14"/>
 | 
			
		||||
                  <line x1="23" y1="11" x2="17" y2="11"/>
 | 
			
		||||
                </svg>
 | 
			
		||||
                Verwandte Tools ({relatedTools.length})
 | 
			
		||||
              </summary>
 | 
			
		||||
              <div style="position: absolute; top: 100%; left: 0; background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1rem; min-width: 200px; z-index: 100; box-shadow: var(--shadow-lg);">
 | 
			
		||||
                {relatedTools.map((tool: any) => (
 | 
			
		||||
                  <a href={tool.projectUrl || tool.url} target="_blank" rel="noopener noreferrer" 
 | 
			
		||||
                     style="display: block; padding: 0.5rem; border-radius: 0.25rem; text-decoration: none; color: var(--color-text); margin-bottom: 0.25rem;"
 | 
			
		||||
                     onmouseover="this.style.backgroundColor='var(--color-bg-secondary)'"
 | 
			
		||||
                     onmouseout="this.style.backgroundColor='transparent'">
 | 
			
		||||
                    {tool.icon && <span style="margin-right: 0.5rem;">{tool.icon}</span>}
 | 
			
		||||
                    {tool.name}
 | 
			
		||||
                  </a>
 | 
			
		||||
                ))}
 | 
			
		||||
              </div>
 | 
			
		||||
            </details>
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
        
 | 
			
		||||
        <!-- Always show return to main page -->
 | 
			
		||||
        <a href="/" class="btn btn-secondary">
 | 
			
		||||
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
 | 
			
		||||
            <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
 | 
			
		||||
@ -166,4 +258,4 @@ const hasValidProjectUrl = tool.projectUrl !== undefined &&
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </article>
 | 
			
		||||
</BaseLayout>
 | 
			
		||||
</BaseLayout>
 | 
			
		||||
@ -1,64 +0,0 @@
 | 
			
		||||
// Theme management
 | 
			
		||||
const THEME_KEY = 'dfir-theme';
 | 
			
		||||
 | 
			
		||||
// Get system preference
 | 
			
		||||
function getSystemTheme() {
 | 
			
		||||
  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Get stored theme or default to auto
 | 
			
		||||
function getStoredTheme() {
 | 
			
		||||
  return localStorage.getItem(THEME_KEY) || 'auto';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Apply theme to document
 | 
			
		||||
function applyTheme(theme) {
 | 
			
		||||
  const effectiveTheme = theme === 'auto' ? getSystemTheme() : theme;
 | 
			
		||||
  document.documentElement.setAttribute('data-theme', effectiveTheme);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Update theme toggle button state
 | 
			
		||||
function updateThemeToggle(theme) {
 | 
			
		||||
  document.querySelectorAll('[data-theme-toggle]').forEach(button => {
 | 
			
		||||
    button.setAttribute('data-current-theme', theme);
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Initialize theme on page load
 | 
			
		||||
function initTheme() {
 | 
			
		||||
  const storedTheme = getStoredTheme();
 | 
			
		||||
  applyTheme(storedTheme);
 | 
			
		||||
  
 | 
			
		||||
  // Update theme toggle buttons immediately
 | 
			
		||||
  updateThemeToggle(storedTheme);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Handle theme toggle
 | 
			
		||||
function toggleTheme() {
 | 
			
		||||
  const current = getStoredTheme();
 | 
			
		||||
  const themes = ['light', 'dark', 'auto'];
 | 
			
		||||
  const currentIndex = themes.indexOf(current);
 | 
			
		||||
  const nextIndex = (currentIndex + 1) % themes.length;
 | 
			
		||||
  const nextTheme = themes[nextIndex];
 | 
			
		||||
  
 | 
			
		||||
  localStorage.setItem(THEME_KEY, nextTheme);
 | 
			
		||||
  applyTheme(nextTheme);
 | 
			
		||||
  updateThemeToggle(nextTheme);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Listen for system theme changes
 | 
			
		||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
 | 
			
		||||
  if (getStoredTheme() === 'auto') {
 | 
			
		||||
    applyTheme('auto');
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Initialize when DOM is ready (for safety)
 | 
			
		||||
document.addEventListener('DOMContentLoaded', initTheme);
 | 
			
		||||
 | 
			
		||||
// Export functions for use in Astro components
 | 
			
		||||
window.themeUtils = {
 | 
			
		||||
  initTheme,
 | 
			
		||||
  toggleTheme,
 | 
			
		||||
  getStoredTheme
 | 
			
		||||
};
 | 
			
		||||
@ -279,13 +279,21 @@ input, select, textarea {
 | 
			
		||||
  background-color: var(--color-bg);
 | 
			
		||||
  color: var(--color-text);
 | 
			
		||||
  font-size: 0.875rem;
 | 
			
		||||
  transition: var(--transition-fast);
 | 
			
		||||
  transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
input:focus, select:focus, textarea:focus {
 | 
			
		||||
  outline: none;
 | 
			
		||||
input:focus, textarea:focus, select:focus {
 | 
			
		||||
  border-color: var(--color-primary);
 | 
			
		||||
  box-shadow: 0 0 0 3px rgb(37 99 235 / 10%);
 | 
			
		||||
  box-shadow: 0 0 0 2px rgba(var(--color-primary-rgb), 0.1);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Form validation states */
 | 
			
		||||
input:invalid:not(:focus), textarea:invalid:not(:focus), select:invalid:not(:focus) {
 | 
			
		||||
  border-color: var(--color-error);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
input:valid:not(:focus), textarea:valid:not(:focus), select:valid:not(:focus) {
 | 
			
		||||
  border-color: var(--color-accent);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
select {
 | 
			
		||||
@ -301,14 +309,67 @@ select {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  gap: 0.5rem;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  transition: var(--transition-fast);
 | 
			
		||||
  user-select: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.checkbox-wrapper:hover {
 | 
			
		||||
  background-color: var(--color-bg-secondary);
 | 
			
		||||
  border-radius: 0.25rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.checkbox-wrapper input[type="checkbox"] {
 | 
			
		||||
  margin-right: 0.5rem;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Scrollable checkbox containers */
 | 
			
		||||
.checkbox-container {
 | 
			
		||||
  max-height: 200px;
 | 
			
		||||
  overflow-y: auto;
 | 
			
		||||
  border: 1px solid var(--color-border);
 | 
			
		||||
  border-radius: 0.375rem;
 | 
			
		||||
  padding: 0.75rem;
 | 
			
		||||
  background-color: var(--color-bg);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.checkbox-container::-webkit-scrollbar {
 | 
			
		||||
  width: 8px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.checkbox-container::-webkit-scrollbar-track {
 | 
			
		||||
  background: var(--color-bg-secondary);
 | 
			
		||||
  border-radius: 4px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.checkbox-container::-webkit-scrollbar-thumb {
 | 
			
		||||
  background: var(--color-border);
 | 
			
		||||
  border-radius: 4px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.checkbox-container::-webkit-scrollbar-thumb:hover {
 | 
			
		||||
  background: var(--color-text-secondary);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
input[type="checkbox"] {
 | 
			
		||||
  width: auto;
 | 
			
		||||
  width: 16px;
 | 
			
		||||
  height: 16px;
 | 
			
		||||
  accent-color: var(--color-primary);
 | 
			
		||||
  margin: 0;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Better focus states for accessibility */
 | 
			
		||||
input[type="checkbox"]:focus,
 | 
			
		||||
input[type="text"]:focus,
 | 
			
		||||
input[type="url"]:focus,
 | 
			
		||||
textarea:focus,
 | 
			
		||||
select:focus {
 | 
			
		||||
  outline: 2px solid var(--color-primary);
 | 
			
		||||
  outline-offset: 2px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Consolidated Card System */
 | 
			
		||||
.card {
 | 
			
		||||
  background-color: var(--color-bg);
 | 
			
		||||
@ -469,6 +530,10 @@ input[type="checkbox"] {
 | 
			
		||||
  background: linear-gradient(to right, transparent 0%, var(--color-method-bg) 70%);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.card-concept .tool-tags-container::after {
 | 
			
		||||
  background: linear-gradient(to right, transparent 0%, var(--color-method-bg) 70%);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tool-card-buttons {
 | 
			
		||||
  margin-top: auto;
 | 
			
		||||
  flex-shrink: 0;
 | 
			
		||||
@ -685,6 +750,7 @@ input[type="checkbox"] {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  background-color: rgb(0 0 0 / 50%);
 | 
			
		||||
  backdrop-filter: blur(2px);
 | 
			
		||||
  z-index: 999;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -843,6 +909,27 @@ input[type="checkbox"] {
 | 
			
		||||
  transform: translateY(-1px);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Loading state improvements */
 | 
			
		||||
.btn.loading {
 | 
			
		||||
  opacity: 0.7;
 | 
			
		||||
  pointer-events: none;
 | 
			
		||||
  position: relative;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn.loading::after {
 | 
			
		||||
  content: "";
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  top: 50%;
 | 
			
		||||
  left: 50%;
 | 
			
		||||
  width: 16px;
 | 
			
		||||
  height: 16px;
 | 
			
		||||
  margin: -8px 0 0 -8px;
 | 
			
		||||
  border: 2px solid transparent;
 | 
			
		||||
  border-top: 2px solid currentColor;
 | 
			
		||||
  border-radius: 50%;
 | 
			
		||||
  animation: spin 1s linear infinite;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Collaboration Tools */
 | 
			
		||||
.collaboration-tools-compact {
 | 
			
		||||
  display: flex;
 | 
			
		||||
@ -944,7 +1031,7 @@ Collaboration Section Collapse */
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.ai-loading, .ai-error, .ai-results {
 | 
			
		||||
  animation: fadeIn 0.3s ease-in;
 | 
			
		||||
  animation: fadeIn 0.3s ease-in-out;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.ai-mode-toggle {
 | 
			
		||||
@ -1278,6 +1365,16 @@ Collaboration Section Collapse */
 | 
			
		||||
  position: relative;
 | 
			
		||||
  transition: var(--transition-medium);
 | 
			
		||||
}
 | 
			
		||||
/*
 | 
			
		||||
.kb-entry {
 | 
			
		||||
  margin-bottom: 1.5rem;
 | 
			
		||||
  border-left: 4px solid var(--color-accent);
 | 
			
		||||
  transition: var(--transition-fast);
 | 
			
		||||
}*/
 | 
			
		||||
.kb-entry:hover {
 | 
			
		||||
  transform: translateY(-2px);
 | 
			
		||||
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.kb-entry:target { animation: highlight-flash 2s ease-out; }
 | 
			
		||||
 | 
			
		||||
@ -1292,6 +1389,9 @@ Collaboration Section Collapse */
 | 
			
		||||
  padding: 1.5rem;
 | 
			
		||||
  border-radius: 0.5rem;
 | 
			
		||||
}
 | 
			
		||||
.dark .kb-entry:hover {
 | 
			
		||||
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.kb-expand-icon svg { transition: var(--transition-medium); }
 | 
			
		||||
 | 
			
		||||
@ -1328,12 +1428,23 @@ footer {
 | 
			
		||||
    max-height: 0;
 | 
			
		||||
    padding-top: 0;
 | 
			
		||||
    margin-top: 0;
 | 
			
		||||
    transform: translateY(-10px);
 | 
			
		||||
  }
 | 
			
		||||
  to {
 | 
			
		||||
    opacity: 1;
 | 
			
		||||
    max-height: 1000px;
 | 
			
		||||
    padding-top: 1rem;
 | 
			
		||||
    margin-top: 1rem;
 | 
			
		||||
    transform: translateY(0);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@keyframes shimmer {
 | 
			
		||||
  0% {
 | 
			
		||||
    opacity: 0.8;
 | 
			
		||||
  }
 | 
			
		||||
  100% {
 | 
			
		||||
    opacity: 1;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1407,9 +1518,16 @@ Strobing borders: Bright colored borders that change with each keyframe
 | 
			
		||||
Higher opacity: More saturated colors (up to 100% on yellow)
 | 
			
		||||
 | 
			
		||||
This will literally assault the user's retinas. They'll need sunglasses to look at their shared tools! 🌈💥👁️🗨️*/
 | 
			
		||||
 | 
			
		||||
@keyframes pulse {
 | 
			
		||||
  0%, 100% { opacity: 1; }
 | 
			
		||||
  50% { opacity: 0.5; }
 | 
			
		||||
  0%, 100% {
 | 
			
		||||
    transform: scale(1);
 | 
			
		||||
    opacity: 1;
 | 
			
		||||
  }
 | 
			
		||||
  50% {
 | 
			
		||||
    transform: scale(1.05);
 | 
			
		||||
    opacity: 0.8;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@keyframes fadeInUp {
 | 
			
		||||
@ -1423,6 +1541,11 @@ This will literally assault the user's retinas. They'll need sunglasses to look
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@keyframes spin {
 | 
			
		||||
  0% { transform: rotate(0deg); }
 | 
			
		||||
  100% { transform: rotate(360deg); }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Consolidated Responsive Design */
 | 
			
		||||
@media (width <= 1200px) {
 | 
			
		||||
  .modals-side-by-side #tool-details-primary.active,
 | 
			
		||||
@ -1503,6 +1626,10 @@ This will literally assault the user's retinas. They'll need sunglasses to look
 | 
			
		||||
    width: 95%; 
 | 
			
		||||
    max-width: none;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .form-grid.two-columns {
 | 
			
		||||
    grid-template-columns: 1fr;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media (width <= 640px) {
 | 
			
		||||
@ -1555,6 +1682,15 @@ This will literally assault the user's retinas. They'll need sunglasses to look
 | 
			
		||||
    grid-template-columns: 1fr;
 | 
			
		||||
    gap: 0.5rem;
 | 
			
		||||
  }
 | 
			
		||||
  .card {
 | 
			
		||||
    padding: 1rem;
 | 
			
		||||
  }
 | 
			
		||||
  .form-grid {
 | 
			
		||||
    gap: 0.75rem;
 | 
			
		||||
  }
 | 
			
		||||
  .checkbox-container {
 | 
			
		||||
    max-height: 150px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media (width <= 480px) {
 | 
			
		||||
@ -1712,4 +1848,228 @@ This will literally assault the user's retinas. They'll need sunglasses to look
 | 
			
		||||
 | 
			
		||||
.share-btn svg {
 | 
			
		||||
  flex-shrink: 0;
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* === LAYOUT UTILITIES === */
 | 
			
		||||
.flex { display: flex; }
 | 
			
		||||
.flex-col { flex-direction: column; }
 | 
			
		||||
.flex-row { flex-direction: row; }
 | 
			
		||||
.flex-wrap { flex-wrap: wrap; }
 | 
			
		||||
.flex-1 { flex: 1; }
 | 
			
		||||
.flex-shrink-0 { flex-shrink: 0; }
 | 
			
		||||
.flex-shrink-1 { flex-shrink: 1; }
 | 
			
		||||
 | 
			
		||||
/* Alignment */
 | 
			
		||||
.items-center { align-items: center; }
 | 
			
		||||
.items-start { align-items: flex-start; }
 | 
			
		||||
.items-end { align-items: flex-end; }
 | 
			
		||||
.justify-center { justify-content: center; }
 | 
			
		||||
.justify-between { justify-content: space-between; }
 | 
			
		||||
.justify-end { justify-content: flex-end; }
 | 
			
		||||
.text-center { text-align: center; }
 | 
			
		||||
.text-left { text-align: left; }
 | 
			
		||||
.text-right { text-align: right; }
 | 
			
		||||
 | 
			
		||||
/* Grid */
 | 
			
		||||
.grid { display: grid; }
 | 
			
		||||
.grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
 | 
			
		||||
.grid-cols-3 { grid-template-columns: repeat(3, 1fr); }
 | 
			
		||||
.grid-auto-fit-sm { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
 | 
			
		||||
.grid-auto-fit-md { grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
 | 
			
		||||
 | 
			
		||||
/* === SPACING UTILITIES === */
 | 
			
		||||
.gap-0 { gap: 0; }
 | 
			
		||||
.gap-1 { gap: 0.25rem; }
 | 
			
		||||
.gap-2 { gap: 0.5rem; }
 | 
			
		||||
.gap-3 { gap: 0.75rem; }
 | 
			
		||||
.gap-4 { gap: 1rem; }
 | 
			
		||||
.gap-5 { gap: 1.25rem; }
 | 
			
		||||
.gap-6 { gap: 1.5rem; }
 | 
			
		||||
.gap-8 { gap: 2rem; }
 | 
			
		||||
 | 
			
		||||
/* Margin */
 | 
			
		||||
.m-0 { margin: 0; }
 | 
			
		||||
.mb-0 { margin-bottom: 0; }
 | 
			
		||||
.mb-1 { margin-bottom: 0.25rem; }
 | 
			
		||||
.mb-2 { margin-bottom: 0.5rem; }
 | 
			
		||||
.mb-3 { margin-bottom: 0.75rem; }
 | 
			
		||||
.mb-4 { margin-bottom: 1rem; }
 | 
			
		||||
.mb-6 { margin-bottom: 1.5rem; }
 | 
			
		||||
.mb-8 { margin-bottom: 2rem; }
 | 
			
		||||
.mt-auto { margin-top: auto; }
 | 
			
		||||
.mr-2 { margin-right: 0.5rem; }
 | 
			
		||||
.mr-3 { margin-right: 0.75rem; }
 | 
			
		||||
 | 
			
		||||
/* Padding */
 | 
			
		||||
.p-0 { padding: 0; }
 | 
			
		||||
.p-4 { padding: 1rem; }
 | 
			
		||||
.p-6 { padding: 1.5rem; }
 | 
			
		||||
.p-8 { padding: 2rem; }
 | 
			
		||||
 | 
			
		||||
/* === TYPOGRAPHY UTILITIES === */
 | 
			
		||||
.text-xs { font-size: 0.75rem; }
 | 
			
		||||
.text-sm { font-size: 0.875rem; }
 | 
			
		||||
.text-base { font-size: 1rem; }
 | 
			
		||||
.text-lg { font-size: 1.125rem; }
 | 
			
		||||
.text-xl { font-size: 1.25rem; }
 | 
			
		||||
.text-2xl { font-size: 1.5rem; }
 | 
			
		||||
 | 
			
		||||
.font-medium { font-weight: 500; }
 | 
			
		||||
.font-semibold { font-weight: 600; }
 | 
			
		||||
.font-bold { font-weight: 700; }
 | 
			
		||||
 | 
			
		||||
/* === VISUAL UTILITIES === */
 | 
			
		||||
.rounded { border-radius: 0.375rem; }
 | 
			
		||||
.rounded-md { border-radius: 0.5rem; }
 | 
			
		||||
.rounded-lg { border-radius: 0.75rem; }
 | 
			
		||||
.rounded-full { border-radius: 9999px; }
 | 
			
		||||
 | 
			
		||||
.shadow-sm { box-shadow: var(--shadow-sm); }
 | 
			
		||||
.shadow-md { box-shadow: var(--shadow-md); }
 | 
			
		||||
.shadow-lg { box-shadow: var(--shadow-lg); }
 | 
			
		||||
 | 
			
		||||
.border { border: 1px solid var(--color-border); }
 | 
			
		||||
.border-l-4 { border-left: 4px solid var(--color-border); }
 | 
			
		||||
 | 
			
		||||
.bg-primary { background-color: var(--color-primary); }
 | 
			
		||||
.bg-secondary { background-color: var(--color-bg-secondary); }
 | 
			
		||||
.bg-tertiary { background-color: var(--color-bg-tertiary); }
 | 
			
		||||
 | 
			
		||||
.text-primary { color: var(--color-primary); }
 | 
			
		||||
.text-secondary { color: var(--color-text-secondary); }
 | 
			
		||||
.text-white { color: white; }
 | 
			
		||||
 | 
			
		||||
/* === POSITION & SIZING === */
 | 
			
		||||
.relative { position: relative; }
 | 
			
		||||
.absolute { position: absolute; }
 | 
			
		||||
.fixed { position: fixed; }
 | 
			
		||||
.w-full { width: 100%; }
 | 
			
		||||
.h-full { height: 100%; }
 | 
			
		||||
.min-w-0 { min-width: 0; }
 | 
			
		||||
.overflow-hidden { overflow: hidden; }
 | 
			
		||||
.cursor-pointer { cursor: pointer; }
 | 
			
		||||
 | 
			
		||||
/* === COMMON COMBINATIONS === */
 | 
			
		||||
.flex-center { 
 | 
			
		||||
  display: flex; 
 | 
			
		||||
  align-items: center; 
 | 
			
		||||
  justify-content: center; 
 | 
			
		||||
}
 | 
			
		||||
.flex-between { 
 | 
			
		||||
  display: flex; 
 | 
			
		||||
  align-items: center; 
 | 
			
		||||
  justify-content: space-between; 
 | 
			
		||||
}
 | 
			
		||||
.flex-start { 
 | 
			
		||||
  display: flex; 
 | 
			
		||||
  align-items: center; 
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.field-help {
 | 
			
		||||
  font-size: 0.8125rem;
 | 
			
		||||
  color: var(--color-text-secondary);
 | 
			
		||||
  line-height: 1.4;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Improved field error styling */
 | 
			
		||||
.field-error {
 | 
			
		||||
  color: var(--color-error);
 | 
			
		||||
  font-size: 0.8125rem;
 | 
			
		||||
  margin-top: 0.25rem;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  gap: 0.25rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.field-error::before {
 | 
			
		||||
  content: "⚠";
 | 
			
		||||
  font-size: 0.75rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Form section improvements */
 | 
			
		||||
.form-section {
 | 
			
		||||
  border: 1px solid var(--color-border);
 | 
			
		||||
  border-radius: 0.5rem;
 | 
			
		||||
  padding: 1rem;
 | 
			
		||||
  margin-bottom: 1.5rem;
 | 
			
		||||
  background-color: var(--color-bg);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-section h3 {
 | 
			
		||||
  margin-top: 0;
 | 
			
		||||
  margin-bottom: 1rem;
 | 
			
		||||
  color: var(--color-primary);
 | 
			
		||||
  font-size: 1.125rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Success/warning notices in forms */
 | 
			
		||||
.form-notice {
 | 
			
		||||
  padding: 1rem;
 | 
			
		||||
  border-radius: 0.5rem;
 | 
			
		||||
  margin-bottom: 1rem;
 | 
			
		||||
  border-left: 3px solid;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-notice.success {
 | 
			
		||||
  background-color: var(--color-oss-bg);
 | 
			
		||||
  border-left-color: var(--color-accent);
 | 
			
		||||
  color: var(--color-text);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-notice.warning {
 | 
			
		||||
  background-color: var(--color-hosted-bg);
 | 
			
		||||
  border-left-color: var(--color-warning);
 | 
			
		||||
  color: var(--color-text);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-notice.info {
 | 
			
		||||
  background-color: var(--color-bg-secondary);
 | 
			
		||||
  border-left-color: var(--color-primary);
 | 
			
		||||
  color: var(--color-text);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Better form grid layout */
 | 
			
		||||
.form-grid {
 | 
			
		||||
  display: grid;
 | 
			
		||||
  gap: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-grid.two-columns {
 | 
			
		||||
  grid-template-columns: 1fr 1fr;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Better spacing for form elements */
 | 
			
		||||
.form-group {
 | 
			
		||||
  margin-bottom: 1.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-group:last-child {
 | 
			
		||||
  margin-bottom: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-label {
 | 
			
		||||
  display: block;
 | 
			
		||||
  margin-bottom: 0.5rem;
 | 
			
		||||
  font-weight: 600;
 | 
			
		||||
  color: var(--color-text);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-label.required::after {
 | 
			
		||||
  content: " *";
 | 
			
		||||
  color: var(--color-error);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#queue-status {
 | 
			
		||||
  animation: slideDown 0.3s ease-out;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#queue-position-badge {
 | 
			
		||||
  animation: pulse 2s infinite;
 | 
			
		||||
  transition: all 0.3s ease;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#queue-progress {
 | 
			
		||||
  background: linear-gradient(90deg, var(--color-primary), var(--color-accent));
 | 
			
		||||
  animation: shimmer 2s ease-in-out infinite alternate;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										157
									
								
								src/utils/api.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										157
									
								
								src/utils/api.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,157 @@
 | 
			
		||||
// src/utils/api.ts
 | 
			
		||||
 | 
			
		||||
// Standard JSON headers for all API responses
 | 
			
		||||
const JSON_HEADERS = {
 | 
			
		||||
  'Content-Type': 'application/json'
 | 
			
		||||
} as const;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Base function to create consistent API responses
 | 
			
		||||
 * All other response helpers use this internally
 | 
			
		||||
 */
 | 
			
		||||
export function createAPIResponse(data: any, status: number = 200, additionalHeaders?: Record<string, string>): Response {
 | 
			
		||||
  const headers = additionalHeaders 
 | 
			
		||||
    ? { ...JSON_HEADERS, ...additionalHeaders }
 | 
			
		||||
    : JSON_HEADERS;
 | 
			
		||||
    
 | 
			
		||||
  return new Response(JSON.stringify(data), {
 | 
			
		||||
    status,
 | 
			
		||||
    headers
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Success responses (2xx status codes)
 | 
			
		||||
 */
 | 
			
		||||
export const apiResponse = {
 | 
			
		||||
  // 200 - Success with data
 | 
			
		||||
  success: (data: any = { success: true }): Response => 
 | 
			
		||||
    createAPIResponse(data, 200),
 | 
			
		||||
  
 | 
			
		||||
  // 201 - Created (for contribution submissions, uploads, etc.)
 | 
			
		||||
  created: (data: any = { success: true }): Response => 
 | 
			
		||||
    createAPIResponse(data, 201),
 | 
			
		||||
    
 | 
			
		||||
  // 202 - Accepted (for async operations)
 | 
			
		||||
  accepted: (data: any = { success: true, message: 'Request accepted for processing' }): Response => 
 | 
			
		||||
    createAPIResponse(data, 202)
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Client error responses (4xx status codes)
 | 
			
		||||
 */
 | 
			
		||||
export const apiError = {
 | 
			
		||||
  // 400 - Bad Request
 | 
			
		||||
  badRequest: (message: string = 'Bad request', details?: string[]): Response => 
 | 
			
		||||
    createAPIResponse({ 
 | 
			
		||||
      success: false, 
 | 
			
		||||
      error: message, 
 | 
			
		||||
      ...(details && { details }) 
 | 
			
		||||
    }, 400),
 | 
			
		||||
  
 | 
			
		||||
  // 401 - Unauthorized
 | 
			
		||||
  unauthorized: (message: string = 'Authentication required'): Response => 
 | 
			
		||||
    createAPIResponse({ success: false, error: message }, 401),
 | 
			
		||||
  
 | 
			
		||||
  // 403 - Forbidden
 | 
			
		||||
  forbidden: (message: string = 'Access denied'): Response => 
 | 
			
		||||
    createAPIResponse({ success: false, error: message }, 403),
 | 
			
		||||
  
 | 
			
		||||
  // 404 - Not Found
 | 
			
		||||
  notFound: (message: string = 'Resource not found'): Response => 
 | 
			
		||||
    createAPIResponse({ success: false, error: message }, 404),
 | 
			
		||||
  
 | 
			
		||||
  // 422 - Unprocessable Entity (validation errors)
 | 
			
		||||
  validation: (message: string = 'Validation failed', details?: string[]): Response => 
 | 
			
		||||
    createAPIResponse({ 
 | 
			
		||||
      success: false, 
 | 
			
		||||
      error: message, 
 | 
			
		||||
      ...(details && { details }) 
 | 
			
		||||
    }, 422),
 | 
			
		||||
  
 | 
			
		||||
  // 429 - Rate Limited
 | 
			
		||||
  rateLimit: (message: string = 'Rate limit exceeded. Please wait before trying again.'): Response => 
 | 
			
		||||
    createAPIResponse({ success: false, error: message }, 429)
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Server error responses (5xx status codes)
 | 
			
		||||
 */
 | 
			
		||||
export const apiServerError = {
 | 
			
		||||
  // 500 - Internal Server Error
 | 
			
		||||
  internal: (message: string = 'Internal server error'): Response => 
 | 
			
		||||
    createAPIResponse({ success: false, error: message }, 500),
 | 
			
		||||
  
 | 
			
		||||
  // 502 - Bad Gateway (external service issues)
 | 
			
		||||
  badGateway: (message: string = 'External service error'): Response => 
 | 
			
		||||
    createAPIResponse({ success: false, error: message }, 502),
 | 
			
		||||
  
 | 
			
		||||
  // 503 - Service Unavailable
 | 
			
		||||
  unavailable: (message: string = 'Service temporarily unavailable'): Response => 
 | 
			
		||||
    createAPIResponse({ success: false, error: message }, 503),
 | 
			
		||||
  
 | 
			
		||||
  // 504 - Gateway Timeout
 | 
			
		||||
  timeout: (message: string = 'Request timeout'): Response => 
 | 
			
		||||
    createAPIResponse({ success: false, error: message }, 504)
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Specialized response helpers for common patterns
 | 
			
		||||
 */
 | 
			
		||||
export const apiSpecial = {
 | 
			
		||||
  // JSON parsing error
 | 
			
		||||
  invalidJSON: (): Response => 
 | 
			
		||||
    apiError.badRequest('Invalid JSON in request body'),
 | 
			
		||||
  
 | 
			
		||||
  // Missing required fields
 | 
			
		||||
  missingRequired: (fields: string[]): Response => 
 | 
			
		||||
    apiError.badRequest(`Missing required fields: ${fields.join(', ')}`),
 | 
			
		||||
  
 | 
			
		||||
  // Empty request body
 | 
			
		||||
  emptyBody: (): Response => 
 | 
			
		||||
    apiError.badRequest('Request body cannot be empty'),
 | 
			
		||||
  
 | 
			
		||||
  // File upload responses
 | 
			
		||||
  uploadSuccess: (data: { url: string; filename: string; size: number; storage: string }): Response => 
 | 
			
		||||
    apiResponse.created(data),
 | 
			
		||||
  
 | 
			
		||||
  uploadFailed: (error: string): Response => 
 | 
			
		||||
    apiServerError.internal(`Upload failed: ${error}`),
 | 
			
		||||
  
 | 
			
		||||
  // Contribution responses
 | 
			
		||||
  contributionSuccess: (data: { prUrl?: string; branchName?: string; message: string }): Response => 
 | 
			
		||||
    apiResponse.created({ success: true, ...data }),
 | 
			
		||||
  
 | 
			
		||||
  contributionFailed: (error: string): Response => 
 | 
			
		||||
    apiServerError.internal(`Contribution failed: ${error}`)
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const apiWithHeaders = {
 | 
			
		||||
  // Success with custom headers (e.g., Set-Cookie)
 | 
			
		||||
  successWithHeaders: (data: any, headers: Record<string, string>): Response => 
 | 
			
		||||
    createAPIResponse(data, 200, headers),
 | 
			
		||||
  
 | 
			
		||||
  // Redirect response
 | 
			
		||||
  redirect: (location: string, temporary: boolean = true): Response => 
 | 
			
		||||
    new Response(null, {
 | 
			
		||||
      status: temporary ? 302 : 301,
 | 
			
		||||
      headers: { 'Location': location }
 | 
			
		||||
    })
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export async function handleAPIRequest<T>(
 | 
			
		||||
  operation: () => Promise<T>,
 | 
			
		||||
  errorMessage: string = 'Request processing failed'
 | 
			
		||||
): Promise<T | Response> {
 | 
			
		||||
  try {
 | 
			
		||||
    return await operation();
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error(`API Error: ${errorMessage}:`, error);
 | 
			
		||||
    return apiServerError.internal(errorMessage);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const createAuthErrorResponse = apiError.unauthorized;
 | 
			
		||||
export const createBadRequestResponse = apiError.badRequest;
 | 
			
		||||
export const createSuccessResponse = apiResponse.success;
 | 
			
		||||
 | 
			
		||||
@ -1,10 +1,50 @@
 | 
			
		||||
import { SignJWT, jwtVerify, type JWTPayload } from 'jose';
 | 
			
		||||
import { serialize, parse } from 'cookie';
 | 
			
		||||
// src/utils/auth.js (FIXED - Cleaned up and enhanced debugging)
 | 
			
		||||
import type { AstroGlobal } from 'astro';
 | 
			
		||||
import crypto from 'crypto';
 | 
			
		||||
import { config } from 'dotenv';
 | 
			
		||||
import { SignJWT, jwtVerify, type JWTPayload } from 'jose';
 | 
			
		||||
import { serialize, parse as parseCookie } from 'cookie';
 | 
			
		||||
 | 
			
		||||
// Load environment variables
 | 
			
		||||
config();
 | 
			
		||||
 | 
			
		||||
// JWT session constants
 | 
			
		||||
const SECRET_KEY = new TextEncoder().encode(
 | 
			
		||||
  process.env.AUTH_SECRET || 
 | 
			
		||||
  'cc24-hub-default-secret-key-change-in-production'
 | 
			
		||||
);
 | 
			
		||||
const SESSION_DURATION = 6 * 60 * 60; // 6 hours in seconds
 | 
			
		||||
 | 
			
		||||
// Types
 | 
			
		||||
export interface SessionData {
 | 
			
		||||
  userId: string;
 | 
			
		||||
  email: string;
 | 
			
		||||
  authenticated: boolean;
 | 
			
		||||
  exp: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface AuthContext {
 | 
			
		||||
  authenticated: boolean;
 | 
			
		||||
  session: SessionData | null;
 | 
			
		||||
  userEmail: string;
 | 
			
		||||
  userId: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type AuthContextType = 'contributions' | 'ai' | 'general';
 | 
			
		||||
 | 
			
		||||
export interface UserInfo {
 | 
			
		||||
  sub?: string;
 | 
			
		||||
  preferred_username?: string;
 | 
			
		||||
  email?: string;
 | 
			
		||||
  given_name?: string;
 | 
			
		||||
  family_name?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface AuthStateData {
 | 
			
		||||
  state: string;
 | 
			
		||||
  returnTo: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Environment variables - use runtime access for server-side
 | 
			
		||||
function getEnv(key: string): string {
 | 
			
		||||
  const value = process.env[key];
 | 
			
		||||
@ -14,90 +54,98 @@ function getEnv(key: string): string {
 | 
			
		||||
  return value;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const SECRET_KEY = new TextEncoder().encode(getEnv('AUTH_SECRET'));
 | 
			
		||||
const SESSION_DURATION = 6 * 60 * 60; // 6 hours in seconds
 | 
			
		||||
 | 
			
		||||
export interface SessionData {
 | 
			
		||||
  userId: string;
 | 
			
		||||
  authenticated: boolean;
 | 
			
		||||
  exp: number;
 | 
			
		||||
// Session management functions
 | 
			
		||||
export function getSessionFromRequest(request: Request): string | null {
 | 
			
		||||
  const cookieHeader = request.headers.get('cookie');
 | 
			
		||||
  console.log('[DEBUG] Cookie header:', cookieHeader ? 'present' : 'missing');
 | 
			
		||||
  
 | 
			
		||||
  if (!cookieHeader) return null;
 | 
			
		||||
  
 | 
			
		||||
  const cookies = parseCookie(cookieHeader);
 | 
			
		||||
  console.log('[DEBUG] Parsed cookies:', Object.keys(cookies));
 | 
			
		||||
  console.log('[DEBUG] Session cookie found:', !!cookies.session);
 | 
			
		||||
  
 | 
			
		||||
  return cookies.session || null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Create a signed JWT session token
 | 
			
		||||
export async function createSession(userId: string): Promise<string> {
 | 
			
		||||
export async function verifySession(sessionToken: string): Promise<SessionData | null> {
 | 
			
		||||
  try {
 | 
			
		||||
    console.log('[DEBUG] Verifying session token, length:', sessionToken.length);
 | 
			
		||||
    const { payload } = await jwtVerify(sessionToken, SECRET_KEY);
 | 
			
		||||
    console.log('[DEBUG] JWT verification successful, payload keys:', Object.keys(payload));
 | 
			
		||||
    
 | 
			
		||||
    // Validate payload structure and cast properly
 | 
			
		||||
    if (
 | 
			
		||||
      typeof payload.userId === 'string' &&
 | 
			
		||||
      typeof payload.email === 'string' &&
 | 
			
		||||
      typeof payload.authenticated === 'boolean' &&
 | 
			
		||||
      typeof payload.exp === 'number'
 | 
			
		||||
    ) {
 | 
			
		||||
      console.log('[DEBUG] Session validation successful for user:', payload.userId);
 | 
			
		||||
      return {
 | 
			
		||||
        userId: payload.userId,
 | 
			
		||||
        email: payload.email,
 | 
			
		||||
        authenticated: payload.authenticated,
 | 
			
		||||
        exp: payload.exp
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    console.log('[DEBUG] Session payload validation failed, payload:', payload);
 | 
			
		||||
    return null;
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.log('[DEBUG] Session verification failed:', error.message);
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function createSession(userId: string, email: string): Promise<string> {
 | 
			
		||||
  const exp = Math.floor(Date.now() / 1000) + SESSION_DURATION;
 | 
			
		||||
  console.log('[DEBUG] Creating session for user:', userId, 'exp:', exp);
 | 
			
		||||
  
 | 
			
		||||
  return await new SignJWT({ 
 | 
			
		||||
  const token = await new SignJWT({ 
 | 
			
		||||
    userId, 
 | 
			
		||||
    email,
 | 
			
		||||
    authenticated: true, 
 | 
			
		||||
    exp 
 | 
			
		||||
  })
 | 
			
		||||
    .setProtectedHeader({ alg: 'HS256' })
 | 
			
		||||
    .setExpirationTime(exp)
 | 
			
		||||
    .sign(SECRET_KEY);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Verify and decode a session token
 | 
			
		||||
export async function verifySession(token: string): Promise<SessionData | null> {
 | 
			
		||||
  try {
 | 
			
		||||
    const { payload } = await jwtVerify(token, SECRET_KEY);
 | 
			
		||||
    
 | 
			
		||||
    // Validate payload structure and cast properly
 | 
			
		||||
    if (
 | 
			
		||||
      typeof payload.userId === 'string' &&
 | 
			
		||||
      typeof payload.authenticated === 'boolean' &&
 | 
			
		||||
      typeof payload.exp === 'number'
 | 
			
		||||
    ) {
 | 
			
		||||
      return {
 | 
			
		||||
        userId: payload.userId,
 | 
			
		||||
        authenticated: payload.authenticated,
 | 
			
		||||
        exp: payload.exp
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    return null;
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.log('Session verification failed:', error);
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
  console.log('[DEBUG] Session token created, length:', token.length);
 | 
			
		||||
  return token;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Get session from request cookies
 | 
			
		||||
export function getSessionFromRequest(request: Request): string | null {
 | 
			
		||||
  const cookieHeader = request.headers.get('cookie');
 | 
			
		||||
  if (!cookieHeader) return null;
 | 
			
		||||
  
 | 
			
		||||
  const cookies = parse(cookieHeader);
 | 
			
		||||
  return cookies.session || null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Create session cookie
 | 
			
		||||
export function createSessionCookie(token: string): string {
 | 
			
		||||
export function createSessionCookie(sessionToken: string): string {
 | 
			
		||||
  const publicBaseUrl = getEnv('PUBLIC_BASE_URL');
 | 
			
		||||
  const isProduction = process.env.NODE_ENV === 'production';
 | 
			
		||||
  const isSecure = publicBaseUrl.startsWith('https://') || isProduction;
 | 
			
		||||
  
 | 
			
		||||
  return serialize('session', token, {
 | 
			
		||||
    httpOnly: true,
 | 
			
		||||
    secure: isSecure,
 | 
			
		||||
    sameSite: 'strict', // More secure than 'lax'
 | 
			
		||||
    maxAge: SESSION_DURATION,
 | 
			
		||||
    path: '/'
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Clear session cookie
 | 
			
		||||
export function clearSessionCookie(): string {
 | 
			
		||||
  const publicBaseUrl = getEnv('PUBLIC_BASE_URL');
 | 
			
		||||
  const isSecure = publicBaseUrl.startsWith('https://');
 | 
			
		||||
  
 | 
			
		||||
  return serialize('session', '', {
 | 
			
		||||
  const cookie = serialize('session', sessionToken, {
 | 
			
		||||
    httpOnly: true,
 | 
			
		||||
    secure: isSecure,
 | 
			
		||||
    sameSite: 'lax',
 | 
			
		||||
    maxAge: 0,
 | 
			
		||||
    maxAge: SESSION_DURATION,
 | 
			
		||||
    path: '/'
 | 
			
		||||
  });
 | 
			
		||||
  
 | 
			
		||||
  console.log('[DEBUG] Created session cookie:', cookie.substring(0, 100) + '...');
 | 
			
		||||
  return cookie;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Authentication utility functions
 | 
			
		||||
export function getUserEmail(userInfo: UserInfo): string {
 | 
			
		||||
  return userInfo.email || userInfo.preferred_username || 'unknown@example.com';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function logAuthEvent(event: string, details?: any): void {
 | 
			
		||||
  const timestamp = new Date().toISOString();
 | 
			
		||||
  console.log(`[AUTH ${timestamp}] ${event}`, details ? JSON.stringify(details) : '');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Generate random state for CSRF protection
 | 
			
		||||
export function generateState(): string {
 | 
			
		||||
  return crypto.randomUUID();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Generate OIDC authorization URL
 | 
			
		||||
@ -118,7 +166,7 @@ export function generateAuthUrl(state: string): string {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Exchange authorization code for tokens
 | 
			
		||||
export async function exchangeCodeForTokens(code: string): Promise<any> {
 | 
			
		||||
export async function exchangeCodeForTokens(code: string): Promise<{ access_token: string }> {
 | 
			
		||||
  const oidcEndpoint = getEnv('OIDC_ENDPOINT');
 | 
			
		||||
  const clientId = getEnv('OIDC_CLIENT_ID');
 | 
			
		||||
  const clientSecret = getEnv('OIDC_CLIENT_SECRET');
 | 
			
		||||
@ -147,7 +195,7 @@ export async function exchangeCodeForTokens(code: string): Promise<any> {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Get user info from OIDC provider
 | 
			
		||||
export async function getUserInfo(accessToken: string): Promise<any> {
 | 
			
		||||
export async function getUserInfo(accessToken: string): Promise<UserInfo> {
 | 
			
		||||
  const oidcEndpoint = getEnv('OIDC_ENDPOINT');
 | 
			
		||||
  
 | 
			
		||||
  const response = await fetch(`${oidcEndpoint}/apps/oidc/userinfo`, {
 | 
			
		||||
@ -165,13 +213,192 @@ export async function getUserInfo(accessToken: string): Promise<any> {
 | 
			
		||||
  return await response.json();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Generate random state for CSRF protection
 | 
			
		||||
export function generateState(): string {
 | 
			
		||||
  return crypto.randomUUID();
 | 
			
		||||
// Parse and validate auth state from cookies
 | 
			
		||||
export function parseAuthState(request: Request): { 
 | 
			
		||||
  isValid: boolean; 
 | 
			
		||||
  stateData: AuthStateData | null; 
 | 
			
		||||
  error?: string 
 | 
			
		||||
} {
 | 
			
		||||
  try {
 | 
			
		||||
    const cookieHeader = request.headers.get('cookie');
 | 
			
		||||
    const cookies = cookieHeader ? parseCookie(cookieHeader) : {};
 | 
			
		||||
    
 | 
			
		||||
    if (!cookies.auth_state) {
 | 
			
		||||
      return { isValid: false, stateData: null, error: 'No auth state cookie' };
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    const stateData = JSON.parse(decodeURIComponent(cookies.auth_state));
 | 
			
		||||
    
 | 
			
		||||
    if (!stateData.state || !stateData.returnTo) {
 | 
			
		||||
      return { isValid: false, stateData: null, error: 'Invalid state data structure' };
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    return { isValid: true, stateData };
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    return { isValid: false, stateData: null, error: 'Failed to parse auth state' };
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Log authentication events for debugging
 | 
			
		||||
export function logAuthEvent(event: string, details?: any) {
 | 
			
		||||
  const timestamp = new Date().toISOString();
 | 
			
		||||
  console.log(`[AUTH ${timestamp}] ${event}`, details ? JSON.stringify(details) : '');
 | 
			
		||||
// Verify state parameter against stored state
 | 
			
		||||
export function verifyAuthState(request: Request, receivedState: string): {
 | 
			
		||||
  isValid: boolean;
 | 
			
		||||
  stateData: AuthStateData | null;
 | 
			
		||||
  error?: string;
 | 
			
		||||
} {
 | 
			
		||||
  const { isValid, stateData, error } = parseAuthState(request);
 | 
			
		||||
  
 | 
			
		||||
  if (!isValid || !stateData) {
 | 
			
		||||
    logAuthEvent('State parsing failed', { error });
 | 
			
		||||
    return { isValid: false, stateData: null, error };
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  if (stateData.state !== receivedState) {
 | 
			
		||||
    logAuthEvent('State mismatch', { 
 | 
			
		||||
      received: receivedState, 
 | 
			
		||||
      stored: stateData.state 
 | 
			
		||||
    });
 | 
			
		||||
    return { 
 | 
			
		||||
      isValid: false, 
 | 
			
		||||
      stateData: null, 
 | 
			
		||||
      error: 'State parameter mismatch' 
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  return { isValid: true, stateData };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getAuthRequirement(context: AuthContextType): boolean {
 | 
			
		||||
  switch (context) {
 | 
			
		||||
    case 'contributions':
 | 
			
		||||
      return process.env.AUTHENTICATION_NECESSARY_CONTRIBUTIONS !== 'false';
 | 
			
		||||
    case 'ai':
 | 
			
		||||
      return process.env.AUTHENTICATION_NECESSARY_AI !== 'false';
 | 
			
		||||
    case 'general':
 | 
			
		||||
      return process.env.AUTHENTICATION_NECESSARY !== 'false';
 | 
			
		||||
    default:
 | 
			
		||||
      return true;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function createSessionWithCookie(userInfo: UserInfo): Promise<{
 | 
			
		||||
  sessionToken: string;
 | 
			
		||||
  sessionCookie: string;
 | 
			
		||||
  clearStateCookie: string;
 | 
			
		||||
  userId: string;
 | 
			
		||||
  userEmail: string;
 | 
			
		||||
}> {
 | 
			
		||||
  const userId = userInfo.sub || userInfo.preferred_username || 'unknown';
 | 
			
		||||
  const userEmail = getUserEmail(userInfo);
 | 
			
		||||
  
 | 
			
		||||
  const sessionToken = await createSession(userId, userEmail);
 | 
			
		||||
  const sessionCookie = createSessionCookie(sessionToken);
 | 
			
		||||
  const clearStateCookie = 'auth_state=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0';
 | 
			
		||||
  
 | 
			
		||||
  return {
 | 
			
		||||
    sessionToken,
 | 
			
		||||
    sessionCookie,
 | 
			
		||||
    clearStateCookie,
 | 
			
		||||
    userId,
 | 
			
		||||
    userEmail
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function withAuth(Astro: AstroGlobal, context: AuthContextType = 'general'): Promise<AuthContext | Response> {
 | 
			
		||||
  const authRequired = getAuthRequirement(context);
 | 
			
		||||
  console.log(`[DEBUG PAGE] Auth required for ${context}:`, authRequired);
 | 
			
		||||
  console.log('[DEBUG PAGE] Request URL:', Astro.url.toString());
 | 
			
		||||
  
 | 
			
		||||
  if (!authRequired) {
 | 
			
		||||
    return {
 | 
			
		||||
      authenticated: true,
 | 
			
		||||
      session: null,
 | 
			
		||||
      userEmail: 'anon@anon.anon',
 | 
			
		||||
      userId: 'anonymous'
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const sessionToken = getSessionFromRequest(Astro.request);
 | 
			
		||||
  console.log('[DEBUG PAGE] Session token found:', !!sessionToken);
 | 
			
		||||
  
 | 
			
		||||
  if (!sessionToken) {
 | 
			
		||||
    console.log('[DEBUG PAGE] No session token, redirecting to login');
 | 
			
		||||
    const loginUrl = `/api/auth/login?returnTo=${encodeURIComponent(Astro.url.toString())}`;
 | 
			
		||||
    return new Response(null, {
 | 
			
		||||
      status: 302,
 | 
			
		||||
      headers: { 'Location': loginUrl }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const session = await verifySession(sessionToken);
 | 
			
		||||
  console.log('[DEBUG PAGE] Session verification result:', !!session);
 | 
			
		||||
  
 | 
			
		||||
  if (!session) {
 | 
			
		||||
    console.log('[DEBUG PAGE] Session verification failed, redirecting to login');
 | 
			
		||||
    const loginUrl = `/api/auth/login?returnTo=${encodeURIComponent(Astro.url.toString())}`;
 | 
			
		||||
    return new Response(null, {
 | 
			
		||||
      status: 302,
 | 
			
		||||
      headers: { 'Location': loginUrl }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  console.log(`[DEBUG PAGE] Page authentication successful for ${context}:`, session.userId);
 | 
			
		||||
  return {
 | 
			
		||||
    authenticated: true,
 | 
			
		||||
    session,
 | 
			
		||||
    userEmail: session.email,
 | 
			
		||||
    userId: session.userId
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function withAPIAuth(request: Request, context: AuthContextType = 'general'): Promise<{ 
 | 
			
		||||
  authenticated: boolean; 
 | 
			
		||||
  userId: string; 
 | 
			
		||||
  session?: SessionData;
 | 
			
		||||
  authRequired: boolean; 
 | 
			
		||||
}> {
 | 
			
		||||
  const authRequired = getAuthRequirement(context);
 | 
			
		||||
  
 | 
			
		||||
  if (!authRequired) {
 | 
			
		||||
    return {
 | 
			
		||||
      authenticated: true,
 | 
			
		||||
      userId: 'anonymous',
 | 
			
		||||
      authRequired: false 
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const sessionToken = getSessionFromRequest(request);
 | 
			
		||||
  console.log(`[DEBUG API] Session token found for ${context}:`, !!sessionToken);
 | 
			
		||||
  
 | 
			
		||||
  if (!sessionToken) {
 | 
			
		||||
    console.log(`[DEBUG API] No session token found for ${context}`);
 | 
			
		||||
    return { 
 | 
			
		||||
      authenticated: false, 
 | 
			
		||||
      userId: '', 
 | 
			
		||||
      authRequired: true 
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const session = await verifySession(sessionToken);
 | 
			
		||||
  console.log(`[DEBUG API] Session verification result for ${context}:`, !!session);
 | 
			
		||||
  
 | 
			
		||||
  if (!session) {
 | 
			
		||||
    console.log(`[DEBUG API] Session verification failed for ${context}`);
 | 
			
		||||
    return { 
 | 
			
		||||
      authenticated: false, 
 | 
			
		||||
      userId: '', 
 | 
			
		||||
      authRequired: true  
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  console.log(`[DEBUG API] Authentication successful for ${context}:`, session.userId);
 | 
			
		||||
  return {
 | 
			
		||||
    authenticated: true,
 | 
			
		||||
    userId: session.userId,
 | 
			
		||||
    session,
 | 
			
		||||
    authRequired: true  
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function getAuthRequirementForContext(context: AuthContextType): boolean {
 | 
			
		||||
  return getAuthRequirement(context);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										395
									
								
								src/utils/gitContributions.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										395
									
								
								src/utils/gitContributions.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,395 @@
 | 
			
		||||
// src/utils/gitContributions.ts
 | 
			
		||||
import { dump } from 'js-yaml';
 | 
			
		||||
 | 
			
		||||
export interface ContributionData {
 | 
			
		||||
  type: 'add' | 'edit';
 | 
			
		||||
  tool: {
 | 
			
		||||
    name: string;
 | 
			
		||||
    icon?: string;
 | 
			
		||||
    type: 'software' | 'method' | 'concept';
 | 
			
		||||
    description: string;
 | 
			
		||||
    domains: string[];
 | 
			
		||||
    phases: string[];
 | 
			
		||||
    platforms: string[];
 | 
			
		||||
    skillLevel: string;
 | 
			
		||||
    accessType?: string;
 | 
			
		||||
    url: string;
 | 
			
		||||
    projectUrl?: string;
 | 
			
		||||
    license?: string;
 | 
			
		||||
    knowledgebase?: boolean;
 | 
			
		||||
    'domain-agnostic-software'?: string[];
 | 
			
		||||
    related_concepts?: string[];
 | 
			
		||||
    tags: string[];
 | 
			
		||||
    statusUrl?: string;
 | 
			
		||||
  };
 | 
			
		||||
  metadata: {
 | 
			
		||||
    submitter: string;
 | 
			
		||||
    reason?: string;
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface GitOperationResult {
 | 
			
		||||
  success: boolean;
 | 
			
		||||
  message: string;
 | 
			
		||||
  issueUrl?: string;
 | 
			
		||||
  issueNumber?: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface KnowledgebaseContribution {
 | 
			
		||||
  toolName?: string;
 | 
			
		||||
  title?: string;
 | 
			
		||||
  description?: string;
 | 
			
		||||
  content?: string;
 | 
			
		||||
  externalLink?: string;
 | 
			
		||||
  difficulty?: string;
 | 
			
		||||
  categories?: string[];
 | 
			
		||||
  tags?: string[];
 | 
			
		||||
  uploadedFiles?: { name: string; url: string }[];
 | 
			
		||||
  submitter: string;
 | 
			
		||||
  reason?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface GitConfig {
 | 
			
		||||
  provider: 'gitea' | 'github' | 'gitlab';
 | 
			
		||||
  apiEndpoint: string;
 | 
			
		||||
  apiToken: string;
 | 
			
		||||
  repoOwner: string;
 | 
			
		||||
  repoName: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class GitContributionManager {
 | 
			
		||||
  private config: GitConfig;
 | 
			
		||||
 | 
			
		||||
  constructor() {
 | 
			
		||||
    const repoUrl = process.env.GIT_REPO_URL || '';
 | 
			
		||||
    const { owner, name } = this.parseRepoUrl(repoUrl);
 | 
			
		||||
    
 | 
			
		||||
    this.config = {
 | 
			
		||||
      provider: (process.env.GIT_PROVIDER as any) || 'gitea',
 | 
			
		||||
      apiEndpoint: process.env.GIT_API_ENDPOINT || '',
 | 
			
		||||
      apiToken: process.env.GIT_API_TOKEN || '',
 | 
			
		||||
      repoOwner: owner,
 | 
			
		||||
      repoName: name
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    if (!this.config.apiEndpoint || !this.config.apiToken) {
 | 
			
		||||
      throw new Error('Missing required git configuration');
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private parseRepoUrl(url: string): { owner: string; name: string } {
 | 
			
		||||
    const match = url.match(/\/([^\/]+)\/([^\/]+?)(?:\.git)?$/);
 | 
			
		||||
    if (!match) {
 | 
			
		||||
      throw new Error('Invalid repository URL format');
 | 
			
		||||
    }
 | 
			
		||||
    return { owner: match[1], name: match[2] };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async submitContribution(data: ContributionData): Promise<GitOperationResult> {
 | 
			
		||||
    try {
 | 
			
		||||
      const toolYaml = this.generateYAML(data.tool);
 | 
			
		||||
      const issueUrl = await this.createIssue(data, toolYaml);
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        success: true,
 | 
			
		||||
        message: 'Tool contribution submitted as issue',
 | 
			
		||||
        issueUrl
 | 
			
		||||
      };
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      throw new Error(`Issue creation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async submitKnowledgebaseContribution(data: KnowledgebaseContribution): Promise<GitOperationResult> {
 | 
			
		||||
    try {
 | 
			
		||||
      const issueUrl = await this.createKnowledgebaseIssue(data);
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        success: true,
 | 
			
		||||
        message: 'Knowledge base article submitted as issue',
 | 
			
		||||
        issueUrl
 | 
			
		||||
      };
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      throw new Error(`KB issue creation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private generateYAML(tool: any): string {
 | 
			
		||||
    // Clean tool object
 | 
			
		||||
    const cleanTool: any = {
 | 
			
		||||
      name: tool.name,
 | 
			
		||||
      type: tool.type,
 | 
			
		||||
      description: tool.description,
 | 
			
		||||
      domains: tool.domains || [],
 | 
			
		||||
      phases: tool.phases || [],
 | 
			
		||||
      skillLevel: tool.skillLevel,
 | 
			
		||||
      url: tool.url
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // Add optional fields
 | 
			
		||||
    if (tool.icon) cleanTool.icon = tool.icon;
 | 
			
		||||
    if (tool.platforms?.length) cleanTool.platforms = tool.platforms;
 | 
			
		||||
    if (tool.license) cleanTool.license = tool.license;
 | 
			
		||||
    if (tool.accessType) cleanTool.accessType = tool.accessType;
 | 
			
		||||
    if (tool.projectUrl) cleanTool.projectUrl = tool.projectUrl;
 | 
			
		||||
    if (tool.knowledgebase) cleanTool.knowledgebase = tool.knowledgebase;
 | 
			
		||||
    if (tool.related_concepts?.length) cleanTool.related_concepts = tool.related_concepts;
 | 
			
		||||
    if (tool.tags?.length) cleanTool.tags = tool.tags;
 | 
			
		||||
    if (tool['domain-agnostic-software']?.length) {
 | 
			
		||||
      cleanTool['domain-agnostic-software'] = tool['domain-agnostic-software'];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return dump(cleanTool, {
 | 
			
		||||
      lineWidth: -1,
 | 
			
		||||
      noRefs: true,
 | 
			
		||||
      quotingType: '"',
 | 
			
		||||
      forceQuotes: false,
 | 
			
		||||
      indent: 2
 | 
			
		||||
    }).trim();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async createIssue(data: ContributionData, toolYaml: string): Promise<string> {
 | 
			
		||||
    const title = `${data.type === 'add' ? 'Add' : 'Update'} tool: ${data.tool.name}`;
 | 
			
		||||
    const body = this.generateIssueBody(data, toolYaml);
 | 
			
		||||
 | 
			
		||||
    let apiUrl: string;
 | 
			
		||||
    let requestBody: any;
 | 
			
		||||
 | 
			
		||||
    switch (this.config.provider) {
 | 
			
		||||
      case 'gitea':
 | 
			
		||||
        apiUrl = `${this.config.apiEndpoint}/repos/${this.config.repoOwner}/${this.config.repoName}/issues`;
 | 
			
		||||
        requestBody = { title, body };
 | 
			
		||||
        break;
 | 
			
		||||
      
 | 
			
		||||
      case 'github':
 | 
			
		||||
        apiUrl = `${this.config.apiEndpoint}/repos/${this.config.repoOwner}/${this.config.repoName}/issues`;
 | 
			
		||||
        requestBody = { title, body };
 | 
			
		||||
        break;
 | 
			
		||||
        
 | 
			
		||||
      case 'gitlab':
 | 
			
		||||
        apiUrl = `${this.config.apiEndpoint}/projects/${encodeURIComponent(this.config.repoOwner + '/' + this.config.repoName)}/issues`;
 | 
			
		||||
        requestBody = { title, description: body };
 | 
			
		||||
        break;
 | 
			
		||||
        
 | 
			
		||||
      default:
 | 
			
		||||
        throw new Error(`Unsupported git provider: ${this.config.provider}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const response = await fetch(apiUrl, {
 | 
			
		||||
      method: 'POST',
 | 
			
		||||
      headers: {
 | 
			
		||||
        'Authorization': `Bearer ${this.config.apiToken}`,
 | 
			
		||||
        'Content-Type': 'application/json'
 | 
			
		||||
      },
 | 
			
		||||
      body: JSON.stringify(requestBody)
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (!response.ok) {
 | 
			
		||||
      const errorText = await response.text();
 | 
			
		||||
      throw new Error(`HTTP ${response.status}: ${errorText}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const issueData = await response.json();
 | 
			
		||||
    
 | 
			
		||||
    // Extract issue URL
 | 
			
		||||
    switch (this.config.provider) {
 | 
			
		||||
      case 'gitea':
 | 
			
		||||
      case 'github':
 | 
			
		||||
        return issueData.html_url || issueData.url;
 | 
			
		||||
      case 'gitlab':
 | 
			
		||||
        return issueData.web_url;
 | 
			
		||||
      default:
 | 
			
		||||
        throw new Error('Unknown provider response format');
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async createKnowledgebaseIssue(data: KnowledgebaseContribution): Promise<string> {
 | 
			
		||||
    const title = `Knowledge Base: ${data.title || data.toolName || 'New Article'}`;
 | 
			
		||||
    const body = this.generateKnowledgebaseIssueBody(data);
 | 
			
		||||
 | 
			
		||||
    let apiUrl: string;
 | 
			
		||||
    let requestBody: any;
 | 
			
		||||
 | 
			
		||||
    switch (this.config.provider) {
 | 
			
		||||
      case 'gitea':
 | 
			
		||||
        apiUrl = `${this.config.apiEndpoint}/repos/${this.config.repoOwner}/${this.config.repoName}/issues`;
 | 
			
		||||
        requestBody = { title, body };
 | 
			
		||||
        break;
 | 
			
		||||
      
 | 
			
		||||
      case 'github':
 | 
			
		||||
        apiUrl = `${this.config.apiEndpoint}/repos/${this.config.repoOwner}/${this.config.repoName}/issues`;
 | 
			
		||||
        requestBody = { title, body };
 | 
			
		||||
        break;
 | 
			
		||||
        
 | 
			
		||||
      case 'gitlab':
 | 
			
		||||
        apiUrl = `${this.config.apiEndpoint}/projects/${encodeURIComponent(this.config.repoOwner + '/' + this.config.repoName)}/issues`;
 | 
			
		||||
        requestBody = { title, description: body };
 | 
			
		||||
        break;
 | 
			
		||||
        
 | 
			
		||||
      default:
 | 
			
		||||
        throw new Error(`Unsupported git provider: ${this.config.provider}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const response = await fetch(apiUrl, {
 | 
			
		||||
      method: 'POST',
 | 
			
		||||
      headers: {
 | 
			
		||||
        'Authorization': `Bearer ${this.config.apiToken}`,
 | 
			
		||||
        'Content-Type': 'application/json'
 | 
			
		||||
      },
 | 
			
		||||
      body: JSON.stringify(requestBody)
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (!response.ok) {
 | 
			
		||||
      const errorText = await response.text();
 | 
			
		||||
      throw new Error(`HTTP ${response.status}: ${errorText}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const issueData = await response.json();
 | 
			
		||||
    
 | 
			
		||||
    // Extract issue URL
 | 
			
		||||
    switch (this.config.provider) {
 | 
			
		||||
      case 'gitea':
 | 
			
		||||
      case 'github':
 | 
			
		||||
        return issueData.html_url || issueData.url;
 | 
			
		||||
      case 'gitlab':
 | 
			
		||||
        return issueData.web_url;
 | 
			
		||||
      default:
 | 
			
		||||
        throw new Error('Unknown provider response format');
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private generateIssueBody(data: ContributionData, toolYaml: string): string {
 | 
			
		||||
    return `## ${data.type === 'add' ? 'Add' : 'Update'} Tool: ${data.tool.name}
 | 
			
		||||
 | 
			
		||||
**Submitted by:** ${data.metadata.submitter}  
 | 
			
		||||
**Type:** ${data.tool.type}  
 | 
			
		||||
**Action:** ${data.type}
 | 
			
		||||
 | 
			
		||||
### Tool Information
 | 
			
		||||
- **Name:** ${data.tool.name}
 | 
			
		||||
- **Description:** ${data.tool.description}
 | 
			
		||||
- **URL:** ${data.tool.url}
 | 
			
		||||
- **Skill Level:** ${data.tool.skillLevel}
 | 
			
		||||
${data.tool.platforms?.length ? `- **Platforms:** ${data.tool.platforms.join(', ')}` : ''}
 | 
			
		||||
${data.tool.license ? `- **License:** ${data.tool.license}` : ''}
 | 
			
		||||
${data.tool.domains?.length ? `- **Domains:** ${data.tool.domains.join(', ')}` : ''}
 | 
			
		||||
${data.tool.phases?.length ? `- **Phases:** ${data.tool.phases.join(', ')}` : ''}
 | 
			
		||||
 | 
			
		||||
${data.metadata.reason ? `### Reason
 | 
			
		||||
${data.metadata.reason}
 | 
			
		||||
 | 
			
		||||
` : ''}### Copy-Paste YAML
 | 
			
		||||
 | 
			
		||||
\`\`\`yaml
 | 
			
		||||
  - ${toolYaml.split('\n').join('\n    ')}
 | 
			
		||||
\`\`\`
 | 
			
		||||
 | 
			
		||||
### For Maintainers
 | 
			
		||||
1. Copy the YAML above
 | 
			
		||||
2. Add to \`src/data/tools.yaml\` in the tools array
 | 
			
		||||
3. Maintain alphabetical order
 | 
			
		||||
4. Close this issue when done
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
*Submitted via ForensicPathways contribution form*`;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private generateKnowledgebaseIssueBody(data: KnowledgebaseContribution): string {
 | 
			
		||||
    const sections: string[] = [];
 | 
			
		||||
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    /* Header                                                             */
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    sections.push(`## Knowledge Base Article: ${data.title ?? 'Untitled'}`);
 | 
			
		||||
    sections.push('');
 | 
			
		||||
    sections.push(`**Submitted by:** ${data.submitter}`);
 | 
			
		||||
    if (data.toolName)   sections.push(`**Related Tool:** ${data.toolName}`);
 | 
			
		||||
    if (data.difficulty) sections.push(`**Difficulty:** ${data.difficulty}`);
 | 
			
		||||
    sections.push('');
 | 
			
		||||
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    /* Description                                                        */
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    if (data.description) {
 | 
			
		||||
      sections.push('### Description');
 | 
			
		||||
      sections.push(data.description);
 | 
			
		||||
      sections.push('');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    /* Content                                                            */
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    if (data.content) {
 | 
			
		||||
      sections.push('### Article Content');
 | 
			
		||||
      sections.push('```markdown');
 | 
			
		||||
      sections.push(data.content);
 | 
			
		||||
      sections.push('```');
 | 
			
		||||
      sections.push('');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    /* External resources                                                 */
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    if (data.externalLink) {
 | 
			
		||||
      sections.push('### External Resource');
 | 
			
		||||
      sections.push(`- [External Documentation](${data.externalLink})`);
 | 
			
		||||
      sections.push('');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    /* Uploaded files                                                     */
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    if (Array.isArray(data.uploadedFiles) && data.uploadedFiles.length) {
 | 
			
		||||
      sections.push('### Uploaded Files');
 | 
			
		||||
      data.uploadedFiles.forEach((file) => {
 | 
			
		||||
        const fileType = file.name.toLowerCase();
 | 
			
		||||
        let icon = '📎';
 | 
			
		||||
 | 
			
		||||
        if (fileType.endsWith('.pdf'))                       icon = '📄';
 | 
			
		||||
        else if (/(png|jpe?g|gif|webp)$/.test(fileType))     icon = '🖼️';
 | 
			
		||||
        else if (/(mp4|webm|mov|avi)$/.test(fileType))       icon = '🎥';
 | 
			
		||||
        else if (/(docx?)$/.test(fileType))                  icon = '📝';
 | 
			
		||||
        else if (/(zip|tar|gz)$/.test(fileType))             icon = '📦';
 | 
			
		||||
 | 
			
		||||
        sections.push(`- ${icon} [${file.name}](${file.url})`);
 | 
			
		||||
      });
 | 
			
		||||
      sections.push('');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    /* Categories & Tags                                                  */
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    const hasCategories = Array.isArray(data.categories) && data.categories.length > 0;
 | 
			
		||||
    const hasTags       = Array.isArray(data.tags)       && data.tags.length > 0;
 | 
			
		||||
 | 
			
		||||
    if (hasCategories || hasTags) {
 | 
			
		||||
      sections.push('### Metadata');
 | 
			
		||||
      if (hasCategories) sections.push(`**Categories:** ${data.categories!.join(', ')}`);
 | 
			
		||||
      if (hasTags)       sections.push(`**Tags:** ${data.tags!.join(', ')}`);
 | 
			
		||||
      sections.push('');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    /* Reason                                                             */
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    if (data.reason) {
 | 
			
		||||
      sections.push('### Reason for Contribution');
 | 
			
		||||
      sections.push(data.reason);
 | 
			
		||||
      sections.push('');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    /* Footer                                                             */
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    sections.push('### For Maintainers');
 | 
			
		||||
    sections.push('1. Review the content for quality and accuracy');
 | 
			
		||||
    sections.push('2. Create the appropriate markdown file in `src/content/knowledgebase/`');
 | 
			
		||||
    sections.push('3. Process any uploaded files as needed');
 | 
			
		||||
    sections.push('4. Close this issue when the article is published');
 | 
			
		||||
    sections.push('');
 | 
			
		||||
    sections.push('---');
 | 
			
		||||
    sections.push('*Submitted via ForensicPathways knowledge base contribution form*');
 | 
			
		||||
 | 
			
		||||
    return sections.join('\n');
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										434
									
								
								src/utils/nextcloud.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										434
									
								
								src/utils/nextcloud.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,434 @@
 | 
			
		||||
// src/utils/nextcloud.ts
 | 
			
		||||
import { promises as fs } from 'fs';
 | 
			
		||||
import path from 'path';
 | 
			
		||||
import crypto from 'crypto';
 | 
			
		||||
 | 
			
		||||
interface NextcloudConfig {
 | 
			
		||||
  endpoint: string;
 | 
			
		||||
  username: string;
 | 
			
		||||
  password: string;
 | 
			
		||||
  uploadPath: string;
 | 
			
		||||
  publicBaseUrl: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface UploadResult {
 | 
			
		||||
  success: boolean;
 | 
			
		||||
  url?: string;
 | 
			
		||||
  filename?: string;
 | 
			
		||||
  error?: string;
 | 
			
		||||
  size?: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface FileValidation {
 | 
			
		||||
  valid: boolean;
 | 
			
		||||
  error?: string;
 | 
			
		||||
  sanitizedName?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class NextcloudUploader {
 | 
			
		||||
  private config: NextcloudConfig;
 | 
			
		||||
  private allowedTypes: Set<string>;
 | 
			
		||||
  private maxFileSize: number; // in bytes
 | 
			
		||||
  
 | 
			
		||||
  constructor() {
 | 
			
		||||
    this.config = {
 | 
			
		||||
      endpoint: process.env.NEXTCLOUD_ENDPOINT || '',
 | 
			
		||||
      username: process.env.NEXTCLOUD_USERNAME || '',
 | 
			
		||||
      password: process.env.NEXTCLOUD_PASSWORD || '',
 | 
			
		||||
      uploadPath: process.env.NEXTCLOUD_UPLOAD_PATH || '/kb-media',
 | 
			
		||||
      publicBaseUrl: process.env.NEXTCLOUD_PUBLIC_URL || ''
 | 
			
		||||
    };
 | 
			
		||||
    
 | 
			
		||||
    // Allowed file types for knowledge base
 | 
			
		||||
    this.allowedTypes = new Set([
 | 
			
		||||
      // Images
 | 
			
		||||
      'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
 | 
			
		||||
      // Videos
 | 
			
		||||
      'video/mp4', 'video/webm', 'video/ogg', 'video/avi', 'video/mov',
 | 
			
		||||
      // Documents
 | 
			
		||||
      'application/pdf',
 | 
			
		||||
      'application/msword',
 | 
			
		||||
      'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
 | 
			
		||||
      'application/vnd.ms-excel',
 | 
			
		||||
      'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
 | 
			
		||||
      'application/vnd.ms-powerpoint',
 | 
			
		||||
      'application/vnd.openxmlformats-officedocument.presentationml.presentation',
 | 
			
		||||
      // Text files
 | 
			
		||||
      'text/plain', 'text/csv', 'application/json',
 | 
			
		||||
      // Archives (for tool downloads)
 | 
			
		||||
      'application/zip', 'application/x-tar', 'application/gzip'
 | 
			
		||||
    ]);
 | 
			
		||||
    
 | 
			
		||||
    this.maxFileSize = 50 * 1024 * 1024; // 50MB
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  /**
 | 
			
		||||
   * Check if Nextcloud upload is properly configured
 | 
			
		||||
   */
 | 
			
		||||
  isConfigured(): boolean {
 | 
			
		||||
    return !!(this.config.endpoint && 
 | 
			
		||||
              this.config.username && 
 | 
			
		||||
              this.config.password && 
 | 
			
		||||
              this.config.publicBaseUrl);
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  /**
 | 
			
		||||
   * Validate file before upload
 | 
			
		||||
   */
 | 
			
		||||
  private validateFile(file: File): FileValidation {
 | 
			
		||||
    // Check file size
 | 
			
		||||
    if (file.size > this.maxFileSize) {
 | 
			
		||||
      return {
 | 
			
		||||
        valid: false,
 | 
			
		||||
        error: `File too large (max ${Math.round(this.maxFileSize / 1024 / 1024)}MB)`
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Check file type
 | 
			
		||||
    if (!this.allowedTypes.has(file.type)) {
 | 
			
		||||
      return {
 | 
			
		||||
        valid: false,
 | 
			
		||||
        error: `File type not allowed: ${file.type}`
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Sanitize filename
 | 
			
		||||
    const sanitizedName = this.sanitizeFilename(file.name);
 | 
			
		||||
    if (!sanitizedName) {
 | 
			
		||||
      return {
 | 
			
		||||
        valid: false,
 | 
			
		||||
        error: 'Invalid filename'
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    return {
 | 
			
		||||
      valid: true,
 | 
			
		||||
      sanitizedName
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  /**
 | 
			
		||||
   * Sanitize filename for safe storage
 | 
			
		||||
   */
 | 
			
		||||
  private sanitizeFilename(filename: string): string {
 | 
			
		||||
    // Remove or replace unsafe characters
 | 
			
		||||
    const sanitized = filename
 | 
			
		||||
      .replace(/[^a-zA-Z0-9._-]/g, '_') // Replace unsafe chars with underscore
 | 
			
		||||
      .replace(/_{2,}/g, '_') // Replace multiple underscores with single
 | 
			
		||||
      .replace(/^_|_$/g, '') // Remove leading/trailing underscores
 | 
			
		||||
      .toLowerCase();
 | 
			
		||||
    
 | 
			
		||||
    // Ensure reasonable length
 | 
			
		||||
    if (sanitized.length > 100) {
 | 
			
		||||
      const ext = path.extname(sanitized);
 | 
			
		||||
      const base = path.basename(sanitized, ext).substring(0, 90);
 | 
			
		||||
      return base + ext;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    return sanitized;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  /**
 | 
			
		||||
   * Generate unique filename to prevent conflicts
 | 
			
		||||
   */
 | 
			
		||||
  private generateUniqueFilename(originalName: string): string {
 | 
			
		||||
    const timestamp = Date.now();
 | 
			
		||||
    const randomId = crypto.randomBytes(4).toString('hex');
 | 
			
		||||
    const ext = path.extname(originalName);
 | 
			
		||||
    const base = path.basename(originalName, ext);
 | 
			
		||||
    
 | 
			
		||||
    return `${timestamp}_${randomId}_${base}${ext}`;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  /**
 | 
			
		||||
   * Upload file to Nextcloud
 | 
			
		||||
   */
 | 
			
		||||
  async uploadFile(file: File, category: string = 'general'): Promise<UploadResult> {
 | 
			
		||||
    try {
 | 
			
		||||
      if (!this.isConfigured()) {
 | 
			
		||||
        return {
 | 
			
		||||
          success: false,
 | 
			
		||||
          error: 'Nextcloud not configured'
 | 
			
		||||
        };
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      // Validate file
 | 
			
		||||
      const validation = this.validateFile(file);
 | 
			
		||||
      if (!validation.valid) {
 | 
			
		||||
        return {
 | 
			
		||||
          success: false,
 | 
			
		||||
          error: validation.error
 | 
			
		||||
        };
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      // Generate unique filename
 | 
			
		||||
      const uniqueFilename = this.generateUniqueFilename(validation.sanitizedName!);
 | 
			
		||||
      
 | 
			
		||||
      // Create category-based path
 | 
			
		||||
      const categoryPath = this.sanitizeFilename(category);
 | 
			
		||||
      const remotePath = `${this.config.uploadPath}/${categoryPath}/${uniqueFilename}`;
 | 
			
		||||
      
 | 
			
		||||
      // **FIX: Ensure directory exists before upload**
 | 
			
		||||
      const dirPath = `${this.config.uploadPath}/${categoryPath}`;
 | 
			
		||||
      await this.ensureDirectoryExists(dirPath);
 | 
			
		||||
      
 | 
			
		||||
      // Convert file to buffer
 | 
			
		||||
      const arrayBuffer = await file.arrayBuffer();
 | 
			
		||||
      const buffer = Buffer.from(arrayBuffer);
 | 
			
		||||
      
 | 
			
		||||
      // Upload to Nextcloud via WebDAV
 | 
			
		||||
      const uploadUrl = `${this.config.endpoint}/remote.php/dav/files/${this.config.username}${remotePath}`;
 | 
			
		||||
      
 | 
			
		||||
      const response = await fetch(uploadUrl, {
 | 
			
		||||
        method: 'PUT',
 | 
			
		||||
        headers: {
 | 
			
		||||
          'Authorization': `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`,
 | 
			
		||||
          'Content-Type': file.type,
 | 
			
		||||
          'Content-Length': buffer.length.toString()
 | 
			
		||||
        },
 | 
			
		||||
        body: buffer
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      if (!response.ok) {
 | 
			
		||||
        throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      // Generate public URL
 | 
			
		||||
      const publicUrl = await this.createPublicLink(remotePath);
 | 
			
		||||
      
 | 
			
		||||
      return {
 | 
			
		||||
        success: true,
 | 
			
		||||
        url: publicUrl,
 | 
			
		||||
        filename: uniqueFilename,
 | 
			
		||||
        size: file.size
 | 
			
		||||
      };
 | 
			
		||||
      
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error('Nextcloud upload error:', error);
 | 
			
		||||
      return {
 | 
			
		||||
        success: false,
 | 
			
		||||
        error: error instanceof Error ? error.message : 'Upload failed'
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async ensureDirectoryExists(dirPath: string): Promise<void> {
 | 
			
		||||
    try {
 | 
			
		||||
      // Split path and create each directory level
 | 
			
		||||
      const parts = dirPath.split('/').filter(part => part);
 | 
			
		||||
      let currentPath = '';
 | 
			
		||||
      
 | 
			
		||||
      for (const part of parts) {
 | 
			
		||||
        currentPath += '/' + part;
 | 
			
		||||
        
 | 
			
		||||
        const mkcolUrl = `${this.config.endpoint}/remote.php/dav/files/${this.config.username}${currentPath}`;
 | 
			
		||||
        
 | 
			
		||||
        const response = await fetch(mkcolUrl, {
 | 
			
		||||
          method: 'MKCOL',
 | 
			
		||||
          headers: {
 | 
			
		||||
            'Authorization': `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
        
 | 
			
		||||
        // 201 = created, 405 = already exists, both are fine
 | 
			
		||||
        if (response.status !== 201 && response.status !== 405) {
 | 
			
		||||
          console.warn(`Directory creation failed: ${response.status} for ${currentPath}`);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.warn('Failed to ensure directory exists:', error);
 | 
			
		||||
      // Don't fail upload for directory creation issues
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  /**
 | 
			
		||||
   * Create a public share link for the uploaded file
 | 
			
		||||
   */
 | 
			
		||||
  private async createPublicLink(remotePath: string): Promise<string> {
 | 
			
		||||
    try {
 | 
			
		||||
      // Use Nextcloud's share API to create public link
 | 
			
		||||
      const shareUrl = `${this.config.endpoint}/ocs/v2.php/apps/files_sharing/api/v1/shares`;
 | 
			
		||||
      
 | 
			
		||||
      const formData = new FormData();
 | 
			
		||||
      formData.append('path', remotePath);
 | 
			
		||||
      formData.append('shareType', '3'); // Public link
 | 
			
		||||
      formData.append('permissions', '1'); // Read only
 | 
			
		||||
      
 | 
			
		||||
      const response = await fetch(shareUrl, {
 | 
			
		||||
        method: 'POST',
 | 
			
		||||
        headers: {
 | 
			
		||||
          'Authorization': `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`,
 | 
			
		||||
          'OCS-APIRequest': 'true'
 | 
			
		||||
        },
 | 
			
		||||
        body: formData
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      if (!response.ok) {
 | 
			
		||||
        throw new Error('Failed to create public link');
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      const text = await response.text();
 | 
			
		||||
      // Parse XML response to extract share URL
 | 
			
		||||
      const urlMatch = text.match(/<url>(.*?)<\/url>/);
 | 
			
		||||
      if (urlMatch) {
 | 
			
		||||
        return urlMatch[1];
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      // Fallback to direct URL construction
 | 
			
		||||
      return `${this.config.publicBaseUrl}${remotePath}`;
 | 
			
		||||
      
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.warn('Failed to create public link, using direct URL:', error);
 | 
			
		||||
      // Fallback to direct URL
 | 
			
		||||
      return `${this.config.publicBaseUrl}${remotePath}`;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  /**
 | 
			
		||||
   * Delete file from Nextcloud
 | 
			
		||||
   */
 | 
			
		||||
  async deleteFile(remotePath: string): Promise<{ success: boolean; error?: string }> {
 | 
			
		||||
    try {
 | 
			
		||||
      if (!this.isConfigured()) {
 | 
			
		||||
        return { success: false, error: 'Nextcloud not configured' };
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      const deleteUrl = `${this.config.endpoint}/remote.php/dav/files/${this.config.username}${remotePath}`;
 | 
			
		||||
      
 | 
			
		||||
      const response = await fetch(deleteUrl, {
 | 
			
		||||
        method: 'DELETE',
 | 
			
		||||
        headers: {
 | 
			
		||||
          'Authorization': `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      if (response.ok || response.status === 404) {
 | 
			
		||||
        return { success: true };
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      throw new Error(`Delete failed: ${response.status} ${response.statusText}`);
 | 
			
		||||
      
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error('Nextcloud delete error:', error);
 | 
			
		||||
      return {
 | 
			
		||||
        success: false,
 | 
			
		||||
        error: error instanceof Error ? error.message : 'Delete failed'
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  /**
 | 
			
		||||
   * Check Nextcloud connectivity and authentication
 | 
			
		||||
   */
 | 
			
		||||
  async testConnection(): Promise<{ success: boolean; error?: string; details?: any }> {
 | 
			
		||||
    try {
 | 
			
		||||
      if (!this.isConfigured()) {
 | 
			
		||||
        return { 
 | 
			
		||||
          success: false, 
 | 
			
		||||
          error: 'Nextcloud not configured',
 | 
			
		||||
          details: {
 | 
			
		||||
            hasEndpoint: !!this.config.endpoint,
 | 
			
		||||
            hasUsername: !!this.config.username,
 | 
			
		||||
            hasPassword: !!this.config.password,
 | 
			
		||||
            hasPublicUrl: !!this.config.publicBaseUrl
 | 
			
		||||
          }
 | 
			
		||||
        };
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      // Test with a simple WebDAV request
 | 
			
		||||
      const testUrl = `${this.config.endpoint}/remote.php/dav/files/${this.config.username}/`;
 | 
			
		||||
      
 | 
			
		||||
      const response = await fetch(testUrl, {
 | 
			
		||||
        method: 'PROPFIND',
 | 
			
		||||
        headers: {
 | 
			
		||||
          'Authorization': `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`,
 | 
			
		||||
          'Depth': '0'
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      if (response.ok) {
 | 
			
		||||
        return { 
 | 
			
		||||
          success: true,
 | 
			
		||||
          details: {
 | 
			
		||||
            endpoint: this.config.endpoint,
 | 
			
		||||
            username: this.config.username,
 | 
			
		||||
            uploadPath: this.config.uploadPath
 | 
			
		||||
          }
 | 
			
		||||
        };
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      throw new Error(`Connection failed: ${response.status} ${response.statusText}`);
 | 
			
		||||
      
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      return {
 | 
			
		||||
        success: false,
 | 
			
		||||
        error: error instanceof Error ? error.message : 'Connection test failed'
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  /**
 | 
			
		||||
   * Get file information from Nextcloud
 | 
			
		||||
   */
 | 
			
		||||
  async getFileInfo(remotePath: string): Promise<{ success: boolean; info?: any; error?: string }> {
 | 
			
		||||
    try {
 | 
			
		||||
      const propfindUrl = `${this.config.endpoint}/remote.php/dav/files/${this.config.username}${remotePath}`;
 | 
			
		||||
      
 | 
			
		||||
      const response = await fetch(propfindUrl, {
 | 
			
		||||
        method: 'PROPFIND',
 | 
			
		||||
        headers: {
 | 
			
		||||
          'Authorization': `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`,
 | 
			
		||||
          'Depth': '0'
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      if (response.ok) {
 | 
			
		||||
        const text = await response.text();
 | 
			
		||||
        // Parse basic file info from WebDAV response
 | 
			
		||||
        return {
 | 
			
		||||
          success: true,
 | 
			
		||||
          info: {
 | 
			
		||||
            path: remotePath,
 | 
			
		||||
            exists: true,
 | 
			
		||||
            response: text.substring(0, 200) + '...' // Truncated for safety
 | 
			
		||||
          }
 | 
			
		||||
        };
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      if (response.status === 404) {
 | 
			
		||||
        return {
 | 
			
		||||
          success: true,
 | 
			
		||||
          info: {
 | 
			
		||||
            path: remotePath,
 | 
			
		||||
            exists: false
 | 
			
		||||
          }
 | 
			
		||||
        };
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      throw new Error(`Failed to get file info: ${response.status}`);
 | 
			
		||||
      
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      return {
 | 
			
		||||
        success: false,
 | 
			
		||||
        error: error instanceof Error ? error.message : 'Failed to get file info'
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Convenience functions for easy usage
 | 
			
		||||
export async function uploadToNextcloud(file: File, category: string = 'general'): Promise<UploadResult> {
 | 
			
		||||
  const uploader = new NextcloudUploader();
 | 
			
		||||
  return await uploader.uploadFile(file, category);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function testNextcloudConnection(): Promise<{ success: boolean; error?: string; details?: any }> {
 | 
			
		||||
  const uploader = new NextcloudUploader();
 | 
			
		||||
  return await uploader.testConnection();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function isNextcloudConfigured(): boolean {
 | 
			
		||||
  const uploader = new NextcloudUploader();
 | 
			
		||||
  return uploader.isConfigured();
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										164
									
								
								src/utils/rateLimitedQueue.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										164
									
								
								src/utils/rateLimitedQueue.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,164 @@
 | 
			
		||||
// src/utils/rateLimitedQueue.ts
 | 
			
		||||
// ------------------------------------------------------------
 | 
			
		||||
// Enhanced FIFO queue with status tracking for visual feedback
 | 
			
		||||
// ------------------------------------------------------------
 | 
			
		||||
 | 
			
		||||
import dotenv from "dotenv";
 | 
			
		||||
 | 
			
		||||
dotenv.config();
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Delay (in **milliseconds**) between two consecutive API calls.
 | 
			
		||||
 * Defaults to **2000 ms** (2 seconds) when not set or invalid.
 | 
			
		||||
 */
 | 
			
		||||
const RATE_LIMIT_DELAY_MS = Number.parseInt(process.env.AI_RATE_LIMIT_DELAY_MS ?? "2000", 10) || 2000;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Internal task type with ID tracking for status updates
 | 
			
		||||
 */
 | 
			
		||||
export type Task<T = unknown> = () => Promise<T>;
 | 
			
		||||
 | 
			
		||||
interface QueuedTask {
 | 
			
		||||
  id: string;
 | 
			
		||||
  task: Task;
 | 
			
		||||
  addedAt: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface QueueStatus {
 | 
			
		||||
  queueLength: number;
 | 
			
		||||
  isProcessing: boolean;
 | 
			
		||||
  estimatedWaitTime: number; // in milliseconds
 | 
			
		||||
  currentPosition?: number; // position of specific request
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class RateLimitedQueue {
 | 
			
		||||
  private queue: QueuedTask[] = [];
 | 
			
		||||
  private processing = false;
 | 
			
		||||
  private delayMs = RATE_LIMIT_DELAY_MS;
 | 
			
		||||
  private lastProcessedAt = 0;
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Schedule a task with ID tracking. Returns a Promise that resolves/rejects 
 | 
			
		||||
   * with the task result once the queue reaches it.
 | 
			
		||||
   */
 | 
			
		||||
  add<T>(task: Task<T>, taskId?: string): Promise<T> {
 | 
			
		||||
    const id = taskId || this.generateTaskId();
 | 
			
		||||
    
 | 
			
		||||
    return new Promise<T>((resolve, reject) => {
 | 
			
		||||
      this.queue.push({
 | 
			
		||||
        id,
 | 
			
		||||
        task: async () => {
 | 
			
		||||
          try {
 | 
			
		||||
            const result = await task();
 | 
			
		||||
            resolve(result);
 | 
			
		||||
          } catch (err) {
 | 
			
		||||
            reject(err);
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        addedAt: Date.now()
 | 
			
		||||
      });
 | 
			
		||||
      this.process();
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Get current queue status for visual feedback
 | 
			
		||||
   */
 | 
			
		||||
  getStatus(taskId?: string): QueueStatus {
 | 
			
		||||
    const queueLength = this.queue.length;
 | 
			
		||||
    const now = Date.now();
 | 
			
		||||
    
 | 
			
		||||
    // Calculate estimated wait time
 | 
			
		||||
    let estimatedWaitTime = 0;
 | 
			
		||||
    if (queueLength > 0) {
 | 
			
		||||
      if (this.processing) {
 | 
			
		||||
        // Time since last request + remaining delay + queue length * delay
 | 
			
		||||
        const timeSinceLastRequest = now - this.lastProcessedAt;
 | 
			
		||||
        const remainingDelay = Math.max(0, this.delayMs - timeSinceLastRequest);
 | 
			
		||||
        estimatedWaitTime = remainingDelay + (queueLength - 1) * this.delayMs;
 | 
			
		||||
      } else {
 | 
			
		||||
        // Queue will start immediately, so just queue length * delay
 | 
			
		||||
        estimatedWaitTime = queueLength * this.delayMs;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const status: QueueStatus = {
 | 
			
		||||
      queueLength,
 | 
			
		||||
      isProcessing: this.processing,
 | 
			
		||||
      estimatedWaitTime
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // Find position of specific task if ID provided
 | 
			
		||||
    if (taskId) {
 | 
			
		||||
      const position = this.queue.findIndex(item => item.id === taskId);
 | 
			
		||||
      if (position >= 0) {
 | 
			
		||||
        status.currentPosition = position + 1; // 1-based indexing for user display
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return status;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Change the delay at runtime
 | 
			
		||||
   */
 | 
			
		||||
  setDelay(ms: number): void {
 | 
			
		||||
    if (!Number.isFinite(ms) || ms < 0) return;
 | 
			
		||||
    this.delayMs = ms;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Get current delay setting
 | 
			
		||||
   */
 | 
			
		||||
  getDelay(): number {
 | 
			
		||||
    return this.delayMs;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // ---------------------------------------
 | 
			
		||||
  // Internal helpers
 | 
			
		||||
  // ---------------------------------------
 | 
			
		||||
  private async process(): Promise<void> {
 | 
			
		||||
    if (this.processing) return;
 | 
			
		||||
    this.processing = true;
 | 
			
		||||
 | 
			
		||||
    while (this.queue.length > 0) {
 | 
			
		||||
      const next = this.queue.shift();
 | 
			
		||||
      if (!next) continue;
 | 
			
		||||
      
 | 
			
		||||
      this.lastProcessedAt = Date.now();
 | 
			
		||||
      await next.task();
 | 
			
		||||
      
 | 
			
		||||
      // Wait before the next one (only if there are more tasks)
 | 
			
		||||
      if (this.queue.length > 0) {
 | 
			
		||||
        await new Promise((r) => setTimeout(r, this.delayMs));
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.processing = false;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private generateTaskId(): string {
 | 
			
		||||
    return `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ------------------------------------------------------------
 | 
			
		||||
// Export singleton instance and convenience functions
 | 
			
		||||
// ------------------------------------------------------------
 | 
			
		||||
const queue = new RateLimitedQueue();
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Helper for convenience: `enqueueApiCall(() => fetch(...), 'optional-id')`.
 | 
			
		||||
 */
 | 
			
		||||
export function enqueueApiCall<T>(task: Task<T>, taskId?: string): Promise<T> {
 | 
			
		||||
  return queue.add(task, taskId);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get current queue status for visual feedback
 | 
			
		||||
 */
 | 
			
		||||
export function getQueueStatus(taskId?: string): QueueStatus {
 | 
			
		||||
  return queue.getStatus(taskId);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default queue;
 | 
			
		||||
@ -1,40 +0,0 @@
 | 
			
		||||
import type { AstroGlobal } from 'astro';
 | 
			
		||||
import { getSessionFromRequest, verifySession, type SessionData } from './auth.js';
 | 
			
		||||
 | 
			
		||||
export interface AuthContext {
 | 
			
		||||
  authenticated: boolean;
 | 
			
		||||
  session: SessionData | null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Check authentication status for server-side pages
 | 
			
		||||
export async function getAuthContext(Astro: AstroGlobal): Promise<AuthContext> {
 | 
			
		||||
  try {
 | 
			
		||||
    const sessionToken = getSessionFromRequest(Astro.request);
 | 
			
		||||
    
 | 
			
		||||
    if (!sessionToken) {
 | 
			
		||||
      return { authenticated: false, session: null };
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    const session = await verifySession(sessionToken);
 | 
			
		||||
    
 | 
			
		||||
    return {
 | 
			
		||||
      authenticated: session !== null,
 | 
			
		||||
      session
 | 
			
		||||
    };
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Failed to get auth context:', error);
 | 
			
		||||
    return { authenticated: false, session: null };
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Redirect to login if not authenticated
 | 
			
		||||
export function requireAuth(authContext: AuthContext, currentUrl: string): Response | null {
 | 
			
		||||
  if (!authContext.authenticated) {
 | 
			
		||||
    const loginUrl = `/api/auth/login?returnTo=${encodeURIComponent(currentUrl)}`;
 | 
			
		||||
    return new Response(null, {
 | 
			
		||||
      status: 302,
 | 
			
		||||
      headers: { 'Location': loginUrl }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
  return null;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										69
									
								
								src/utils/toolHelpers.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								src/utils/toolHelpers.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,69 @@
 | 
			
		||||
/**
 | 
			
		||||
 * CONSOLIDATED Tool utility functions for consistent tool operations across the app
 | 
			
		||||
 * Works in both server (Node.js) and client (browser) environments
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
export interface Tool {
 | 
			
		||||
  name: string;
 | 
			
		||||
  type?: 'software' | 'method' | 'concept';
 | 
			
		||||
  projectUrl?: string | null;
 | 
			
		||||
  license?: string;
 | 
			
		||||
  knowledgebase?: boolean;
 | 
			
		||||
  domains?: string[];
 | 
			
		||||
  phases?: string[];
 | 
			
		||||
  platforms?: string[];
 | 
			
		||||
  skillLevel?: string;
 | 
			
		||||
  description?: string;
 | 
			
		||||
  tags?: string[];
 | 
			
		||||
  related_concepts?: string[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Creates a URL-safe slug from a tool name
 | 
			
		||||
 * Used for URLs, IDs, and file names consistently across the app
 | 
			
		||||
 */
 | 
			
		||||
export function createToolSlug(toolName: string): string {
 | 
			
		||||
  if (!toolName || typeof toolName !== 'string') {
 | 
			
		||||
    console.warn('[toolHelpers] Invalid toolName provided to createToolSlug:', toolName);
 | 
			
		||||
    return '';
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  return toolName.toLowerCase()
 | 
			
		||||
    .replace(/[^a-z0-9\s-]/g, '')     // Remove special characters
 | 
			
		||||
    .replace(/\s+/g, '-')             // Replace spaces with hyphens
 | 
			
		||||
    .replace(/-+/g, '-')              // Remove duplicate hyphens
 | 
			
		||||
    .replace(/^-|-$/g, '');           // Remove leading/trailing hyphens
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Finds a tool by name or slug from tools array
 | 
			
		||||
 */
 | 
			
		||||
export function findToolByIdentifier(tools: Tool[], identifier: string): Tool | undefined {
 | 
			
		||||
  if (!identifier || !Array.isArray(tools)) return undefined;
 | 
			
		||||
  
 | 
			
		||||
  return tools.find(tool => 
 | 
			
		||||
    tool.name === identifier || 
 | 
			
		||||
    createToolSlug(tool.name) === identifier.toLowerCase()
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Checks if tool has a valid project URL (hosted on CC24 server)
 | 
			
		||||
 */
 | 
			
		||||
export function isToolHosted(tool: Tool): boolean {
 | 
			
		||||
  return tool.projectUrl !== undefined && 
 | 
			
		||||
         tool.projectUrl !== null && 
 | 
			
		||||
         tool.projectUrl !== "" && 
 | 
			
		||||
         tool.projectUrl.trim() !== "";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Determines tool category for styling/logic
 | 
			
		||||
 */
 | 
			
		||||
export function getToolCategory(tool: Tool): 'concept' | 'method' | 'hosted' | 'oss' | 'proprietary' {
 | 
			
		||||
  if (tool.type === 'concept') return 'concept';
 | 
			
		||||
  if (tool.type === 'method') return 'method';
 | 
			
		||||
  if (isToolHosted(tool)) return 'hosted';
 | 
			
		||||
  if (tool.license && tool.license !== 'Proprietary') return 'oss';
 | 
			
		||||
  return 'proprietary';
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user