change framework
This commit is contained in:
parent
d90b345819
commit
921abfb5b9
5
.astro/settings.json
Normal file
5
.astro/settings.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"_variables": {
|
||||||
|
"lastUpdateCheck": 1752478949435
|
||||||
|
}
|
||||||
|
}
|
1
.astro/types.d.ts
vendored
Normal file
1
.astro/types.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="astro/client" />
|
77
.eleventy.js
77
.eleventy.js
@ -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
37
astro-config.mjs
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
6135
package-lock.json
generated
6135
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
9
public/logo-placeholder.svg
Normal file
9
public/logo-placeholder.svg
Normal 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 |
@ -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>
|
|
@ -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>
|
|
24
src/components/Footer.astro
Normal file
24
src/components/Footer.astro
Normal 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>
|
37
src/components/Navigation.astro
Normal file
37
src/components/Navigation.astro
Normal 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>
|
51
src/components/ThemeToggle.astro
Normal file
51
src/components/ThemeToggle.astro
Normal 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>
|
80
src/components/ToolCard.astro
Normal file
80
src/components/ToolCard.astro
Normal 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>
|
177
src/components/ToolFilters.astro
Normal file
177
src/components/ToolFilters.astro
Normal 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>
|
170
src/components/ToolMatrix.astro
Normal file
170
src/components/ToolMatrix.astro
Normal 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>
|
638
src/css/main.css
638
src/css/main.css
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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
|
|
@ -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"
|
|
||||||
totalTools: 8
|
|
||||||
domains:
|
domains:
|
||||||
- "Filesystem Forensics"
|
- "application-code"
|
||||||
- "Network Forensics"
|
- "network-communication"
|
||||||
- "Memory Forensics"
|
|
||||||
- "Live Forensics"
|
|
||||||
- "Malware Analysis"
|
|
||||||
- "Cryptocurrency"
|
|
||||||
phases:
|
phases:
|
||||||
- "Data Collection"
|
- "examination"
|
||||||
- "Examination"
|
- "analysis"
|
||||||
- "Analysis"
|
platforms: ["Linux"]
|
||||||
- "Reporting"
|
skillLevel: "advanced"
|
||||||
|
accessType: "self-hosted"
|
||||||
|
url: "https://cuckoosandbox.org/"
|
||||||
|
license: "GPL-3.0"
|
||||||
|
tags: ["malware-analysis", "sandbox", "dynamic-analysis"]
|
||||||
|
isHosted: true
|
||||||
|
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
1
src/env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference path="../.astro/types.d.ts" />
|
214
src/index.njk
214
src/index.njk
@ -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>
|
|
244
src/js/modal.js
244
src/js/modal.js
@ -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();
|
|
||||||
}
|
|
||||||
})();
|
|
383
src/js/search.js
383
src/js/search.js
@ -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();
|
|
||||||
}
|
|
||||||
})();
|
|
386
src/js/status.js
386
src/js/status.js
@ -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();
|
|
||||||
}
|
|
||||||
})();
|
|
141
src/js/theme.js
141
src/js/theme.js
@ -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();
|
|
||||||
}
|
|
||||||
})();
|
|
35
src/layouts/BaseLayout.astro
Normal file
35
src/layouts/BaseLayout.astro
Normal 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
53
src/pages/about.astro
Normal 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
163
src/pages/index.astro
Normal 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
83
src/pages/status.astro
Normal 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>
|
@ -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
60
src/scripts/theme.js
Normal 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
|
||||||
|
};
|
@ -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
485
src/styles/global.css
Normal 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;
|
||||||
|
}
|
Reference in New Issue
Block a user