change framework

This commit is contained in:
Mario Stöckl 2025-07-14 10:11:42 +02:00
parent d90b345819
commit 921abfb5b9
32 changed files with 6736 additions and 3848 deletions

5
.astro/settings.json Normal file
View File

@ -0,0 +1,5 @@
{
"_variables": {
"lastUpdateCheck": 1752478949435
}
}

1
.astro/types.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="astro/client" />

View File

@ -1,77 +0,0 @@
// File: ./eleventy.js
const yaml = require('js-yaml');
const fs = require('fs');
module.exports = function(eleventyConfig) {
// Copy static assets
eleventyConfig.addPassthroughCopy("src/js");
eleventyConfig.addPassthroughCopy("src/images");
eleventyConfig.addPassthroughCopy("src/icons");
eleventyConfig.addPassthroughCopy("src/css");
// Watch for changes
eleventyConfig.addWatchTarget("src/css/");
eleventyConfig.addWatchTarget("src/js/");
eleventyConfig.addWatchTarget("src/data/");
// Custom YAML data loader
eleventyConfig.addDataExtension("yaml", contents => yaml.load(contents));
eleventyConfig.addDataExtension("yml", contents => yaml.load(contents));
// Filters
eleventyConfig.addFilter("filterByDomain", function(tools, domain) {
if (!domain) return tools;
return tools.filter(tool => tool.domains && tool.domains.includes(domain));
});
eleventyConfig.addFilter("filterByPhase", function(tools, phase) {
if (!phase) return tools;
return tools.filter(tool => tool.phases && tool.phases.includes(phase));
});
eleventyConfig.addFilter("filterByType", function(tools, type) {
if (!type) return tools;
return tools.filter(tool => tool.type === type);
});
eleventyConfig.addFilter("searchTools", function(tools, searchTerm) {
if (!searchTerm) return tools;
const term = searchTerm.toLowerCase();
return tools.filter(tool =>
tool.name.toLowerCase().includes(term) ||
tool.description.toLowerCase().includes(term) ||
(tool.tags && tool.tags.some(tag => tag.toLowerCase().includes(term)))
);
});
// Global data
eleventyConfig.addGlobalData("domains", [
'Filesystem Forensics',
'Network Forensics',
'Memory Forensics',
'Live Forensics',
'Malware Analysis',
'Cryptocurrency'
]);
eleventyConfig.addGlobalData("phases", [
'Data Collection',
'Examination',
'Analysis',
'Reporting'
]);
// Configuration
return {
dir: {
input: "src",
output: "_site",
includes: "_includes",
layouts: "_layouts",
data: "data"
},
templateFormats: ["md", "njk", "html"],
markdownTemplateEngine: "njk",
htmlTemplateEngine: "njk"
};
};

37
astro-config.mjs Normal file
View File

@ -0,0 +1,37 @@
import { defineConfig } from 'astro/config';
// https://astro.build/config
export default defineConfig({
// No integrations needed - keeping it minimal
integrations: [],
// Build configuration
build: {
// Inline styles for better performance
inlineStylesheets: 'auto',
// Generate static site
format: 'file'
},
// Disable telemetry for privacy
telemetry: false,
// Server configuration for development
server: {
port: 3000,
host: true
},
// Vite configuration
vite: {
build: {
// Optimize for speed
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
},
},
},
}
});

6149
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,20 +1,22 @@
{ {
"name": "dfir-tools-hub", "name": "dfir-tools-hub",
"type": "module",
"version": "1.0.0", "version": "1.0.0",
"description": "Self-hosted DFIR tools directory and service status hub", "description": "Fast, self-hosted DFIR tools hub for academic and lab environments",
"scripts": { "scripts": {
"start": "eleventy --serve", "dev": "astro dev",
"build": "eleventy", "start": "astro dev",
"debug": "DEBUG=Eleventy* eleventy", "build": "astro build",
"clean": "rm -rf _site" "preview": "astro preview",
}, "astro": "astro"
"keywords": ["dfir", "digital-forensics", "incident-response", "tools"],
"author": "Your Lab",
"license": "MIT",
"devDependencies": {
"@11ty/eleventy": "^3.1.2"
}, },
"dependencies": { "dependencies": {
"js-yaml": "^4.1.0" "astro": "^4.0.0"
},
"devDependencies": {
"terser": "^5.27.0"
},
"engines": {
"node": ">=18.0.0"
} }
} }

View File

@ -0,0 +1,9 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="6" fill="#2563eb"/>
<path d="M16 6L8 12V20L16 26L24 20V12L16 6Z" stroke="white" stroke-width="2" stroke-linejoin="round"/>
<circle cx="16" cy="16" r="3" fill="white"/>
<line x1="16" y1="6" x2="16" y2="13" stroke="white" stroke-width="1.5"/>
<line x1="16" y1="19" x2="16" y2="26" stroke="white" stroke-width="1.5"/>
<line x1="8" y1="12" x2="13" y2="14.5" stroke="white" stroke-width="1.5"/>
<line x1="19" y1="17.5" x2="24" y2="20" stroke="white" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 615 B

View File

@ -1,176 +0,0 @@
<!-- file: "./src/_layouts/base.njk" -->
<!DOCTYPE html>
<html lang="en" class="theme-auto">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% if title %}{{ title }} - {% endif %}DFIR Tools Hub</title>
<meta name="description" content="{{ description or 'Comprehensive directory of FOSS tools for Digital Forensics and Incident Response' }}">
<!-- Preload critical assets -->
<link rel="preload" href="/css/main.css" as="style">
<!-- Styles -->
<link rel="stylesheet" href="/css/main.css">
<!-- Theme detection script (inline to prevent FOUC) -->
<script>
(function() {
const theme = localStorage.getItem('theme') || 'auto';
document.documentElement.className = 'theme-' + theme;
if (theme === 'auto') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', isDark);
} else {
document.documentElement.classList.toggle('dark', theme === 'dark');
}
})();
</script>
</head>
<body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors duration-200 min-h-screen flex flex-col">
<!-- Navigation -->
<nav class="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 w-full">
<div class="flex justify-between items-center h-16">
<!-- Left Navigation -->
<div class="flex items-center space-x-4 md:space-x-8">
<a href="/"
class="nav-link {% if page.url == '/' %}nav-link-active{% endif %}"
data-page="home">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
</svg>
<span class="hidden sm:inline">Start</span>
</a>
<a href="/status/"
class="nav-link {% if '/status/' in page.url %}nav-link-active{% endif %}"
data-page="status">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
<span class="hidden sm:inline">Status</span>
</a>
<a href="/about/"
class="nav-link {% if '/about/' in page.url %}nav-link-active{% endif %}"
data-page="about">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span class="hidden sm:inline">Über</span>
</a>
<a href="/privacy/"
class="nav-link {% if '/privacy/' in page.url %}nav-link-active{% endif %}"
data-page="privacy">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg>
<span class="hidden sm:inline">Datenschutz</span>
</a>
</div>
<!-- Center Logo -->
<div class="flex items-center">
<div class="bg-blue-600 dark:bg-blue-500 text-white px-4 py-2 rounded-lg font-bold text-lg shadow-sm">
DFIR
</div>
</div>
<!-- Right Theme Selector -->
<div class="flex items-center">
<div class="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-1" id="theme-selector">
<button class="theme-btn p-2 rounded-md transition-colors" data-theme="light" title="Light theme">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
</button>
<button class="theme-btn p-2 rounded-md transition-colors" data-theme="auto" title="Auto theme">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
</button>
<button class="theme-btn p-2 rounded-md transition-colors" data-theme="dark" title="Dark theme">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
</svg>
</button>
</div>
</div>
</div>
</div>
</nav>
<!-- Main Content -->
<main class="flex-grow">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 w-full py-8">
{{ content | safe }}
</div>
</main>
<!-- Footer -->
<footer class="mt-auto">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 w-full py-8">
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<!-- About Section -->
<div>
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">DFIR Tools Hub</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Self-hosted directory for Digital Forensics and Incident Response tools. Built for academic and lab environments.
</p>
</div>
<!-- Quick Links -->
<div>
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Quick Links</h3>
<ul class="space-y-2 text-sm">
<li>
<a href="https://github.com/your-lab/dfir-tools-hub" class="footer-link">
GitHub Repository
</a>
</li>
<li>
<a href="/api/tools.json" class="footer-link">
Tools API
</a>
</li>
<li>
<a href="/api/status.json" class="footer-link">
Status API
</a>
</li>
</ul>
</div>
</div>
<div class="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
<p class="text-center text-sm text-gray-500 dark:text-gray-400">
© 2025 DFIR Lab. Open source under MIT License.
</p>
</div>
</div>
</footer>
<!-- Tool Detail Modal (initially hidden) -->
<div id="tool-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50 hidden">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div class="p-6" id="tool-modal-content">
<!-- Content will be populated by JavaScript -->
</div>
</div>
</div>
<!-- Scripts -->
<script src="/js/theme.js"></script>
<script src="/js/search.js"></script>
<script src="/js/modal.js"></script>
{% if page.url == "/status/" %}
<script src="/js/status.js"></script>
{% endif %}
</body>
</html>

View File

@ -1,32 +0,0 @@
---
layout: base.njk
title: "Über"
description: "Über DFIR Tools Hub"
---
<!-- ffile: "./src/about/index.njk" -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="max-w-4xl">
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-4">Über</h1>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">DFIR Tools Hub</h2>
<p class="text-gray-600 dark:text-gray-300 mb-4">
[Platzhalter für Projektbeschreibung]
</p>
<h3 class="text-md font-semibold text-gray-900 dark:text-gray-100 mb-2 mt-4">Technische Details</h3>
<ul class="text-sm text-gray-600 dark:text-gray-300 space-y-1">
<li>• Statische Website mit 11ty</li>
<li>• YAML-basierte Datenverwaltung</li>
<li>• Keine externen Abhängigkeiten</li>
<li>• Uptime Kuma Integration</li>
</ul>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,24 @@
---
// Footer component
---
<footer>
<div class="container">
<div class="footer-content">
<div>
<p class="text-muted" style="margin: 0;">
© 2025 DFIR Tools Hub - Academic Research Project
</p>
</div>
<div style="display: flex; gap: 2rem; align-items: center;">
<a href="https://github.com/your-org/dfir-tools-hub" target="_blank" rel="noopener noreferrer">
<svg width="20" height="20" 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"/>
</svg>
GitHub Repository
</a>
<a href="/impressum">Impressum</a>
</div>
</div>
</div>
</footer>

View File

@ -0,0 +1,37 @@
---
import ThemeToggle from './ThemeToggle.astro';
const currentPath = Astro.url.pathname;
---
<nav>
<div class="container">
<div class="nav-wrapper">
<div class="nav-brand">
<img src="/logo-placeholder.svg" alt="DFIR Tools Hub" class="nav-logo" />
<span style="font-weight: 600; font-size: 1.125rem;">DFIR Tools Hub</span>
</div>
<ul class="nav-links">
<li>
<a href="/" class={`nav-link ${currentPath === '/' ? 'active' : ''}`}>
Home
</a>
</li>
<li>
<a href="/status" class={`nav-link ${currentPath === '/status' ? 'active' : ''}`}>
Status
</a>
</li>
<li>
<a href="/about" class={`nav-link ${currentPath === '/about' ? 'active' : ''}`}>
About
</a>
</li>
<li>
<ThemeToggle />
</li>
</ul>
</div>
</div>
</nav>

View File

@ -0,0 +1,51 @@
---
// Theme toggle component
---
<button
class="btn-icon"
data-theme-toggle
onclick="window.themeUtils.toggleTheme()"
title="Toggle theme"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<!-- Sun icon -->
<g class="theme-icon-light">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</g>
<!-- Moon icon -->
<path class="theme-icon-dark" d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
<!-- Auto icon -->
<g class="theme-icon-auto">
<circle cx="12" cy="12" r="9"></circle>
<path d="M12 3v18"></path>
<path d="M12 3a9 9 0 0 1 0 18"></path>
</g>
</svg>
</button>
<style>
.theme-icon-light,
.theme-icon-dark,
.theme-icon-auto {
display: none;
}
[data-current-theme="light"] .theme-icon-light,
[data-current-theme="dark"] .theme-icon-dark,
[data-current-theme="auto"] .theme-icon-auto {
display: block;
}
[data-current-theme="auto"] path {
fill: currentColor;
}
</style>

View File

@ -0,0 +1,80 @@
---
export interface Props {
tool: {
name: string;
description: string;
domains: string[];
phases: string[];
platforms: string[];
skillLevel: string;
accessType: string;
url: string;
license: string;
tags: string[];
isHosted: boolean;
statusUrl?: string;
};
}
const { tool } = Astro.props;
// Determine card styling
const cardClass = tool.isHosted ? 'card card-hosted' : (tool.license !== 'Proprietary' ? 'card card-oss' : 'card');
---
<div class={cardClass}>
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.75rem;">
<h3 style="margin: 0;">{tool.name}</h3>
<div style="display: flex; gap: 0.5rem;">
{tool.isHosted && <span class="badge badge-primary">Self-Hosted</span>}
{tool.license !== 'Proprietary' && <span class="badge badge-success">Open Source</span>}
</div>
</div>
<p class="text-muted" style="font-size: 0.875rem; margin-bottom: 1rem;">
{tool.description}
</p>
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem;">
<div style="display: flex; align-items: center; gap: 0.25rem;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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="text-muted" style="font-size: 0.75rem;">
{tool.platforms.join(', ')}
</span>
</div>
<div style="display: flex; align-items: center; gap: 0.25rem;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 6v6l4 2"></path>
</svg>
<span class="text-muted" style="font-size: 0.75rem;">
{tool.skillLevel}
</span>
</div>
<div style="display: flex; align-items: center; gap: 0.25rem;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" 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"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
<span class="text-muted" style="font-size: 0.75rem;">
{tool.license}
</span>
</div>
</div>
<div style="display: flex; flex-wrap: wrap; gap: 0.25rem; margin-bottom: 1rem;">
{tool.tags.map(tag => (
<span class="tag">{tag}</span>
))}
</div>
<a href={tool.url} target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="width: 100%;">
{tool.isHosted ? 'Access Service' : 'Visit Website'}
</a>
</div>

View File

@ -0,0 +1,177 @@
---
import { promises as fs } from 'fs';
import { load } from 'js-yaml';
import path from 'path';
// Load tools data
const yamlPath = path.join(process.cwd(), 'src/data/tools.yaml');
const yamlContent = await fs.readFile(yamlPath, 'utf8');
const data = load(yamlContent) as any;
const domains = data.domains;
const phases = data.phases;
// Get unique tags from all tools
const allTags = [...new Set(data.tools.flatMap((tool: any) => tool.tags))].sort();
---
<div class="filters-container">
<!-- Search Bar -->
<div style="margin-bottom: 1.5rem;">
<input
type="text"
id="search-input"
placeholder="Search tools by name, description, or tags..."
style="width: 100%;"
/>
</div>
<!-- Domain and Phase Dropdowns -->
<div class="grid grid-cols-2 gap-4" style="margin-bottom: 1.5rem;">
<div>
<label for="domain-select" style="display: block; margin-bottom: 0.5rem; font-weight: 500;">
Forensic Domain
</label>
<select id="domain-select">
<option value="">All Domains</option>
{domains.map((domain: any) => (
<option value={domain.id}>{domain.name}</option>
))}
</select>
</div>
<div>
<label for="phase-select" style="display: block; margin-bottom: 0.5rem; font-weight: 500;">
Investigation Phase
</label>
<select id="phase-select">
<option value="">All Phases</option>
{phases.map((phase: any) => (
<option value={phase.id}>{phase.name}</option>
))}
</select>
</div>
</div>
<!-- Additional Filters -->
<div style="margin-bottom: 1.5rem;">
<div class="checkbox-wrapper" style="margin-bottom: 1rem;">
<input type="checkbox" id="include-proprietary" />
<label for="include-proprietary">Include Proprietary Software</label>
</div>
<!-- Tag Filters -->
<details>
<summary style="cursor: pointer; font-weight: 500; margin-bottom: 0.5rem;">
Filter by Tags
</summary>
<div class="grid grid-cols-3 gap-2" style="margin-top: 0.5rem;">
{allTags.map(tag => (
<div class="checkbox-wrapper">
<input type="checkbox" id={`tag-${tag}`} data-tag={tag} class="tag-filter" />
<label for={`tag-${tag}`} style="font-size: 0.875rem;">{tag}</label>
</div>
))}
</div>
</details>
</div>
<!-- View Toggle -->
<div style="display: flex; gap: 1rem; margin-bottom: 1.5rem;">
<button class="btn btn-secondary view-toggle active" data-view="grid">Grid View</button>
<button class="btn btn-secondary view-toggle" data-view="matrix">Matrix View</button>
<button class="btn btn-secondary view-toggle" data-view="hosted">Self-Hosted Only</button>
</div>
</div>
<script define:vars={{ toolsData: data.tools }}>
// Store tools data globally for filtering
window.toolsData = toolsData;
// Initialize filters
document.addEventListener('DOMContentLoaded', () => {
const searchInput = document.getElementById('search-input');
const domainSelect = document.getElementById('domain-select');
const phaseSelect = document.getElementById('phase-select');
const proprietaryCheckbox = document.getElementById('include-proprietary');
const tagFilters = document.querySelectorAll('.tag-filter');
const viewToggles = document.querySelectorAll('.view-toggle');
// Filter function
function filterTools() {
const searchTerm = searchInput.value.toLowerCase();
const selectedDomain = domainSelect.value;
const selectedPhase = phaseSelect.value;
const includeProprietary = proprietaryCheckbox.checked;
const selectedTags = Array.from(tagFilters)
.filter(cb => cb.checked)
.map(cb => cb.getAttribute('data-tag'));
const filtered = window.toolsData.filter(tool => {
// Search filter
if (searchTerm && !(
tool.name.toLowerCase().includes(searchTerm) ||
tool.description.toLowerCase().includes(searchTerm) ||
tool.tags.some(tag => tag.toLowerCase().includes(searchTerm))
)) {
return false;
}
// Domain filter
if (selectedDomain && !tool.domains.includes(selectedDomain)) {
return false;
}
// Phase filter
if (selectedPhase && !tool.phases.includes(selectedPhase)) {
return false;
}
// Proprietary filter
if (!includeProprietary && tool.license === 'Proprietary') {
return false;
}
// Tag filter
if (selectedTags.length > 0 && !selectedTags.some(tag => tool.tags.includes(tag))) {
return false;
}
return true;
});
// Emit custom event with filtered results
window.dispatchEvent(new CustomEvent('toolsFiltered', { detail: filtered }));
}
// View toggle handler
function handleViewToggle(view) {
viewToggles.forEach(btn => {
btn.classList.toggle('active', btn.getAttribute('data-view') === view);
});
window.dispatchEvent(new CustomEvent('viewChanged', { detail: view }));
// Apply view-specific filters
if (view === 'hosted') {
const hosted = window.toolsData.filter(tool => tool.isHosted);
window.dispatchEvent(new CustomEvent('toolsFiltered', { detail: hosted }));
} else {
filterTools();
}
}
// Attach event listeners
searchInput.addEventListener('input', filterTools);
domainSelect.addEventListener('change', filterTools);
phaseSelect.addEventListener('change', filterTools);
proprietaryCheckbox.addEventListener('change', filterTools);
tagFilters.forEach(cb => cb.addEventListener('change', filterTools));
viewToggles.forEach(btn => {
btn.addEventListener('click', () => handleViewToggle(btn.getAttribute('data-view')));
});
// Initial filter
filterTools();
});
</script>

View File

@ -0,0 +1,170 @@
---
import { promises as fs } from 'fs';
import { load } from 'js-yaml';
import path from 'path';
// Load tools data
const yamlPath = path.join(process.cwd(), 'src/data/tools.yaml');
const yamlContent = await fs.readFile(yamlPath, 'utf8');
const data = load(yamlContent) as any;
const domains = data.domains;
const phases = data.phases;
const tools = data.tools;
// Create matrix structure
const matrix: Record<string, Record<string, any[]>> = {};
domains.forEach((domain: any) => {
matrix[domain.id] = {};
phases.forEach((phase: any) => {
matrix[domain.id][phase.id] = tools.filter((tool: any) =>
tool.domains.includes(domain.id) && tool.phases.includes(phase.id)
);
});
});
---
<div id="matrix-container" class="matrix-wrapper" style="display: none;">
<table class="matrix-table">
<thead>
<tr>
<th style="width: 200px;">Domain / Phase</th>
{phases.map((phase: any) => (
<th>{phase.name}</th>
))}
</tr>
</thead>
<tbody>
{domains.map((domain: any) => (
<tr>
<th>{domain.name}</th>
{phases.map((phase: any) => (
<td class="matrix-cell" data-domain={domain.id} data-phase={phase.id}>
{matrix[domain.id][phase.id].map((tool: any) => (
<span
class={`tool-chip ${tool.isHosted ? 'tool-chip-hosted' : tool.license !== 'Proprietary' ? 'tool-chip-oss' : ''}`}
data-tool-name={tool.name}
onclick={`window.showToolDetails('${tool.name}')`}
>
{tool.name}
</span>
))}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<!-- Tool Details Modal -->
<div class="modal-overlay" id="modal-overlay" onclick="window.hideToolDetails()"></div>
<div class="tool-details" id="tool-details">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 1rem;">
<h2 id="tool-name" style="margin: 0;">Tool Name</h2>
<button class="btn-icon" onclick="window.hideToolDetails()">
<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>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<p id="tool-description" class="text-muted"></p>
<div id="tool-badges" style="display: flex; gap: 0.5rem; margin-bottom: 1rem;"></div>
<div id="tool-metadata" style="margin-bottom: 1rem;"></div>
<div id="tool-tags" style="margin-bottom: 1rem;"></div>
<a id="tool-link" href="#" target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="width: 100%;">
Visit Website
</a>
</div>
<script define:vars={{ toolsData: tools }}>
// Tool details functions
window.showToolDetails = function(toolName) {
const tool = toolsData.find(t => t.name === toolName);
if (!tool) return;
// Update modal content
document.getElementById('tool-name').textContent = tool.name;
document.getElementById('tool-description').textContent = tool.description;
// Badges
const badgesContainer = document.getElementById('tool-badges');
badgesContainer.innerHTML = '';
if (tool.isHosted) {
badgesContainer.innerHTML += '<span class="badge badge-primary">Self-Hosted</span>';
}
if (tool.license !== 'Proprietary') {
badgesContainer.innerHTML += '<span class="badge badge-success">Open Source</span>';
}
// Metadata
const metadataContainer = document.getElementById('tool-metadata');
metadataContainer.innerHTML = `
<div style="display: grid; gap: 0.5rem;">
<div><strong>Platforms:</strong> ${tool.platforms.join(', ')}</div>
<div><strong>Skill Level:</strong> ${tool.skillLevel}</div>
<div><strong>License:</strong> ${tool.license}</div>
<div><strong>Access Type:</strong> ${tool.accessType}</div>
<div><strong>Domains:</strong> ${tool.domains.join(', ')}</div>
<div><strong>Phases:</strong> ${tool.phases.join(', ')}</div>
</div>
`;
// Tags
const tagsContainer = document.getElementById('tool-tags');
tagsContainer.innerHTML = `
<div style="display: flex; flex-wrap: wrap; gap: 0.25rem;">
${tool.tags.map(tag => `<span class="tag">${tag}</span>`).join('')}
</div>
`;
// Link
const linkElement = document.getElementById('tool-link');
linkElement.href = tool.url;
linkElement.textContent = tool.isHosted ? 'Access Service' : 'Visit Website';
// Show modal
document.getElementById('modal-overlay').classList.add('active');
document.getElementById('tool-details').classList.add('active');
};
window.hideToolDetails = function() {
document.getElementById('modal-overlay').classList.remove('active');
document.getElementById('tool-details').classList.remove('active');
};
// Update matrix on filter change
window.addEventListener('toolsFiltered', (event) => {
const filtered = event.detail;
const currentView = document.querySelector('.view-toggle.active')?.getAttribute('data-view');
if (currentView === 'matrix') {
// Update matrix cells
document.querySelectorAll('.matrix-cell').forEach(cell => {
cell.innerHTML = '';
});
// Re-populate with filtered tools
filtered.forEach(tool => {
tool.domains.forEach(domain => {
tool.phases.forEach(phase => {
const cell = document.querySelector(`[data-domain="${domain}"][data-phase="${phase}"]`);
if (cell) {
const chip = document.createElement('span');
chip.className = `tool-chip ${tool.isHosted ? 'tool-chip-hosted' : tool.license !== 'Proprietary' ? 'tool-chip-oss' : ''}`;
chip.textContent = tool.name;
chip.onclick = () => window.showToolDetails(tool.name);
cell.appendChild(chip);
}
});
});
});
}
});
</script>

View File

@ -1,638 +0,0 @@
/* File: ./src/css/main.css */
/* CSS Reset and Base Styles */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* CSS Variables for Colors */
:root {
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-300: #d1d5db;
--gray-400: #9ca3af;
--gray-500: #6b7280;
--gray-600: #4b5563;
--gray-700: #374151;
--gray-800: #1f2937;
--gray-900: #111827;
--blue-50: #eff6ff;
--blue-100: #dbeafe;
--blue-400: #60a5fa;
--blue-500: #3b82f6;
--blue-600: #2563eb;
--blue-700: #1d4ed8;
--blue-800: #1e40af;
--blue-900: #1e3a8a;
--green-100: #dcfce7;
--green-400: #4ade80;
--green-500: #22c55e;
--green-600: #16a34a;
--green-800: #166534;
--green-900: #14532d;
--purple-100: #f3e8ff;
--purple-600: #9333ea;
--purple-800: #6b21a8;
--purple-900: #581c87;
--yellow-100: #fef3c7;
--yellow-400: #fbbf24;
--yellow-500: #f59e0b;
--yellow-600: #d97706;
--yellow-800: #92400e;
--yellow-900: #78350f;
--red-100: #fee2e2;
--red-400: #f87171;
--red-500: #ef4444;
--red-600: #dc2626;
--red-800: #991b1b;
--red-900: #7f1d1d;
}
/* Dark Theme Variables */
.dark {
--gray-50: #111827;
--gray-100: #1f2937;
--gray-200: #374151;
--gray-300: #4b5563;
--gray-400: #6b7280;
--gray-500: #9ca3af;
--gray-600: #d1d5db;
--gray-700: #e5e7eb;
--gray-800: #f3f4f6;
--gray-900: #f9fafb;
}
/* Background Colors */
.bg-gray-50 { background-color: var(--gray-50); }
.bg-gray-100 { background-color: var(--gray-100); }
.bg-gray-600 { background-color: var(--gray-600); }
.bg-gray-700 { background-color: var(--gray-700); }
.bg-gray-800 { background-color: var(--gray-800); }
.bg-gray-900 { background-color: var(--gray-900); }
.bg-white { background-color: white; }
.bg-blue-50 { background-color: var(--blue-50); }
.bg-blue-100 { background-color: var(--blue-100); }
.bg-blue-500 { background-color: var(--blue-500); }
.bg-blue-600 { background-color: var(--blue-600); }
.bg-blue-700 { background-color: var(--blue-700); }
.bg-blue-800 { background-color: var(--blue-800); }
.bg-blue-900 { background-color: var(--blue-900); }
.bg-green-50 { background-color: #dcfce7; }
.bg-green-100 { background-color: var(--green-100); }
.bg-green-900 { background-color: var(--green-900); }
.bg-purple-50 { background-color: #f3e8ff; }
.bg-purple-100 { background-color: var(--purple-100); }
.bg-purple-900 { background-color: var(--purple-900); }
.bg-yellow-50 { background-color: #fef3c7; }
.bg-yellow-100 { background-color: var(--yellow-100); }
.bg-yellow-900 { background-color: var(--yellow-900); }
.bg-red-100 { background-color: var(--red-100); }
.bg-red-900 { background-color: var(--red-900); }
/* Text Colors */
.text-gray-100 { color: var(--gray-100); }
.text-gray-200 { color: var(--gray-200); }
.text-gray-300 { color: var(--gray-300); }
.text-gray-400 { color: var(--gray-400); }
.text-gray-500 { color: var(--gray-500); }
.text-gray-600 { color: var(--gray-600); }
.text-gray-700 { color: var(--gray-700); }
.text-gray-800 { color: var(--gray-800); }
.text-gray-900 { color: var(--gray-900); }
.text-white { color: white; }
.text-blue-400 { color: var(--blue-400); }
.text-blue-600 { color: var(--blue-600); }
.text-blue-800 { color: var(--blue-800); }
.text-blue-900 { color: var(--blue-900); }
.text-green-400 { color: var(--green-400); }
.text-green-600 { color: var(--green-600); }
.text-green-800 { color: var(--green-800); }
.text-purple-800 { color: var(--purple-800); }
.text-purple-200 { color: #c4b5fd; }
.text-yellow-400 { color: var(--yellow-400); }
.text-yellow-600 { color: var(--yellow-600); }
.text-yellow-800 { color: var(--yellow-800); }
.text-red-400 { color: var(--red-400); }
.text-red-600 { color: var(--red-600); }
.text-red-800 { color: var(--red-800); }
/* Layout Utilities */
.min-h-screen { min-height: 100vh; }
.max-w-md { max-width: 28rem; }
.max-w-2xl { max-width: 42rem; }
.max-w-4xl { max-width: 56rem; }
.max-w-6xl { max-width: 72rem; }
.max-w-7xl { max-width: 80rem; }
.mx-auto { margin-left: auto; margin-right: auto; }
/* Spacing */
.px-4 { padding-left: 1rem; padding-right: 1rem; }
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
.py-4 { padding-top: 1rem; padding-bottom: 1rem; }
.py-6 { padding-top: 1.5rem; padding-bottom: 1.5rem; }
.py-8 { padding-top: 2rem; padding-bottom: 2rem; }
.py-12 { padding-top: 3rem; padding-bottom: 3rem; }
.py-16 { padding-top: 4rem; padding-bottom: 4rem; }
.p-1 { padding: 0.25rem; }
.p-2 { padding: 0.5rem; }
.p-4 { padding: 1rem; }
.p-6 { padding: 1.5rem; }
.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-4 { margin-top: 1rem; }
.pl-10 { padding-left: 2.5rem; }
.pr-4 { padding-right: 1rem; }
/* Flexbox */
.flex { display: flex; }
.items-center { align-items: center; }
.items-start { align-items: flex-start; }
.items-end { align-items: flex-end; }
.justify-between { justify-content: space-between; }
.justify-center { justify-content: center; }
.space-x-8 > * + * { margin-left: 2rem; }
.space-x-4 > * + * { margin-left: 1rem; }
.space-x-2 > * + * { margin-left: 0.5rem; }
.space-y-2 > * + * { margin-top: 0.5rem; }
.space-y-4 > * + * { margin-top: 1rem; }
.space-y-6 > * + * { margin-top: 1.5rem; }
.space-y-8 > * + * { margin-top: 2rem; }
.gap-2 { gap: 0.5rem; }
.gap-4 { gap: 1rem; }
.gap-6 { gap: 1.5rem; }
.flex-wrap { flex-wrap: wrap; }
.flex-col { flex-direction: column; }
/* Grid */
.grid { display: grid; }
.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
@media (min-width: 768px) {
.md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.md\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.md\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
@media (min-width: 1024px) {
.lg\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
/* Typography */
.text-lg { font-size: 1.125rem; line-height: 1.75rem; }
.text-xl { font-size: 1.25rem; line-height: 1.75rem; }
.text-2xl { font-size: 1.5rem; line-height: 2rem; }
.text-3xl { font-size: 1.875rem; line-height: 2.25rem; }
.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
.text-xs { font-size: 0.75rem; line-height: 1rem; }
.font-bold { font-weight: 700; }
.font-semibold { font-weight: 600; }
.font-medium { font-weight: 500; }
.leading-relaxed { line-height: 1.625; }
/* Borders and Shadows */
.border { border-width: 1px; }
.border-b { border-bottom-width: 1px; }
.border-gray-200 { border-color: var(--gray-200); }
.border-gray-300 { border-color: var(--gray-300); }
.border-gray-600 { border-color: var(--gray-600); }
.border-gray-700 { border-color: var(--gray-700); }
.border-blue-200 { border-color: var(--blue-200); }
.border-blue-700 { border-color: var(--blue-700); }
.rounded-lg { border-radius: 0.5rem; }
.rounded-md { border-radius: 0.375rem; }
.rounded { border-radius: 0.25rem; }
.shadow-sm { box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); }
/* Positioning */
.relative { position: relative; }
.absolute { position: absolute; }
.fixed { position: fixed; }
.inset-0 { top: 0; right: 0; bottom: 0; left: 0; }
.top-1\/2 { top: 50%; }
.left-3 { left: 0.75rem; }
.right-3 { right: 0.75rem; }
.transform { transform: var(--tw-transform); }
.-translate-y-1\/2 { --tw-translate-y: -50%; transform: translateY(var(--tw-translate-y)); }
.z-50 { z-index: 50; }
/* Display */
.hidden { display: none; }
.block { display: block; }
.inline-block { display: inline-block; }
/* Sizing */
.w-4 { width: 1rem; }
.w-5 { width: 1.25rem; }
.w-16 { width: 4rem; }
.w-full { width: 100%; }
.h-4 { height: 1rem; }
.h-5 { height: 1.25rem; }
.h-16 { height: 4rem; }
.min-w-48 { min-width: 12rem; }
.min-w-64 { min-width: 16rem; }
.max-h-\[90vh\] { max-height: 90vh; }
/* Overflow */
.overflow-x-auto { overflow-x: auto; }
.overflow-y-auto { overflow-y: auto; }
/* Text Alignment */
.text-left { text-align: left; }
.text-center { text-align: center; }
/* Cursor */
.cursor-pointer { cursor: pointer; }
/* Focus and Interaction */
.hover\:bg-gray-300:hover { background-color: var(--gray-300); }
.hover\:bg-gray-600:hover { background-color: var(--gray-600); }
.hover\:bg-gray-700:hover { background-color: var(--gray-700); }
.hover\:bg-blue-700:hover { background-color: var(--blue-700); }
.hover\:bg-blue-100:hover { background-color: var(--blue-100); }
.hover\:bg-blue-800:hover { background-color: var(--blue-800); }
.hover\:text-gray-900:hover { color: var(--gray-900); }
.hover\:text-gray-100:hover { color: var(--gray-100); }
.hover\:text-gray-300:hover { color: var(--gray-300); }
.hover\:text-blue-600:hover { color: var(--blue-600); }
.hover\:text-blue-400:hover { color: var(--blue-400); }
.hover\:shadow-md:hover { box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); }
.focus\:ring-2:focus { box-shadow: 0 0 0 2px var(--blue-500); }
.focus\:border-blue-500:focus { border-color: var(--blue-500); }
/* Transitions */
.transition-colors { transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out; }
.transition-shadow { transition: box-shadow 0.15s ease-in-out; }
.duration-200 { transition-duration: 200ms; }
/* Misc */
.appearance-none { appearance: none; }
.pointer-events-none { pointer-events: none; }
.align-top { vertical-align: top; }
/* Custom Component Styles */
.nav-link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s ease-in-out;
color: var(--gray-700);
text-decoration: none;
background-color: transparent;
}
.nav-link:hover {
color: var(--gray-900);
background-color: var(--gray-100);
}
.dark .nav-link {
color: var(--gray-400);
}
.dark .nav-link:hover {
color: var(--gray-100);
background-color: var(--gray-700);
}
.nav-link-active {
color: var(--blue-700) !important;
background-color: var(--blue-50) !important;
font-weight: 600;
}
.dark .nav-link-active {
color: var(--blue-400) !important;
background-color: var(--blue-900) !important;
}
.view-mode-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
transition: all 0.2s ease-in-out;
background-color: white;
color: var(--gray-700);
border: 1px solid var(--gray-300);
cursor: pointer;
font-weight: 500;
}
.view-mode-btn:hover {
background-color: var(--gray-50);
border-color: var(--gray-400);
}
.dark .view-mode-btn {
background-color: var(--gray-800);
color: var(--gray-400);
border-color: var(--gray-600);
}
.dark .view-mode-btn:hover {
background-color: var(--gray-700);
border-color: var(--gray-500);
}
.view-mode-active {
background-color: var(--blue-600) !important;
color: white !important;
border-color: var(--blue-600) !important;
border-radius: 0.5rem !important;
}
.dark .view-mode-active {
background-color: var(--blue-500) !important;
color: white !important;
border-color: var(--blue-500) !important;
}
.view-mode-active:hover {
background-color: var(--blue-700) !important;
border-color: var(--blue-700) !important;
}
.dark .view-mode-active:hover {
background-color: var(--blue-600) !important;
border-color: var(--blue-600) !important;
}
.theme-btn {
color: var(--gray-600);
padding: 0.5rem;
border-radius: 0.375rem;
transition: all 0.15s ease-in-out;
background: none;
border: none;
cursor: pointer;
}
.theme-btn:hover {
color: var(--gray-900);
}
.dark .theme-btn {
color: var(--gray-400);
}
.dark .theme-btn:hover {
color: var(--gray-200);
}
.theme-btn.active {
background-color: white;
color: var(--gray-900);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
.dark .theme-btn.active {
background-color: var(--gray-600);
color: var(--gray-100);
}
.tool-card {
background-color: white;
border: 1px solid var(--gray-200);
border-radius: 0.5rem;
padding: 1rem;
transition: box-shadow 0.15s ease-in-out;
cursor: pointer;
}
.tool-card:hover {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.dark .tool-card {
background-color: var(--gray-800);
border-color: var(--gray-700);
}
.tag {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
display: inline-block;
margin-right: 0.25rem;
margin-bottom: 0.25rem;
}
.tag-blue {
background-color: var(--blue-100);
color: var(--blue-800);
}
.dark .tag-blue {
background-color: var(--blue-900);
color: #bfdbfe;
}
.tag-green {
background-color: var(--green-100);
color: var(--green-800);
}
.dark .tag-green {
background-color: var(--green-900);
color: #86efac;
}
.tag-purple {
background-color: var(--purple-100);
color: var(--purple-800);
}
.dark .tag-purple {
background-color: var(--purple-900);
color: var(--purple-200);
}
.tag-gray {
background-color: var(--gray-100);
color: var(--gray-800);
}
.dark .tag-gray {
background-color: var(--gray-700);
color: var(--gray-200);
}
.matrix-cell-tool {
background-color: var(--blue-50);
border: 1px solid #bfdbfe;
border-radius: 0.25rem;
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
cursor: pointer;
transition: background-color 0.15s ease-in-out;
color: var(--blue-900);
display: block;
margin-bottom: 0.5rem;
}
.matrix-cell-tool:hover {
background-color: var(--blue-100);
}
.dark .matrix-cell-tool {
background-color: var(--blue-900);
border-color: var(--blue-700);
color: var(--blue-100);
}
.dark .matrix-cell-tool:hover {
background-color: var(--blue-800);
}
.status-indicator {
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
}
.status-indicator.operational { background-color: var(--green-500); }
.status-indicator.degraded { background-color: var(--yellow-500); }
.status-indicator.maintenance { background-color: var(--blue-500); }
.status-indicator.down { background-color: var(--red-500); }
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
}
.status-badge.operational {
color: var(--green-600);
background-color: var(--green-100);
}
.dark .status-badge.operational {
color: var(--green-400);
background-color: var(--green-900);
}
.status-badge.degraded {
color: var(--yellow-600);
background-color: var(--yellow-100);
}
.dark .status-badge.degraded {
color: var(--yellow-400);
background-color: var(--yellow-900);
}
.status-badge.maintenance {
color: var(--blue-600);
background-color: var(--blue-100);
}
.dark .status-badge.maintenance {
color: var(--blue-400);
background-color: var(--blue-900);
}
.status-badge.down {
color: var(--red-600);
background-color: var(--red-100);
}
.dark .status-badge.down {
color: var(--red-400);
background-color: var(--red-900);
}
/* Form Styles */
input, select, textarea {
background-color: white;
color: var(--gray-900);
}
.dark input,
.dark select,
.dark textarea {
background-color: var(--gray-700);
color: var(--gray-100);
}
/* Table Styles */
table {
border-collapse: collapse;
width: 100%;
}
/* Prose Styles */
.prose {
max-width: none;
}
.prose p {
margin-bottom: 1rem;
}
/* View Mode Management */
.view-mode.hidden {
display: none;
}
.view-mode.view-mode-active {
display: block;
}
/* Animation */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.tool-card {
animation: fadeIn 0.3s ease-out;
}
/* Responsive Utilities */
@media (max-width: 767px) {
.space-x-8 > * + * {
margin-left: 1rem;
}
.px-4 {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.overflow-x-auto {
-webkit-overflow-scrolling: touch;
}
}

View File

@ -1,85 +0,0 @@
# File: ./src/data/services.yaml
# Service Status Configuration
# This file configures the services monitored on the status page
# Connect to Uptime Kuma API for real-time data
# Uptime Kuma Configuration
uptimeKuma:
enabled: false # Set to true when Uptime Kuma is configured
apiUrl: "https://status.lab.local/api"
apiKey: "" # Add your Uptime Kuma API key here
refreshInterval: 30000 # Refresh every 30 seconds
# Static service definitions (used when Uptime Kuma is not available)
services:
- id: timesketch
name: "Timesketch Instance"
description: "Collaborative forensic timeline analysis platform"
url: "https://timesketch.lab.local"
category: "Analysis Tools"
status: "operational" # operational|degraded|maintenance|down
uptime: "99.9%"
responseTime: "245ms"
lastChecked: "2025-01-15T10:30:00Z"
- id: thehive
name: "TheHive Platform"
description: "Incident response and case management"
url: "https://thehive.lab.local"
category: "Case Management"
status: "operational"
uptime: "99.7%"
responseTime: "180ms"
lastChecked: "2025-01-15T10:30:00Z"
- id: misp
name: "MISP Instance"
description: "Threat intelligence sharing platform"
url: "https://misp.lab.local"
category: "Threat Intelligence"
status: "degraded"
uptime: "98.2%"
responseTime: "890ms"
lastChecked: "2025-01-15T10:29:00Z"
issues: "High response times due to database optimization"
- id: elasticsearch
name: "Elasticsearch Cluster"
description: "Search and analytics engine"
url: "https://elastic.lab.local"
category: "Infrastructure"
status: "operational"
uptime: "99.8%"
responseTime: "120ms"
lastChecked: "2025-01-15T10:30:00Z"
- id: neo4j
name: "Neo4j Database"
description: "Graph database for relationship analysis"
url: "https://neo4j.lab.local"
category: "Infrastructure"
status: "maintenance"
uptime: "97.1%"
responseTime: "N/A"
lastChecked: "2025-01-15T09:00:00Z"
issues: "Scheduled maintenance window: 09:00-11:00 UTC"
# Service categories for organization
categories:
- name: "Analysis Tools"
description: "Forensic analysis and timeline tools"
- name: "Case Management"
description: "Incident response and case tracking"
- name: "Threat Intelligence"
description: "IOC sharing and threat analysis"
- name: "Infrastructure"
description: "Backend services and databases"
# Overall status calculation
overall:
status: "partial_outage" # operational|degraded|partial_outage|major_outage
message: "4 of 5 services operational • 1 service under maintenance"
operationalCount: 3
degradedCount: 1
maintenanceCount: 1
downCount: 0

View File

@ -1,196 +1,312 @@
# File: ./src/data/tools.yaml
# DFIR Tools Database # DFIR Tools Database
# Edit this file to add, remove, or modify tools # Each tool can appear in multiple domains and phases
# Structure: Each tool should have required fields marked with * # Self-hosted services have isHosted: true and statusUrl for monitoring
tools: tools:
- id: sleuthkit - name: "Autopsy"
name: "The Sleuth Kit" # * Display name description: "Open source digital forensics platform with a graphical interface"
description: "Collection of command line tools for digital forensic analysis" # * Brief description
domains: # * Array of forensic domains
- "Filesystem Forensics"
phases: # * Array of DFIR phases
- "Examination"
- "Analysis"
platforms: # * Supported platforms
- "Linux"
- "Windows"
- "macOS"
skillLevel: "Intermediate" # * Beginner|Intermediate|Advanced
accessType: "CLI" # * CLI|GUI|Web|SaaS
url: "https://sleuthkit.org" # * Project homepage
tags: # Optional tags for search
- "timeline"
- "file-recovery"
- "metadata"
type: "FOSS" # * FOSS|SaaS
- id: volatility
name: "Volatility"
description: "Advanced memory forensics framework"
domains: domains:
- "Memory Forensics" - "storage-file-system"
- "Live Forensics" - "application-code"
phases: phases:
- "Examination" - "examination"
- "Analysis" - "analysis"
platforms: platforms: ["Windows", "Linux", "macOS"]
- "Linux" skillLevel: "intermediate"
- "Windows" accessType: "download"
- "macOS" url: "https://www.autopsy.com/"
skillLevel: "Advanced" license: "Apache 2.0"
accessType: "CLI" tags: ["disk-forensics", "file-recovery", "timeline-analysis"]
url: "https://volatilityfoundation.org" isHosted: false
tags:
- "memory-analysis"
- "malware-detection"
- "process-analysis"
type: "FOSS"
- id: wireshark - name: "Volatility 3"
name: "Wireshark" description: "Advanced memory forensics framework for incident response and malware analysis"
description: "Network protocol analyzer and packet capture tool"
domains: domains:
- "Network Forensics" - "memory-runtime"
phases: phases:
- "Examination" - "examination"
- "Reporting" - "analysis"
platforms: platforms: ["Windows", "Linux", "macOS"]
- "Linux" skillLevel: "advanced"
- "Windows" accessType: "download"
- "macOS" url: "https://www.volatilityfoundation.org/"
skillLevel: "Intermediate" license: "VSL"
accessType: "GUI" tags: ["memory-forensics", "malware-analysis", "incident-response"]
url: "https://wireshark.org" isHosted: false
tags:
- "packet-analysis"
- "network-traffic"
- "protocol-dissection"
type: "FOSS"
- id: plaso - name: "TheHive"
name: "Plaso" description: "Security incident response platform for SOCs, CERTs and security teams"
description: "Super timeline all the things"
domains: domains:
- "Filesystem Forensics" - "storage-file-system"
- "network-communication"
- "application-code"
phases: phases:
- "Analysis" - "data-collection"
- "Reporting" - "examination"
platforms: - "analysis"
- "Linux" - "reporting"
- "Windows" platforms: ["Web"]
- "macOS" skillLevel: "intermediate"
skillLevel: "Advanced" accessType: "self-hosted"
accessType: "CLI" url: "https://thehive.example.lab"
url: "https://plaso.readthedocs.io" license: "AGPL-3.0"
tags: tags: ["incident-response", "case-management", "collaboration"]
- "timeline" isHosted: true
- "log-analysis" statusUrl: "https://uptime.example.lab/api/badge/1/status"
- "artifact-parsing"
type: "FOSS"
- id: yara - name: "MISP"
name: "YARA" description: "Malware Information Sharing Platform for threat intelligence"
description: "Pattern matching engine for malware research"
domains: domains:
- "Malware Analysis" - "network-communication"
- "Live Forensics" - "application-code"
phases: phases:
- "Data Collection" - "data-collection"
- "Analysis" - "analysis"
platforms: - "reporting"
- "Linux" platforms: ["Web"]
- "Windows" skillLevel: "intermediate"
- "macOS" accessType: "self-hosted"
skillLevel: "Advanced" url: "https://misp.example.lab"
accessType: "CLI" license: "AGPL-3.0"
url: "https://virustotal.github.io/yara/" tags: ["threat-intelligence", "ioc-sharing", "collaboration"]
tags: isHosted: true
- "pattern-matching" statusUrl: "https://uptime.example.lab/api/badge/2/status"
- "malware-detection"
- "signatures"
type: "FOSS"
# Self-hosted services (what you call "SaaS Tools") - name: "Timesketch"
- id: timesketch description: "Collaborative forensic timeline analysis platform"
name: "Timesketch"
description: "Collaborative forensic timeline analysis"
domains: domains:
- "Filesystem Forensics" - "storage-file-system"
- "Network Forensics" - "network-communication"
phases: phases:
- "Analysis" - "analysis"
- "Reporting" - "reporting"
platforms: platforms: ["Web"]
- "Web" skillLevel: "intermediate"
skillLevel: "Intermediate" accessType: "self-hosted"
accessType: "Web" url: "https://timesketch.example.lab"
url: "https://timesketch.org" license: "Apache 2.0"
tags: tags: ["timeline-analysis", "collaboration", "visualization"]
- "timeline" isHosted: true
- "collaboration" statusUrl: "https://uptime.example.lab/api/badge/3/status"
- "visualization"
type: "SaaS"
selfHosted: true
serviceUrl: "https://timesketch.lab.local" # Internal lab URL
- id: thehive - name: "Wireshark"
name: "TheHive" description: "Network protocol analyzer for network troubleshooting and analysis"
description: "Scalable incident response platform"
domains: domains:
- "Live Forensics" - "network-communication"
phases: phases:
- "Data Collection" - "data-collection"
- "Analysis" - "examination"
- "Reporting" - "analysis"
platforms: platforms: ["Windows", "Linux", "macOS"]
- "Web" skillLevel: "intermediate"
skillLevel: "Intermediate" accessType: "download"
accessType: "Web" url: "https://www.wireshark.org/"
url: "https://thehive-project.org" license: "GPL-2.0"
tags: tags: ["network-analysis", "pcap", "protocol-analysis"]
- "incident-response" isHosted: false
- "case-management"
- "collaboration"
type: "SaaS"
selfHosted: true
serviceUrl: "https://thehive.lab.local"
- id: misp - name: "EnCase"
name: "MISP" description: "Commercial digital investigation platform"
description: "Threat intelligence sharing platform"
domains: domains:
- "Malware Analysis" - "storage-file-system"
- "Live Forensics" - "memory-runtime"
phases: phases:
- "Analysis" - "data-collection"
- "Reporting" - "examination"
platforms: - "analysis"
- "Web" - "reporting"
skillLevel: "Advanced" platforms: ["Windows"]
accessType: "Web" skillLevel: "advanced"
url: "https://misp-project.org" accessType: "commercial"
tags: url: "https://www.opentext.com/products/encase-forensic"
- "threat-intelligence" license: "Proprietary"
- "ioc-sharing" tags: ["commercial", "enterprise", "court-approved"]
- "attribution" isHosted: false
type: "SaaS"
selfHosted: true
serviceUrl: "https://misp.lab.local"
# Additional metadata - name: "Cuckoo Sandbox"
metadata: description: "Automated malware analysis system using virtualization"
lastUpdated: "2025-01-15" domains:
totalTools: 8 - "application-code"
domains: - "network-communication"
- "Filesystem Forensics" phases:
- "Network Forensics" - "examination"
- "Memory Forensics" - "analysis"
- "Live Forensics" platforms: ["Linux"]
- "Malware Analysis" skillLevel: "advanced"
- "Cryptocurrency" accessType: "self-hosted"
phases: url: "https://cuckoosandbox.org/"
- "Data Collection" license: "GPL-3.0"
- "Examination" tags: ["malware-analysis", "sandbox", "dynamic-analysis"]
- "Analysis" isHosted: true
- "Reporting" statusUrl: ""
- name: "FTK Imager"
description: "Forensic imaging and preview tool by Exterro"
domains:
- "storage-file-system"
phases:
- "data-collection"
- "examination"
platforms: ["Windows"]
skillLevel: "intermediate"
accessType: "download"
url: "https://exterro.com/ftk-imager"
license: "Proprietary"
tags: ["disk-imaging", "preview", "data-acquisition"]
isHosted: false
- name: "GRR Rapid Response"
description: "Remote live forensics platform by Google"
domains:
- "platform-infrastructure"
- "storage-file-system"
phases:
- "data-collection"
- "examination"
platforms: ["Linux", "Windows"]
skillLevel: "advanced"
accessType: "self-hosted"
url: "https://github.com/google/grr"
license: "Apache 2.0"
tags: ["live-forensics", "remote-response", "dfir"]
isHosted: true
statusUrl: ""
- name: "Plaso (log2timeline)"
description: "Tool for automatic creation of timelines from various log files"
domains:
- "storage-file-system"
- "application-code"
phases:
- "analysis"
platforms: ["Linux", "Windows", "macOS"]
skillLevel: "intermediate"
accessType: "download"
url: "https://plaso.readthedocs.io/"
license: "Apache 2.0"
tags: ["timeline-analysis", "log-parsing", "dfir"]
isHosted: false
- name: "NetworkMiner"
description: "Network forensic analysis tool (NFAT)"
domains:
- "network-communication"
phases:
- "examination"
- "analysis"
platforms: ["Windows", "Linux (Mono)"]
skillLevel: "intermediate"
accessType: "download"
url: "https://www.netresec.com/?page=NetworkMiner"
license: "Freeware/Commercial"
tags: ["pcap-analysis", "passive-sniffing", "credential-recovery"]
isHosted: false
- name: "Redline"
description: "Memory and host analysis tool from FireEye"
domains:
- "memory-runtime"
- "application-code"
phases:
- "examination"
- "analysis"
platforms: ["Windows"]
skillLevel: "intermediate"
accessType: "download"
url: "https://www.mandiant.com/resources/download/redline"
license: "Proprietary"
tags: ["memory-analysis", "ioc-scan", "host-analysis"]
isHosted: false
- name: "KAPE"
description: "Triage tool to collect and parse forensic artifacts quickly"
domains:
- "storage-file-system"
- "platform-infrastructure"
phases:
- "data-collection"
- "analysis"
platforms: ["Windows"]
skillLevel: "intermediate"
accessType: "download"
url: "https://www.kroll.com/en/services/cyber-risk/incident-response-litigation-support/kroll-artifact-parser-extractor-kape"
license: "Freeware"
tags: ["triage", "artifact-collection", "parsing"]
isHosted: false
- name: "Velociraptor"
description: "Endpoint visibility and DFIR tool by Rapid7"
domains:
- "platform-infrastructure"
- "storage-file-system"
phases:
- "data-collection"
- "examination"
platforms: ["Windows", "Linux", "macOS"]
skillLevel: "advanced"
accessType: "self-hosted"
url: "https://www.velociraptor.app/"
license: "Apache 2.0"
tags: ["dfir", "hunting", "endpoint-monitoring"]
isHosted: true
statusUrl: ""
- name: "Arkime"
description: "Large-scale full packet capture and analysis"
domains:
- "network-communication"
phases:
- "data-collection"
- "analysis"
platforms: ["Linux"]
skillLevel: "advanced"
accessType: "self-hosted"
url: "https://arkime.com/"
license: "Apache 2.0"
tags: ["packet-capture", "full-packet-analysis", "network-forensics"]
isHosted: true
statusUrl: ""
- name: "X-Ways Forensics"
description: "Advanced work environment for computer forensic examiners"
domains:
- "storage-file-system"
phases:
- "examination"
- "analysis"
- "reporting"
platforms: ["Windows"]
skillLevel: "advanced"
accessType: "commercial"
url: "https://www.x-ways.net/forensics/"
license: "Proprietary"
tags: ["disk-forensics", "file-recovery", "commercial"]
isHosted: false
# Domain definitions for reference
domains:
- id: "storage-file-system"
name: "Storage & File System Artifacts"
- id: "memory-runtime"
name: "Memory & Runtime Artifacts"
- id: "network-communication"
name: "Network & Communication Artifacts"
- id: "application-code"
name: "Application & Code Artifacts"
- id: "multimedia-content"
name: "Multimedia & Content Artifacts"
- id: "transaction-financial"
name: "Transaction & Financial Artifacts"
- id: "platform-infrastructure"
name: "Platform & Infrastructure Artifacts"
# Phase definitions for reference
phases:
- id: "data-collection"
name: "Data Collection"
- id: "examination"
name: "Examination"
- id: "analysis"
name: "Analysis"
- id: "reporting"
name: "Reporting"

1
src/env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference path="../.astro/types.d.ts" />

View File

@ -1,214 +0,0 @@
---
layout: base.njk
title: "Start"
description: "DFIR Tools Verzeichnis"
---
<!-- file: "./src/index.njk" -->
<!-- Header Content -->
<div class="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<!-- Search Bar -->
<div class="mb-6">
<div class="relative max-w-md">
<svg class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-gray-500 w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input
type="text"
id="search-input"
placeholder="Tools suchen..."
class="pl-10 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 w-full bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
/>
</div>
</div>
<!-- Navigation Links -->
<div class="flex flex-wrap gap-3">
<button
id="mode-selector"
class="view-mode-btn view-mode-active"
data-mode="selector"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.207A1 1 0 013 6.5V4z"/>
</svg>
Tool Auswahl
</button>
<button
id="mode-matrix"
class="view-mode-btn"
data-mode="matrix"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
</svg>
Matrix Ansicht
</button>
<button
id="mode-saas"
class="view-mode-btn"
data-mode="saas"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"/>
</svg>
Dienste
</button>
</div>
</div>
</div>
<!-- Main Content -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">`
<!-- Tool Selector Mode -->
<div id="view-selector" class="view-mode view-mode-active">
<!-- Dimension Selectors -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Bereich</label>
<div class="relative">
<select
id="domain-select"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-gray-500 appearance-none bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
>
<option value="">Alle Bereiche</option>
{% for domain in domains %}
<option value="{{ domain }}">{{ domain }}</option>
{% endfor %}
</select>
<svg class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-gray-500 w-5 h-5 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Phase</label>
<div class="relative">
<select
id="phase-select"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-gray-500 appearance-none bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
>
<option value="">Alle Phasen</option>
{% for phase in phases %}
<option value="{{ phase }}">{{ phase }}</option>
{% endfor %}
</select>
<svg class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-gray-500 w-5 h-5 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
</div>
<div class="flex items-end">
<button
id="reset-filters"
class="w-full px-4 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
>
Zurücksetzen
</button>
</div>
</div>
</div>
<!-- Results -->
<div id="tool-results" class="hidden">
<div class="mb-4">
<h2 id="results-title" class="text-xl font-semibold text-gray-900 dark:text-gray-100">
Ergebnisse (<span id="results-count">0</span>)
</h2>
<p id="results-description" class="text-sm text-gray-600 dark:text-gray-400"></p>
</div>
<div id="tools-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Tools will be populated by JavaScript -->
</div>
<div id="no-results" class="text-center py-12 hidden">
<p class="text-gray-500 dark:text-gray-400">Keine Tools gefunden.</p>
<button
id="reset-filters-empty"
class="mt-4 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
>
Zurücksetzen
</button>
</div>
</div>
<!-- Default State -->
<div id="default-state" class="text-center py-16">
<div class="max-w-md mx-auto">
<svg class="w-12 h-12 text-gray-400 dark:text-gray-500 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.207A1 1 0 013 6.5V4z"/>
</svg>
<p class="text-gray-600 dark:text-gray-300">
Wählen Sie Bereich und/oder Phase aus.
</p>
</div>
</div>
</div>
<!-- Matrix View Mode -->
<div id="view-matrix" class="view-mode hidden">
<div class="mb-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100">Tool Matrix</h2>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="p-4 text-left font-semibold text-gray-900 dark:text-gray-100 min-w-48 bg-gray-50 dark:bg-gray-900">Bereich / Phase</th>
{% for phase in phases %}
<th class="p-4 text-center font-semibold min-w-64 text-gray-900 dark:text-gray-100 bg-gray-50 dark:bg-gray-900">
{{ phase }}
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for domain in domains %}
<tr class="border-b border-gray-200 dark:border-gray-700">
<td class="p-4 font-medium text-gray-900 dark:text-gray-100">{{ domain }}</td>
{% for phase in phases %}
<td class="p-4 align-top">
<div class="space-y-2" data-domain="{{ domain }}" data-phase="{{ phase }}">
<!-- Tools will be populated by JavaScript -->
</div>
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- SaaS Tools Mode -->
<div id="view-saas" class="view-mode hidden">
<div class="mb-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100">
Dienste (<span id="saas-count">0</span>)
</h2>
</div>
<div id="saas-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- SaaS tools will be populated by JavaScript -->
</div>
<div id="no-saas-results" class="text-center py-12 hidden">
<p class="text-gray-500 dark:text-gray-400">Keine Dienste gefunden.</p>
</div>
</div>
</div>
<!-- Pass data to JavaScript -->
<script>
window.toolsData = {{ tools.tools | dump | safe }};
window.domains = {{ domains | dump | safe }};
window.phases = {{ phases | dump | safe }};
</script>

View File

@ -1,244 +0,0 @@
// File: ./src/js/modal.js
// Tool detail modal functionality
(function() {
'use strict';
let modal;
let modalContent;
let currentTool = null;
// Initialize modal system
function init() {
modal = document.getElementById('tool-modal');
modalContent = document.getElementById('tool-modal-content');
if (!modal || !modalContent) {
console.warn('Modal elements not found');
return;
}
// Add event listeners
setupEventListeners();
}
// Set up event listeners for modal
function setupEventListeners() {
// Click outside modal to close
modal.addEventListener('click', (event) => {
if (event.target === modal) {
closeModal();
}
});
// Escape key to close modal
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !modal.classList.contains('hidden')) {
closeModal();
}
});
// Prevent body scroll when modal is open
modal.addEventListener('scroll', (event) => {
event.stopPropagation();
});
}
// Show tool detail modal
function showToolModal(tool) {
if (!tool || !modal || !modalContent) return;
currentTool = tool;
// Generate modal content
modalContent.innerHTML = generateModalContent(tool);
// Add close button handler
const closeButton = modalContent.querySelector('.modal-close');
if (closeButton) {
closeButton.addEventListener('click', closeModal);
}
// Add external link handler
const externalLink = modalContent.querySelector('.external-link');
if (externalLink) {
externalLink.addEventListener('click', (event) => {
// Let the link work normally, but track the click
trackToolClick(tool);
});
}
// Show modal
modal.classList.remove('hidden');
document.body.style.overflow = 'hidden';
// Focus trap
trapFocus();
}
// Close modal
function closeModal() {
if (!modal) return;
modal.classList.add('hidden');
document.body.style.overflow = '';
currentTool = null;
// Return focus to the tool card that was clicked
const activeToolCard = document.querySelector('.tool-card:focus');
if (activeToolCard) {
activeToolCard.focus();
}
}
// Generate modal HTML content
function generateModalContent(tool) {
const typeLabel = tool.type === 'SaaS' ?
'<span class="tag tag-purple">SaaS</span>' :
'<span class="tag tag-green">FOSS</span>';
const domains = (tool.domains || []).map(domain =>
`<span class="block tag tag-green mb-2">${domain}</span>`
).join('');
const phases = (tool.phases || []).map(phase =>
`<span class="block tag tag-blue mb-2">${phase}</span>`
).join('');
const tags = (tool.tags || []).map(tag =>
`<span class="tag tag-gray mr-2 mb-2">${tag}</span>`
).join('');
const platforms = (tool.platforms || []).join(', ');
// Handle service URL for self-hosted services
const serviceInfo = tool.selfHosted && tool.serviceUrl ?
`<div class="mb-4 p-3 bg-blue-50 dark:bg-blue-900 rounded-lg">
<p class="text-sm text-blue-800 dark:text-blue-200">
<strong>Lab Instance:</strong>
<a href="${tool.serviceUrl}" target="_blank" rel="noopener noreferrer"
class="underline hover:no-underline">${tool.serviceUrl}</a>
</p>
</div>` : '';
return `
<div class="flex justify-between items-start mb-4">
<div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">${tool.name}</h2>
<div class="flex items-center gap-2 mt-1">
${typeLabel}
<span class="text-sm text-gray-500 dark:text-gray-400">${tool.skillLevel}</span>
</div>
</div>
<button
class="modal-close text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 text-2xl"
aria-label="Schließen"
>
×
</button>
</div>
<p class="text-gray-600 dark:text-gray-300 mb-6">${tool.description}</p>
${serviceInfo}
<div class="grid grid-cols-2 gap-6 mb-6">
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3">Bereiche</h3>
<div class="space-y-2">
${domains || '<span class="text-gray-500 dark:text-gray-400 text-sm">Keine angegeben</span>'}
</div>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3">Phasen</h3>
<div class="space-y-2">
${phases || '<span class="text-gray-500 dark:text-gray-400 text-sm">Keine angegeben</span>'}
</div>
</div>
</div>
<div class="mb-6">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3">Tags</h3>
<div class="flex flex-wrap">
${tags || '<span class="text-gray-500 dark:text-gray-400 text-sm">Keine Tags</span>'}
</div>
</div>
<div class="flex justify-between items-center pt-4 border-t border-gray-200 dark:border-gray-700">
<div class="text-sm text-gray-600 dark:text-gray-400">
<div>Zugriff: <span class="font-medium">${tool.accessType}</span></div>
<div>Plattformen: <span class="font-medium">${platforms}</span></div>
</div>
<a
href="${tool.url}"
target="_blank"
rel="noopener noreferrer"
class="external-link flex items-center gap-2 px-6 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
>
Projekt öffnen
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
</a>
</div>
`;
}
// Simple focus trap for modal
function trapFocus() {
const focusableElements = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// Focus first element
firstElement.focus();
// Handle tab key
modal.addEventListener('keydown', (event) => {
if (event.key !== 'Tab') return;
if (event.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
} else {
// Tab
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
});
}
// Track tool clicks for analytics (placeholder)
function trackToolClick(tool) {
// This is where you'd send analytics data
console.log('Tool clicked:', tool.name, tool.url);
// Could send to analytics service:
// analytics.track('tool_clicked', {
// tool_name: tool.name,
// tool_type: tool.type,
// tool_url: tool.url
// });
}
// Export functions for use by other scripts
window.showToolModal = showToolModal;
window.closeToolModal = closeModal;
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@ -1,383 +0,0 @@
// File: ./src/js/search.js
// Search and filtering functionality
(function() {
'use strict';
// State management
let currentFilters = {
search: '',
domain: '',
phase: '',
mode: 'selector'
};
let allTools = [];
let domains = [];
let phases = [];
// DOM elements
let searchInput;
let domainSelect;
let phaseSelect;
let resetButton;
let resetEmptyButton;
let toolResults;
let toolsGrid;
let defaultState;
let noResults;
let resultsTitle;
let resultsCount;
let resultsDescription;
let viewModes;
let modeButtons;
// Initialize when DOM is loaded
function init() {
// Get data from global variables (set by template)
if (typeof window.toolsData !== 'undefined') {
allTools = window.toolsData;
domains = window.domains || [];
phases = window.phases || [];
}
// Get DOM elements
searchInput = document.getElementById('search-input');
domainSelect = document.getElementById('domain-select');
phaseSelect = document.getElementById('phase-select');
resetButton = document.getElementById('reset-filters');
resetEmptyButton = document.getElementById('reset-filters-empty');
toolResults = document.getElementById('tool-results');
toolsGrid = document.getElementById('tools-grid');
defaultState = document.getElementById('default-state');
noResults = document.getElementById('no-results');
resultsTitle = document.getElementById('results-title');
resultsCount = document.getElementById('results-count');
resultsDescription = document.getElementById('results-description');
// View mode elements
viewModes = {
selector: document.getElementById('view-selector'),
matrix: document.getElementById('view-matrix'),
saas: document.getElementById('view-saas')
};
modeButtons = {
selector: document.getElementById('mode-selector'),
matrix: document.getElementById('mode-matrix'),
saas: document.getElementById('mode-saas')
};
// Add event listeners
if (searchInput) {
searchInput.addEventListener('input', handleSearchChange);
}
if (domainSelect) {
domainSelect.addEventListener('change', handleDomainChange);
}
if (phaseSelect) {
phaseSelect.addEventListener('change', handlePhaseChange);
}
if (resetButton) {
resetButton.addEventListener('click', resetFilters);
}
if (resetEmptyButton) {
resetEmptyButton.addEventListener('click', resetFilters);
}
// Mode switching
Object.keys(modeButtons).forEach(mode => {
if (modeButtons[mode]) {
modeButtons[mode].addEventListener('click', () => switchMode(mode));
}
});
// Initialize views
updateDisplay();
populateMatrix();
populateSaasGrid();
}
// Event handlers
function handleSearchChange(event) {
currentFilters.search = event.target.value;
updateDisplay();
}
function handleDomainChange(event) {
currentFilters.domain = event.target.value;
updateDisplay();
}
function handlePhaseChange(event) {
currentFilters.phase = event.target.value;
updateDisplay();
}
function resetFilters() {
currentFilters.search = '';
currentFilters.domain = '';
currentFilters.phase = '';
if (searchInput) searchInput.value = '';
if (domainSelect) domainSelect.value = '';
if (phaseSelect) phaseSelect.value = '';
updateDisplay();
}
function switchMode(mode) {
currentFilters.mode = mode;
// Update button states
Object.keys(modeButtons).forEach(m => {
if (modeButtons[m]) {
modeButtons[m].classList.toggle('view-mode-active', m === mode);
modeButtons[m].classList.toggle('view-mode-btn', m !== mode);
}
});
// Update view visibility
Object.keys(viewModes).forEach(m => {
if (viewModes[m]) {
viewModes[m].classList.toggle('hidden', m !== mode);
viewModes[m].classList.toggle('view-mode-active', m === mode);
}
});
// Update displays based on mode
if (mode === 'matrix') {
populateMatrix();
} else if (mode === 'saas') {
populateSaasGrid();
} else {
updateDisplay();
}
}
// Filter tools based on current filters
function filterTools() {
let filtered = allTools;
// Filter by type based on mode
if (currentFilters.mode === 'saas') {
filtered = filtered.filter(tool => tool.type === 'SaaS');
} else if (currentFilters.mode === 'selector' || currentFilters.mode === 'matrix') {
filtered = filtered.filter(tool => tool.type === 'FOSS');
}
// Apply search filter
if (currentFilters.search) {
const searchTerm = currentFilters.search.toLowerCase();
filtered = filtered.filter(tool =>
tool.name.toLowerCase().includes(searchTerm) ||
tool.description.toLowerCase().includes(searchTerm) ||
(tool.tags && tool.tags.some(tag => tag.toLowerCase().includes(searchTerm)))
);
}
// Apply domain filter (only for selector mode)
if (currentFilters.mode === 'selector' && currentFilters.domain) {
filtered = filtered.filter(tool =>
tool.domains && tool.domains.includes(currentFilters.domain)
);
}
// Apply phase filter (only for selector mode)
if (currentFilters.mode === 'selector' && currentFilters.phase) {
filtered = filtered.filter(tool =>
tool.phases && tool.phases.includes(currentFilters.phase)
);
}
return filtered;
}
// Update the display based on current filters
function updateDisplay() {
if (currentFilters.mode !== 'selector') return;
const hasFilters = currentFilters.search || currentFilters.domain || currentFilters.phase;
if (!hasFilters) {
// Show default state
if (toolResults) toolResults.classList.add('hidden');
if (defaultState) defaultState.classList.remove('hidden');
return;
}
const filtered = filterTools();
// Hide default state, show results
if (defaultState) defaultState.classList.add('hidden');
if (toolResults) toolResults.classList.remove('hidden');
// Update results metadata
if (resultsCount) resultsCount.textContent = filtered.length;
updateResultsDescription();
// Show/hide no results message
if (filtered.length === 0) {
if (toolsGrid) toolsGrid.classList.add('hidden');
if (noResults) noResults.classList.remove('hidden');
} else {
if (noResults) noResults.classList.add('hidden');
if (toolsGrid) toolsGrid.classList.remove('hidden');
renderTools(filtered);
}
}
// Update results description text
function updateResultsDescription() {
if (!resultsDescription) return;
const parts = [];
if (currentFilters.domain) parts.push(`Bereich: ${currentFilters.domain}`);
if (currentFilters.phase) parts.push(`Phase: ${currentFilters.phase}`);
if (currentFilters.search) parts.push(`Suche: "${currentFilters.search}"`);
resultsDescription.textContent = parts.join(' • ');
}
// Render tools in the grid
function renderTools(tools) {
if (!toolsGrid) return;
toolsGrid.innerHTML = tools.map(tool => createToolCard(tool)).join('');
// Add click handlers for tool cards
const toolCards = toolsGrid.querySelectorAll('.tool-card');
toolCards.forEach((card, index) => {
card.addEventListener('click', () => showToolModal(tools[index]));
});
}
// Create HTML for a tool card
function createToolCard(tool) {
const typeLabel = tool.type === 'SaaS' ?
'<span class="tag tag-purple">SaaS</span>' : '';
const tags = (tool.tags || []).slice(0, 3).map(tag =>
`<span class="tag tag-blue">${tag}</span>`
).join('');
const platforms = (tool.platforms || []).join(', ');
return `
<div class="tool-card" data-tool-id="${tool.id}">
<div class="flex items-start justify-between mb-2">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 text-lg">
${tool.name}
</h3>
<div class="flex items-center gap-2">
${typeLabel}
<svg class="w-4 h-4 text-gray-400 hover:text-blue-600 dark:text-gray-500 dark:hover:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
</div>
</div>
<p class="text-gray-600 dark:text-gray-300 text-sm mb-3">${tool.description}</p>
<div class="flex flex-wrap gap-1 mb-2">
${tags}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
${tool.skillLevel} ${tool.accessType} ${platforms}
</div>
</div>
`;
}
// Populate matrix view
function populateMatrix() {
if (currentFilters.mode !== 'matrix') return;
domains.forEach(domain => {
phases.forEach(phase => {
const cell = document.querySelector(`[data-domain="${domain}"][data-phase="${phase}"]`);
if (!cell) return;
const cellTools = allTools.filter(tool =>
tool.type === 'FOSS' &&
tool.domains && tool.domains.includes(domain) &&
tool.phases && tool.phases.includes(phase) &&
(!currentFilters.search ||
tool.name.toLowerCase().includes(currentFilters.search.toLowerCase()) ||
tool.description.toLowerCase().includes(currentFilters.search.toLowerCase()) ||
(tool.tags && tool.tags.some(tag => tag.toLowerCase().includes(currentFilters.search.toLowerCase()))))
);
if (cellTools.length === 0) {
cell.innerHTML = '<div class="text-gray-400 dark:text-gray-500 text-sm text-center py-2">-</div>';
} else {
cell.innerHTML = cellTools.map(tool =>
`<div class="matrix-cell-tool" data-tool-id="${tool.id}">${tool.name}</div>`
).join('');
// Add click handlers
const toolElements = cell.querySelectorAll('.matrix-cell-tool');
toolElements.forEach((element, index) => {
element.addEventListener('click', () => showToolModal(cellTools[index]));
});
}
});
});
}
// Populate SaaS grid
function populateSaasGrid() {
if (currentFilters.mode !== 'saas') return;
const saasGrid = document.getElementById('saas-grid');
const saasCount = document.getElementById('saas-count');
const noSaasResults = document.getElementById('no-saas-results');
if (!saasGrid) return;
const saasTools = filterTools();
if (saasCount) saasCount.textContent = saasTools.length;
if (saasTools.length === 0) {
saasGrid.classList.add('hidden');
if (noSaasResults) noSaasResults.classList.remove('hidden');
} else {
if (noSaasResults) noSaasResults.classList.add('hidden');
saasGrid.classList.remove('hidden');
saasGrid.innerHTML = saasTools.map(tool => createToolCard(tool)).join('');
// Add click handlers
const toolCards = saasGrid.querySelectorAll('.tool-card');
toolCards.forEach((card, index) => {
card.addEventListener('click', () => showToolModal(saasTools[index]));
});
}
}
// Show tool modal (defined in modal.js)
function showToolModal(tool) {
if (typeof window.showToolModal === 'function') {
window.showToolModal(tool);
}
}
// Export for use by other scripts
window.searchModule = {
init,
filterTools,
updateDisplay,
populateMatrix,
populateSaasGrid,
resetFilters
};
// Auto-initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@ -1,386 +0,0 @@
// File: ./src/js/status.js
// Status page functionality for Uptime Kuma integration
(function() {
'use strict';
let servicesData = {};
let uptimeKumaConfig = {};
let refreshInterval;
// Initialize status page
function init() {
// Get data from global variables
if (typeof window.servicesData !== 'undefined') {
servicesData = window.servicesData;
}
if (typeof window.uptimeKumaConfig !== 'undefined') {
uptimeKumaConfig = window.uptimeKumaConfig || {};
}
// Set up refresh button
const refreshButton = document.getElementById('refresh-status');
if (refreshButton) {
refreshButton.addEventListener('click', refreshStatus);
}
// Update Kuma configuration display
updateKumaStatusDisplay();
// Initial load
loadServiceStatus();
// Set up auto-refresh if Uptime Kuma is enabled
if (uptimeKumaConfig.enabled && uptimeKumaConfig.refreshInterval) {
refreshInterval = setInterval(loadServiceStatus, uptimeKumaConfig.refreshInterval);
}
}
// Load service status (from Uptime Kuma API or static data)
async function loadServiceStatus() {
try {
let services;
if (uptimeKumaConfig.enabled && uptimeKumaConfig.apiUrl) {
// Try to fetch from Uptime Kuma API
services = await fetchFromUptimeKuma();
}
if (!services) {
// Fall back to static data
services = servicesData.services || [];
updateKumaConnectionStatus('static');
} else {
updateKumaConnectionStatus('connected');
}
// Render services
renderServices(services);
renderOverallStatus(services);
updateLastUpdated();
} catch (error) {
console.error('Failed to load service status:', error);
// Fall back to static data
const services = servicesData.services || [];
renderServices(services);
renderOverallStatus(services);
updateKumaConnectionStatus('error');
updateLastUpdated();
}
}
// Fetch data from Uptime Kuma API
async function fetchFromUptimeKuma() {
if (!uptimeKumaConfig.apiUrl) {
throw new Error('Uptime Kuma API URL not configured');
}
const headers = {};
if (uptimeKumaConfig.apiKey) {
headers['Authorization'] = `Bearer ${uptimeKumaConfig.apiKey}`;
}
try {
const response = await fetch(`${uptimeKumaConfig.apiUrl}/status-page/heartbeat`, {
headers,
timeout: 5000
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// Transform Uptime Kuma data to our format
return transformUptimeKumaData(data);
} catch (error) {
console.warn('Failed to fetch from Uptime Kuma:', error.message);
return null;
}
}
// Transform Uptime Kuma API response to our service format
function transformUptimeKumaData(kumaData) {
// This is a simplified transformation - adjust based on actual Uptime Kuma API response
const services = [];
if (kumaData.heartbeatList) {
Object.entries(kumaData.heartbeatList).forEach(([monitorId, heartbeats]) => {
const monitor = kumaData.monitors?.[monitorId];
if (!monitor) return;
const latestHeartbeat = heartbeats[heartbeats.length - 1];
if (!latestHeartbeat) return;
services.push({
id: monitorId,
name: monitor.name,
description: monitor.description || '',
url: monitor.url,
category: monitor.tags?.[0] || 'Infrastructure',
status: latestHeartbeat.status === 1 ? 'operational' : 'down',
uptime: calculateUptime(heartbeats),
responseTime: `${latestHeartbeat.ping}ms`,
lastChecked: new Date(latestHeartbeat.time).toISOString()
});
});
}
return services;
}
// Calculate uptime percentage from heartbeat data
function calculateUptime(heartbeats) {
if (!heartbeats || heartbeats.length === 0) return '0%';
const total = heartbeats.length;
const successful = heartbeats.filter(h => h.status === 1).length;
const percentage = (successful / total) * 100;
return `${percentage.toFixed(1)}%`;
}
// Render services list
function renderServices(services) {
const serviceList = document.getElementById('service-list');
if (!serviceList) return;
if (!services || services.length === 0) {
serviceList.innerHTML = `
<div class="text-center py-8">
<p class="text-gray-500 dark:text-gray-400">Keine Dienste konfiguriert</p>
</div>
`;
return;
}
serviceList.innerHTML = services.map(service => createServiceCard(service)).join('');
}
// Create HTML for a service card
function createServiceCard(service) {
const statusClass = getStatusClass(service.status);
const statusText = service.status.charAt(0).toUpperCase() + service.status.slice(1);
const lastChecked = service.lastChecked ?
new Date(service.lastChecked).toLocaleString() : 'Unknown';
const issuesSection = service.issues ?
`<div class="mt-3 p-3 bg-yellow-50 dark:bg-yellow-900 rounded border-l-4 border-yellow-400">
<p class="text-sm text-yellow-800 dark:text-yellow-200">${service.issues}</p>
</div>` : '';
return `
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="status-indicator ${service.status}"></div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">${service.name}</h3>
${service.description ? `<p class="text-sm text-gray-600 dark:text-gray-400">${service.description}</p>` : ''}
</div>
</div>
<span class="status-badge ${service.status}">${statusText}</span>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span class="text-gray-500 dark:text-gray-400">Uptime:</span>
<span class="ml-2 font-medium text-gray-900 dark:text-gray-100">${service.uptime || 'N/A'}</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Response:</span>
<span class="ml-2 font-medium text-gray-900 dark:text-gray-100">${service.responseTime || 'N/A'}</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Category:</span>
<span class="ml-2 font-medium text-gray-900 dark:text-gray-100">${service.category || 'Unknown'}</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Last Check:</span>
<span class="ml-2 font-medium text-gray-900 dark:text-gray-100" title="${lastChecked}">
${formatRelativeTime(service.lastChecked)}
</span>
</div>
</div>
${issuesSection}
</div>
`;
}
// Render overall status summary
function renderOverallStatus(services) {
if (!services || services.length === 0) return;
const statusCounts = {
operational: services.filter(s => s.status === 'operational').length,
degraded: services.filter(s => s.status === 'degraded').length,
maintenance: services.filter(s => s.status === 'maintenance').length,
down: services.filter(s => s.status === 'down').length
};
const total = services.length;
// Determine overall status
let overallStatus, overallText;
if (statusCounts.down > 0) {
overallStatus = 'down';
overallText = 'Major Outage';
} else if (statusCounts.degraded > 0 || statusCounts.maintenance > 0) {
overallStatus = statusCounts.maintenance > 0 ? 'maintenance' : 'degraded';
overallText = statusCounts.maintenance > 0 ? 'Partial Service' : 'Degraded Performance';
} else {
overallStatus = 'operational';
overallText = 'All Systems Operational';
}
// Update overall status display
const indicator = document.getElementById('overall-indicator');
const textElement = document.getElementById('overall-text');
const summaryElement = document.getElementById('overall-summary');
if (indicator) {
const statusDot = indicator.querySelector('.status-indicator');
if (statusDot) {
statusDot.className = `status-indicator ${overallStatus}`;
}
}
if (textElement) {
textElement.textContent = overallText;
textElement.className = getStatusTextClass(overallStatus);
}
if (summaryElement) {
const parts = [];
if (statusCounts.operational > 0) parts.push(`${statusCounts.operational} operational`);
if (statusCounts.degraded > 0) parts.push(`${statusCounts.degraded} degraded`);
if (statusCounts.maintenance > 0) parts.push(`${statusCounts.maintenance} maintenance`);
if (statusCounts.down > 0) parts.push(`${statusCounts.down} down`);
summaryElement.textContent = `${parts.join(' • ')} of ${total} services`;
}
}
// Update Uptime Kuma connection status display
function updateKumaConnectionStatus(status) {
const kumaStatus = document.getElementById('kuma-connection-status');
const kumaEndpoint = document.getElementById('kuma-endpoint');
if (kumaStatus) {
switch (status) {
case 'connected':
kumaStatus.textContent = 'Connected';
kumaStatus.className = 'tag tag-green';
break;
case 'static':
kumaStatus.textContent = 'Using Static Data';
kumaStatus.className = 'tag tag-blue';
break;
case 'error':
kumaStatus.textContent = 'Connection Failed';
kumaStatus.className = 'tag tag-red';
break;
default:
kumaStatus.textContent = 'Not Configured';
kumaStatus.className = 'tag tag-gray';
}
}
if (kumaEndpoint) {
kumaEndpoint.textContent = uptimeKumaConfig.apiUrl || 'Not configured';
}
}
// Update Kuma status display
function updateKumaStatusDisplay() {
const kumaStatusEl = document.getElementById('uptime-kuma-status');
if (!kumaStatusEl) return;
if (uptimeKumaConfig.enabled) {
kumaStatusEl.classList.remove('border-dashed');
kumaStatusEl.classList.add('border-solid', 'border-blue-300', 'dark:border-blue-600');
kumaStatusEl.classList.remove('bg-gray-100', 'dark:bg-gray-700');
kumaStatusEl.classList.add('bg-blue-50', 'dark:bg-blue-900');
}
}
// Manual refresh
function refreshStatus() {
const button = document.getElementById('refresh-status');
if (button) {
button.disabled = true;
button.textContent = 'Refreshing...';
}
loadServiceStatus().finally(() => {
if (button) {
button.disabled = false;
button.textContent = 'Refresh Status';
}
});
}
// Update last updated timestamp
function updateLastUpdated() {
const element = document.getElementById('last-updated');
if (element) {
element.textContent = new Date().toLocaleString();
}
}
// Utility functions
function getStatusClass(status) {
switch (status) {
case 'operational': return 'operational';
case 'degraded': return 'degraded';
case 'maintenance': return 'maintenance';
case 'down': return 'down';
default: return 'down';
}
}
function getStatusTextClass(status) {
switch (status) {
case 'operational': return 'text-green-600 dark:text-green-400 font-medium';
case 'degraded': return 'text-yellow-600 dark:text-yellow-400 font-medium';
case 'maintenance': return 'text-blue-600 dark:text-blue-400 font-medium';
case 'down': return 'text-red-600 dark:text-red-400 font-medium';
default: return 'text-gray-600 dark:text-gray-400 font-medium';
}
}
function formatRelativeTime(dateString) {
if (!dateString) return 'Unknown';
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
}
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
});
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@ -1,141 +0,0 @@
// File: ./src/js/theme.js
// Theme management functionality
(function() {
'use strict';
let currentTheme = 'auto';
let mediaQuery;
// Initialize theme system
function init() {
// Get stored theme or default to auto
currentTheme = localStorage.getItem('theme') || 'auto';
// Set up media query listener for auto mode
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', handleSystemThemeChange);
// Apply initial theme
applyTheme(currentTheme);
// Set up theme selector buttons
setupThemeSelector();
}
// Set up theme selector button event handlers
function setupThemeSelector() {
const themeButtons = document.querySelectorAll('.theme-btn');
themeButtons.forEach(button => {
const theme = button.getAttribute('data-theme');
// Set initial active state
button.classList.toggle('active', theme === currentTheme);
// Add click handler
button.addEventListener('click', () => {
setTheme(theme);
});
});
}
// Set theme and update UI
function setTheme(theme) {
if (!['light', 'dark', 'auto'].includes(theme)) {
console.warn('Invalid theme:', theme);
return;
}
currentTheme = theme;
// Store in localStorage
localStorage.setItem('theme', theme);
// Apply theme
applyTheme(theme);
// Update button states
updateThemeButtons();
}
// Apply theme to document
function applyTheme(theme) {
const html = document.documentElement;
// Update theme class
html.className = html.className.replace(/theme-\w+/g, '');
html.classList.add(`theme-${theme}`);
// Handle dark mode class
if (theme === 'auto') {
// Use system preference
const isDark = mediaQuery ? mediaQuery.matches :
window.matchMedia('(prefers-color-scheme: dark)').matches;
html.classList.toggle('dark', isDark);
} else {
// Use explicit theme
html.classList.toggle('dark', theme === 'dark');
}
}
// Handle system theme changes (for auto mode)
function handleSystemThemeChange(event) {
if (currentTheme === 'auto') {
document.documentElement.classList.toggle('dark', event.matches);
}
}
// Update theme button active states
function updateThemeButtons() {
const themeButtons = document.querySelectorAll('.theme-btn');
themeButtons.forEach(button => {
const theme = button.getAttribute('data-theme');
button.classList.toggle('active', theme === currentTheme);
});
}
// Get current effective theme (resolves 'auto' to actual theme)
function getEffectiveTheme() {
if (currentTheme === 'auto') {
return mediaQuery && mediaQuery.matches ? 'dark' : 'light';
}
return currentTheme;
}
// Toggle between light and dark (skips auto)
function toggleTheme() {
const effectiveTheme = getEffectiveTheme();
setTheme(effectiveTheme === 'dark' ? 'light' : 'dark');
}
// Keyboard shortcut support
function setupKeyboardShortcuts() {
document.addEventListener('keydown', (event) => {
// Ctrl/Cmd + Shift + T to toggle theme
if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === 'T') {
event.preventDefault();
toggleTheme();
}
});
}
// Export functions for external use
window.themeManager = {
setTheme,
getTheme: () => currentTheme,
getEffectiveTheme,
toggleTheme
};
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
init();
setupKeyboardShortcuts();
});
} else {
init();
setupKeyboardShortcuts();
}
})();

View File

@ -0,0 +1,35 @@
---
import Navigation from '../components/Navigation.astro';
import Footer from '../components/Footer.astro';
import '../styles/global.css';
export interface Props {
title: string;
description?: string;
}
const { title, description = 'DFIR Tools Hub - A comprehensive directory of digital forensics and incident response tools' } = Astro.props;
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content={description}>
<title>{title} - DFIR Tools Hub</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.themeUtils.initTheme();
</script>
</head>
<body>
<Navigation />
<main class="container" style="flex: 1; padding: 2rem 1rem;">
<slot />
</main>
<Footer />
</body>
</html>

53
src/pages/about.astro Normal file
View File

@ -0,0 +1,53 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="About">
<section style="padding: 2rem 0; max-width: 800px; margin: 0 auto;">
<h1 style="text-align: center; margin-bottom: 2rem;">About DFIR Tools Hub</h1>
<div class="card" style="margin-bottom: 2rem;">
<h2>Project Overview</h2>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
</p>
<p>
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
</p>
</div>
<div class="card" style="margin-bottom: 2rem;">
<h2>DFIR Methodology</h2>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit:</p>
<ul style="margin-left: 1.5rem; margin-bottom: 1rem;">
<li><strong>Data Collection:</strong> Lorem ipsum dolor sit amet</li>
<li><strong>Examination:</strong> Consectetur adipiscing elit</li>
<li><strong>Analysis:</strong> Sed do eiusmod tempor incididunt</li>
<li><strong>Reporting:</strong> Ut labore et dolore magna aliqua</li>
</ul>
</div>
<div class="card" style="margin-bottom: 2rem;">
<h2>Forensic Domains</h2>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit:</p>
<ul style="margin-left: 1.5rem;">
<li>Storage & File System Artifacts</li>
<li>Memory & Runtime Artifacts</li>
<li>Network & Communication Artifacts</li>
<li>Application & Code Artifacts</li>
<li>Multimedia & Content Artifacts</li>
<li>Transaction & Financial Artifacts</li>
<li>Platform & Infrastructure Artifacts</li>
</ul>
</div>
<div class="card">
<h2>Contributing</h2>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Visit our <a href="https://github.com/your-org/dfir-tools-hub" target="_blank" rel="noopener noreferrer">GitHub repository</a>
to contribute or report issues.
</p>
</div>
</section>
</BaseLayout>

163
src/pages/index.astro Normal file
View File

@ -0,0 +1,163 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import ToolCard from '../components/ToolCard.astro';
import ToolFilters from '../components/ToolFilters.astro';
import ToolMatrix from '../components/ToolMatrix.astro';
import { promises as fs } from 'fs';
import { load } from 'js-yaml';
import path from 'path';
// Load tools data
const yamlPath = path.join(process.cwd(), 'src/data/tools.yaml');
const yamlContent = await fs.readFile(yamlPath, 'utf8');
const data = load(yamlContent) as any;
const tools = data.tools;
---
<BaseLayout title="Home">
<!-- Hero Section -->
<section style="text-align: center; padding: 3rem 0; border-bottom: 1px solid var(--color-border);">
<h1 style="margin-bottom: 1rem;">DFIR Tools Hub</h1>
<p class="text-muted" style="font-size: 1.125rem; max-width: 800px; margin: 0 auto;">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation.
</p>
</section>
<!-- Filters Section -->
<section style="padding: 2rem 0;">
<ToolFilters />
</section>
<!-- Tools Grid -->
<section id="tools-grid" style="padding-bottom: 2rem;">
<div class="grid grid-cols-3 gap-4" id="tools-container">
{tools.map((tool: any) => (
<ToolCard tool={tool} />
))}
</div>
<!-- No results message -->
<div id="no-results" style="display: none; text-align: center; padding: 4rem 0;">
<p class="text-muted" style="font-size: 1.125rem;">No tools found matching your criteria.</p>
</div>
</section>
<!-- Matrix View -->
<ToolMatrix />
</BaseLayout>
<script>
// Handle view changes and filtering
document.addEventListener('DOMContentLoaded', () => {
const toolsContainer = document.getElementById('tools-container');
const toolsGrid = document.getElementById('tools-grid');
const matrixContainer = document.getElementById('matrix-container');
const noResults = document.getElementById('no-results');
// Initial tools HTML
const initialToolsHTML = toolsContainer.innerHTML;
// Handle filtered results
window.addEventListener('toolsFiltered', (event) => {
const filtered = event.detail;
const currentView = document.querySelector('.view-toggle.active')?.getAttribute('data-view');
if (currentView === 'matrix') {
// Matrix view handles its own rendering
return;
}
// Clear container
toolsContainer.innerHTML = '';
if (filtered.length === 0) {
noResults.style.display = 'block';
} else {
noResults.style.display = 'none';
// Render filtered tools
filtered.forEach(tool => {
const toolCard = createToolCard(tool);
toolsContainer.appendChild(toolCard);
});
}
});
// Handle view changes
window.addEventListener('viewChanged', (event) => {
const view = event.detail;
if (view === 'matrix') {
toolsGrid.style.display = 'none';
matrixContainer.style.display = 'block';
} else {
toolsGrid.style.display = 'block';
matrixContainer.style.display = 'none';
}
});
// Create tool card element
function createToolCard(tool) {
const cardDiv = document.createElement('div');
const cardClass = tool.isHosted ? 'card card-hosted' : (tool.license !== 'Proprietary' ? 'card card-oss' : 'card');
cardDiv.className = cardClass;
cardDiv.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.75rem;">
<h3 style="margin: 0;">${tool.name}</h3>
<div style="display: flex; gap: 0.5rem;">
${tool.isHosted ? '<span class="badge badge-primary">Self-Hosted</span>' : ''}
${tool.license !== 'Proprietary' ? '<span class="badge badge-success">Open Source</span>' : ''}
</div>
</div>
<p class="text-muted" style="font-size: 0.875rem; margin-bottom: 1rem;">
${tool.description}
</p>
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem;">
<div style="display: flex; align-items: center; gap: 0.25rem;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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="text-muted" style="font-size: 0.75rem;">
${tool.platforms.join(', ')}
</span>
</div>
<div style="display: flex; align-items: center; gap: 0.25rem;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 6v6l4 2"></path>
</svg>
<span class="text-muted" style="font-size: 0.75rem;">
${tool.skillLevel}
</span>
</div>
<div style="display: flex; align-items: center; gap: 0.25rem;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" 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"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
<span class="text-muted" style="font-size: 0.75rem;">
${tool.license}
</span>
</div>
</div>
<div style="display: flex; flex-wrap: wrap; gap: 0.25rem; margin-bottom: 1rem;">
${tool.tags.map(tag => `<span class="tag">${tag}</span>`).join('')}
</div>
<a href="${tool.url}" target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="width: 100%;">
${tool.isHosted ? 'Access Service' : 'Visit Website'}
</a>
`;
return cardDiv;
}
});
</script>

83
src/pages/status.astro Normal file
View File

@ -0,0 +1,83 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { promises as fs } from 'fs';
import { load } from 'js-yaml';
import path from 'path';
// Load tools data to get self-hosted services
const yamlPath = path.join(process.cwd(), 'src/data/tools.yaml');
const yamlContent = await fs.readFile(yamlPath, 'utf8');
const data = load(yamlContent) as any;
const hostedServices = data.tools.filter((tool: any) => tool.isHosted);
---
<BaseLayout title="Service Status">
<section style="padding: 2rem 0;">
<h1 style="text-align: center; margin-bottom: 1rem;">Service Status</h1>
<p class="text-muted" style="text-align: center; max-width: 600px; margin: 0 auto 3rem;">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Real-time monitoring of our self-hosted DFIR services.
</p>
<!-- Service Status Grid -->
<div class="grid grid-cols-2 gap-4" style="margin-bottom: 3rem;">
{hostedServices.map((service: any) => (
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="margin: 0;">{service.name}</h3>
<div id={`status-${service.name.toLowerCase().replace(/\s+/g, '-')}`}>
<!-- Status badge will be inserted here -->
<span class="badge badge-warning">Loading...</span>
</div>
</div>
<p class="text-muted" style="font-size: 0.875rem; margin-bottom: 1rem;">
{service.description}
</p>
<a href={service.url} target="_blank" rel="noopener noreferrer" class="btn btn-secondary">
Access Service →
</a>
</div>
))}
</div>
<!-- Uptime Kuma Embed -->
<div class="card" style="padding: 0; overflow: hidden;">
<iframe
src="https://uptime.example.lab/status/lab-services"
style="width: 100%; height: 600px; border: none;"
title="Uptime Kuma Status Page"
></iframe>
</div>
</section>
</BaseLayout>
<script define:vars={{ hostedServices }}>
// Fetch status for each service
document.addEventListener('DOMContentLoaded', async () => {
for (const service of hostedServices) {
if (service.statusUrl) {
try {
// Fetch status badge/API endpoint
const response = await fetch(service.statusUrl);
const data = await response.json();
// Update status badge
const statusElement = document.getElementById(`status-${service.name.toLowerCase().replace(/\s+/g, '-')}`);
if (statusElement) {
const isUp = data.status === 'up' || data.status === 1;
statusElement.innerHTML = `
<span class="badge ${isUp ? 'badge-success' : 'badge-error'}">
${isUp ? 'Operational' : 'Down'}
</span>
`;
}
} catch (error) {
console.error(`Failed to fetch status for ${service.name}:`, error);
const statusElement = document.getElementById(`status-${service.name.toLowerCase().replace(/\s+/g, '-')}`);
if (statusElement) {
statusElement.innerHTML = '<span class="badge badge-warning">Unknown</span>';
}
}
}
}
});
</script>

View File

@ -1,45 +0,0 @@
---
layout: base.njk
title: "Datenschutz"
description: "Datenschutzerklärung"
---
<!-- file: "./src/privacy/index.njk" -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="max-w-4xl">
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-4">Datenschutz</h1>
<p class="text-sm text-gray-500 dark:text-gray-400">
Stand: 13.07.2025
</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div class="space-y-4">
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">Datensammlung</h2>
<p class="text-sm text-gray-600 dark:text-gray-300">
[Platzhalter für Datenschutzinformationen]
</p>
</div>
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">Lokaler Speicher</h2>
<p class="text-sm text-gray-600 dark:text-gray-300">
• Theme-Einstellungen (hell/dunkel)<br>
• Keine Cookies<br>
• Keine Tracking-Mechanismen
</p>
</div>
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">Externe Links</h2>
<p class="text-sm text-gray-600 dark:text-gray-300">
[Platzhalter für Informationen zu externen Links]
</p>
</div>
</div>
</div>
</div>
</div>
</div>

60
src/scripts/theme.js Normal file
View File

@ -0,0 +1,60 @@
// 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);
}
// Initialize theme on page load
function initTheme() {
const storedTheme = getStoredTheme();
applyTheme(storedTheme);
// Update theme toggle buttons
document.querySelectorAll('[data-theme-toggle]').forEach(button => {
button.setAttribute('data-current-theme', 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);
// Update all theme toggle buttons
document.querySelectorAll('[data-theme-toggle]').forEach(button => {
button.setAttribute('data-current-theme', nextTheme);
});
}
// Listen for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (getStoredTheme() === 'auto') {
applyTheme('auto');
}
});
// Export functions for use in Astro components
window.themeUtils = {
initTheme,
toggleTheme,
getStoredTheme
};

View File

@ -1,55 +0,0 @@
---
layout: base.njk
title: "Status"
description: "Service Status"
---
<!-- file: "./src/status/index.njk" -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-4">Service Status</h1>
</div>
<!-- Overall Status -->
<div id="overall-status" class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Gesamtstatus</h2>
<div id="overall-indicator" class="flex items-center gap-2">
<div class="status-indicator operational"></div>
<span id="overall-text" class="text-green-600 dark:text-green-400 font-medium">Lädt...</span>
</div>
</div>
<p id="overall-summary" class="text-sm text-gray-600 dark:text-gray-300">
Status wird geprüft...
</p>
</div>
<!-- Uptime Kuma Connection Status -->
<div id="uptime-kuma-status" class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-300 dark:border-gray-600 p-4">
<div class="flex items-center gap-3 mb-2">
<svg class="w-5 h-5 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
<h3 class="text-md font-semibold text-gray-700 dark:text-gray-300">Uptime Kuma</h3>
<span id="kuma-connection-status" class="tag tag-gray text-xs">Prüft...</span>
</div>
<p class="text-xs text-gray-600 dark:text-gray-400">
Endpoint: <span id="kuma-endpoint">Nicht konfiguriert</span>
</p>
</div>
<!-- Service List -->
<div id="service-list" class="space-y-3">
<!-- Services will be populated by JavaScript -->
</div>
<!-- Last Updated -->
<div class="text-center text-sm text-gray-500 dark:text-gray-400">
<p>Aktualisiert: <span id="last-updated">Nie</span></p>
<button id="refresh-status" class="mt-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
Aktualisieren
</button>
</div>
</div>
</div>

485
src/styles/global.css Normal file
View File

@ -0,0 +1,485 @@
/* CSS Reset and Base Styles */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* CSS Variables for Theming */
:root {
/* Light Theme Colors */
--color-bg: #ffffff;
--color-bg-secondary: #f5f5f5;
--color-bg-tertiary: #e0e0e0;
--color-text: #1a1a1a;
--color-text-secondary: #666666;
--color-border: #d0d0d0;
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--color-accent: #10b981;
--color-accent-hover: #059669;
--color-warning: #f59e0b;
--color-error: #ef4444;
--color-hosted: #8b5cf6;
--color-hosted-bg: #f3f0ff;
--color-oss: #10b981;
--color-oss-bg: #ecfdf5;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
[data-theme="dark"] {
/* Dark Theme Colors */
--color-bg: #0f0f0f;
--color-bg-secondary: #1a1a1a;
--color-bg-tertiary: #262626;
--color-text: #e5e5e5;
--color-text-secondary: #a3a3a3;
--color-border: #404040;
--color-primary: #3b82f6;
--color-primary-hover: #60a5fa;
--color-accent: #34d399;
--color-accent-hover: #6ee7b7;
--color-warning: #fbbf24;
--color-error: #f87171;
--color-hosted: #a78bfa;
--color-hosted-bg: #2e1065;
--color-oss: #34d399;
--color-oss-bg: #064e3b;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
}
/* Base Styles */
html {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 16px;
line-height: 1.6;
color: var(--color-text);
background-color: var(--color-bg);
transition: background-color 0.3s ease, color 0.3s ease;
}
body {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Typography */
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
line-height: 1.25;
margin-bottom: 0.5rem;
}
h1 { font-size: 2.5rem; }
h2 { font-size: 2rem; }
h3 { font-size: 1.5rem; }
h4 { font-size: 1.25rem; }
h5 { font-size: 1.125rem; }
h6 { font-size: 1rem; }
p {
margin-bottom: 1rem;
}
a {
color: var(--color-primary);
text-decoration: none;
transition: color 0.2s ease;
}
a:hover {
color: var(--color-primary-hover);
text-decoration: underline;
}
/* Container */
.container {
width: 100%;
max-width: 1280px;
margin: 0 auto;
padding: 0 1rem;
}
/* Navigation */
nav {
background-color: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
z-index: 100;
}
.nav-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 0;
}
.nav-brand {
display: flex;
align-items: center;
gap: 0.5rem;
}
.nav-logo {
width: 32px;
height: 32px;
}
.nav-links {
display: flex;
align-items: center;
gap: 2rem;
list-style: none;
}
.nav-link {
color: var(--color-text);
font-weight: 500;
transition: color 0.2s ease;
}
.nav-link:hover {
color: var(--color-primary);
text-decoration: none;
}
.nav-link.active {
color: var(--color-primary);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
border: 1px solid transparent;
border-radius: 0.375rem;
font-weight: 500;
font-size: 0.875rem;
line-height: 1.25rem;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
}
.btn:hover {
text-decoration: none;
}
.btn-primary {
background-color: var(--color-primary);
color: white;
}
.btn-primary:hover {
background-color: var(--color-primary-hover);
}
.btn-secondary {
background-color: var(--color-bg-secondary);
color: var(--color-text);
border-color: var(--color-border);
}
.btn-secondary:hover {
background-color: var(--color-bg-tertiary);
}
.btn-icon {
padding: 0.5rem;
border-radius: 0.375rem;
background-color: transparent;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
transition: all 0.2s ease;
}
.btn-icon:hover {
background-color: var(--color-bg-secondary);
color: var(--color-text);
}
/* Forms */
input, select, textarea {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: 0.375rem;
background-color: var(--color-bg);
color: var(--color-text);
font-size: 0.875rem;
transition: border-color 0.2s ease;
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
padding-right: 2rem;
}
.checkbox-wrapper {
display: flex;
align-items: center;
gap: 0.5rem;
}
input[type="checkbox"] {
width: auto;
margin: 0;
cursor: pointer;
}
/* Cards */
.card {
background-color: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: var(--shadow-sm);
transition: all 0.2s ease;
}
.card:hover {
box-shadow: var(--shadow-md);
}
.card-hosted {
background-color: var(--color-hosted-bg);
border-color: var(--color-hosted);
box-shadow: 0 0 0 1px var(--color-hosted);
}
.card-oss {
background-color: var(--color-oss-bg);
border-color: var(--color-oss);
}
/* Grid and Layout */
.grid {
display: grid;
gap: 1rem;
}
.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
/* Tool Matrix */
.matrix-wrapper {
overflow-x: auto;
margin: 2rem 0;
}
.matrix-table {
width: 100%;
border-collapse: collapse;
min-width: 800px;
}
.matrix-table th,
.matrix-table td {
border: 1px solid var(--color-border);
padding: 0.75rem;
text-align: left;
}
.matrix-table th {
background-color: var(--color-bg-secondary);
font-weight: 600;
position: sticky;
top: 0;
}
.matrix-table th:first-child {
position: sticky;
left: 0;
z-index: 2;
background-color: var(--color-bg-secondary);
}
.matrix-cell {
min-height: 60px;
vertical-align: top;
}
.tool-chip {
display: inline-block;
padding: 0.25rem 0.5rem;
margin: 0.125rem;
background-color: var(--color-bg-secondary);
border-radius: 0.25rem;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
}
.tool-chip:hover {
background-color: var(--color-primary);
color: white;
}
.tool-chip-hosted {
background-color: var(--color-hosted);
color: white;
}
.tool-chip-oss {
background-color: var(--color-oss);
color: white;
}
/* Tool Details Modal */
.tool-details {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
padding: 2rem;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
z-index: 1000;
box-shadow: var(--shadow-lg);
}
.tool-details.active {
display: block;
}
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 999;
}
.modal-overlay.active {
display: block;
}
/* Badges */
.badge {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.badge-primary {
background-color: var(--color-primary);
color: white;
}
.badge-success {
background-color: var(--color-accent);
color: white;
}
.badge-warning {
background-color: var(--color-warning);
color: white;
}
.badge-error {
background-color: var(--color-error);
color: white;
}
/* Tags */
.tag {
display: inline-block;
padding: 0.125rem 0.5rem;
background-color: var(--color-bg-secondary);
border-radius: 0.25rem;
font-size: 0.75rem;
margin: 0.125rem;
}
/* Footer */
footer {
margin-top: auto;
background-color: var(--color-bg-secondary);
border-top: 1px solid var(--color-border);
padding: 2rem 0;
}
.footer-content {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
/* Utility Classes */
.text-center { text-align: center; }
.text-right { text-align: right; }
.text-muted { color: var(--color-text-secondary); }
.mt-1 { margin-top: 0.25rem; }
.mt-2 { margin-top: 0.5rem; }
.mt-4 { margin-top: 1rem; }
.mb-1 { margin-bottom: 0.25rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-4 { margin-bottom: 1rem; }
.gap-1 { gap: 0.25rem; }
.gap-2 { gap: 0.5rem; }
.gap-4 { gap: 1rem; }
/* Responsive */
@media (max-width: 768px) {
.grid-cols-2 { grid-template-columns: 1fr; }
.grid-cols-3 { grid-template-columns: 1fr; }
.grid-cols-4 { grid-template-columns: 1fr; }
.nav-links {
gap: 1rem;
}
h1 { font-size: 2rem; }
h2 { font-size: 1.5rem; }
.footer-content {
flex-direction: column;
text-align: center;
}
}
/* Animations */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.fade-in {
animation: fadeIn 0.3s ease-in;
}