remove dev comments
This commit is contained in:
parent
86d2370976
commit
f24531d86d
2
.astro/content.d.ts
vendored
2
.astro/content.d.ts
vendored
@ -202,6 +202,6 @@ declare module 'astro:content' {
|
|||||||
LiveContentConfig['collections'][C]['loader']
|
LiveContentConfig['collections'][C]['loader']
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type ContentConfig = typeof import("../src/content/config.js");
|
export type ContentConfig = never;
|
||||||
export type LiveContentConfig = never;
|
export type LiveContentConfig = never;
|
||||||
}
|
}
|
||||||
|
File diff suppressed because one or more lines are too long
61
README.md
61
README.md
@ -135,38 +135,45 @@ sudo chown -R www-data:www-data /opt/cc24-hub
|
|||||||
Erstelle `/opt/cc24-hub/.env`:
|
Erstelle `/opt/cc24-hub/.env`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# === GRUNDKONFIGURATION ===
|
# ===========================================
|
||||||
|
# ForensicPathways Environment Configuration
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
# Authentication & OIDC (Required)
|
||||||
|
AUTH_SECRET=change-this-to-a-strong-secret-key-in-production
|
||||||
|
OIDC_ENDPOINT=https://your-oidc-provider.com
|
||||||
|
OIDC_CLIENT_ID=your-oidc-client-id
|
||||||
|
OIDC_CLIENT_SECRET=your-oidc-client-secret
|
||||||
|
|
||||||
|
# Auth Scopes - set to true in prod
|
||||||
|
AUTHENTICATION_NECESSARY_CONTRIBUTIONS=true
|
||||||
|
AUTHENTICATION_NECESSARY_AI=true
|
||||||
|
|
||||||
|
# Application Configuration (Required)
|
||||||
|
PUBLIC_BASE_URL=https://your-domain.com
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
PUBLIC_BASE_URL=https://ihre-domain.de
|
|
||||||
|
|
||||||
# === AI-KONFIGURATION (Optional) ===
|
# AI Service Configuration (Required for AI features)
|
||||||
AI_API_ENDPOINT=https://api.mistral.ai/v1
|
AI_MODEL=mistral-large-latest
|
||||||
AI_API_KEY=your_mistral_api_key
|
AI_API_ENDPOINT=https://api.mistral.ai
|
||||||
AI_MODEL=mistral-small-latest
|
AI_API_KEY=your-mistral-api-key
|
||||||
AI_RATE_LIMIT_DELAY_MS=2000
|
AI_RATE_LIMIT_DELAY_MS=1000
|
||||||
|
|
||||||
# === AUTHENTIFIZIERUNG ===
|
# Git Integration (Required for contributions)
|
||||||
AUTHENTICATION_NECESSARY=true
|
GIT_REPO_URL=https://git.cc24.dev/mstoeck3/cc24-hub
|
||||||
OIDC_ENDPOINT=https://ihr-oidc-provider.de
|
|
||||||
OIDC_CLIENT_ID=cc24-hub-client
|
|
||||||
OIDC_CLIENT_SECRET=your_super_secret_client_secret
|
|
||||||
AUTH_SECRET=your_jwt_secret_min_32_characters_long
|
|
||||||
|
|
||||||
# === NEXTCLOUD (Optional) ===
|
|
||||||
NEXTCLOUD_ENDPOINT=https://ihre-nextcloud.de
|
|
||||||
NEXTCLOUD_USERNAME=cc24-hub-user
|
|
||||||
NEXTCLOUD_PASSWORD=nextcloud_app_password
|
|
||||||
NEXTCLOUD_UPLOAD_PATH=/kb-media
|
|
||||||
NEXTCLOUD_PUBLIC_URL=https://ihre-nextcloud.de/s
|
|
||||||
|
|
||||||
# === GIT-INTEGRATION (Optional) ===
|
|
||||||
GIT_PROVIDER=gitea
|
GIT_PROVIDER=gitea
|
||||||
GIT_API_ENDPOINT=https://ihr-git-server.de/api/v1
|
GIT_API_ENDPOINT=https://git.cc24.dev/api/v1
|
||||||
GIT_API_TOKEN=your_git_api_token
|
GIT_API_TOKEN=your-git-api-token
|
||||||
GIT_REPO_URL=https://ihr-git-server.de/user/cc24-hub
|
|
||||||
|
|
||||||
# === UPLOAD-KONFIGURATION ===
|
# File Upload Configuration (Optional)
|
||||||
LOCAL_UPLOAD_PATH=/opt/cc24-hub/public/uploads
|
LOCAL_UPLOAD_PATH=./public/uploads
|
||||||
|
|
||||||
|
# Nextcloud Integration (Optional)
|
||||||
|
NEXTCLOUD_ENDPOINT=https://your-nextcloud.com
|
||||||
|
NEXTCLOUD_USERNAME=your-username
|
||||||
|
NEXTCLOUD_PASSWORD=your-password
|
||||||
|
NEXTCLOUD_UPLOAD_PATH=/kb-media
|
||||||
|
NEXTCLOUD_PUBLIC_URL=https://your-nextcloud.com/s/
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
---
|
---
|
||||||
import { getToolsData } from '../utils/dataService.js';
|
import { getToolsData } from '../utils/dataService.js';
|
||||||
|
|
||||||
// Load tools data for validation
|
|
||||||
const data = await getToolsData();
|
const data = await getToolsData();
|
||||||
|
|
||||||
const tools = data.tools;
|
const tools = data.tools;
|
||||||
@ -9,7 +8,6 @@ const phases = data.phases;
|
|||||||
const domainAgnosticSoftware = data['domain-agnostic-software'] || [];
|
const domainAgnosticSoftware = data['domain-agnostic-software'] || [];
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- AI Query Interface -->
|
|
||||||
<section id="ai-interface" class="ai-interface" style="display: none;">
|
<section id="ai-interface" class="ai-interface" style="display: none;">
|
||||||
<div class="ai-query-section">
|
<div class="ai-query-section">
|
||||||
<div style="text-align: center; margin-bottom: 2rem;">
|
<div style="text-align: center; margin-bottom: 2rem;">
|
||||||
@ -27,7 +25,6 @@ const domainAgnosticSoftware = data['domain-agnostic-software'] || [];
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ai-input-container" style="max-width: 800px; margin: 0 auto;">
|
<div class="ai-input-container" style="max-width: 800px; margin: 0 auto;">
|
||||||
<!-- Mode Toggle -->
|
|
||||||
<div class="ai-mode-toggle" style="display: flex; align-items: center; justify-content: center; gap: 1rem; margin-bottom: 1.5rem; padding: 1rem; background-color: var(--color-bg-secondary); border-radius: 0.75rem; border: 1px solid var(--color-border);">
|
<div class="ai-mode-toggle" style="display: flex; align-items: center; justify-content: center; gap: 1rem; margin-bottom: 1.5rem; padding: 1rem; background-color: var(--color-bg-secondary); border-radius: 0.75rem; border: 1px solid var(--color-border);">
|
||||||
<span id="workflow-label" class="toggle-label active" style="font-weight: 500; color: var(--color-primary); cursor: pointer; transition: var(--transition-fast);">
|
<span id="workflow-label" class="toggle-label active" style="font-weight: 500; color: var(--color-primary); cursor: pointer; transition: var(--transition-fast);">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem; vertical-align: middle;">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem; vertical-align: middle;">
|
||||||
@ -56,7 +53,6 @@ const domainAgnosticSoftware = data['domain-agnostic-software'] || [];
|
|||||||
maxlength="2000"
|
maxlength="2000"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
<!-- Privacy Notice -->
|
|
||||||
<div style="margin-top: 0.5rem; margin-bottom: 1rem;">
|
<div style="margin-top: 0.5rem; margin-bottom: 1rem;">
|
||||||
<p style="font-size: 0.75rem; color: var(--color-text-secondary); text-align: center; line-height: 1.4;">
|
<p style="font-size: 0.75rem; color: var(--color-text-secondary); text-align: center; line-height: 1.4;">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.25rem; vertical-align: middle;">
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.25rem; vertical-align: middle;">
|
||||||
@ -82,8 +78,6 @@ const domainAgnosticSoftware = data['domain-agnostic-software'] || [];
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- This should be your loading section in AIQueryInterface.astro -->
|
|
||||||
<!-- Loading State -->
|
|
||||||
<div id="ai-loading" class="ai-loading" style="display: none; text-align: center; padding: 2rem;">
|
<div id="ai-loading" class="ai-loading" style="display: none; text-align: center; padding: 2rem;">
|
||||||
<div style="display: inline-block; margin-bottom: 1rem;">
|
<div style="display: inline-block; margin-bottom: 1rem;">
|
||||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" stroke-width="2" style="animation: pulse 2s ease-in-out infinite;">
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" stroke-width="2" style="animation: pulse 2s ease-in-out infinite;">
|
||||||
@ -94,7 +88,6 @@ const domainAgnosticSoftware = data['domain-agnostic-software'] || [];
|
|||||||
</div>
|
</div>
|
||||||
<p id="loading-text" style="color: var(--color-text-secondary);">Analysiere Szenario und generiere Empfehlungen...</p>
|
<p id="loading-text" style="color: var(--color-text-secondary);">Analysiere Szenario und generiere Empfehlungen...</p>
|
||||||
|
|
||||||
<!-- Queue Status Display - THIS SECTION SHOULD BE PRESENT -->
|
|
||||||
<div id="queue-status" style="margin-top: 1rem; padding: 1rem; background-color: var(--color-bg-secondary); border-radius: 0.5rem; border: 1px solid var(--color-border); display: none;">
|
<div id="queue-status" style="margin-top: 1rem; padding: 1rem; background-color: var(--color-bg-secondary); border-radius: 0.5rem; border: 1px solid var(--color-border); display: none;">
|
||||||
<div style="display: flex; align-items: center; justify-content: center; gap: 1rem; margin-bottom: 0.75rem;">
|
<div style="display: flex; align-items: center; justify-content: center; gap: 1rem; margin-bottom: 0.75rem;">
|
||||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
@ -114,14 +107,12 @@ const domainAgnosticSoftware = data['domain-agnostic-software'] || [];
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Progress bar -->
|
|
||||||
<div style="margin-top: 1rem; background-color: var(--color-bg-tertiary); border-radius: 0.25rem; height: 4px; overflow: hidden;">
|
<div style="margin-top: 1rem; background-color: var(--color-bg-tertiary); border-radius: 0.25rem; height: 4px; overflow: hidden;">
|
||||||
<div id="queue-progress" style="height: 100%; background-color: var(--color-primary); width: 0%; transition: width 0.3s ease;"></div>
|
<div id="queue-progress" style="height: 100%; background-color: var(--color-primary); width: 0%; transition: width 0.3s ease;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error State -->
|
|
||||||
<div id="ai-error" class="ai-error" style="display: none; text-align: center; padding: 2rem;">
|
<div id="ai-error" class="ai-error" style="display: none; text-align: center; padding: 2rem;">
|
||||||
<div style="background-color: var(--color-error); color: white; padding: 1rem; border-radius: 0.5rem; max-width: 600px; margin: 0 auto;">
|
<div style="background-color: var(--color-error); color: white; padding: 1rem; border-radius: 0.5rem; max-width: 600px; margin: 0 auto;">
|
||||||
<h3 style="margin-bottom: 0.5rem;">Fehler bei der KI-Anfrage</h3>
|
<h3 style="margin-bottom: 0.5rem;">Fehler bei der KI-Anfrage</h3>
|
||||||
@ -129,9 +120,7 @@ const domainAgnosticSoftware = data['domain-agnostic-software'] || [];
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- AI Results -->
|
|
||||||
<div id="ai-results" class="ai-results" style="display: none;">
|
<div id="ai-results" class="ai-results" style="display: none;">
|
||||||
<!-- Results will be populated here by JavaScript -->
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -154,21 +143,19 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const aiResults = document.getElementById('ai-results');
|
const aiResults = document.getElementById('ai-results');
|
||||||
const aiDescription = document.getElementById('ai-description');
|
const aiDescription = document.getElementById('ai-description');
|
||||||
|
|
||||||
// Toggle elements
|
|
||||||
const toggleSwitch = document.querySelector('.toggle-switch');
|
const toggleSwitch = document.querySelector('.toggle-switch');
|
||||||
const toggleSlider = document.querySelector('.toggle-slider');
|
const toggleSlider = document.querySelector('.toggle-slider');
|
||||||
const workflowLabel = document.getElementById('workflow-label');
|
const workflowLabel = document.getElementById('workflow-label');
|
||||||
const toolLabel = document.getElementById('tool-label');
|
const toolLabel = document.getElementById('tool-label');
|
||||||
|
|
||||||
let currentRecommendation = null;
|
let currentRecommendation = null;
|
||||||
let currentMode = 'workflow'; // 'workflow' or 'tool'
|
let currentMode = 'workflow';
|
||||||
|
|
||||||
if (!aiInput || !aiSubmitBtn || !aiLoading || !aiError || !aiResults) {
|
if (!aiInput || !aiSubmitBtn || !aiLoading || !aiError || !aiResults) {
|
||||||
console.error('AI interface elements not found');
|
console.error('AI interface elements not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mode content configuration
|
|
||||||
const modeConfig = {
|
const modeConfig = {
|
||||||
workflow: {
|
workflow: {
|
||||||
placeholder: "Beschreiben Sie Ihr forensisches Szenario... z.B. 'Verdacht auf Ransomware-Angriff auf Windows-Domänencontroller mit verschlüsselten Dateien und verdächtigen Netzwerkverbindungen'",
|
placeholder: "Beschreiben Sie Ihr forensisches Szenario... z.B. 'Verdacht auf Ransomware-Angriff auf Windows-Domänencontroller mit verschlüsselten Dateien und verdächtigen Netzwerkverbindungen'",
|
||||||
@ -184,23 +171,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update UI based on current mode
|
|
||||||
function updateModeUI() {
|
function updateModeUI() {
|
||||||
const config = modeConfig[currentMode];
|
const config = modeConfig[currentMode];
|
||||||
|
|
||||||
// Update placeholder
|
|
||||||
aiInput.placeholder = config.placeholder;
|
aiInput.placeholder = config.placeholder;
|
||||||
|
|
||||||
// Update description
|
|
||||||
aiDescription.textContent = config.description;
|
aiDescription.textContent = config.description;
|
||||||
|
|
||||||
// Update submit button text
|
|
||||||
submitBtnText.textContent = config.submitText;
|
submitBtnText.textContent = config.submitText;
|
||||||
|
|
||||||
// Update loading text
|
|
||||||
loadingText.textContent = config.loadingText;
|
loadingText.textContent = config.loadingText;
|
||||||
|
|
||||||
// Update toggle visual state
|
|
||||||
if (currentMode === 'workflow') {
|
if (currentMode === 'workflow') {
|
||||||
toggleSlider.style.transform = 'translateX(0)';
|
toggleSlider.style.transform = 'translateX(0)';
|
||||||
toggleSwitch.style.backgroundColor = 'var(--color-primary)';
|
toggleSwitch.style.backgroundColor = 'var(--color-primary)';
|
||||||
@ -217,13 +198,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
workflowLabel.classList.remove('active');
|
workflowLabel.classList.remove('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear previous results when switching modes
|
|
||||||
aiResults.style.display = 'none';
|
aiResults.style.display = 'none';
|
||||||
aiError.style.display = 'none';
|
aiError.style.display = 'none';
|
||||||
currentRecommendation = null;
|
currentRecommendation = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle mode handlers
|
|
||||||
function switchToMode(mode) {
|
function switchToMode(mode) {
|
||||||
if (currentMode !== mode) {
|
if (currentMode !== mode) {
|
||||||
currentMode = mode;
|
currentMode = mode;
|
||||||
@ -243,7 +222,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
switchToMode('tool');
|
switchToMode('tool');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Character counter for input
|
|
||||||
const updateCharacterCount = () => {
|
const updateCharacterCount = () => {
|
||||||
const length = aiInput.value.length;
|
const length = aiInput.value.length;
|
||||||
const maxLength = 2000;
|
const maxLength = 2000;
|
||||||
@ -267,7 +245,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
aiInput.addEventListener('input', updateCharacterCount);
|
aiInput.addEventListener('input', updateCharacterCount);
|
||||||
updateCharacterCount();
|
updateCharacterCount();
|
||||||
|
|
||||||
// Submit handler with enhanced queue feedback
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
const query = aiInput.value.trim();
|
const query = aiInput.value.trim();
|
||||||
|
|
||||||
@ -281,15 +258,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate task ID for tracking
|
|
||||||
const taskId = `ai_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`;
|
const taskId = `ai_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`;
|
||||||
|
|
||||||
// Hide previous results and errors
|
|
||||||
aiResults.style.display = 'none';
|
aiResults.style.display = 'none';
|
||||||
aiError.style.display = 'none';
|
aiError.style.display = 'none';
|
||||||
aiLoading.style.display = 'block';
|
aiLoading.style.display = 'block';
|
||||||
|
|
||||||
// Show queue status section
|
|
||||||
const queueStatus = document.getElementById('queue-status');
|
const queueStatus = document.getElementById('queue-status');
|
||||||
const taskIdDisplay = document.getElementById('current-task-id');
|
const taskIdDisplay = document.getElementById('current-task-id');
|
||||||
if (queueStatus && taskIdDisplay) {
|
if (queueStatus && taskIdDisplay) {
|
||||||
@ -297,11 +271,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
taskIdDisplay.textContent = taskId;
|
taskIdDisplay.textContent = taskId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable submit button
|
|
||||||
aiSubmitBtn.disabled = true;
|
aiSubmitBtn.disabled = true;
|
||||||
submitBtnText.textContent = currentMode === 'workflow' ? 'Generiere Empfehlungen...' : 'Suche passende Methode...';
|
submitBtnText.textContent = currentMode === 'workflow' ? 'Generiere Empfehlungen...' : 'Suche passende Methode...';
|
||||||
|
|
||||||
// Start queue status polling
|
|
||||||
let statusInterval;
|
let statusInterval;
|
||||||
let startTime = Date.now();
|
let startTime = Date.now();
|
||||||
|
|
||||||
@ -329,14 +301,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (positionBadge && data.currentPosition) {
|
if (positionBadge && data.currentPosition) {
|
||||||
positionBadge.textContent = data.currentPosition;
|
positionBadge.textContent = data.currentPosition;
|
||||||
|
|
||||||
// Update progress bar (inverse of position)
|
|
||||||
if (progressBar && data.queueLength > 0) {
|
if (progressBar && data.queueLength > 0) {
|
||||||
const progress = Math.max(0, ((data.queueLength - data.currentPosition + 1) / data.queueLength) * 100);
|
const progress = Math.max(0, ((data.queueLength - data.currentPosition + 1) / data.queueLength) * 100);
|
||||||
progressBar.style.width = `${progress}%`;
|
progressBar.style.width = `${progress}%`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If processing and no position (request is being handled)
|
|
||||||
if (data.isProcessing && !data.currentPosition) {
|
if (data.isProcessing && !data.currentPosition) {
|
||||||
if (positionBadge) positionBadge.textContent = '⚡';
|
if (positionBadge) positionBadge.textContent = '⚡';
|
||||||
if (progressBar) progressBar.style.width = '100%';
|
if (progressBar) progressBar.style.width = '100%';
|
||||||
@ -348,10 +318,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initial status update
|
|
||||||
updateQueueStatus();
|
updateQueueStatus();
|
||||||
|
|
||||||
// Poll every 500ms for status updates
|
|
||||||
statusInterval = setInterval(updateQueueStatus, 500);
|
statusInterval = setInterval(updateQueueStatus, 500);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -363,13 +331,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
query,
|
query,
|
||||||
mode: currentMode,
|
mode: currentMode,
|
||||||
taskId // Include task ID for backend tracking
|
taskId
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
// Clear status polling
|
|
||||||
if (statusInterval) clearInterval(statusInterval);
|
if (statusInterval) clearInterval(statusInterval);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@ -380,10 +347,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
throw new Error(data.error || 'Unknown error');
|
throw new Error(data.error || 'Unknown error');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store recommendation for restoration
|
|
||||||
currentRecommendation = data.recommendation;
|
currentRecommendation = data.recommendation;
|
||||||
|
|
||||||
// Display results based on mode
|
|
||||||
if (currentMode === 'workflow') {
|
if (currentMode === 'workflow') {
|
||||||
displayWorkflowResults(data.recommendation, query);
|
displayWorkflowResults(data.recommendation, query);
|
||||||
} else {
|
} else {
|
||||||
@ -396,13 +361,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('AI query failed:', error);
|
console.error('AI query failed:', error);
|
||||||
|
|
||||||
// Clear status polling
|
|
||||||
if (statusInterval) clearInterval(statusInterval);
|
if (statusInterval) clearInterval(statusInterval);
|
||||||
|
|
||||||
aiLoading.style.display = 'none';
|
aiLoading.style.display = 'none';
|
||||||
aiError.style.display = 'block';
|
aiError.style.display = 'block';
|
||||||
|
|
||||||
// Show user-friendly error messages
|
|
||||||
if (error.message.includes('429')) {
|
if (error.message.includes('429')) {
|
||||||
aiErrorMessage.textContent = 'Zu viele Anfragen. Bitte warten Sie einen Moment und versuchen Sie es erneut.';
|
aiErrorMessage.textContent = 'Zu viele Anfragen. Bitte warten Sie einen Moment und versuchen Sie es erneut.';
|
||||||
} else if (error.message.includes('401')) {
|
} else if (error.message.includes('401')) {
|
||||||
@ -413,7 +376,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
aiErrorMessage.textContent = `Fehler: ${error.message}`;
|
aiErrorMessage.textContent = `Fehler: ${error.message}`;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// Re-enable submit button and hide queue status
|
|
||||||
aiSubmitBtn.disabled = false;
|
aiSubmitBtn.disabled = false;
|
||||||
const config = modeConfig[currentMode];
|
const config = modeConfig[currentMode];
|
||||||
submitBtnText.textContent = config.submitText;
|
submitBtnText.textContent = config.submitText;
|
||||||
@ -423,7 +385,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Event listeners
|
|
||||||
aiSubmitBtn.addEventListener('click', handleSubmit);
|
aiSubmitBtn.addEventListener('click', handleSubmit);
|
||||||
|
|
||||||
aiInput.addEventListener('keydown', (e) => {
|
aiInput.addEventListener('keydown', (e) => {
|
||||||
@ -433,7 +394,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Function to restore AI results when switching back to AI view
|
|
||||||
window.restoreAIResults = () => {
|
window.restoreAIResults = () => {
|
||||||
if (currentRecommendation) {
|
if (currentRecommendation) {
|
||||||
aiResults.style.display = 'block';
|
aiResults.style.display = 'block';
|
||||||
@ -442,7 +402,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to format workflow suggestions as proper lists
|
|
||||||
function formatWorkflowSuggestion(text) {
|
function formatWorkflowSuggestion(text) {
|
||||||
const numberedListPattern = /(\d+\.\s)/g;
|
const numberedListPattern = /(\d+\.\s)/g;
|
||||||
|
|
||||||
@ -485,7 +444,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return `<p style="margin: 0; line-height: 1.6; color: var(--color-text);">${text}</p>`;
|
return `<p style="margin: 0; line-height: 1.6; color: var(--color-text);">${text}</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// NEW: Function to render background knowledge concepts
|
|
||||||
function renderBackgroundKnowledge(backgroundKnowledge) {
|
function renderBackgroundKnowledge(backgroundKnowledge) {
|
||||||
if (!backgroundKnowledge || backgroundKnowledge.length === 0) {
|
if (!backgroundKnowledge || backgroundKnowledge.length === 0) {
|
||||||
return '';
|
return '';
|
||||||
@ -523,9 +481,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display results for workflow mode (ENHANCED with concepts)
|
|
||||||
function displayWorkflowResults(recommendation, originalQuery) {
|
function displayWorkflowResults(recommendation, originalQuery) {
|
||||||
// Group tools by phase
|
|
||||||
const toolsByPhase = {};
|
const toolsByPhase = {};
|
||||||
|
|
||||||
const phaseOrder = phases.map(phase => phase.id);
|
const phaseOrder = phases.map(phase => phase.id);
|
||||||
@ -534,12 +490,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
// Initialize phases
|
|
||||||
phaseOrder.forEach(phase => {
|
phaseOrder.forEach(phase => {
|
||||||
toolsByPhase[phase] = [];
|
toolsByPhase[phase] = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
// Group recommended tools by phase
|
|
||||||
recommendation.recommended_tools?.forEach(recTool => {
|
recommendation.recommended_tools?.forEach(recTool => {
|
||||||
if (toolsByPhase[recTool.phase]) {
|
if (toolsByPhase[recTool.phase]) {
|
||||||
const fullTool = tools.find(t => t.name === recTool.name);
|
const fullTool = tools.find(t => t.name === recTool.name);
|
||||||
@ -682,7 +636,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
aiResults.appendChild(tempDiv);
|
aiResults.appendChild(tempDiv);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display results for tool-specific mode (ENHANCED with concepts)
|
|
||||||
function displayToolResults(recommendation, originalQuery) {
|
function displayToolResults(recommendation, originalQuery) {
|
||||||
function getSuitabilityText(score) {
|
function getSuitabilityText(score) {
|
||||||
const suitabilityTexts = {
|
const suitabilityTexts = {
|
||||||
@ -693,7 +646,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return suitabilityTexts[score] || 'GEEIGNET';
|
return suitabilityTexts[score] || 'GEEIGNET';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get phase names for a tool
|
|
||||||
function getToolPhases(tool) {
|
function getToolPhases(tool) {
|
||||||
if (!tool.phases || tool.phases.length === 0) return '';
|
if (!tool.phases || tool.phases.length === 0) return '';
|
||||||
|
|
||||||
@ -900,7 +852,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
aiResults.appendChild(tempDiv);
|
aiResults.appendChild(tempDiv);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize UI
|
|
||||||
updateModeUI();
|
updateModeUI();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
@ -18,7 +18,6 @@ const {
|
|||||||
style = ''
|
style = ''
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
|
||||||
// Generate appropriate URLs and text based on type
|
|
||||||
let href: string;
|
let href: string;
|
||||||
let defaultText: string;
|
let defaultText: string;
|
||||||
let icon: string;
|
let icon: string;
|
||||||
|
@ -17,27 +17,27 @@ const currentPath = Astro.url.pathname;
|
|||||||
<ul class="nav-links">
|
<ul class="nav-links">
|
||||||
<li>
|
<li>
|
||||||
<a href="/" class={`nav-link ${currentPath === '/' ? 'active' : ''}`}>
|
<a href="/" class={`nav-link ${currentPath === '/' ? 'active' : ''}`}>
|
||||||
~/
|
Start
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="/knowledgebase" class={`nav-link ${currentPath === '/knowledgebase' ? 'active' : ''}`}>
|
<a href="/knowledgebase" class={`nav-link ${currentPath === '/knowledgebase' ? 'active' : ''}`}>
|
||||||
~/knowledgebase
|
Knowledgebase
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="/contribute" class={`nav-link ${currentPath.startsWith('/contribute') ? 'active' : ''}`}>
|
<a href="/contribute" class={`nav-link ${currentPath.startsWith('/contribute') ? 'active' : ''}`}>
|
||||||
~/contribute
|
Beitragen
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="/status" class={`nav-link ${currentPath === '/status' ? 'active' : ''}`}>
|
<a href="/status" class={`nav-link ${currentPath === '/status' ? 'active' : ''}`}>
|
||||||
~/status
|
Status
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="/about" class={`nav-link ${currentPath === '/about' ? 'active' : ''}`}>
|
<a href="/about" class={`nav-link ${currentPath === '/about' ? 'active' : ''}`}>
|
||||||
~/about
|
Über das Projekt
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
|
@ -9,7 +9,6 @@ export interface Props {
|
|||||||
|
|
||||||
const { toolName, context, size = 'small' } = Astro.props;
|
const { toolName, context, size = 'small' } = Astro.props;
|
||||||
|
|
||||||
// AFTER: Single line with centralized function
|
|
||||||
const toolSlug = createToolSlug(toolName);
|
const toolSlug = createToolSlug(toolName);
|
||||||
|
|
||||||
const iconSize = size === 'small' ? '14' : '16';
|
const iconSize = size === 'small' ? '14' : '16';
|
||||||
|
@ -24,26 +24,21 @@ export interface Props {
|
|||||||
|
|
||||||
const { tool } = Astro.props;
|
const { tool } = Astro.props;
|
||||||
|
|
||||||
// Check types
|
|
||||||
const isMethod = tool.type === 'method';
|
const isMethod = tool.type === 'method';
|
||||||
const isConcept = tool.type === 'concept';
|
const isConcept = tool.type === 'concept';
|
||||||
|
|
||||||
// Check if tool has a valid project URL (means we're hosting it)
|
|
||||||
const hasValidProjectUrl = tool.projectUrl !== undefined &&
|
const hasValidProjectUrl = tool.projectUrl !== undefined &&
|
||||||
tool.projectUrl !== null &&
|
tool.projectUrl !== null &&
|
||||||
tool.projectUrl !== "" &&
|
tool.projectUrl !== "" &&
|
||||||
tool.projectUrl.trim() !== "";
|
tool.projectUrl.trim() !== "";
|
||||||
|
|
||||||
// Check if tool has knowledgebase entry
|
|
||||||
const hasKnowledgebase = tool.knowledgebase === true;
|
const hasKnowledgebase = tool.knowledgebase === true;
|
||||||
|
|
||||||
// Determine card styling based on type and hosting status
|
|
||||||
const cardClass = isConcept ? 'card card-concept tool-card cursor-pointer' :
|
const cardClass = isConcept ? 'card card-concept tool-card cursor-pointer' :
|
||||||
isMethod ? 'card card-method tool-card cursor-pointer' :
|
isMethod ? 'card card-method tool-card cursor-pointer' :
|
||||||
hasValidProjectUrl ? 'card card-hosted tool-card cursor-pointer' :
|
hasValidProjectUrl ? 'card card-hosted tool-card cursor-pointer' :
|
||||||
(tool.license !== 'Proprietary' ? 'card card-oss tool-card cursor-pointer' : 'card tool-card cursor-pointer');
|
(tool.license !== 'Proprietary' ? 'card card-oss tool-card cursor-pointer' : 'card tool-card cursor-pointer');
|
||||||
|
|
||||||
// ENHANCED: Data attributes for filtering
|
|
||||||
const toolDataAttributes = {
|
const toolDataAttributes = {
|
||||||
'data-tool-name': tool.name.toLowerCase(),
|
'data-tool-name': tool.name.toLowerCase(),
|
||||||
'data-tool-type': tool.type,
|
'data-tool-type': tool.type,
|
||||||
@ -62,26 +57,22 @@ const toolDataAttributes = {
|
|||||||
{...toolDataAttributes}
|
{...toolDataAttributes}
|
||||||
onclick={`window.showToolDetails('${tool.name}')`}
|
onclick={`window.showToolDetails('${tool.name}')`}
|
||||||
>
|
>
|
||||||
<!-- Card Header with Fixed Height -->
|
|
||||||
<div class="tool-card-header">
|
<div class="tool-card-header">
|
||||||
<h3>
|
<h3>
|
||||||
{tool.icon && <span class="mr-2 text-lg">{tool.icon}</span>}
|
{tool.icon && <span class="mr-2 text-lg">{tool.icon}</span>}
|
||||||
{tool.name}
|
{tool.name}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="tool-card-badges">
|
<div class="tool-card-badges">
|
||||||
<!-- Only show CC24-Server and Knowledgebase badges -->
|
|
||||||
{!isMethod && hasValidProjectUrl && <span class="badge badge-primary">CC24-Server</span>}
|
{!isMethod && hasValidProjectUrl && <span class="badge badge-primary">CC24-Server</span>}
|
||||||
{hasKnowledgebase && <span class="badge badge-error">📖</span>}
|
{hasKnowledgebase && <span class="badge badge-error">📖</span>}
|
||||||
<ShareButton toolName={tool.name} context="card" size="small" />
|
<ShareButton toolName={tool.name} context="card" size="small" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description - Truncated to 2 lines -->
|
|
||||||
<p class="text-muted">
|
<p class="text-muted">
|
||||||
{tool.description}
|
{tool.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Metadata - Compact Icons with Better Alignment -->
|
|
||||||
<div class="tool-card-metadata flex items-center gap-4 mb-3" style="line-height: 1;">
|
<div class="tool-card-metadata flex items-center gap-4 mb-3" style="line-height: 1;">
|
||||||
<div class="metadata-item flex items-center gap-2 text-xs text-secondary flex-shrink-1 min-w-0">
|
<div class="metadata-item flex items-center gap-2 text-xs text-secondary flex-shrink-1 min-w-0">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="flex-shrink-0">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="flex-shrink-0">
|
||||||
@ -115,14 +106,12 @@ const toolDataAttributes = {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tags - Two Lines with Fade -->
|
|
||||||
<div class="tool-tags-container">
|
<div class="tool-tags-container">
|
||||||
{tool.tags.slice(0, 8).map(tag => (
|
{tool.tags.slice(0, 8).map(tag => (
|
||||||
<span class="tag">{tag}</span>
|
<span class="tag">{tag}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Buttons - Fixed at Bottom (NO EDIT BUTTONS - Available in modals) -->
|
|
||||||
<div class="tool-card-buttons" onclick="event.stopPropagation();">
|
<div class="tool-card-buttons" onclick="event.stopPropagation();">
|
||||||
{isConcept ? (
|
{isConcept ? (
|
||||||
<a href={tool.url} target="_blank" rel="noopener noreferrer" class="btn btn-primary single-button" style="background-color: var(--color-concept); border-color: var(--color-concept);">
|
<a href={tool.url} target="_blank" rel="noopener noreferrer" class="btn btn-primary single-button" style="background-color: var(--color-concept); border-color: var(--color-concept);">
|
||||||
|
@ -1,13 +1,11 @@
|
|||||||
---
|
---
|
||||||
import { getToolsData } from '../utils/dataService.js';
|
import { getToolsData } from '../utils/dataService.js';
|
||||||
|
|
||||||
// Load tools data
|
|
||||||
const data = await getToolsData();
|
const data = await getToolsData();
|
||||||
|
|
||||||
const domains = data.domains;
|
const domains = data.domains;
|
||||||
const phases = data.phases;
|
const phases = data.phases;
|
||||||
|
|
||||||
// Get unique tags from all tools with frequency count
|
|
||||||
const tagFrequency = data.tools.reduce((acc: Record<string, number>, tool: any) => {
|
const tagFrequency = data.tools.reduce((acc: Record<string, number>, tool: any) => {
|
||||||
tool.tags.forEach((tag: string) => {
|
tool.tags.forEach((tag: string) => {
|
||||||
acc[tag] = (acc[tag] || 0) + 1;
|
acc[tag] = (acc[tag] || 0) + 1;
|
||||||
@ -15,14 +13,12 @@ const tagFrequency = data.tools.reduce((acc: Record<string, number>, tool: any)
|
|||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
// Sort tags by frequency (descending)
|
|
||||||
const sortedTags = Object.entries(tagFrequency)
|
const sortedTags = Object.entries(tagFrequency)
|
||||||
.sort(([,a], [,b]) => (b as number) - (a as number))
|
.sort(([,a], [,b]) => (b as number) - (a as number))
|
||||||
.map(([tag]) => tag);
|
.map(([tag]) => tag);
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="filters-container">
|
<div class="filters-container">
|
||||||
<!-- Search Bar -->
|
|
||||||
<div style="margin-bottom: 1.5rem;">
|
<div style="margin-bottom: 1.5rem;">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -32,9 +28,7 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Domain Dropdown and Phase Buttons -->
|
|
||||||
<div class="domain-phase-container" style="margin-bottom: 1.5rem;">
|
<div class="domain-phase-container" style="margin-bottom: 1.5rem;">
|
||||||
<!-- Domain Selection -->
|
|
||||||
<div class="domain-section">
|
<div class="domain-section">
|
||||||
<label for="domain-select" style="display: block; margin-bottom: 0.5rem; font-weight: 500;">
|
<label for="domain-select" style="display: block; margin-bottom: 0.5rem; font-weight: 500;">
|
||||||
Forensische Domäne
|
Forensische Domäne
|
||||||
@ -47,7 +41,6 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Phase Selection Buttons -->
|
|
||||||
<div class="phase-section">
|
<div class="phase-section">
|
||||||
<label style="display: block; margin-bottom: 0.75rem; font-weight: 500;">
|
<label style="display: block; margin-bottom: 0.75rem; font-weight: 500;">
|
||||||
Untersuchungsphase
|
Untersuchungsphase
|
||||||
@ -66,14 +59,12 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Additional Filters -->
|
|
||||||
<div style="margin-bottom: 1.5rem;">
|
<div style="margin-bottom: 1.5rem;">
|
||||||
<div class="checkbox-wrapper" style="margin-bottom: 1rem;">
|
<div class="checkbox-wrapper" style="margin-bottom: 1rem;">
|
||||||
<input type="checkbox" id="include-proprietary" checked />
|
<input type="checkbox" id="include-proprietary" checked />
|
||||||
<label for="include-proprietary">Proprietäre Software mit einschließen</label>
|
<label for="include-proprietary">Proprietäre Software mit einschließen</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tag Cloud -->
|
|
||||||
<div style="margin-bottom: 1rem;">
|
<div style="margin-bottom: 1rem;">
|
||||||
<div class="tag-header">
|
<div class="tag-header">
|
||||||
<label style="font-weight: 500;">
|
<label style="font-weight: 500;">
|
||||||
@ -103,12 +94,10 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- View Toggle -->
|
|
||||||
<div style="display: flex; gap: 1rem; margin-bottom: 1.5rem; align-items: center;">
|
<div style="display: flex; gap: 1rem; margin-bottom: 1.5rem; align-items: center;">
|
||||||
<button class="btn btn-secondary view-toggle active" style="height:50px" data-view="grid">Kachelansicht</button>
|
<button class="btn btn-secondary view-toggle active" style="height:50px" data-view="grid">Kachelansicht</button>
|
||||||
<button class="btn btn-secondary view-toggle" style="height:50px" data-view="matrix">Matrix-Ansicht</button>
|
<button class="btn btn-secondary view-toggle" style="height:50px" data-view="matrix">Matrix-Ansicht</button>
|
||||||
|
|
||||||
<!-- AI Recommendations Button (only visible when authenticated) -->
|
|
||||||
<button
|
<button
|
||||||
id="ai-view-toggle"
|
id="ai-view-toggle"
|
||||||
class="btn btn-secondary view-toggle"
|
class="btn btn-secondary view-toggle"
|
||||||
@ -125,10 +114,8 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script define:vars={{ toolsData: data.tools, tagFrequency, sortedTags }}>
|
<script define:vars={{ toolsData: data.tools, tagFrequency, sortedTags }}>
|
||||||
// Store tools data globally for filtering
|
|
||||||
window.toolsData = toolsData;
|
window.toolsData = toolsData;
|
||||||
|
|
||||||
// Initialize filters
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const searchInput = document.getElementById('search-input');
|
const searchInput = document.getElementById('search-input');
|
||||||
const domainSelect = document.getElementById('domain-select');
|
const domainSelect = document.getElementById('domain-select');
|
||||||
@ -140,12 +127,10 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
const viewToggles = document.querySelectorAll('.view-toggle');
|
const viewToggles = document.querySelectorAll('.view-toggle');
|
||||||
const aiViewToggle = document.getElementById('ai-view-toggle');
|
const aiViewToggle = document.getElementById('ai-view-toggle');
|
||||||
|
|
||||||
// Track selected tags and phase
|
|
||||||
let selectedTags = new Set();
|
let selectedTags = new Set();
|
||||||
let selectedPhase = '';
|
let selectedPhase = '';
|
||||||
let isTagCloudExpanded = false;
|
let isTagCloudExpanded = false;
|
||||||
|
|
||||||
// Initialize tag cloud state
|
|
||||||
function initTagCloud() {
|
function initTagCloud() {
|
||||||
const visibleCount = 22;
|
const visibleCount = 22;
|
||||||
tagCloudItems.forEach((item, index) => {
|
tagCloudItems.forEach((item, index) => {
|
||||||
@ -155,7 +140,6 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle tag cloud expansion
|
|
||||||
function toggleTagCloud() {
|
function toggleTagCloud() {
|
||||||
isTagCloudExpanded = !isTagCloudExpanded;
|
isTagCloudExpanded = !isTagCloudExpanded;
|
||||||
const visibleCount = 22;
|
const visibleCount = 22;
|
||||||
@ -189,7 +173,6 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter tag cloud based on search input
|
|
||||||
function filterTagCloud() {
|
function filterTagCloud() {
|
||||||
const searchTerm = searchInput.value.toLowerCase();
|
const searchTerm = searchInput.value.toLowerCase();
|
||||||
let visibleCount = 0;
|
let visibleCount = 0;
|
||||||
@ -219,7 +202,6 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
tagCloudToggle.style.display = hasHiddenTags ? 'block' : 'none';
|
tagCloudToggle.style.display = hasHiddenTags ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if tool is hosted (has valid projectUrl)
|
|
||||||
function isToolHosted(tool) {
|
function isToolHosted(tool) {
|
||||||
return tool.projectUrl !== undefined &&
|
return tool.projectUrl !== undefined &&
|
||||||
tool.projectUrl !== null &&
|
tool.projectUrl !== null &&
|
||||||
@ -227,12 +209,10 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
tool.projectUrl.trim() !== "";
|
tool.projectUrl.trim() !== "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if item is a method
|
|
||||||
function isMethod(tool) {
|
function isMethod(tool) {
|
||||||
return tool.type === 'method';
|
return tool.type === 'method';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update matrix highlighting based on current filters
|
|
||||||
function updateMatrixHighlighting() {
|
function updateMatrixHighlighting() {
|
||||||
const currentView = document.querySelector('.view-toggle.active')?.getAttribute('data-view');
|
const currentView = document.querySelector('.view-toggle.active')?.getAttribute('data-view');
|
||||||
if (currentView !== 'matrix') return;
|
if (currentView !== 'matrix') return;
|
||||||
@ -240,14 +220,12 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
const matrixTable = document.querySelector('.matrix-table');
|
const matrixTable = document.querySelector('.matrix-table');
|
||||||
if (!matrixTable) return;
|
if (!matrixTable) return;
|
||||||
|
|
||||||
// Clear existing highlights
|
|
||||||
matrixTable.querySelectorAll('.highlight-row, .highlight-column').forEach(el => {
|
matrixTable.querySelectorAll('.highlight-row, .highlight-column').forEach(el => {
|
||||||
el.classList.remove('highlight-row', 'highlight-column');
|
el.classList.remove('highlight-row', 'highlight-column');
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedDomain = domainSelect.value;
|
const selectedDomain = domainSelect.value;
|
||||||
|
|
||||||
// Highlight selected domain row
|
|
||||||
if (selectedDomain) {
|
if (selectedDomain) {
|
||||||
const domainRow = matrixTable.querySelector(`tr[data-domain="${selectedDomain}"]`);
|
const domainRow = matrixTable.querySelector(`tr[data-domain="${selectedDomain}"]`);
|
||||||
if (domainRow) {
|
if (domainRow) {
|
||||||
@ -255,7 +233,6 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Highlight selected phase column
|
|
||||||
if (selectedPhase) {
|
if (selectedPhase) {
|
||||||
const phaseHeaders = matrixTable.querySelectorAll('thead th[data-phase]');
|
const phaseHeaders = matrixTable.querySelectorAll('thead th[data-phase]');
|
||||||
const phaseIndex = Array.from(phaseHeaders).findIndex(th =>
|
const phaseIndex = Array.from(phaseHeaders).findIndex(th =>
|
||||||
@ -263,7 +240,7 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (phaseIndex >= 0) {
|
if (phaseIndex >= 0) {
|
||||||
const columnIndex = phaseIndex + 1; // +1 because first column is domain names
|
const columnIndex = phaseIndex + 1;
|
||||||
matrixTable.querySelectorAll(`tr`).forEach(row => {
|
matrixTable.querySelectorAll(`tr`).forEach(row => {
|
||||||
const cell = row.children[columnIndex];
|
const cell = row.children[columnIndex];
|
||||||
if (cell) {
|
if (cell) {
|
||||||
@ -274,19 +251,16 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ENHANCED: Filter function with better performance for show/hide pattern
|
|
||||||
function filterTools() {
|
function filterTools() {
|
||||||
const searchTerm = searchInput.value.toLowerCase();
|
const searchTerm = searchInput.value.toLowerCase();
|
||||||
const selectedDomain = domainSelect.value;
|
const selectedDomain = domainSelect.value;
|
||||||
const includeProprietary = proprietaryCheckbox.checked;
|
const includeProprietary = proprietaryCheckbox.checked;
|
||||||
|
|
||||||
const filtered = window.toolsData.filter(tool => {
|
const filtered = window.toolsData.filter(tool => {
|
||||||
// Ensure arrays exist with fallbacks
|
|
||||||
const domains = tool.domains || [];
|
const domains = tool.domains || [];
|
||||||
const phases = tool.phases || [];
|
const phases = tool.phases || [];
|
||||||
const tags = tool.tags || [];
|
const tags = tool.tags || [];
|
||||||
|
|
||||||
// Search filter - more comprehensive
|
|
||||||
if (searchTerm && !(
|
if (searchTerm && !(
|
||||||
tool.name.toLowerCase().includes(searchTerm) ||
|
tool.name.toLowerCase().includes(searchTerm) ||
|
||||||
tool.description.toLowerCase().includes(searchTerm) ||
|
tool.description.toLowerCase().includes(searchTerm) ||
|
||||||
@ -295,22 +269,18 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Domain filter
|
|
||||||
if (selectedDomain && !domains.includes(selectedDomain)) {
|
if (selectedDomain && !domains.includes(selectedDomain)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase filter
|
|
||||||
if (selectedPhase && !phases.includes(selectedPhase)) {
|
if (selectedPhase && !phases.includes(selectedPhase)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Proprietary filter (skip for methods and concepts since they don't have licenses)
|
|
||||||
if (!includeProprietary && !isMethod(tool) && tool.type !== 'concept' && tool.license === 'Proprietary') {
|
if (!includeProprietary && !isMethod(tool) && tool.type !== 'concept' && tool.license === 'Proprietary') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tag filter - ensure all selected tags are present
|
|
||||||
if (selectedTags.size > 0 && !Array.from(selectedTags).every(tag => tags.includes(tag))) {
|
if (selectedTags.size > 0 && !Array.from(selectedTags).every(tag => tags.includes(tag))) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -318,14 +288,11 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update matrix highlighting
|
|
||||||
updateMatrixHighlighting();
|
updateMatrixHighlighting();
|
||||||
|
|
||||||
// Emit custom event with filtered results
|
|
||||||
window.dispatchEvent(new CustomEvent('toolsFiltered', { detail: filtered }));
|
window.dispatchEvent(new CustomEvent('toolsFiltered', { detail: filtered }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle tag cloud clicks
|
|
||||||
function handleTagClick(tagItem) {
|
function handleTagClick(tagItem) {
|
||||||
const tag = tagItem.getAttribute('data-tag');
|
const tag = tagItem.getAttribute('data-tag');
|
||||||
|
|
||||||
@ -340,7 +307,6 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
filterTools();
|
filterTools();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle phase button clicks
|
|
||||||
function handlePhaseClick(button) {
|
function handlePhaseClick(button) {
|
||||||
const phase = button.getAttribute('data-phase');
|
const phase = button.getAttribute('data-phase');
|
||||||
|
|
||||||
@ -356,7 +322,6 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
filterTools();
|
filterTools();
|
||||||
}
|
}
|
||||||
|
|
||||||
// View toggle handler
|
|
||||||
function handleViewToggle(view) {
|
function handleViewToggle(view) {
|
||||||
viewToggles.forEach(btn => {
|
viewToggles.forEach(btn => {
|
||||||
btn.classList.toggle('active', btn.getAttribute('data-view') === view);
|
btn.classList.toggle('active', btn.getAttribute('data-view') === view);
|
||||||
@ -372,14 +337,12 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear all tag filters function
|
|
||||||
function clearTagFilters() {
|
function clearTagFilters() {
|
||||||
selectedTags.clear();
|
selectedTags.clear();
|
||||||
tagCloudItems.forEach(item => item.classList.remove('active'));
|
tagCloudItems.forEach(item => item.classList.remove('active'));
|
||||||
filterTools();
|
filterTools();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear all filters function
|
|
||||||
function clearAllFilters() {
|
function clearAllFilters() {
|
||||||
searchInput.value = '';
|
searchInput.value = '';
|
||||||
domainSelect.value = '';
|
domainSelect.value = '';
|
||||||
@ -389,7 +352,6 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
filterTagCloud();
|
filterTagCloud();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event listeners
|
|
||||||
searchInput.addEventListener('input', () => {
|
searchInput.addEventListener('input', () => {
|
||||||
filterTagCloud();
|
filterTagCloud();
|
||||||
filterTools();
|
filterTools();
|
||||||
@ -411,15 +373,12 @@ const sortedTags = Object.entries(tagFrequency)
|
|||||||
btn.addEventListener('click', () => handleViewToggle(btn.getAttribute('data-view')));
|
btn.addEventListener('click', () => handleViewToggle(btn.getAttribute('data-view')));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Expose functions globally for potential use
|
|
||||||
window.clearTagFilters = clearTagFilters;
|
window.clearTagFilters = clearTagFilters;
|
||||||
window.clearAllFilters = clearAllFilters;
|
window.clearAllFilters = clearAllFilters;
|
||||||
|
|
||||||
// Initialize
|
|
||||||
initTagCloud();
|
initTagCloud();
|
||||||
filterTagCloud();
|
filterTagCloud();
|
||||||
|
|
||||||
// Delay initial filter to ensure all event listeners are ready
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
filterTools();
|
filterTools();
|
||||||
}, 100);
|
}, 100);
|
||||||
|
@ -2,7 +2,6 @@
|
|||||||
import { getToolsData } from '../utils/dataService.js';
|
import { getToolsData } from '../utils/dataService.js';
|
||||||
import ShareButton from './ShareButton.astro';
|
import ShareButton from './ShareButton.astro';
|
||||||
|
|
||||||
// Load tools data
|
|
||||||
const data = await getToolsData();
|
const data = await getToolsData();
|
||||||
|
|
||||||
const domains = data.domains;
|
const domains = data.domains;
|
||||||
@ -10,7 +9,6 @@ const phases = data.phases;
|
|||||||
const tools = data.tools;
|
const tools = data.tools;
|
||||||
const domainAgnosticSoftware = data['domain-agnostic-software'] || [];
|
const domainAgnosticSoftware = data['domain-agnostic-software'] || [];
|
||||||
|
|
||||||
// Get tools for each domain-agnostic section based on the tool's domain-agnostic-software field
|
|
||||||
const domainAgnosticTools = domainAgnosticSoftware.map((section: any) => ({
|
const domainAgnosticTools = domainAgnosticSoftware.map((section: any) => ({
|
||||||
section,
|
section,
|
||||||
tools: tools.filter((tool: any) =>
|
tools: tools.filter((tool: any) =>
|
||||||
@ -18,13 +16,12 @@ const domainAgnosticTools = domainAgnosticSoftware.map((section: any) => ({
|
|||||||
)
|
)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Matrix shows ALL tools based on domains × phases (independent of domain-agnostic-software)
|
|
||||||
const matrix: Record<string, Record<string, any[]>> = {};
|
const matrix: Record<string, Record<string, any[]>> = {};
|
||||||
domains.forEach((domain: any) => {
|
domains.forEach((domain: any) => {
|
||||||
matrix[domain.id] = {};
|
matrix[domain.id] = {};
|
||||||
phases.forEach((phase: any) => {
|
phases.forEach((phase: any) => {
|
||||||
matrix[domain.id][phase.id] = tools.filter((tool: any) =>
|
matrix[domain.id][phase.id] = tools.filter((tool: any) =>
|
||||||
tool.type !== 'concept' && // Exclude concepts from matrix
|
tool.type !== 'concept' &&
|
||||||
tool.domains && tool.domains.includes(domain.id) &&
|
tool.domains && tool.domains.includes(domain.id) &&
|
||||||
tool.phases && tool.phases.includes(phase.id)
|
tool.phases && tool.phases.includes(phase.id)
|
||||||
);
|
);
|
||||||
@ -33,7 +30,6 @@ domains.forEach((domain: any) => {
|
|||||||
---
|
---
|
||||||
|
|
||||||
<div id="matrix-container" class="matrix-wrapper" style="display: none;">
|
<div id="matrix-container" class="matrix-wrapper" style="display: none;">
|
||||||
<!-- Domain-Agnostic Software Sections -->
|
|
||||||
{domainAgnosticTools.map((sectionData: any, index: number) => (
|
{domainAgnosticTools.map((sectionData: any, index: number) => (
|
||||||
<div id={`domain-agnostic-section-${sectionData.section.id}`} class="card collaboration-section-collapsed mb-6 border-l-4" style="border-left-color: var(--color-accent);">
|
<div id={`domain-agnostic-section-${sectionData.section.id}`} class="card collaboration-section-collapsed mb-6 border-l-4" style="border-left-color: var(--color-accent);">
|
||||||
<div class="collaboration-header cursor-pointer flex items-center gap-3" onclick={`toggleDomainAgnosticSection('${sectionData.section.id}')`} style="margin-bottom: 0.1rem;">
|
<div class="collaboration-header cursor-pointer flex items-center gap-3" onclick={`toggleDomainAgnosticSection('${sectionData.section.id}')`} style="margin-bottom: 0.1rem;">
|
||||||
@ -91,7 +87,6 @@ domains.forEach((domain: any) => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<!-- DFIR Tools Matrix -->
|
|
||||||
<div id="dfir-matrix-section">
|
<div id="dfir-matrix-section">
|
||||||
<h2 class="mb-4" style="color: var(--color-text);">MATRIX</h2>
|
<h2 class="mb-4" style="color: var(--color-text);">MATRIX</h2>
|
||||||
<table class="matrix-table">
|
<table class="matrix-table">
|
||||||
@ -139,19 +134,15 @@ domains.forEach((domain: any) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tool Details Modals - Dual Modal System -->
|
|
||||||
<div class="modal-overlay" id="modal-overlay" onclick="window.hideAllToolDetails()"></div>
|
<div class="modal-overlay" id="modal-overlay" onclick="window.hideAllToolDetails()"></div>
|
||||||
|
|
||||||
<!-- Primary Modal -->
|
|
||||||
<div class="tool-details" id="tool-details-primary">
|
<div class="tool-details" id="tool-details-primary">
|
||||||
<div class="flex justify-between items-start mb-4">
|
<div class="flex justify-between items-start mb-4">
|
||||||
<h2 id="tool-name-primary" class="m-0">Tool Name</h2>
|
<h2 id="tool-name-primary" class="m-0">Tool Name</h2>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div id="share-button-primary" style="display: none;">
|
<div id="share-button-primary" style="display: none;">
|
||||||
<!-- Share button will be populated by JavaScript -->
|
|
||||||
</div>
|
</div>
|
||||||
<div id="contribute-button-primary" style="display: none;">
|
<div id="contribute-button-primary" style="display: none;">
|
||||||
<!-- Contribution button will be populated by JavaScript -->
|
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-icon" onclick="window.hideToolDetails('primary')">
|
<button class="btn-icon" onclick="window.hideToolDetails('primary')">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
@ -173,16 +164,13 @@ domains.forEach((domain: any) => {
|
|||||||
<div id="tool-links-primary" class="flex flex-col gap-2"></div>
|
<div id="tool-links-primary" class="flex flex-col gap-2"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Secondary Modal -->
|
|
||||||
<div class="tool-details" id="tool-details-secondary">
|
<div class="tool-details" id="tool-details-secondary">
|
||||||
<div class="flex justify-between items-start mb-4">
|
<div class="flex justify-between items-start mb-4">
|
||||||
<h2 id="tool-name-secondary" class="m-0">Tool Name</h2>
|
<h2 id="tool-name-secondary" class="m-0">Tool Name</h2>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div id="share-button-secondary" style="display: none;">
|
<div id="share-button-secondary" style="display: none;">
|
||||||
<!-- Share button will be populated by JavaScript -->
|
|
||||||
</div>
|
</div>
|
||||||
<div id="contribute-button-secondary" style="display: none;">
|
<div id="contribute-button-secondary" style="display: none;">
|
||||||
<!-- Contribution button will be populated by JavaScript -->
|
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-icon" onclick="window.hideToolDetails('secondary')">
|
<button class="btn-icon" onclick="window.hideToolDetails('secondary')">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
@ -205,24 +193,20 @@ domains.forEach((domain: any) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script define:vars={{ toolsData: tools, domainAgnosticSoftware, domainAgnosticTools }}>
|
<script define:vars={{ toolsData: tools, domainAgnosticSoftware, domainAgnosticTools }}>
|
||||||
// Helper function to get selected phase from active button
|
|
||||||
function getSelectedPhase() {
|
function getSelectedPhase() {
|
||||||
const activePhaseButton = document.querySelector('.phase-button.active');
|
const activePhaseButton = document.querySelector('.phase-button.active');
|
||||||
return activePhaseButton ? activePhaseButton.getAttribute('data-phase') : '';
|
return activePhaseButton ? activePhaseButton.getAttribute('data-phase') : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get selected domain from dropdown
|
|
||||||
function getSelectedDomain() {
|
function getSelectedDomain() {
|
||||||
const domainSelect = document.getElementById('domain-select');
|
const domainSelect = document.getElementById('domain-select');
|
||||||
return domainSelect ? domainSelect.value : '';
|
return domainSelect ? domainSelect.value : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update matrix highlighting based on current filters
|
|
||||||
function updateMatrixHighlighting() {
|
function updateMatrixHighlighting() {
|
||||||
const matrixTable = document.querySelector('.matrix-table');
|
const matrixTable = document.querySelector('.matrix-table');
|
||||||
if (!matrixTable) return;
|
if (!matrixTable) return;
|
||||||
|
|
||||||
// Clear existing highlights
|
|
||||||
matrixTable.querySelectorAll('.highlight-row, .highlight-column').forEach(el => {
|
matrixTable.querySelectorAll('.highlight-row, .highlight-column').forEach(el => {
|
||||||
el.classList.remove('highlight-row', 'highlight-column');
|
el.classList.remove('highlight-row', 'highlight-column');
|
||||||
});
|
});
|
||||||
@ -230,7 +214,6 @@ domains.forEach((domain: any) => {
|
|||||||
const selectedDomain = getSelectedDomain();
|
const selectedDomain = getSelectedDomain();
|
||||||
const selectedPhase = getSelectedPhase();
|
const selectedPhase = getSelectedPhase();
|
||||||
|
|
||||||
// Highlight selected domain row
|
|
||||||
if (selectedDomain) {
|
if (selectedDomain) {
|
||||||
const domainRow = matrixTable.querySelector(`tr[data-domain="${selectedDomain}"]`);
|
const domainRow = matrixTable.querySelector(`tr[data-domain="${selectedDomain}"]`);
|
||||||
if (domainRow) {
|
if (domainRow) {
|
||||||
@ -238,7 +221,6 @@ domains.forEach((domain: any) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Highlight selected phase column
|
|
||||||
if (selectedPhase) {
|
if (selectedPhase) {
|
||||||
const phaseHeaders = matrixTable.querySelectorAll('thead th[data-phase]');
|
const phaseHeaders = matrixTable.querySelectorAll('thead th[data-phase]');
|
||||||
const phaseIndex = Array.from(phaseHeaders).findIndex(th =>
|
const phaseIndex = Array.from(phaseHeaders).findIndex(th =>
|
||||||
@ -246,7 +228,7 @@ domains.forEach((domain: any) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (phaseIndex >= 0) {
|
if (phaseIndex >= 0) {
|
||||||
const columnIndex = phaseIndex + 1; // +1 because first column is domain names
|
const columnIndex = phaseIndex + 1;
|
||||||
matrixTable.querySelectorAll(`tr`).forEach(row => {
|
matrixTable.querySelectorAll(`tr`).forEach(row => {
|
||||||
const cell = row.children[columnIndex];
|
const cell = row.children[columnIndex];
|
||||||
if (cell) {
|
if (cell) {
|
||||||
@ -257,7 +239,6 @@ domains.forEach((domain: any) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle domain-agnostic section
|
|
||||||
function toggleDomainAgnosticSection(sectionId) {
|
function toggleDomainAgnosticSection(sectionId) {
|
||||||
const section = document.getElementById(`domain-agnostic-section-${sectionId}`);
|
const section = document.getElementById(`domain-agnostic-section-${sectionId}`);
|
||||||
const content = section?.querySelector('.collaboration-content');
|
const content = section?.querySelector('.collaboration-content');
|
||||||
@ -268,13 +249,11 @@ domains.forEach((domain: any) => {
|
|||||||
const isExpanded = section.classList.contains('collaboration-section-expanded');
|
const isExpanded = section.classList.contains('collaboration-section-expanded');
|
||||||
|
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
// Collapse
|
|
||||||
section.classList.remove('collaboration-section-expanded');
|
section.classList.remove('collaboration-section-expanded');
|
||||||
section.classList.add('collaboration-section-collapsed');
|
section.classList.add('collaboration-section-collapsed');
|
||||||
content.style.display = 'none';
|
content.style.display = 'none';
|
||||||
icon.style.transform = 'rotate(0deg)';
|
icon.style.transform = 'rotate(0deg)';
|
||||||
} else {
|
} else {
|
||||||
// Expand
|
|
||||||
section.classList.remove('collaboration-section-collapsed');
|
section.classList.remove('collaboration-section-collapsed');
|
||||||
section.classList.add('collaboration-section-expanded');
|
section.classList.add('collaboration-section-expanded');
|
||||||
content.style.display = 'block';
|
content.style.display = 'block';
|
||||||
@ -282,7 +261,6 @@ domains.forEach((domain: any) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate share URLs
|
|
||||||
function generateShareURL(toolName, view, modal = null) {
|
function generateShareURL(toolName, view, modal = null) {
|
||||||
const toolSlug = window.createToolSlug(toolName);
|
const toolSlug = window.createToolSlug(toolName);
|
||||||
const baseUrl = window.location.origin + window.location.pathname;
|
const baseUrl = window.location.origin + window.location.pathname;
|
||||||
@ -295,12 +273,10 @@ domains.forEach((domain: any) => {
|
|||||||
return `${baseUrl}?${params.toString()}`;
|
return `${baseUrl}?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy to clipboard with feedback
|
|
||||||
async function copyToClipboard(text, button) {
|
async function copyToClipboard(text, button) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
|
|
||||||
// Show feedback
|
|
||||||
const originalHTML = button.innerHTML;
|
const originalHTML = button.innerHTML;
|
||||||
button.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20,6 9,17 4,12"/></svg> Kopiert!';
|
button.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20,6 9,17 4,12"/></svg> Kopiert!';
|
||||||
button.style.color = 'var(--color-accent)';
|
button.style.color = 'var(--color-accent)';
|
||||||
@ -310,7 +286,6 @@ domains.forEach((domain: any) => {
|
|||||||
button.style.color = '';
|
button.style.color = '';
|
||||||
}, 2000);
|
}, 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Fallback for older browsers
|
|
||||||
const textArea = document.createElement('textarea');
|
const textArea = document.createElement('textarea');
|
||||||
textArea.value = text;
|
textArea.value = text;
|
||||||
document.body.appendChild(textArea);
|
document.body.appendChild(textArea);
|
||||||
@ -318,7 +293,6 @@ domains.forEach((domain: any) => {
|
|||||||
document.execCommand('copy');
|
document.execCommand('copy');
|
||||||
document.body.removeChild(textArea);
|
document.body.removeChild(textArea);
|
||||||
|
|
||||||
// Show feedback
|
|
||||||
const originalHTML = button.innerHTML;
|
const originalHTML = button.innerHTML;
|
||||||
button.innerHTML = 'Kopiert!';
|
button.innerHTML = 'Kopiert!';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -327,12 +301,10 @@ domains.forEach((domain: any) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show share dialog
|
|
||||||
window.showShareDialog = function(shareButton) {
|
window.showShareDialog = function(shareButton) {
|
||||||
const toolName = shareButton.getAttribute('data-tool-name');
|
const toolName = shareButton.getAttribute('data-tool-name');
|
||||||
const context = shareButton.getAttribute('data-context');
|
const context = shareButton.getAttribute('data-context');
|
||||||
|
|
||||||
// Create modal backdrop
|
|
||||||
let backdrop = document.getElementById('share-modal-backdrop');
|
let backdrop = document.getElementById('share-modal-backdrop');
|
||||||
if (!backdrop) {
|
if (!backdrop) {
|
||||||
backdrop = document.createElement('div');
|
backdrop = document.createElement('div');
|
||||||
@ -354,7 +326,6 @@ domains.forEach((domain: any) => {
|
|||||||
document.body.appendChild(backdrop);
|
document.body.appendChild(backdrop);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create share dialog
|
|
||||||
const dialog = document.createElement('div');
|
const dialog = document.createElement('div');
|
||||||
dialog.style.cssText = `
|
dialog.style.cssText = `
|
||||||
background-color: var(--color-bg);
|
background-color: var(--color-bg);
|
||||||
@ -440,13 +411,11 @@ domains.forEach((domain: any) => {
|
|||||||
|
|
||||||
backdrop.appendChild(dialog);
|
backdrop.appendChild(dialog);
|
||||||
|
|
||||||
// Show with animation
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
backdrop.style.opacity = '1';
|
backdrop.style.opacity = '1';
|
||||||
dialog.style.transform = 'scale(1)';
|
dialog.style.transform = 'scale(1)';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Event handlers
|
|
||||||
const closeDialog = () => {
|
const closeDialog = () => {
|
||||||
backdrop.style.opacity = '0';
|
backdrop.style.opacity = '0';
|
||||||
dialog.style.transform = 'scale(0.9)';
|
dialog.style.transform = 'scale(0.9)';
|
||||||
@ -463,7 +432,6 @@ domains.forEach((domain: any) => {
|
|||||||
|
|
||||||
document.getElementById('close-share-dialog').addEventListener('click', closeDialog);
|
document.getElementById('close-share-dialog').addEventListener('click', closeDialog);
|
||||||
|
|
||||||
// Share option handlers
|
|
||||||
dialog.querySelectorAll('.share-option-btn').forEach(btn => {
|
dialog.querySelectorAll('.share-option-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const url = btn.getAttribute('data-url');
|
const url = btn.getAttribute('data-url');
|
||||||
@ -472,10 +440,8 @@ domains.forEach((domain: any) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Make functions globally available
|
|
||||||
window.toggleDomainAgnosticSection = toggleDomainAgnosticSection;
|
window.toggleDomainAgnosticSection = toggleDomainAgnosticSection;
|
||||||
|
|
||||||
// Enhanced modal system for side-by-side display
|
|
||||||
window.showToolDetails = function(toolName, modalType = 'primary') {
|
window.showToolDetails = function(toolName, modalType = 'primary') {
|
||||||
const tool = toolsData.find(t => t.name === toolName);
|
const tool = toolsData.find(t => t.name === toolName);
|
||||||
if (!tool) {
|
if (!tool) {
|
||||||
@ -486,7 +452,6 @@ domains.forEach((domain: any) => {
|
|||||||
const isMethod = tool.type === 'method';
|
const isMethod = tool.type === 'method';
|
||||||
const isConcept = tool.type === 'concept';
|
const isConcept = tool.type === 'concept';
|
||||||
|
|
||||||
// Get modal-specific element IDs
|
|
||||||
const elements = {
|
const elements = {
|
||||||
name: document.getElementById(`tool-name-${modalType}`),
|
name: document.getElementById(`tool-name-${modalType}`),
|
||||||
description: document.getElementById(`tool-description-${modalType}`),
|
description: document.getElementById(`tool-description-${modalType}`),
|
||||||
@ -496,7 +461,6 @@ domains.forEach((domain: any) => {
|
|||||||
links: document.getElementById(`tool-links-${modalType}`)
|
links: document.getElementById(`tool-links-${modalType}`)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if all elements exist
|
|
||||||
for (const [key, element] of Object.entries(elements)) {
|
for (const [key, element] of Object.entries(elements)) {
|
||||||
if (!element) {
|
if (!element) {
|
||||||
console.error(`Element not found: tool-${key}-${modalType}`);
|
console.error(`Element not found: tool-${key}-${modalType}`);
|
||||||
@ -504,12 +468,10 @@ domains.forEach((domain: any) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update modal content
|
|
||||||
const iconHtml = tool.icon ? `<span class="mr-3 text-xl">${tool.icon}</span>` : '';
|
const iconHtml = tool.icon ? `<span class="mr-3 text-xl">${tool.icon}</span>` : '';
|
||||||
elements.name.innerHTML = `${iconHtml}${tool.name}`;
|
elements.name.innerHTML = `${iconHtml}${tool.name}`;
|
||||||
elements.description.textContent = tool.description;
|
elements.description.textContent = tool.description;
|
||||||
|
|
||||||
// Badges
|
|
||||||
const hasValidProjectUrl = window.isToolHosted(tool);
|
const hasValidProjectUrl = window.isToolHosted(tool);
|
||||||
|
|
||||||
elements.badges.innerHTML = '';
|
elements.badges.innerHTML = '';
|
||||||
@ -525,7 +487,6 @@ domains.forEach((domain: any) => {
|
|||||||
elements.badges.innerHTML += '<span class="badge badge-error">📖</span>';
|
elements.badges.innerHTML += '<span class="badge badge-error">📖</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metadata
|
|
||||||
const domains = tool.domains || [];
|
const domains = tool.domains || [];
|
||||||
const phases = tool.phases || [];
|
const phases = tool.phases || [];
|
||||||
const domainsText = domains.length > 0 ? domains.join(', ') : 'Domain-agnostic';
|
const domainsText = domains.length > 0 ? domains.join(', ') : 'Domain-agnostic';
|
||||||
@ -551,7 +512,6 @@ domains.forEach((domain: any) => {
|
|||||||
|
|
||||||
elements.metadata.innerHTML = metadataHTML;
|
elements.metadata.innerHTML = metadataHTML;
|
||||||
|
|
||||||
// Tags and Related Concepts
|
|
||||||
const tags = tool.tags || [];
|
const tags = tool.tags || [];
|
||||||
let tagsHTML = `
|
let tagsHTML = `
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
@ -559,7 +519,6 @@ domains.forEach((domain: any) => {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Related Concepts section - only show in primary modal to avoid infinite loops
|
|
||||||
const relatedConcepts = tool.related_concepts || [];
|
const relatedConcepts = tool.related_concepts || [];
|
||||||
if (relatedConcepts.length > 0 && modalType === 'primary') {
|
if (relatedConcepts.length > 0 && modalType === 'primary') {
|
||||||
const conceptLinks = relatedConcepts.map(conceptName => {
|
const conceptLinks = relatedConcepts.map(conceptName => {
|
||||||
@ -575,7 +534,6 @@ domains.forEach((domain: any) => {
|
|||||||
return `<span class="tag" style="background-color: var(--color-bg-tertiary); color: var(--color-text-secondary);">${conceptName}</span>`;
|
return `<span class="tag" style="background-color: var(--color-bg-tertiary); color: var(--color-text-secondary);">${conceptName}</span>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
// Check if mobile device
|
|
||||||
const isMobile = window.innerWidth <= 768;
|
const isMobile = window.innerWidth <= 768;
|
||||||
const collapseOnMobile = isMobile && relatedConcepts.length > 2;
|
const collapseOnMobile = isMobile && relatedConcepts.length > 2;
|
||||||
|
|
||||||
@ -600,7 +558,6 @@ domains.forEach((domain: any) => {
|
|||||||
|
|
||||||
elements.tags.innerHTML = tagsHTML;
|
elements.tags.innerHTML = tagsHTML;
|
||||||
|
|
||||||
// Links
|
|
||||||
let linksHTML = '';
|
let linksHTML = '';
|
||||||
|
|
||||||
if (isConcept) {
|
if (isConcept) {
|
||||||
@ -652,7 +609,6 @@ domains.forEach((domain: any) => {
|
|||||||
|
|
||||||
elements.links.innerHTML = linksHTML;
|
elements.links.innerHTML = linksHTML;
|
||||||
|
|
||||||
// ===== POPULATE SHARE BUTTON =====
|
|
||||||
const shareButtonContainer = document.getElementById(`share-button-${modalType}`);
|
const shareButtonContainer = document.getElementById(`share-button-${modalType}`);
|
||||||
if (shareButtonContainer) {
|
if (shareButtonContainer) {
|
||||||
const toolSlug = window.createToolSlug(tool.name);
|
const toolSlug = window.createToolSlug(tool.name);
|
||||||
@ -676,7 +632,6 @@ domains.forEach((domain: any) => {
|
|||||||
shareButtonContainer.style.display = 'block';
|
shareButtonContainer.style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== POPULATE CONTRIBUTION BUTTON =====
|
|
||||||
const contributeButtonContainer = document.getElementById(`contribute-button-${modalType}`);
|
const contributeButtonContainer = document.getElementById(`contribute-button-${modalType}`);
|
||||||
if (contributeButtonContainer) {
|
if (contributeButtonContainer) {
|
||||||
contributeButtonContainer.innerHTML = `
|
contributeButtonContainer.innerHTML = `
|
||||||
@ -696,7 +651,6 @@ domains.forEach((domain: any) => {
|
|||||||
contributeButtonContainer.style.display = 'block';
|
contributeButtonContainer.style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show modals and update layout
|
|
||||||
const overlay = document.getElementById('modal-overlay');
|
const overlay = document.getElementById('modal-overlay');
|
||||||
const primaryModal = document.getElementById('tool-details-primary');
|
const primaryModal = document.getElementById('tool-details-primary');
|
||||||
const secondaryModal = document.getElementById('tool-details-secondary');
|
const secondaryModal = document.getElementById('tool-details-secondary');
|
||||||
@ -705,7 +659,6 @@ domains.forEach((domain: any) => {
|
|||||||
if (modalType === 'primary' && primaryModal) primaryModal.classList.add('active');
|
if (modalType === 'primary' && primaryModal) primaryModal.classList.add('active');
|
||||||
if (modalType === 'secondary' && secondaryModal) secondaryModal.classList.add('active');
|
if (modalType === 'secondary' && secondaryModal) secondaryModal.classList.add('active');
|
||||||
|
|
||||||
// Check if both modals are now active
|
|
||||||
const primaryActive = primaryModal && primaryModal.classList.contains('active');
|
const primaryActive = primaryModal && primaryModal.classList.contains('active');
|
||||||
const secondaryActive = secondaryModal && secondaryModal.classList.contains('active');
|
const secondaryActive = secondaryModal && secondaryModal.classList.contains('active');
|
||||||
|
|
||||||
@ -749,7 +702,6 @@ domains.forEach((domain: any) => {
|
|||||||
if (contributeButtonSecondary) contributeButtonSecondary.style.display = 'none';
|
if (contributeButtonSecondary) contributeButtonSecondary.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if any modal is still active
|
|
||||||
const primaryActive = primaryModal && primaryModal.classList.contains('active');
|
const primaryActive = primaryModal && primaryModal.classList.contains('active');
|
||||||
const secondaryActive = secondaryModal && secondaryModal.classList.contains('active');
|
const secondaryActive = secondaryModal && secondaryModal.classList.contains('active');
|
||||||
|
|
||||||
@ -757,7 +709,6 @@ domains.forEach((domain: any) => {
|
|||||||
if (overlay) overlay.classList.remove('active');
|
if (overlay) overlay.classList.remove('active');
|
||||||
document.body.classList.remove('modals-side-by-side');
|
document.body.classList.remove('modals-side-by-side');
|
||||||
} else if (primaryActive !== secondaryActive) {
|
} else if (primaryActive !== secondaryActive) {
|
||||||
// Only one modal left - remove side-by-side layout
|
|
||||||
document.body.classList.remove('modals-side-by-side');
|
document.body.classList.remove('modals-side-by-side');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -766,7 +717,6 @@ domains.forEach((domain: any) => {
|
|||||||
window.hideToolDetails('both');
|
window.hideToolDetails('both');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keep existing event listeners
|
|
||||||
window.addEventListener('viewChanged', (event) => {
|
window.addEventListener('viewChanged', (event) => {
|
||||||
const view = event.detail;
|
const view = event.detail;
|
||||||
if (view === 'matrix') {
|
if (view === 'matrix') {
|
||||||
|
4
src/env.d.ts
vendored
4
src/env.d.ts
vendored
@ -1,6 +1,5 @@
|
|||||||
/// <reference path="../.astro/types.d.ts" />
|
/// <reference path="../.astro/types.d.ts" />
|
||||||
|
|
||||||
// Extend the Window interface to include custom properties
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
themeUtils: {
|
themeUtils: {
|
||||||
@ -19,12 +18,10 @@ declare global {
|
|||||||
clearTagFilters?: () => void;
|
clearTagFilters?: () => void;
|
||||||
clearAllFilters?: () => void;
|
clearAllFilters?: () => void;
|
||||||
|
|
||||||
// CONSOLIDATED: Tool utility functions
|
|
||||||
createToolSlug: (toolName: string) => string;
|
createToolSlug: (toolName: string) => string;
|
||||||
findToolByIdentifier: (tools: any[], identifier: string) => any | undefined;
|
findToolByIdentifier: (tools: any[], identifier: string) => any | undefined;
|
||||||
isToolHosted: (tool: any) => boolean;
|
isToolHosted: (tool: any) => boolean;
|
||||||
|
|
||||||
// CONSOLIDATED: Auth utility functions (now in BaseLayout)
|
|
||||||
checkClientAuth: () => Promise<{authenticated: boolean; authRequired: boolean; expires?: string}>;
|
checkClientAuth: () => Promise<{authenticated: boolean; authRequired: boolean; expires?: string}>;
|
||||||
requireClientAuth: (callback?: () => void, returnUrl?: string) => Promise<boolean>;
|
requireClientAuth: (callback?: () => void, returnUrl?: string) => Promise<boolean>;
|
||||||
showIfAuthenticated: (selector: string) => Promise<void>;
|
showIfAuthenticated: (selector: string) => Promise<void>;
|
||||||
@ -32,7 +29,6 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also declare the modules that might not be recognized
|
|
||||||
declare module 'js-yaml' {
|
declare module 'js-yaml' {
|
||||||
export function load(str: string): any;
|
export function load(str: string): any;
|
||||||
}
|
}
|
||||||
|
@ -21,9 +21,7 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
|
|||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Initialize theme immediately to prevent flash
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Theme management (consolidated from theme.js)
|
|
||||||
const THEME_KEY = 'dfir-theme';
|
const THEME_KEY = 'dfir-theme';
|
||||||
|
|
||||||
function getSystemTheme() {
|
function getSystemTheme() {
|
||||||
@ -63,7 +61,6 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
|
|||||||
updateThemeToggle(nextTheme);
|
updateThemeToggle(nextTheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for system theme changes
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||||
if (getStoredTheme() === 'auto') {
|
if (getStoredTheme() === 'auto') {
|
||||||
applyTheme('auto');
|
applyTheme('auto');
|
||||||
@ -105,13 +102,10 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
|
|||||||
tool.projectUrl.trim() !== "";
|
tool.projectUrl.trim() !== "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXED: Use type assertions to avoid TypeScript errors
|
|
||||||
// Make functions available globally for existing code compatibility
|
|
||||||
(window as any).createToolSlug = createToolSlug;
|
(window as any).createToolSlug = createToolSlug;
|
||||||
(window as any).findToolByIdentifier = findToolByIdentifier;
|
(window as any).findToolByIdentifier = findToolByIdentifier;
|
||||||
(window as any).isToolHosted = isToolHosted;
|
(window as any).isToolHosted = isToolHosted;
|
||||||
|
|
||||||
// Client-side auth functions (consolidated from client-auth.js)
|
|
||||||
async function checkClientAuth(context = 'general') {
|
async function checkClientAuth(context = 'general') {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/auth/status');
|
const response = await fetch('/api/auth/status');
|
||||||
@ -183,7 +177,6 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
|
|||||||
|
|
||||||
console.log('[AUTH] Contribute button clicked:', button.getAttribute('data-contribute-button'));
|
console.log('[AUTH] Contribute button clicked:', button.getAttribute('data-contribute-button'));
|
||||||
|
|
||||||
// ENHANCED: Use contributions context
|
|
||||||
await requireClientAuth(() => {
|
await requireClientAuth(() => {
|
||||||
console.log('[AUTH] Navigation approved, redirecting to:', (button as HTMLAnchorElement).href);
|
console.log('[AUTH] Navigation approved, redirecting to:', (button as HTMLAnchorElement).href);
|
||||||
window.location.href = (button as HTMLAnchorElement).href;
|
window.location.href = (button as HTMLAnchorElement).href;
|
||||||
@ -191,13 +184,11 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make auth functions available globally
|
|
||||||
(window as any).checkClientAuth = checkClientAuth;
|
(window as any).checkClientAuth = checkClientAuth;
|
||||||
(window as any).requireClientAuth = requireClientAuth;
|
(window as any).requireClientAuth = requireClientAuth;
|
||||||
(window as any).showIfAuthenticated = showIfAuthenticated;
|
(window as any).showIfAuthenticated = showIfAuthenticated;
|
||||||
(window as any).setupAuthButtons = setupAuthButtons;
|
(window as any).setupAuthButtons = setupAuthButtons;
|
||||||
|
|
||||||
// Initialize everything
|
|
||||||
initTheme();
|
initTheme();
|
||||||
setupAuthButtons('[data-contribute-button]');
|
setupAuthButtons('[data-contribute-button]');
|
||||||
|
|
||||||
|
@ -4,7 +4,6 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||||||
|
|
||||||
<BaseLayout title="Über das Projekt" description="ForensicPathways - Ein Projekt für die Seminargruppe CC24-w1">
|
<BaseLayout title="Über das Projekt" description="ForensicPathways - Ein Projekt für die Seminargruppe CC24-w1">
|
||||||
<section style="padding: 2rem 0; max-width: 900px; margin: 0 auto;">
|
<section style="padding: 2rem 0; max-width: 900px; margin: 0 auto;">
|
||||||
<!-- Hero Section -->
|
|
||||||
<div style="text-align: center; margin-bottom: 3rem; padding: 2rem; background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%); border-radius: 1rem; border: 1px solid var(--color-border);">
|
<div style="text-align: center; margin-bottom: 3rem; padding: 2rem; background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%); border-radius: 1rem; border: 1px solid var(--color-border);">
|
||||||
<h1 style="margin-bottom: 1rem; font-size: 2.5rem; color: var(--color-primary);">ForensicPathways</h1>
|
<h1 style="margin-bottom: 1rem; font-size: 2.5rem; color: var(--color-primary);">ForensicPathways</h1>
|
||||||
<p style="font-size: 1.25rem; color: var(--color-text-secondary); margin-bottom: 0.5rem;">
|
<p style="font-size: 1.25rem; color: var(--color-text-secondary); margin-bottom: 0.5rem;">
|
||||||
@ -15,7 +14,6 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Project Goal -->
|
|
||||||
<div class="card" style="margin-bottom: 2rem; border-left: 4px solid var(--color-primary);">
|
<div class="card" style="margin-bottom: 2rem; border-left: 4px solid var(--color-primary);">
|
||||||
<div style="display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1rem;">
|
<div style="display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1rem;">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" stroke-width="2">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" stroke-width="2">
|
||||||
@ -41,7 +39,6 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Methodology -->
|
|
||||||
<div class="card" style="margin-bottom: 2rem; border-left: 4px solid var(--color-accent);">
|
<div class="card" style="margin-bottom: 2rem; border-left: 4px solid var(--color-accent);">
|
||||||
<div style="display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1rem;">
|
<div style="display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1rem;">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" stroke-width="2">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" stroke-width="2">
|
||||||
@ -77,7 +74,6 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Open Source Focus -->
|
|
||||||
<div class="card" style="margin-bottom: 2rem; border-left: 4px solid var(--color-warning);">
|
<div class="card" style="margin-bottom: 2rem; border-left: 4px solid var(--color-warning);">
|
||||||
<div style="display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1rem;">
|
<div style="display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1rem;">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-warning)" stroke-width="2">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-warning)" stroke-width="2">
|
||||||
@ -87,7 +83,6 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||||||
<h2 style="margin: 0; color: var(--color-warning);">Weshalb der Fokus auf Open Source?</h2>
|
<h2 style="margin: 0; color: var(--color-warning);">Weshalb der Fokus auf Open Source?</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hypothesis Box -->
|
|
||||||
<div style="background: linear-gradient(135deg, var(--color-warning) 0%, var(--color-accent) 100%); color: white; padding: 1.5rem; border-radius: 0.75rem; margin-bottom: 1.5rem;">
|
<div style="background: linear-gradient(135deg, var(--color-warning) 0%, var(--color-accent) 100%); color: white; padding: 1.5rem; border-radius: 0.75rem; margin-bottom: 1.5rem;">
|
||||||
<h3 style="margin: 0 0 1rem 0; font-size: 1.125rem;">💡 Zentrale Hypothese</h3>
|
<h3 style="margin: 0 0 1rem 0; font-size: 1.125rem;">💡 Zentrale Hypothese</h3>
|
||||||
<p style="margin: 0; line-height: 1.6; font-style: italic;">
|
<p style="margin: 0; line-height: 1.6; font-style: italic;">
|
||||||
@ -131,7 +126,6 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Support Section -->
|
|
||||||
<div id= "support" class="card" style="margin-bottom: 2rem; border-left: 4px solid var(--color-primary);">
|
<div id= "support" class="card" style="margin-bottom: 2rem; border-left: 4px solid var(--color-primary);">
|
||||||
<div style="display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1rem;">
|
<div style="display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1rem;">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" stroke-width="2">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" stroke-width="2">
|
||||||
@ -144,7 +138,6 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||||||
oder sonstige Probleme auftreten: <strong>Schreibt mir einfach auf Signal</strong> oder an <a href="mailto:mstoeck3@hs-mittweida.de">mstoeck3@hs-mittweida.de</a>.
|
oder sonstige Probleme auftreten: <strong>Schreibt mir einfach auf Signal</strong> oder an <a href="mailto:mstoeck3@hs-mittweida.de">mstoeck3@hs-mittweida.de</a>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Special Note Box -->
|
|
||||||
<div style="background-color: var(--color-warning); color: white; padding: 1.25rem; border-radius: 0.5rem; margin-top: 1.5rem;">
|
<div style="background-color: var(--color-warning); color: white; padding: 1.25rem; border-radius: 0.5rem; margin-top: 1.5rem;">
|
||||||
<h4 style="margin: 0 0 0.75rem 0; display: flex; align-items: center; gap: 0.5rem;">
|
<h4 style="margin: 0 0 0.75rem 0; display: flex; align-items: center; gap: 0.5rem;">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
@ -178,8 +171,6 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Contributing Section -->
|
|
||||||
<!-- Contribution Section -->
|
|
||||||
<div class="card" style="margin-bottom: 2rem; border-left: 4px solid var(--color-accent);">
|
<div class="card" style="margin-bottom: 2rem; border-left: 4px solid var(--color-accent);">
|
||||||
<div style="display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1rem;">
|
<div style="display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1rem;">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" stroke-width="2">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" stroke-width="2">
|
||||||
@ -192,7 +183,6 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display: grid; gap: 1.25rem;">
|
<div style="display: grid; gap: 1.25rem;">
|
||||||
<!-- Suggestions -->
|
|
||||||
<div style="background-color: var(--color-bg-secondary); padding: 1.25rem; border-radius: 0.5rem;">
|
<div style="background-color: var(--color-bg-secondary); padding: 1.25rem; border-radius: 0.5rem;">
|
||||||
<h4 style="margin: 0 0 0.5rem 0; color: var(--color-accent);">🔍 Vorschläge</h4>
|
<h4 style="margin: 0 0 0.5rem 0; color: var(--color-accent);">🔍 Vorschläge</h4>
|
||||||
<p style="margin: 0;">
|
<p style="margin: 0;">
|
||||||
@ -201,7 +191,6 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Corrections & Updates -->
|
|
||||||
<div style="background-color: var(--color-bg-secondary); padding: 1.25rem; border-radius: 0.5rem;">
|
<div style="background-color: var(--color-bg-secondary); padding: 1.25rem; border-radius: 0.5rem;">
|
||||||
<h4 style="margin: 0 0 0.5rem 0; color: var(--color-accent);">🔧 Korrekturen & Updates</h4>
|
<h4 style="margin: 0 0 0.5rem 0; color: var(--color-accent);">🔧 Korrekturen & Updates</h4>
|
||||||
<p style="margin: 0;">
|
<p style="margin: 0;">
|
||||||
@ -210,7 +199,6 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Code Contributions -->
|
|
||||||
<div style="background-color: var(--color-bg-secondary); padding: 1.25rem; border-radius: 0.5rem;">
|
<div style="background-color: var(--color-bg-secondary); padding: 1.25rem; border-radius: 0.5rem;">
|
||||||
<h4 style="margin: 0 0 0.5rem 0; color: var(--color-accent);">💻 Code‑Beiträge</h4>
|
<h4 style="margin: 0 0 0.5rem 0; color: var(--color-accent);">💻 Code‑Beiträge</h4>
|
||||||
<p style="margin-bottom: 0.75rem;">
|
<p style="margin-bottom: 0.75rem;">
|
||||||
@ -235,7 +223,6 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer Note -->
|
|
||||||
<div style="text-align: center; padding: 2rem; background-color: var(--color-bg-secondary); border-radius: 0.75rem; border: 1px solid var(--color-border);">
|
<div style="text-align: center; padding: 2rem; background-color: var(--color-bg-secondary); border-radius: 0.75rem; border: 1px solid var(--color-border);">
|
||||||
<p style="margin: 0; color: var(--color-text-secondary); font-size: 0.875rem;">
|
<p style="margin: 0; color: var(--color-text-secondary); font-size: 0.875rem;">
|
||||||
<strong>Inoffizielles Studienprojekt</strong> | Mario Stöckl |
|
<strong>Inoffizielles Studienprojekt</strong> | Mario Stöckl |
|
||||||
|
@ -17,12 +17,10 @@ function getEnv(key: string): string {
|
|||||||
|
|
||||||
const AI_MODEL = getEnv('AI_MODEL');
|
const AI_MODEL = getEnv('AI_MODEL');
|
||||||
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
|
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
|
||||||
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
|
const RATE_LIMIT_WINDOW = 60 * 1000;
|
||||||
const RATE_LIMIT_MAX = 10; // 10 requests per minute per user
|
const RATE_LIMIT_MAX = 10;
|
||||||
|
|
||||||
// Input validation and sanitization
|
|
||||||
function sanitizeInput(input: string): string {
|
function sanitizeInput(input: string): string {
|
||||||
// Remove any content that looks like system instructions
|
|
||||||
let sanitized = input
|
let sanitized = input
|
||||||
.replace(/```[\s\S]*?```/g, '[CODE_BLOCK_REMOVED]') // Remove code blocks
|
.replace(/```[\s\S]*?```/g, '[CODE_BLOCK_REMOVED]') // Remove code blocks
|
||||||
.replace(/\<\/?[^>]+(>|$)/g, '') // Remove HTML tags
|
.replace(/\<\/?[^>]+(>|$)/g, '') // Remove HTML tags
|
||||||
@ -30,22 +28,18 @@ function sanitizeInput(input: string): string {
|
|||||||
.replace(/\b(ignore|forget|disregard)\s+(previous|all|your)\s+(instructions?|context|rules?)/gi, '[INSTRUCTION_REMOVED]')
|
.replace(/\b(ignore|forget|disregard)\s+(previous|all|your)\s+(instructions?|context|rules?)/gi, '[INSTRUCTION_REMOVED]')
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
// Limit length and remove excessive whitespace
|
|
||||||
sanitized = sanitized.slice(0, 2000).replace(/\s+/g, ' ');
|
sanitized = sanitized.slice(0, 2000).replace(/\s+/g, ' ');
|
||||||
|
|
||||||
return sanitized;
|
return sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip markdown code blocks from AI response
|
|
||||||
function stripMarkdownJson(content: string): string {
|
function stripMarkdownJson(content: string): string {
|
||||||
// Remove ```json and ``` wrappers
|
|
||||||
return content
|
return content
|
||||||
.replace(/^```json\s*/i, '')
|
.replace(/^```json\s*/i, '')
|
||||||
.replace(/\s*```\s*$/, '')
|
.replace(/\s*```\s*$/, '')
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limiting check
|
|
||||||
function checkRateLimit(userId: string): boolean {
|
function checkRateLimit(userId: string): boolean {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const userLimit = rateLimitStore.get(userId);
|
const userLimit = rateLimitStore.get(userId);
|
||||||
@ -74,7 +68,6 @@ function cleanupExpiredRateLimits() {
|
|||||||
|
|
||||||
setInterval(cleanupExpiredRateLimits, 5 * 60 * 1000);
|
setInterval(cleanupExpiredRateLimits, 5 * 60 * 1000);
|
||||||
|
|
||||||
// Load tools database
|
|
||||||
async function loadToolsDatabase() {
|
async function loadToolsDatabase() {
|
||||||
try {
|
try {
|
||||||
return await getCompressedToolsDataForAI();
|
return await getCompressedToolsDataForAI();
|
||||||
@ -84,7 +77,6 @@ async function loadToolsDatabase() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create system prompt for workflow mode
|
|
||||||
function createWorkflowSystemPrompt(toolsData: any): string {
|
function createWorkflowSystemPrompt(toolsData: any): string {
|
||||||
const toolsList = toolsData.tools.map((tool: any) => ({
|
const toolsList = toolsData.tools.map((tool: any) => ({
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
@ -99,7 +91,6 @@ function createWorkflowSystemPrompt(toolsData: any): string {
|
|||||||
related_concepts: tool.related_concepts || []
|
related_concepts: tool.related_concepts || []
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Include concepts for background knowledge
|
|
||||||
const conceptsList = toolsData.concepts.map((concept: any) => ({
|
const conceptsList = toolsData.concepts.map((concept: any) => ({
|
||||||
name: concept.name,
|
name: concept.name,
|
||||||
description: concept.description,
|
description: concept.description,
|
||||||
@ -109,13 +100,10 @@ function createWorkflowSystemPrompt(toolsData: any): string {
|
|||||||
tags: concept.tags
|
tags: concept.tags
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Get regular phases
|
|
||||||
const regularPhases = toolsData.phases || [];
|
const regularPhases = toolsData.phases || [];
|
||||||
|
|
||||||
// Get domain-agnostic software phases
|
|
||||||
const domainAgnosticSoftware = toolsData['domain-agnostic-software'] || [];
|
const domainAgnosticSoftware = toolsData['domain-agnostic-software'] || [];
|
||||||
|
|
||||||
// Combine all phases for the description
|
|
||||||
const allPhaseItems = [
|
const allPhaseItems = [
|
||||||
...regularPhases,
|
...regularPhases,
|
||||||
...domainAgnosticSoftware
|
...domainAgnosticSoftware
|
||||||
@ -125,22 +113,18 @@ function createWorkflowSystemPrompt(toolsData: any): string {
|
|||||||
`- ${phase.id}: ${phase.name}`
|
`- ${phase.id}: ${phase.name}`
|
||||||
).join('\n');
|
).join('\n');
|
||||||
|
|
||||||
// Dynamically build domains list from configuration
|
|
||||||
const domainsDescription = toolsData.domains.map((domain: any) =>
|
const domainsDescription = toolsData.domains.map((domain: any) =>
|
||||||
`- ${domain.id}: ${domain.name}`
|
`- ${domain.id}: ${domain.name}`
|
||||||
).join('\n');
|
).join('\n');
|
||||||
|
|
||||||
// Build dynamic phase descriptions for tool selection
|
|
||||||
const phaseDescriptions = regularPhases.map((phase: any) =>
|
const phaseDescriptions = regularPhases.map((phase: any) =>
|
||||||
`- ${phase.name}: ${phase.description || 'Tools/Methods for this phase'}`
|
`- ${phase.name}: ${phase.description || 'Tools/Methods for this phase'}`
|
||||||
).join('\n');
|
).join('\n');
|
||||||
|
|
||||||
// Add domain-agnostic software descriptions
|
|
||||||
const domainAgnosticDescriptions = domainAgnosticSoftware.map((section: any) =>
|
const domainAgnosticDescriptions = domainAgnosticSoftware.map((section: any) =>
|
||||||
`- ${section.name}: ${section.description || 'Cross-cutting software and platforms'}`
|
`- ${section.name}: ${section.description || 'Cross-cutting software and platforms'}`
|
||||||
).join('\n');
|
).join('\n');
|
||||||
|
|
||||||
// Create valid phase values for JSON schema
|
|
||||||
const validPhases = [
|
const validPhases = [
|
||||||
...regularPhases.map((p: any) => p.id),
|
...regularPhases.map((p: any) => p.id),
|
||||||
...domainAgnosticSoftware.map((s: any) => s.id)
|
...domainAgnosticSoftware.map((s: any) => s.id)
|
||||||
@ -201,7 +185,6 @@ ANTWORT-FORMAT (strict JSON):
|
|||||||
Antworte NUR mit validen JSON. Keine zusätzlichen Erklärungen außerhalb des JSON.`;
|
Antworte NUR mit validen JSON. Keine zusätzlichen Erklärungen außerhalb des JSON.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create system prompt for tool-specific mode
|
|
||||||
function createToolSystemPrompt(toolsData: any): string {
|
function createToolSystemPrompt(toolsData: any): string {
|
||||||
const toolsList = toolsData.tools.map((tool: any) => ({
|
const toolsList = toolsData.tools.map((tool: any) => ({
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
@ -217,7 +200,6 @@ function createToolSystemPrompt(toolsData: any): string {
|
|||||||
related_concepts: tool.related_concepts || []
|
related_concepts: tool.related_concepts || []
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Include concepts for background knowledge
|
|
||||||
const conceptsList = toolsData.concepts.map((concept: any) => ({
|
const conceptsList = toolsData.concepts.map((concept: any) => ({
|
||||||
name: concept.name,
|
name: concept.name,
|
||||||
description: concept.description,
|
description: concept.description,
|
||||||
@ -277,7 +259,6 @@ Antworte NUR mit validen JSON. Keine zusätzlichen Erklärungen außerhalb des J
|
|||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
try {
|
try {
|
||||||
// Authentication check
|
|
||||||
const authResult = await withAPIAuth(request, 'ai');
|
const authResult = await withAPIAuth(request, 'ai');
|
||||||
if (!authResult.authenticated) {
|
if (!authResult.authenticated) {
|
||||||
return createAuthErrorResponse();
|
return createAuthErrorResponse();
|
||||||
@ -285,16 +266,13 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
|
|
||||||
const userId = authResult.userId;
|
const userId = authResult.userId;
|
||||||
|
|
||||||
// Rate limiting
|
|
||||||
if (!checkRateLimit(userId)) {
|
if (!checkRateLimit(userId)) {
|
||||||
return apiError.rateLimit('Rate limit exceeded');
|
return apiError.rateLimit('Rate limit exceeded');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse request body
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { query, mode = 'workflow', taskId: clientTaskId } = body;
|
const { query, mode = 'workflow', taskId: clientTaskId } = body;
|
||||||
|
|
||||||
// Validation
|
|
||||||
if (!query || typeof query !== 'string') {
|
if (!query || typeof query !== 'string') {
|
||||||
return apiError.badRequest('Query required');
|
return apiError.badRequest('Query required');
|
||||||
}
|
}
|
||||||
@ -303,24 +281,19 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
return apiError.badRequest('Invalid mode. Must be "workflow" or "tool"');
|
return apiError.badRequest('Invalid mode. Must be "workflow" or "tool"');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanitize input
|
|
||||||
const sanitizedQuery = sanitizeInput(query);
|
const sanitizedQuery = sanitizeInput(query);
|
||||||
if (sanitizedQuery.includes('[FILTERED]')) {
|
if (sanitizedQuery.includes('[FILTERED]')) {
|
||||||
return apiError.badRequest('Invalid input detected');
|
return apiError.badRequest('Invalid input detected');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load tools database
|
|
||||||
const toolsData = await loadToolsDatabase();
|
const toolsData = await loadToolsDatabase();
|
||||||
|
|
||||||
// Create appropriate system prompt based on mode
|
|
||||||
const systemPrompt = mode === 'workflow'
|
const systemPrompt = mode === 'workflow'
|
||||||
? createWorkflowSystemPrompt(toolsData)
|
? createWorkflowSystemPrompt(toolsData)
|
||||||
: createToolSystemPrompt(toolsData);
|
: createToolSystemPrompt(toolsData);
|
||||||
|
|
||||||
// Generate task ID for queue tracking (use client-provided ID if available)
|
|
||||||
const taskId = clientTaskId || `ai_${userId}_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`;
|
const taskId = clientTaskId || `ai_${userId}_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`;
|
||||||
|
|
||||||
// Make AI API call through rate-limited queue
|
|
||||||
const aiResponse = await enqueueApiCall(() =>
|
const aiResponse = await enqueueApiCall(() =>
|
||||||
fetch(process.env.AI_API_ENDPOINT + '/v1/chat/completions', {
|
fetch(process.env.AI_API_ENDPOINT + '/v1/chat/completions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -346,7 +319,6 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
})
|
})
|
||||||
, taskId);
|
, taskId);
|
||||||
|
|
||||||
// AI response handling
|
|
||||||
if (!aiResponse.ok) {
|
if (!aiResponse.ok) {
|
||||||
console.error('AI API error:', await aiResponse.text());
|
console.error('AI API error:', await aiResponse.text());
|
||||||
return apiServerError.unavailable('AI service unavailable');
|
return apiServerError.unavailable('AI service unavailable');
|
||||||
@ -359,7 +331,6 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
return apiServerError.unavailable('No response from AI');
|
return apiServerError.unavailable('No response from AI');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse AI JSON response
|
|
||||||
let recommendation;
|
let recommendation;
|
||||||
try {
|
try {
|
||||||
const cleanedContent = stripMarkdownJson(aiContent);
|
const cleanedContent = stripMarkdownJson(aiContent);
|
||||||
@ -369,7 +340,6 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
return apiServerError.unavailable('Invalid AI response format');
|
return apiServerError.unavailable('Invalid AI response format');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate tool names and concept names against database
|
|
||||||
const validToolNames = new Set(toolsData.tools.map((t: any) => t.name));
|
const validToolNames = new Set(toolsData.tools.map((t: any) => t.name));
|
||||||
const validConceptNames = new Set(toolsData.concepts.map((c: any) => c.name));
|
const validConceptNames = new Set(toolsData.concepts.map((c: any) => c.name));
|
||||||
|
|
||||||
@ -404,8 +374,8 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
return true;
|
return true;
|
||||||
}).map((tool: any, index: number) => ({
|
}).map((tool: any, index: number) => ({
|
||||||
...tool,
|
...tool,
|
||||||
rank: tool.rank || (index + 1), // Ensure rank is set
|
rank: tool.rank || (index + 1),
|
||||||
suitability_score: tool.suitability_score || 'medium', // Default suitability
|
suitability_score: tool.suitability_score || 'medium',
|
||||||
pros: Array.isArray(tool.pros) ? tool.pros : [],
|
pros: Array.isArray(tool.pros) ? tool.pros : [],
|
||||||
cons: Array.isArray(tool.cons) ? tool.cons : []
|
cons: Array.isArray(tool.cons) ? tool.cons : []
|
||||||
})) || [],
|
})) || [],
|
||||||
@ -419,10 +389,8 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log successful query
|
|
||||||
console.log(`[AI Query] Mode: ${mode}, User: ${userId}, Query length: ${sanitizedQuery.length}, Tools: ${validatedRecommendation.recommended_tools.length}, Concepts: ${validatedRecommendation.background_knowledge?.length || 0}`);
|
console.log(`[AI Query] Mode: ${mode}, User: ${userId}, Query length: ${sanitizedQuery.length}, Tools: ${validatedRecommendation.recommended_tools.length}, Concepts: ${validatedRecommendation.background_knowledge?.length || 0}`);
|
||||||
|
|
||||||
// Success response with task ID
|
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
mode,
|
mode,
|
||||||
|
@ -8,17 +8,14 @@ export const GET: APIRoute = async ({ url, redirect }) => {
|
|||||||
const state = generateState();
|
const state = generateState();
|
||||||
const authUrl = generateAuthUrl(state);
|
const authUrl = generateAuthUrl(state);
|
||||||
|
|
||||||
// Debug: log the generated URL
|
|
||||||
console.log('Generated auth URL:', authUrl);
|
console.log('Generated auth URL:', authUrl);
|
||||||
|
|
||||||
// Get the intended destination after login (if any)
|
|
||||||
const returnTo = url.searchParams.get('returnTo') || '/';
|
const returnTo = url.searchParams.get('returnTo') || '/';
|
||||||
|
|
||||||
logAuthEvent('Login initiated', { returnTo, authUrl });
|
logAuthEvent('Login initiated', { returnTo, authUrl });
|
||||||
|
|
||||||
// Store state and returnTo in a cookie for the callback
|
|
||||||
const stateData = JSON.stringify({ state, returnTo });
|
const stateData = JSON.stringify({ state, returnTo });
|
||||||
const stateCookie = `auth_state=${encodeURIComponent(stateData)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=600`; // 10 minutes
|
const stateCookie = `auth_state=${encodeURIComponent(stateData)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=600`;
|
||||||
|
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
|
@ -13,7 +13,6 @@ export const prerender = false;
|
|||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
return await handleAPIRequest(async () => {
|
return await handleAPIRequest(async () => {
|
||||||
// Parse request body
|
|
||||||
let body;
|
let body;
|
||||||
try {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
@ -29,17 +28,14 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
return apiSpecial.missingRequired(['code', 'state']);
|
return apiSpecial.missingRequired(['code', 'state']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// CONSOLIDATED: Single function call replaces 15+ lines of duplicated state verification
|
|
||||||
const stateVerification = verifyAuthState(request, state);
|
const stateVerification = verifyAuthState(request, state);
|
||||||
if (!stateVerification.isValid || !stateVerification.stateData) {
|
if (!stateVerification.isValid || !stateVerification.stateData) {
|
||||||
return apiError.badRequest(stateVerification.error || 'Invalid state parameter');
|
return apiError.badRequest(stateVerification.error || 'Invalid state parameter');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exchange code for tokens and get user info
|
|
||||||
const tokens = await exchangeCodeForTokens(code);
|
const tokens = await exchangeCodeForTokens(code);
|
||||||
const userInfo = await getUserInfo(tokens.access_token);
|
const userInfo = await getUserInfo(tokens.access_token);
|
||||||
|
|
||||||
// CONSOLIDATED: Single function call replaces 10+ lines of session creation
|
|
||||||
const sessionResult = await createSessionWithCookie(userInfo);
|
const sessionResult = await createSessionWithCookie(userInfo);
|
||||||
|
|
||||||
logAuthEvent('Authentication successful', {
|
logAuthEvent('Authentication successful', {
|
||||||
@ -47,11 +43,9 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
email: sessionResult.userEmail
|
email: sessionResult.userEmail
|
||||||
});
|
});
|
||||||
|
|
||||||
// FIXED: Create response with multiple Set-Cookie headers
|
|
||||||
const responseHeaders = new Headers();
|
const responseHeaders = new Headers();
|
||||||
responseHeaders.set('Content-Type', 'application/json');
|
responseHeaders.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
// Each cookie needs its own Set-Cookie header
|
|
||||||
responseHeaders.append('Set-Cookie', sessionResult.sessionCookie);
|
responseHeaders.append('Set-Cookie', sessionResult.sessionCookie);
|
||||||
responseHeaders.append('Set-Cookie', sessionResult.clearStateCookie);
|
responseHeaders.append('Set-Cookie', sessionResult.clearStateCookie);
|
||||||
|
|
||||||
|
@ -7,7 +7,6 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
// Simple schema - all fields optional except for having some content
|
|
||||||
const KnowledgebaseContributionSchema = z.object({
|
const KnowledgebaseContributionSchema = z.object({
|
||||||
toolName: z.string().optional().nullable().transform(val => val || undefined),
|
toolName: z.string().optional().nullable().transform(val => val || undefined),
|
||||||
title: z.string().optional().nullable().transform(val => val || undefined),
|
title: z.string().optional().nullable().transform(val => val || undefined),
|
||||||
@ -40,7 +39,6 @@ interface KnowledgebaseContributionData {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limiting
|
|
||||||
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
|
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
|
||||||
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour
|
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour
|
||||||
const RATE_LIMIT_MAX = 3; // Max 3 submissions per hour per user
|
const RATE_LIMIT_MAX = 3; // Max 3 submissions per hour per user
|
||||||
@ -63,8 +61,6 @@ function checkRateLimit(userEmail: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateKnowledgebaseData(data: KnowledgebaseContributionData): { valid: boolean; errors?: string[] } {
|
function validateKnowledgebaseData(data: KnowledgebaseContributionData): { valid: boolean; errors?: string[] } {
|
||||||
// Very minimal validation - just check that SOMETHING was provided
|
|
||||||
// Use nullish coalescing to avoid “possibly undefined” errors in strict mode
|
|
||||||
const hasContent = (data.content ?? '').trim().length > 0;
|
const hasContent = (data.content ?? '').trim().length > 0;
|
||||||
const hasLink = (data.externalLink ?? '').trim().length > 0;
|
const hasLink = (data.externalLink ?? '').trim().length > 0;
|
||||||
const hasFiles = Array.isArray(data.uploadedFiles) && data.uploadedFiles.length > 0;
|
const hasFiles = Array.isArray(data.uploadedFiles) && data.uploadedFiles.length > 0;
|
||||||
@ -83,7 +79,6 @@ function validateKnowledgebaseData(data: KnowledgebaseContributionData): { valid
|
|||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
return await handleAPIRequest(async () => {
|
return await handleAPIRequest(async () => {
|
||||||
// Check authentication
|
|
||||||
const authResult = await withAPIAuth(request, 'contributions');
|
const authResult = await withAPIAuth(request, 'contributions');
|
||||||
if (authResult.authRequired && !authResult.authenticated) {
|
if (authResult.authRequired && !authResult.authenticated) {
|
||||||
return apiError.unauthorized();
|
return apiError.unauthorized();
|
||||||
@ -91,12 +86,10 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
|
|
||||||
const userEmail = authResult.session?.email || 'anon@anon.anon';
|
const userEmail = authResult.session?.email || 'anon@anon.anon';
|
||||||
|
|
||||||
// Rate limiting
|
|
||||||
if (!checkRateLimit(userEmail)) {
|
if (!checkRateLimit(userEmail)) {
|
||||||
return apiError.rateLimit('Rate limit exceeded. Please wait before submitting again.');
|
return apiError.rateLimit('Rate limit exceeded. Please wait before submitting again.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse form data
|
|
||||||
let formData;
|
let formData;
|
||||||
try {
|
try {
|
||||||
formData = await request.formData();
|
formData = await request.formData();
|
||||||
@ -106,7 +99,6 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
|
|
||||||
const rawData = Object.fromEntries(formData);
|
const rawData = Object.fromEntries(formData);
|
||||||
|
|
||||||
// Validate and sanitize data
|
|
||||||
let validatedData;
|
let validatedData;
|
||||||
try {
|
try {
|
||||||
validatedData = KnowledgebaseContributionSchema.parse(rawData);
|
validatedData = KnowledgebaseContributionSchema.parse(rawData);
|
||||||
@ -121,13 +113,11 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
return apiError.badRequest('Invalid request data');
|
return apiError.badRequest('Invalid request data');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Basic content validation
|
|
||||||
const contentValidation = validateKnowledgebaseData(validatedData);
|
const contentValidation = validateKnowledgebaseData(validatedData);
|
||||||
if (!contentValidation.valid) {
|
if (!contentValidation.valid) {
|
||||||
return apiError.validation('Content validation failed', contentValidation.errors);
|
return apiError.validation('Content validation failed', contentValidation.errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit as issue via Git
|
|
||||||
try {
|
try {
|
||||||
const gitManager = new GitContributionManager();
|
const gitManager = new GitContributionManager();
|
||||||
const result = await gitManager.submitKnowledgebaseContribution({
|
const result = await gitManager.submitKnowledgebaseContribution({
|
||||||
|
@ -7,7 +7,6 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
// Enhanced tool schema for contributions (stricter validation)
|
|
||||||
const ContributionToolSchema = z.object({
|
const ContributionToolSchema = z.object({
|
||||||
name: z.string().min(1, 'Tool name is required').max(100, 'Tool name too long'),
|
name: z.string().min(1, 'Tool name is required').max(100, 'Tool name too long'),
|
||||||
icon: z.string().optional().nullable(),
|
icon: z.string().optional().nullable(),
|
||||||
@ -42,7 +41,6 @@ const ContributionRequestSchema = z.object({
|
|||||||
}).optional()
|
}).optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
// Rate limiting
|
|
||||||
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
|
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
|
||||||
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour
|
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour
|
||||||
const RATE_LIMIT_MAX = 15; // 15 contributions per hour per user
|
const RATE_LIMIT_MAX = 15; // 15 contributions per hour per user
|
||||||
@ -64,7 +62,6 @@ function checkRateLimit(userId: string): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Input sanitization
|
|
||||||
function sanitizeInput(obj: any): any {
|
function sanitizeInput(obj: any): any {
|
||||||
if (typeof obj === 'string') {
|
if (typeof obj === 'string') {
|
||||||
return obj.trim().slice(0, 1000);
|
return obj.trim().slice(0, 1000);
|
||||||
@ -82,15 +79,12 @@ function sanitizeInput(obj: any): any {
|
|||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool validation function
|
|
||||||
async function validateToolData(tool: any, action: string): Promise<{ valid: boolean; errors: string[] }> {
|
async function validateToolData(tool: any, action: string): Promise<{ valid: boolean; errors: string[] }> {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Load existing data for validation
|
const existingData = { tools: [] };
|
||||||
const existingData = { tools: [] }; // Replace with actual data loading
|
|
||||||
|
|
||||||
// Check for duplicate names (on add)
|
|
||||||
if (action === 'add') {
|
if (action === 'add') {
|
||||||
const existingNames = new Set(existingData.tools.map((t: any) => t.name.toLowerCase()));
|
const existingNames = new Set(existingData.tools.map((t: any) => t.name.toLowerCase()));
|
||||||
if (existingNames.has(tool.name.toLowerCase())) {
|
if (existingNames.has(tool.name.toLowerCase())) {
|
||||||
@ -98,7 +92,6 @@ async function validateToolData(tool: any, action: string): Promise<{ valid: boo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type-specific validation
|
|
||||||
if (tool.type === 'method') {
|
if (tool.type === 'method') {
|
||||||
if (tool.platforms && tool.platforms.length > 0) {
|
if (tool.platforms && tool.platforms.length > 0) {
|
||||||
errors.push('Methods should not have platform information');
|
errors.push('Methods should not have platform information');
|
||||||
@ -126,7 +119,6 @@ async function validateToolData(tool: any, action: string): Promise<{ valid: boo
|
|||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
return await handleAPIRequest(async () => {
|
return await handleAPIRequest(async () => {
|
||||||
// Authentication check
|
|
||||||
const authResult = await withAPIAuth(request, 'contributions');
|
const authResult = await withAPIAuth(request, 'contributions');
|
||||||
if (authResult.authRequired && !authResult.authenticated) {
|
if (authResult.authRequired && !authResult.authenticated) {
|
||||||
return apiError.unauthorized();
|
return apiError.unauthorized();
|
||||||
@ -135,12 +127,10 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
const userId = authResult.session?.userId || 'anonymous';
|
const userId = authResult.session?.userId || 'anonymous';
|
||||||
const userEmail = authResult.session?.email || 'anon@anon.anon';
|
const userEmail = authResult.session?.email || 'anon@anon.anon';
|
||||||
|
|
||||||
// Rate limiting
|
|
||||||
if (!checkRateLimit(userId)) {
|
if (!checkRateLimit(userId)) {
|
||||||
return apiError.rateLimit('Rate limit exceeded. Please wait before submitting another contribution.');
|
return apiError.rateLimit('Rate limit exceeded. Please wait before submitting another contribution.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse and sanitize request body
|
|
||||||
let body;
|
let body;
|
||||||
try {
|
try {
|
||||||
const rawBody = await request.text();
|
const rawBody = await request.text();
|
||||||
@ -152,10 +142,8 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
return apiSpecial.invalidJSON();
|
return apiSpecial.invalidJSON();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanitize input
|
|
||||||
const sanitizedBody = sanitizeInput(body);
|
const sanitizedBody = sanitizeInput(body);
|
||||||
|
|
||||||
// Validate request structure
|
|
||||||
let validatedData;
|
let validatedData;
|
||||||
try {
|
try {
|
||||||
validatedData = ContributionRequestSchema.parse(sanitizedBody);
|
validatedData = ContributionRequestSchema.parse(sanitizedBody);
|
||||||
@ -170,13 +158,11 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
return apiError.badRequest('Invalid request data');
|
return apiError.badRequest('Invalid request data');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Additional tool-specific validation
|
|
||||||
const toolValidation = await validateToolData(validatedData.tool, validatedData.action);
|
const toolValidation = await validateToolData(validatedData.tool, validatedData.action);
|
||||||
if (!toolValidation.valid) {
|
if (!toolValidation.valid) {
|
||||||
return apiError.validation('Tool validation failed', toolValidation.errors);
|
return apiError.validation('Tool validation failed', toolValidation.errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare contribution data
|
|
||||||
const contributionData: ContributionData = {
|
const contributionData: ContributionData = {
|
||||||
type: validatedData.action,
|
type: validatedData.action,
|
||||||
tool: validatedData.tool,
|
tool: validatedData.tool,
|
||||||
@ -186,9 +172,7 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// CRITICAL FIX: Enhanced error handling for Git operations
|
|
||||||
try {
|
try {
|
||||||
// Submit contribution via Git (now creates issue instead of PR)
|
|
||||||
const gitManager = new GitContributionManager();
|
const gitManager = new GitContributionManager();
|
||||||
const result = await gitManager.submitContribution(contributionData);
|
const result = await gitManager.submitContribution(contributionData);
|
||||||
|
|
||||||
@ -207,10 +191,8 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
return apiServerError.internal(`Contribution failed: ${result.message}`);
|
return apiServerError.internal(`Contribution failed: ${result.message}`);
|
||||||
}
|
}
|
||||||
} catch (gitError) {
|
} catch (gitError) {
|
||||||
// CRITICAL: Handle Git operation errors properly
|
|
||||||
console.error(`[GIT ERROR] ${validatedData.action} "${validatedData.tool.name}" by ${userEmail}:`, gitError);
|
console.error(`[GIT ERROR] ${validatedData.action} "${validatedData.tool.name}" by ${userEmail}:`, gitError);
|
||||||
|
|
||||||
// Return proper error response
|
|
||||||
const errorMessage = gitError instanceof Error ? gitError.message : 'Git operation failed';
|
const errorMessage = gitError instanceof Error ? gitError.message : 'Git operation failed';
|
||||||
return apiServerError.internal(`Git operation failed: ${errorMessage}`);
|
return apiServerError.internal(`Git operation failed: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
|
@ -18,15 +18,11 @@ interface UploadResult {
|
|||||||
storage?: 'nextcloud' | 'local';
|
storage?: 'nextcloud' | 'local';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configuration
|
|
||||||
const UPLOAD_CONFIG = {
|
const UPLOAD_CONFIG = {
|
||||||
maxFileSize: 50 * 1024 * 1024, // 50MB
|
maxFileSize: 50 * 1024 * 1024, // 50MB
|
||||||
allowedTypes: new Set([
|
allowedTypes: new Set([
|
||||||
// Images
|
|
||||||
'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
|
'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
|
||||||
// Videos
|
|
||||||
'video/mp4', 'video/webm', 'video/ogg', 'video/avi', 'video/mov',
|
'video/mp4', 'video/webm', 'video/ogg', 'video/avi', 'video/mov',
|
||||||
// Documents
|
|
||||||
'application/pdf',
|
'application/pdf',
|
||||||
'application/msword',
|
'application/msword',
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
@ -40,10 +36,9 @@ const UPLOAD_CONFIG = {
|
|||||||
publicBaseUrl: process.env.PUBLIC_BASE_URL || 'http://localhost:4321'
|
publicBaseUrl: process.env.PUBLIC_BASE_URL || 'http://localhost:4321'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Rate limiting for uploads
|
|
||||||
const uploadRateLimit = new Map<string, { count: number; resetTime: number }>();
|
const uploadRateLimit = new Map<string, { count: number; resetTime: number }>();
|
||||||
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour
|
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour
|
||||||
const RATE_LIMIT_MAX = 100; // Max 100 uploads per hour per user
|
const RATE_LIMIT_MAX = 10; // Max 10 uploads per hour per user
|
||||||
|
|
||||||
function checkUploadRateLimit(userEmail: string): boolean {
|
function checkUploadRateLimit(userEmail: string): boolean {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@ -63,7 +58,6 @@ function checkUploadRateLimit(userEmail: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateFile(file: File): { valid: boolean; error?: string } {
|
function validateFile(file: File): { valid: boolean; error?: string } {
|
||||||
// File size check
|
|
||||||
if (file.size > UPLOAD_CONFIG.maxFileSize) {
|
if (file.size > UPLOAD_CONFIG.maxFileSize) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
@ -71,7 +65,6 @@ function validateFile(file: File): { valid: boolean; error?: string } {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// File type check
|
|
||||||
if (!UPLOAD_CONFIG.allowedTypes.has(file.type)) {
|
if (!UPLOAD_CONFIG.allowedTypes.has(file.type)) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
@ -105,21 +98,17 @@ async function uploadToNextcloud(file: File, userEmail: string): Promise<UploadR
|
|||||||
|
|
||||||
async function uploadToLocal(file: File, userType: string): Promise<UploadResult> {
|
async function uploadToLocal(file: File, userType: string): Promise<UploadResult> {
|
||||||
try {
|
try {
|
||||||
// Ensure upload directory exists
|
|
||||||
await fs.mkdir(UPLOAD_CONFIG.localUploadPath, { recursive: true });
|
await fs.mkdir(UPLOAD_CONFIG.localUploadPath, { recursive: true });
|
||||||
|
|
||||||
// Generate unique filename
|
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
const randomString = crypto.randomBytes(8).toString('hex');
|
const randomString = crypto.randomBytes(8).toString('hex');
|
||||||
const extension = path.extname(file.name);
|
const extension = path.extname(file.name);
|
||||||
const filename = `${timestamp}-${randomString}${extension}`;
|
const filename = `${timestamp}-${randomString}${extension}`;
|
||||||
|
|
||||||
// Save file
|
|
||||||
const filepath = path.join(UPLOAD_CONFIG.localUploadPath, filename);
|
const filepath = path.join(UPLOAD_CONFIG.localUploadPath, filename);
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
await fs.writeFile(filepath, buffer);
|
await fs.writeFile(filepath, buffer);
|
||||||
|
|
||||||
// Generate public URL
|
|
||||||
const publicUrl = `${UPLOAD_CONFIG.publicBaseUrl}/uploads/${filename}`;
|
const publicUrl = `${UPLOAD_CONFIG.publicBaseUrl}/uploads/${filename}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -166,19 +155,16 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
return apiSpecial.missingRequired(['file']);
|
return apiSpecial.missingRequired(['file']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate file
|
|
||||||
const validation = validateFile(file);
|
const validation = validateFile(file);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return apiError.badRequest(validation.error!);
|
return apiError.badRequest(validation.error!);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt upload (Nextcloud first, then local fallback)
|
|
||||||
let result: UploadResult;
|
let result: UploadResult;
|
||||||
|
|
||||||
if (isNextcloudConfigured()) {
|
if (isNextcloudConfigured()) {
|
||||||
result = await uploadToNextcloud(file, userEmail);
|
result = await uploadToNextcloud(file, userEmail);
|
||||||
|
|
||||||
// If Nextcloud fails, try local fallback
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.warn('Nextcloud upload failed, trying local fallback:', result.error);
|
console.warn('Nextcloud upload failed, trying local fallback:', result.error);
|
||||||
result = await uploadToLocal(file, type);
|
result = await uploadToLocal(file, type);
|
||||||
@ -188,16 +174,8 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Log successful upload
|
|
||||||
console.log(`[MEDIA UPLOAD] ${file.name} (${file.size} bytes) by ${userEmail} -> ${result.storage}: ${result.url}`);
|
console.log(`[MEDIA UPLOAD] ${file.name} (${file.size} bytes) by ${userEmail} -> ${result.storage}: ${result.url}`);
|
||||||
|
|
||||||
// BEFORE: Manual success response (5 lines)
|
|
||||||
// return new Response(JSON.stringify(result), {
|
|
||||||
// status: 200,
|
|
||||||
// headers: { 'Content-Type': 'application/json' }
|
|
||||||
// });
|
|
||||||
|
|
||||||
// AFTER: Single line with specialized helper
|
|
||||||
return apiSpecial.uploadSuccess({
|
return apiSpecial.uploadSuccess({
|
||||||
url: result.url!,
|
url: result.url!,
|
||||||
filename: result.filename!,
|
filename: result.filename!,
|
||||||
@ -205,35 +183,23 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
storage: result.storage!
|
storage: result.storage!
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Log failed upload
|
|
||||||
console.error(`[MEDIA UPLOAD FAILED] ${file.name} by ${userEmail}: ${result.error}`);
|
console.error(`[MEDIA UPLOAD FAILED] ${file.name} by ${userEmail}: ${result.error}`);
|
||||||
|
|
||||||
// BEFORE: Manual error response (5 lines)
|
|
||||||
// return new Response(JSON.stringify(result), {
|
|
||||||
// status: 500,
|
|
||||||
// headers: { 'Content-Type': 'application/json' }
|
|
||||||
// });
|
|
||||||
|
|
||||||
// AFTER: Single line with specialized helper
|
|
||||||
return apiSpecial.uploadFailed(result.error!);
|
return apiSpecial.uploadFailed(result.error!);
|
||||||
}
|
}
|
||||||
|
|
||||||
}, 'Media upload processing failed');
|
}, 'Media upload processing failed');
|
||||||
};
|
};
|
||||||
|
|
||||||
// GET endpoint for upload status/info
|
|
||||||
export const GET: APIRoute = async ({ request }) => {
|
export const GET: APIRoute = async ({ request }) => {
|
||||||
return await handleAPIRequest(async () => {
|
return await handleAPIRequest(async () => {
|
||||||
// Authentication check
|
|
||||||
const authResult = await withAPIAuth(request);
|
const authResult = await withAPIAuth(request);
|
||||||
if (authResult.authRequired && !authResult.authenticated) {
|
if (authResult.authRequired && !authResult.authenticated) {
|
||||||
return apiError.unauthorized();
|
return apiError.unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return upload configuration and status
|
|
||||||
const nextcloudConfigured = isNextcloudConfigured();
|
const nextcloudConfigured = isNextcloudConfigured();
|
||||||
|
|
||||||
// Check local upload directory
|
|
||||||
let localStorageAvailable = false;
|
let localStorageAvailable = false;
|
||||||
try {
|
try {
|
||||||
await fs.access(UPLOAD_CONFIG.localUploadPath);
|
await fs.access(UPLOAD_CONFIG.localUploadPath);
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
---
|
---
|
||||||
// src/pages/auth/callback.astro - Fixed with Email
|
// src/pages/auth/callback.astro
|
||||||
// Since server-side URL parameters aren't working,
|
|
||||||
// we'll handle this client-side and POST to the API
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<html>
|
<html>
|
||||||
@ -58,7 +56,6 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
// Get URL parameters from client-side
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const code = urlParams.get('code');
|
const code = urlParams.get('code');
|
||||||
const state = urlParams.get('state');
|
const state = urlParams.get('state');
|
||||||
@ -77,7 +74,6 @@
|
|||||||
window.location.href = '/?auth=error';
|
window.location.href = '/?auth=error';
|
||||||
}, 3000);
|
}, 3000);
|
||||||
} else if (code && state) {
|
} else if (code && state) {
|
||||||
// Send the parameters to our API endpoint
|
|
||||||
fetch('/api/auth/process', {
|
fetch('/api/auth/process', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
---
|
---
|
||||||
// src/pages/contribute/index.astro - Consolidated Auth
|
// src/pages/contribute/index.astro - Consolidated Auth
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
import { withAuth } from '../../utils/auth.js'; // Note: .js extension!
|
import { withAuth } from '../../utils/auth.js';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
// CONSOLIDATED: Replace 15+ lines with single function call
|
|
||||||
const authResult = await withAuth(Astro, 'contributions');
|
const authResult = await withAuth(Astro, 'contributions');
|
||||||
if (authResult instanceof Response) {
|
if (authResult instanceof Response) {
|
||||||
return authResult; // Redirect to login
|
return authResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { authenticated, userEmail, userId } = authResult;
|
const { authenticated, userEmail, userId } = authResult;
|
||||||
@ -16,7 +15,6 @@ const { authenticated, userEmail, userId } = authResult;
|
|||||||
|
|
||||||
<BaseLayout title="Contribute" description="Inhalte zum ForensicPathways beitragen">
|
<BaseLayout title="Contribute" description="Inhalte zum ForensicPathways beitragen">
|
||||||
<section style="padding: 2rem 0;">
|
<section style="padding: 2rem 0;">
|
||||||
<!-- Header -->
|
|
||||||
<div style="text-align: center; margin-bottom: 3rem; padding: 2rem; background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%); color: white; border-radius: 1rem; border: 1px solid var(--color-border);">
|
<div style="text-align: center; margin-bottom: 3rem; padding: 2rem; background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%); color: white; border-radius: 1rem; border: 1px solid var(--color-border);">
|
||||||
<h1 style="margin-bottom: 1rem; font-size: 2.5rem;">
|
<h1 style="margin-bottom: 1rem; font-size: 2.5rem;">
|
||||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.75rem; vertical-align: middle;">
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.75rem; vertical-align: middle;">
|
||||||
@ -38,8 +36,6 @@ const { authenticated, userEmail, userId } = authResult;
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Contribution Options -->
|
|
||||||
<!-- WRAPPER -->
|
|
||||||
<div
|
<div
|
||||||
style="
|
style="
|
||||||
display:grid;
|
display:grid;
|
||||||
@ -50,9 +46,6 @@ const { authenticated, userEmail, userId } = authResult;
|
|||||||
"
|
"
|
||||||
>
|
>
|
||||||
|
|
||||||
<!-- src/pages/contribute/index.astro - Replace the Tools/Methods/Concepts card -->
|
|
||||||
|
|
||||||
<!-- Tools, Methods & Concepts - IMPROVED UX -->
|
|
||||||
<div class="card"
|
<div class="card"
|
||||||
style="padding: 2rem; border-left: 4px solid var(--color-primary); transition: var(--transition-fast);
|
style="padding: 2rem; border-left: 4px solid var(--color-primary); transition: var(--transition-fast);
|
||||||
display:flex; flex-direction:column;">
|
display:flex; flex-direction:column;">
|
||||||
@ -85,7 +78,6 @@ const { authenticated, userEmail, userId } = authResult;
|
|||||||
Neuer Eintrag
|
Neuer Eintrag
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- IMPROVED: Clear guidance instead of confusing button -->
|
|
||||||
<div style="background-color: var(--color-bg-secondary); padding: 1.25rem; border-radius: 0.5rem; border-left: 3px solid var(--color-accent);">
|
<div style="background-color: var(--color-bg-secondary); padding: 1.25rem; border-radius: 0.5rem; border-left: 3px solid var(--color-accent);">
|
||||||
<div style="display: flex; align-items: start; gap: 0.75rem;">
|
<div style="display: flex; align-items: start; gap: 0.75rem;">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" stroke-width="2" style="flex-shrink: 0; margin-top: 0.125rem;">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" stroke-width="2" style="flex-shrink: 0; margin-top: 0.125rem;">
|
||||||
@ -112,7 +104,6 @@ const { authenticated, userEmail, userId } = authResult;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Knowledgebase Articles -->
|
|
||||||
<div class="card"
|
<div class="card"
|
||||||
style="padding: 2rem; border-left: 4px solid var(--color-accent); cursor: pointer; transition: var(--transition-fast);
|
style="padding: 2rem; border-left: 4px solid var(--color-accent); cursor: pointer; transition: var(--transition-fast);
|
||||||
display:flex; flex-direction:column;"
|
display:flex; flex-direction:column;"
|
||||||
@ -148,7 +139,6 @@ const { authenticated, userEmail, userId } = authResult;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Issues & Improvements -->
|
|
||||||
<div class="card"
|
<div class="card"
|
||||||
style="padding: 2rem; border-left: 4px solid var(--color-accent); cursor: pointer; transition: var(--transition-fast);
|
style="padding: 2rem; border-left: 4px solid var(--color-accent); cursor: pointer; transition: var(--transition-fast);
|
||||||
display:flex; flex-direction:column;">
|
display:flex; flex-direction:column;">
|
||||||
@ -187,14 +177,12 @@ const { authenticated, userEmail, userId } = authResult;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Push this actions block down if you add more later -->
|
|
||||||
<div style="margin-top:auto;"></div>
|
<div style="margin-top:auto;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- Guidelines -->
|
|
||||||
<div class="card" style="margin-bottom: 2rem;">
|
<div class="card" style="margin-bottom: 2rem;">
|
||||||
<h3 style="margin-bottom: 1.5rem; color: var(--color-text);">Richtlinien</h3>
|
<h3 style="margin-bottom: 1.5rem; color: var(--color-text);">Richtlinien</h3>
|
||||||
|
|
||||||
@ -236,7 +224,6 @@ const { authenticated, userEmail, userId } = authResult;
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Add hover effects for cards
|
|
||||||
document.querySelectorAll('.card[onclick]').forEach((card) => {
|
document.querySelectorAll('.card[onclick]').forEach((card) => {
|
||||||
const cardEl = card as HTMLElement;
|
const cardEl = card as HTMLElement;
|
||||||
cardEl.addEventListener('mouseenter', function() {
|
cardEl.addEventListener('mouseenter', function() {
|
||||||
|
@ -6,7 +6,6 @@ import { getToolsData } from '../../utils/dataService.js';
|
|||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
// Check authentication
|
|
||||||
const authResult = await withAuth(Astro, 'contributions');
|
const authResult = await withAuth(Astro, 'contributions');
|
||||||
if (authResult instanceof Response) {
|
if (authResult instanceof Response) {
|
||||||
return authResult;
|
return authResult;
|
||||||
@ -14,7 +13,6 @@ if (authResult instanceof Response) {
|
|||||||
|
|
||||||
const { authenticated, userEmail, userId } = authResult;
|
const { authenticated, userEmail, userId } = authResult;
|
||||||
|
|
||||||
// Load tools for reference (optional dropdown)
|
|
||||||
const data = await getToolsData();
|
const data = await getToolsData();
|
||||||
const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.name));
|
const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.name));
|
||||||
---
|
---
|
||||||
@ -22,18 +20,15 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
|
|||||||
<BaseLayout title="Contribute Knowledge Base Article">
|
<BaseLayout title="Contribute Knowledge Base Article">
|
||||||
<div class="container" style="max-width: 900px; margin: 0 auto; padding: 2rem 1rem;">
|
<div class="container" style="max-width: 900px; margin: 0 auto; padding: 2rem 1rem;">
|
||||||
|
|
||||||
<!-- Header -->
|
|
||||||
<div style="text-align: center; margin-bottom: 2rem; padding: 2rem; background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%); color: white; border-radius: 1rem;">
|
<div style="text-align: center; margin-bottom: 2rem; padding: 2rem; background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%); color: white; border-radius: 1rem;">
|
||||||
<h1 style="margin-bottom: 1rem; font-size: 2rem;">Knowledgebase-Artikel</h1>
|
<h1 style="margin-bottom: 1rem; font-size: 2rem;">Knowledgebase-Artikel</h1>
|
||||||
<p style="margin: 0.5rem 0; opacity: 0.9;">Danke für deinen Beitrag!</p>
|
<p style="margin: 0.5rem 0; opacity: 0.9;">Danke für deinen Beitrag!</p>
|
||||||
{userEmail && <p style="margin: 0.5rem 0; opacity: 0.8;"><strong>Eingeloggt als:</strong> {userEmail}</p>}
|
{userEmail && <p style="margin: 0.5rem 0; opacity: 0.8;"><strong>Eingeloggt als:</strong> {userEmail}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Form -->
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<form id="kb-form" novalidate>
|
<form id="kb-form" novalidate>
|
||||||
|
|
||||||
<!-- Basic Information -->
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h3 class="section-title">Grundinformationen</h3>
|
<h3 class="section-title">Grundinformationen</h3>
|
||||||
|
|
||||||
@ -87,7 +82,6 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h3 class="section-title">Inhalt</h3>
|
<h3 class="section-title">Inhalt</h3>
|
||||||
|
|
||||||
@ -114,7 +108,6 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- File Upload -->
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h3 class="section-title">Dateien hochladen</h3>
|
<h3 class="section-title">Dateien hochladen</h3>
|
||||||
|
|
||||||
@ -139,7 +132,6 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Additional Information -->
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h3 class="section-title">Zusatzinformation</h3>
|
<h3 class="section-title">Zusatzinformation</h3>
|
||||||
|
|
||||||
@ -181,7 +173,6 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Submit Button -->
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<a href="/" class="btn btn-secondary">Abbruch</a>
|
<a href="/" class="btn btn-secondary">Abbruch</a>
|
||||||
<button type="submit" id="submit-btn" class="btn btn-accent">
|
<button type="submit" id="submit-btn" class="btn btn-accent">
|
||||||
@ -192,7 +183,6 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Success Modal -->
|
|
||||||
<div id="success-modal"
|
<div id="success-modal"
|
||||||
style="display:none; position:fixed; top:0; left:0; width:100%; height:100%;
|
style="display:none; position:fixed; top:0; left:0; width:100%; height:100%;
|
||||||
background:rgba(0,0,0,.5); z-index:1000; align-items:center; justify-content:center;">
|
background:rgba(0,0,0,.5); z-index:1000; align-items:center; justify-content:center;">
|
||||||
@ -209,7 +199,6 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Message Container -->
|
|
||||||
<div id="message-container" class="message-container"></div>
|
<div id="message-container" class="message-container"></div>
|
||||||
</div>
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
@ -239,7 +228,6 @@ class KnowledgebaseForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
// Get elements
|
|
||||||
this.elements = {
|
this.elements = {
|
||||||
form: document.getElementById('kb-form'),
|
form: document.getElementById('kb-form'),
|
||||||
submitBtn: document.getElementById('submit-btn'),
|
submitBtn: document.getElementById('submit-btn'),
|
||||||
@ -262,7 +250,6 @@ class KnowledgebaseForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setupEventListeners() {
|
private setupEventListeners() {
|
||||||
// Form submission
|
|
||||||
this.elements.form?.addEventListener('submit', (e) => {
|
this.elements.form?.addEventListener('submit', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!this.isSubmitting) {
|
if (!this.isSubmitting) {
|
||||||
@ -381,7 +368,6 @@ class KnowledgebaseForm {
|
|||||||
|
|
||||||
this.isSubmitting = true;
|
this.isSubmitting = true;
|
||||||
|
|
||||||
// Update UI
|
|
||||||
(this.elements.submitBtn as HTMLButtonElement).disabled = true;
|
(this.elements.submitBtn as HTMLButtonElement).disabled = true;
|
||||||
(this.elements.submitText as HTMLElement).textContent = 'Submitting...';
|
(this.elements.submitText as HTMLElement).textContent = 'Submitting...';
|
||||||
(this.elements.submitSpinner as HTMLElement).style.display = 'inline';
|
(this.elements.submitSpinner as HTMLElement).style.display = 'inline';
|
||||||
@ -389,7 +375,6 @@ class KnowledgebaseForm {
|
|||||||
try {
|
try {
|
||||||
const formData = new FormData(this.elements.form as HTMLFormElement);
|
const formData = new FormData(this.elements.form as HTMLFormElement);
|
||||||
|
|
||||||
// Process categories and tags
|
|
||||||
const categoriesValue = (formData.get('categories') as string) || '';
|
const categoriesValue = (formData.get('categories') as string) || '';
|
||||||
const tagsValue = (formData.get('tags') as string) || '';
|
const tagsValue = (formData.get('tags') as string) || '';
|
||||||
|
|
||||||
@ -398,7 +383,6 @@ class KnowledgebaseForm {
|
|||||||
formData.set('categories', JSON.stringify(categories));
|
formData.set('categories', JSON.stringify(categories));
|
||||||
formData.set('tags', JSON.stringify(tags));
|
formData.set('tags', JSON.stringify(tags));
|
||||||
|
|
||||||
// Add uploaded files
|
|
||||||
formData.set('uploadedFiles', JSON.stringify(this.uploadedFiles.filter(f => f.uploaded)));
|
formData.set('uploadedFiles', JSON.stringify(this.uploadedFiles.filter(f => f.uploaded)));
|
||||||
|
|
||||||
const response = await fetch('/api/contribute/knowledgebase', {
|
const response = await fetch('/api/contribute/knowledgebase', {
|
||||||
@ -440,7 +424,6 @@ class KnowledgebaseForm {
|
|||||||
|
|
||||||
(this.elements.successModal as HTMLElement).style.display = 'flex';
|
(this.elements.successModal as HTMLElement).style.display = 'flex';
|
||||||
|
|
||||||
// Reset form
|
|
||||||
(this.elements.form as HTMLFormElement).reset();
|
(this.elements.form as HTMLFormElement).reset();
|
||||||
this.uploadedFiles = [];
|
this.uploadedFiles = [];
|
||||||
this.renderFileList();
|
this.renderFileList();
|
||||||
@ -458,23 +441,19 @@ class KnowledgebaseForm {
|
|||||||
setTimeout(() => messageEl.remove(), 5000);
|
setTimeout(() => messageEl.remove(), 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public method for file removal
|
|
||||||
public removeFileById(fileId: string) {
|
public removeFileById(fileId: string) {
|
||||||
this.removeFile(fileId);
|
this.removeFile(fileId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global instance
|
|
||||||
let formInstance: KnowledgebaseForm;
|
let formInstance: KnowledgebaseForm;
|
||||||
|
|
||||||
// Global function for file removal
|
|
||||||
window.removeFile = (fileId: string) => {
|
window.removeFile = (fileId: string) => {
|
||||||
if (formInstance) {
|
if (formInstance) {
|
||||||
formInstance.removeFileById(fileId);
|
formInstance.removeFileById(fileId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize form
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
formInstance = new KnowledgebaseForm();
|
formInstance = new KnowledgebaseForm();
|
||||||
});
|
});
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
---
|
---
|
||||||
// src/pages/contribute/tool.astro - COMPLETE REWRITE
|
// src/pages/contribute/tool.astro
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
import { withAuth } from '../../utils/auth.js';
|
import { withAuth } from '../../utils/auth.js';
|
||||||
import { getToolsData } from '../../utils/dataService.js';
|
import { getToolsData } from '../../utils/dataService.js';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
// Check authentication
|
|
||||||
const authResult = await withAuth(Astro, 'contributions');
|
const authResult = await withAuth(Astro, 'contributions');
|
||||||
if (authResult instanceof Response) {
|
if (authResult instanceof Response) {
|
||||||
return authResult;
|
return authResult;
|
||||||
@ -14,14 +13,12 @@ if (authResult instanceof Response) {
|
|||||||
|
|
||||||
const { authenticated, userEmail, userId } = authResult;
|
const { authenticated, userEmail, userId } = authResult;
|
||||||
|
|
||||||
// Load existing data
|
|
||||||
const data = await getToolsData();
|
const data = await getToolsData();
|
||||||
const domains = data.domains;
|
const domains = data.domains;
|
||||||
const phases = data.phases;
|
const phases = data.phases;
|
||||||
const domainAgnosticSoftware = data['domain-agnostic-software'] || [];
|
const domainAgnosticSoftware = data['domain-agnostic-software'] || [];
|
||||||
const existingTools = data.tools;
|
const existingTools = data.tools;
|
||||||
|
|
||||||
// Check if this is an edit operation
|
|
||||||
const editToolName = Astro.url.searchParams.get('edit');
|
const editToolName = Astro.url.searchParams.get('edit');
|
||||||
const editTool = editToolName ? existingTools.find(tool => tool.name === editToolName) : null;
|
const editTool = editToolName ? existingTools.find(tool => tool.name === editToolName) : null;
|
||||||
const isEdit = !!editTool;
|
const isEdit = !!editTool;
|
||||||
@ -30,7 +27,6 @@ const isEdit = !!editTool;
|
|||||||
<BaseLayout title={isEdit ? `Edit ${editTool?.name}` : 'Contribute Tool'}>
|
<BaseLayout title={isEdit ? `Edit ${editTool?.name}` : 'Contribute Tool'}>
|
||||||
<div class="container" style="max-width: 900px; margin: 0 auto; padding: 2rem 1rem;">
|
<div class="container" style="max-width: 900px; margin: 0 auto; padding: 2rem 1rem;">
|
||||||
|
|
||||||
<!-- Header -->
|
|
||||||
<div style="text-align: center; margin-bottom: 2rem; padding: 2rem; background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%); color: white; border-radius: 1rem;">
|
<div style="text-align: center; margin-bottom: 2rem; padding: 2rem; background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%); color: white; border-radius: 1rem;">
|
||||||
<h1 style="margin-bottom: 1rem; font-size: 2rem;">{isEdit ? `Edit: ${editTool?.name}` : 'Tool / Methode / Konzept beitragen'}</h1>
|
<h1 style="margin-bottom: 1rem; font-size: 2rem;">{isEdit ? `Edit: ${editTool?.name}` : 'Tool / Methode / Konzept beitragen'}</h1>
|
||||||
<p style="margin: 0.5rem 0; opacity: 0.9;">
|
<p style="margin: 0.5rem 0; opacity: 0.9;">
|
||||||
@ -42,17 +38,14 @@ const isEdit = !!editTool;
|
|||||||
{userEmail && <p style="margin: 0.5rem 0; opacity: 0.8;"><strong>Eingeloggt als:</strong> {userEmail}</p>}
|
{userEmail && <p style="margin: 0.5rem 0; opacity: 0.8;"><strong>Eingeloggt als:</strong> {userEmail}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Validation Error Display -->
|
|
||||||
<div id="validation-errors" class="card" style="display: none; background-color: var(--color-error); color: white; margin-bottom: 2rem;">
|
<div id="validation-errors" class="card" style="display: none; background-color: var(--color-error); color: white; margin-bottom: 2rem;">
|
||||||
<h3 style="margin: 0 0 1rem 0;">⚠️ Please fix the following issues:</h3>
|
<h3 style="margin: 0 0 1rem 0;">⚠️ Please fix the following issues:</h3>
|
||||||
<ul id="error-list" style="margin: 0; padding-left: 1.5rem;"></ul>
|
<ul id="error-list" style="margin: 0; padding-left: 1.5rem;"></ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Form -->
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<form id="contribution-form" novalidate style="padding: 2rem;">
|
<form id="contribution-form" novalidate style="padding: 2rem;">
|
||||||
|
|
||||||
<!-- Basic Information -->
|
|
||||||
<div style="border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 2rem;">
|
<div style="border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 2rem;">
|
||||||
<h3 style="margin: 0 0 1.5rem 0; color: var(--color-primary); border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem;">Grundlegende Infos</h3>
|
<h3 style="margin: 0 0 1.5rem 0; color: var(--color-primary); border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem;">Grundlegende Infos</h3>
|
||||||
|
|
||||||
@ -109,7 +102,6 @@ const isEdit = !!editTool;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Categories -->
|
|
||||||
<div style="border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 2rem;">
|
<div style="border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 2rem;">
|
||||||
<h3 style="margin: 0 0 1.5rem 0; color: var(--color-primary); border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem;">Kategorien</h3>
|
<h3 style="margin: 0 0 1.5rem 0; color: var(--color-primary); border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem;">Kategorien</h3>
|
||||||
|
|
||||||
@ -142,7 +134,6 @@ const isEdit = !!editTool;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Software-Specific Fields -->
|
|
||||||
<div id="software-fields" style="border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 2rem; display: none;">
|
<div id="software-fields" style="border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 2rem; display: none;">
|
||||||
<h3 style="margin: 0 0 1.5rem 0; color: var(--color-primary); border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem;">Software Details</h3>
|
<h3 style="margin: 0 0 1.5rem 0; color: var(--color-primary); border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem;">Software Details</h3>
|
||||||
|
|
||||||
@ -203,7 +194,6 @@ const isEdit = !!editTool;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Related Concepts -->
|
|
||||||
<div id="concepts-fields" style="border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 2rem; display: none;">
|
<div id="concepts-fields" style="border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 2rem; display: none;">
|
||||||
<h3 style="margin: 0 0 1.5rem 0; color: var(--color-primary); border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem;">Konzepte im Zusammenhang</h3>
|
<h3 style="margin: 0 0 1.5rem 0; color: var(--color-primary); border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem;">Konzepte im Zusammenhang</h3>
|
||||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0.5rem;">
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0.5rem;">
|
||||||
@ -217,7 +207,6 @@ const isEdit = !!editTool;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Additional Information -->
|
|
||||||
<div style="border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 2rem;">
|
<div style="border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 2rem;">
|
||||||
<h3 style="margin: 0 0 1.5rem 0; color: var(--color-primary); border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem;">Zusatzinfos</h3>
|
<h3 style="margin: 0 0 1.5rem 0; color: var(--color-primary); border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem;">Zusatzinfos</h3>
|
||||||
|
|
||||||
@ -245,7 +234,6 @@ const isEdit = !!editTool;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- YAML Preview -->
|
|
||||||
<div style="border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 2rem;">
|
<div style="border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 2rem;">
|
||||||
<h3 style="margin: 0 0 1.5rem 0; color: var(--color-primary); border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem;">Preview</h3>
|
<h3 style="margin: 0 0 1.5rem 0; color: var(--color-primary); border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem;">Preview</h3>
|
||||||
<div style="border: 1px solid var(--color-border); border-radius: 0.375rem; overflow: hidden;">
|
<div style="border: 1px solid var(--color-border); border-radius: 0.375rem; overflow: hidden;">
|
||||||
@ -253,7 +241,6 @@ const isEdit = !!editTool;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Submit -->
|
|
||||||
<div style="display: flex; gap: 1rem; justify-content: flex-end; margin-top: 2rem; padding-top: 2rem; border-top: 1px solid var(--color-border);">
|
<div style="display: flex; gap: 1rem; justify-content: flex-end; margin-top: 2rem; padding-top: 2rem; border-top: 1px solid var(--color-border);">
|
||||||
<a href="/" class="btn btn-secondary">Cancel</a>
|
<a href="/" class="btn btn-secondary">Cancel</a>
|
||||||
<button type="submit" id="submit-btn" class="btn btn-primary">
|
<button type="submit" id="submit-btn" class="btn btn-primary">
|
||||||
@ -264,7 +251,6 @@ const isEdit = !!editTool;
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Success Modal -->
|
|
||||||
<div id="success-modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); z-index: 1000; align-items: center; justify-content: center;">
|
<div id="success-modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); z-index: 1000; align-items: center; justify-content: center;">
|
||||||
<div class="card" style="max-width: 500px; width: 90%; margin: 2rem; text-align: center;">
|
<div class="card" style="max-width: 500px; width: 90%; margin: 2rem; text-align: center;">
|
||||||
<div style="font-size: 3rem; margin-bottom: 1rem;">✅</div>
|
<div style="font-size: 3rem; margin-bottom: 1rem;">✅</div>
|
||||||
@ -280,7 +266,6 @@ const isEdit = !!editTool;
|
|||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
<script define:vars={{ isEdit, editTool, domains, phases, domainAgnosticSoftware }}>
|
<script define:vars={{ isEdit, editTool, domains, phases, domainAgnosticSoftware }}>
|
||||||
// FIXED: Prevent duplicate form submissions
|
|
||||||
console.log('[FORM] Script loaded, initializing...');
|
console.log('[FORM] Script loaded, initializing...');
|
||||||
|
|
||||||
class ContributionForm {
|
class ContributionForm {
|
||||||
@ -288,14 +273,13 @@ class ContributionForm {
|
|||||||
this.isEdit = isEdit;
|
this.isEdit = isEdit;
|
||||||
this.editTool = editTool;
|
this.editTool = editTool;
|
||||||
this.elements = {};
|
this.elements = {};
|
||||||
this.isSubmitting = false; // NEW: Prevent concurrent submissions
|
this.isSubmitting = false;
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
console.log('[FORM] Starting initialization...');
|
console.log('[FORM] Starting initialization...');
|
||||||
|
|
||||||
// Get all form elements
|
|
||||||
this.elements = {
|
this.elements = {
|
||||||
form: document.getElementById('contribution-form'),
|
form: document.getElementById('contribution-form'),
|
||||||
submitBtn: document.getElementById('submit-btn'),
|
submitBtn: document.getElementById('submit-btn'),
|
||||||
@ -320,19 +304,16 @@ class ContributionForm {
|
|||||||
licenseInput: document.getElementById('license')
|
licenseInput: document.getElementById('license')
|
||||||
};
|
};
|
||||||
|
|
||||||
// Verify critical elements
|
|
||||||
if (!this.elements.form || !this.elements.submitBtn) {
|
if (!this.elements.form || !this.elements.submitBtn) {
|
||||||
console.error('[FORM] Critical elements missing!');
|
console.error('[FORM] Critical elements missing!');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXED: Check if already initialized
|
|
||||||
if (this.elements.form.hasAttribute('data-form-initialized')) {
|
if (this.elements.form.hasAttribute('data-form-initialized')) {
|
||||||
console.log('[FORM] Form already initialized, skipping...');
|
console.log('[FORM] Form already initialized, skipping...');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark as initialized
|
|
||||||
this.elements.form.setAttribute('data-form-initialized', 'true');
|
this.elements.form.setAttribute('data-form-initialized', 'true');
|
||||||
|
|
||||||
console.log('[FORM] Setting up handlers...');
|
console.log('[FORM] Setting up handlers...');
|
||||||
@ -345,13 +326,11 @@ class ContributionForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setupEventListeners() {
|
setupEventListeners() {
|
||||||
// Type change handler
|
|
||||||
this.elements.typeSelect.addEventListener('change', () => {
|
this.elements.typeSelect.addEventListener('change', () => {
|
||||||
this.updateFieldVisibility();
|
this.updateFieldVisibility();
|
||||||
this.updateYAMLPreview();
|
this.updateYAMLPreview();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Form input handlers
|
|
||||||
this.elements.form.addEventListener('input', () => {
|
this.elements.form.addEventListener('input', () => {
|
||||||
this.debounce(() => this.updateYAMLPreview(), 300);
|
this.debounce(() => this.updateYAMLPreview(), 300);
|
||||||
});
|
});
|
||||||
@ -360,12 +339,10 @@ class ContributionForm {
|
|||||||
this.updateYAMLPreview();
|
this.updateYAMLPreview();
|
||||||
});
|
});
|
||||||
|
|
||||||
// FIXED: Single submit handler with double-submission prevention
|
|
||||||
this.elements.form.addEventListener('submit', (e) => {
|
this.elements.form.addEventListener('submit', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
// Prevent double submission
|
|
||||||
if (this.isSubmitting) {
|
if (this.isSubmitting) {
|
||||||
console.log('[FORM] Submission already in progress, ignoring...');
|
console.log('[FORM] Submission already in progress, ignoring...');
|
||||||
return;
|
return;
|
||||||
@ -380,15 +357,12 @@ class ContributionForm {
|
|||||||
updateFieldVisibility() {
|
updateFieldVisibility() {
|
||||||
const type = this.elements.typeSelect.value;
|
const type = this.elements.typeSelect.value;
|
||||||
|
|
||||||
// Hide all conditional fields
|
|
||||||
this.elements.softwareFields.style.display = 'none';
|
this.elements.softwareFields.style.display = 'none';
|
||||||
this.elements.conceptsFields.style.display = 'none';
|
this.elements.conceptsFields.style.display = 'none';
|
||||||
|
|
||||||
// Hide required indicators
|
|
||||||
if (this.elements.platformsRequired) this.elements.platformsRequired.style.display = 'none';
|
if (this.elements.platformsRequired) this.elements.platformsRequired.style.display = 'none';
|
||||||
if (this.elements.licenseRequired) this.elements.licenseRequired.style.display = 'none';
|
if (this.elements.licenseRequired) this.elements.licenseRequired.style.display = 'none';
|
||||||
|
|
||||||
// Show relevant fields based on type
|
|
||||||
if (type === 'software') {
|
if (type === 'software') {
|
||||||
this.elements.softwareFields.style.display = 'block';
|
this.elements.softwareFields.style.display = 'block';
|
||||||
this.elements.conceptsFields.style.display = 'block';
|
this.elements.conceptsFields.style.display = 'block';
|
||||||
@ -437,12 +411,10 @@ updateYAMLPreview() {
|
|||||||
url: formData.get('url') || 'https://example.com'
|
url: formData.get('url') || 'https://example.com'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add icon if provided
|
|
||||||
if (formData.get('icon')) {
|
if (formData.get('icon')) {
|
||||||
tool.icon = formData.get('icon');
|
tool.icon = formData.get('icon');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add software-specific fields
|
|
||||||
if (tool.type === 'software') {
|
if (tool.type === 'software') {
|
||||||
tool.platforms = formData.getAll('platforms');
|
tool.platforms = formData.getAll('platforms');
|
||||||
tool.license = formData.get('license') || 'Unknown';
|
tool.license = formData.get('license') || 'Unknown';
|
||||||
@ -455,7 +427,6 @@ updateYAMLPreview() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add optional fields
|
|
||||||
if (formData.has('knowledgebase')) {
|
if (formData.has('knowledgebase')) {
|
||||||
tool.knowledgebase = true;
|
tool.knowledgebase = true;
|
||||||
}
|
}
|
||||||
@ -470,7 +441,6 @@ updateYAMLPreview() {
|
|||||||
tool.related_concepts = relatedConcepts;
|
tool.related_concepts = relatedConcepts;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate YAML
|
|
||||||
const yaml = this.generateYAML(tool);
|
const yaml = this.generateYAML(tool);
|
||||||
this.elements.yamlPreview.textContent = yaml;
|
this.elements.yamlPreview.textContent = yaml;
|
||||||
|
|
||||||
@ -515,7 +485,6 @@ validateForm() {
|
|||||||
const errors = [];
|
const errors = [];
|
||||||
const formData = new FormData(this.elements.form);
|
const formData = new FormData(this.elements.form);
|
||||||
|
|
||||||
// Required field validation
|
|
||||||
const name = formData.get('name')?.trim();
|
const name = formData.get('name')?.trim();
|
||||||
if (!name) {
|
if (!name) {
|
||||||
errors.push('Tool name is required');
|
errors.push('Tool name is required');
|
||||||
@ -549,7 +518,6 @@ validateForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Software-specific validation
|
|
||||||
if (type === 'software') {
|
if (type === 'software') {
|
||||||
const platforms = formData.getAll('platforms');
|
const platforms = formData.getAll('platforms');
|
||||||
if (platforms.length === 0) {
|
if (platforms.length === 0) {
|
||||||
@ -571,26 +539,21 @@ showValidationErrors(errors) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear previous errors
|
|
||||||
this.elements.errorList.innerHTML = '';
|
this.elements.errorList.innerHTML = '';
|
||||||
|
|
||||||
// Add each error as list item
|
|
||||||
errors.forEach(error => {
|
errors.forEach(error => {
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.textContent = error;
|
li.textContent = error;
|
||||||
this.elements.errorList.appendChild(li);
|
this.elements.errorList.appendChild(li);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show error container
|
|
||||||
this.elements.validationErrors.style.display = 'block';
|
this.elements.validationErrors.style.display = 'block';
|
||||||
|
|
||||||
// Scroll to top to show errors
|
|
||||||
this.elements.validationErrors.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
this.elements.validationErrors.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
}
|
}
|
||||||
async handleSubmit() {
|
async handleSubmit() {
|
||||||
console.log('[FORM] Submit handler called!');
|
console.log('[FORM] Submit handler called!');
|
||||||
|
|
||||||
// FIXED: Immediate submission lock
|
|
||||||
if (this.isSubmitting) {
|
if (this.isSubmitting) {
|
||||||
console.log('[FORM] Already submitting, aborting...');
|
console.log('[FORM] Already submitting, aborting...');
|
||||||
return;
|
return;
|
||||||
@ -598,19 +561,16 @@ showValidationErrors(errors) {
|
|||||||
|
|
||||||
this.isSubmitting = true;
|
this.isSubmitting = true;
|
||||||
|
|
||||||
// Validate before submitting
|
|
||||||
const validationErrors = this.validateForm();
|
const validationErrors = this.validateForm();
|
||||||
if (validationErrors.length > 0) {
|
if (validationErrors.length > 0) {
|
||||||
console.log('[FORM] Validation failed:', validationErrors);
|
console.log('[FORM] Validation failed:', validationErrors);
|
||||||
this.showValidationErrors(validationErrors);
|
this.showValidationErrors(validationErrors);
|
||||||
this.isSubmitting = false; // Reset lock
|
this.isSubmitting = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide validation errors
|
|
||||||
this.elements.validationErrors.style.display = 'none';
|
this.elements.validationErrors.style.display = 'none';
|
||||||
|
|
||||||
// Immediate UI feedback
|
|
||||||
this.elements.submitBtn.disabled = true;
|
this.elements.submitBtn.disabled = true;
|
||||||
this.elements.submitText.textContent = this.isEdit ? 'Updating...' : 'Submitting...';
|
this.elements.submitText.textContent = this.isEdit ? 'Updating...' : 'Submitting...';
|
||||||
this.elements.submitSpinner.style.display = 'inline';
|
this.elements.submitSpinner.style.display = 'inline';
|
||||||
@ -618,7 +578,6 @@ showValidationErrors(errors) {
|
|||||||
try {
|
try {
|
||||||
const formData = new FormData(this.elements.form);
|
const formData = new FormData(this.elements.form);
|
||||||
|
|
||||||
// Build submission object
|
|
||||||
const submission = {
|
const submission = {
|
||||||
action: this.isEdit ? 'edit' : 'add',
|
action: this.isEdit ? 'edit' : 'add',
|
||||||
tool: {
|
tool: {
|
||||||
@ -637,11 +596,9 @@ showValidationErrors(errors) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add optional fields
|
|
||||||
if (formData.get('icon')) submission.tool.icon = formData.get('icon');
|
if (formData.get('icon')) submission.tool.icon = formData.get('icon');
|
||||||
if (formData.has('knowledgebase')) submission.tool.knowledgebase = true;
|
if (formData.has('knowledgebase')) submission.tool.knowledgebase = true;
|
||||||
|
|
||||||
// Add software-specific fields
|
|
||||||
if (submission.tool.type === 'software') {
|
if (submission.tool.type === 'software') {
|
||||||
submission.tool.platforms = formData.getAll('platforms');
|
submission.tool.platforms = formData.getAll('platforms');
|
||||||
submission.tool.license = formData.get('license');
|
submission.tool.license = formData.get('license');
|
||||||
@ -654,7 +611,6 @@ showValidationErrors(errors) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add related concepts
|
|
||||||
if (submission.tool.type !== 'concept') {
|
if (submission.tool.type !== 'concept') {
|
||||||
const related = formData.getAll('relatedConcepts');
|
const related = formData.getAll('relatedConcepts');
|
||||||
if (related.length > 0) {
|
if (related.length > 0) {
|
||||||
@ -664,7 +620,6 @@ showValidationErrors(errors) {
|
|||||||
|
|
||||||
console.log('[FORM] Sending submission:', submission);
|
console.log('[FORM] Sending submission:', submission);
|
||||||
|
|
||||||
// Submit to API
|
|
||||||
const response = await fetch('/api/contribute/tool', {
|
const response = await fetch('/api/contribute/tool', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@ -685,7 +640,6 @@ showValidationErrors(errors) {
|
|||||||
console.error('[FORM] Submission error:', error);
|
console.error('[FORM] Submission error:', error);
|
||||||
alert(`Submission failed: ${error.message}\n\nPlease try again or contact support if the problem persists.`);
|
alert(`Submission failed: ${error.message}\n\nPlease try again or contact support if the problem persists.`);
|
||||||
} finally {
|
} finally {
|
||||||
// FIXED: Always reset submission state
|
|
||||||
this.isSubmitting = false;
|
this.isSubmitting = false;
|
||||||
this.elements.submitBtn.disabled = false;
|
this.elements.submitBtn.disabled = false;
|
||||||
this.elements.submitText.textContent = this.isEdit ? 'Update Tool' : 'Submit Contribution';
|
this.elements.submitText.textContent = this.isEdit ? 'Update Tool' : 'Submit Contribution';
|
||||||
@ -694,13 +648,11 @@ showValidationErrors(errors) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
showSuccess(result) {
|
showSuccess(result) {
|
||||||
// Update success message
|
|
||||||
const successMessage = document.getElementById('success-message');
|
const successMessage = document.getElementById('success-message');
|
||||||
if (successMessage) {
|
if (successMessage) {
|
||||||
successMessage.textContent = `Your ${this.isEdit ? 'update' : 'contribution'} has been submitted as an issue and will be reviewed by maintainers.`;
|
successMessage.textContent = `Your ${this.isEdit ? 'update' : 'contribution'} has been submitted as an issue and will be reviewed by maintainers.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show issue link if available
|
|
||||||
if (result.issueUrl) {
|
if (result.issueUrl) {
|
||||||
const prLink = document.getElementById('pr-link');
|
const prLink = document.getElementById('pr-link');
|
||||||
if (prLink) {
|
if (prLink) {
|
||||||
@ -710,7 +662,6 @@ showValidationErrors(errors) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show modal
|
|
||||||
this.elements.successModal.style.display = 'flex';
|
this.elements.successModal.style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -723,7 +674,6 @@ showValidationErrors(errors) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXED: Single initialization only
|
|
||||||
function initializeForm() {
|
function initializeForm() {
|
||||||
const form = document.getElementById('contribution-form');
|
const form = document.getElementById('contribution-form');
|
||||||
if (!form) {
|
if (!form) {
|
||||||
@ -740,7 +690,6 @@ function initializeForm() {
|
|||||||
new ContributionForm();
|
new ContributionForm();
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXED: Simple initialization
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', initializeForm);
|
document.addEventListener('DOMContentLoaded', initializeForm);
|
||||||
} else {
|
} else {
|
||||||
|
@ -4,7 +4,6 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||||||
|
|
||||||
<BaseLayout title="Impressum" description="ForensicPathways - Impressum">
|
<BaseLayout title="Impressum" description="ForensicPathways - Impressum">
|
||||||
<section style="padding: 2rem 0; max-width: 900px; margin: 0 auto;">
|
<section style="padding: 2rem 0; max-width: 900px; margin: 0 auto;">
|
||||||
<!-- Hero Section -->
|
|
||||||
<div style="text-align: center; margin-bottom: 3rem; padding: 2rem; background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%); border-radius: 1rem; border: 1px solid var(--color-border);">
|
<div style="text-align: center; margin-bottom: 3rem; padding: 2rem; background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%); border-radius: 1rem; border: 1px solid var(--color-border);">
|
||||||
<p>Bei dieser Webseite handelt es sich um ein privates Werk. Keine Bildungseinrichtung, kein Unternehmen sind finanziell oder moderierend Teil dieses Projekts.</p>
|
<p>Bei dieser Webseite handelt es sich um ein privates Werk. Keine Bildungseinrichtung, kein Unternehmen sind finanziell oder moderierend Teil dieses Projekts.</p>
|
||||||
<h1>Impressum</h1>
|
<h1>Impressum</h1>
|
||||||
|
@ -6,13 +6,11 @@ import ToolMatrix from '../components/ToolMatrix.astro';
|
|||||||
import AIQueryInterface from '../components/AIQueryInterface.astro';
|
import AIQueryInterface from '../components/AIQueryInterface.astro';
|
||||||
import { getToolsData } from '../utils/dataService.js';
|
import { getToolsData } from '../utils/dataService.js';
|
||||||
|
|
||||||
// Load tools data
|
|
||||||
const data = await getToolsData();
|
const data = await getToolsData();
|
||||||
const tools = data.tools;
|
const tools = data.tools;
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title="~/">
|
<BaseLayout title="~/">
|
||||||
<!-- Hero Section -->
|
|
||||||
<section style="padding: 2rem 0 1rem; border-bottom: 1px solid var(--color-border);">
|
<section style="padding: 2rem 0 1rem; border-bottom: 1px solid var(--color-border);">
|
||||||
<div style="text-align: center; margin-bottom: 2rem; padding: 2rem; background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%); border-radius: 1rem; border: 1px solid var(--color-border);">
|
<div style="text-align: center; margin-bottom: 2rem; padding: 2rem; background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%); border-radius: 1rem; border: 1px solid var(--color-border);">
|
||||||
<h1 style="margin-bottom: 1rem; font-size: 1.5rem; color: var(--color-primary);">ForensicPathways</h1>
|
<h1 style="margin-bottom: 1rem; font-size: 1.5rem; color: var(--color-primary);">ForensicPathways</h1>
|
||||||
@ -43,7 +41,6 @@ const tools = data.tools;
|
|||||||
Infos zu SSO & Zugang
|
Infos zu SSO & Zugang
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- AI Query Button -->
|
|
||||||
<button id="ai-query-btn" class="btn btn-accent" style="padding: 0.75rem 1.5rem; background-color: var(--color-accent); color: white;">
|
<button id="ai-query-btn" class="btn btn-accent" style="padding: 0.75rem 1.5rem; background-color: var(--color-accent); color: white;">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
|
||||||
<path d="M9 11H5a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7a2 2 0 0 0-2-2h-4"/>
|
<path d="M9 11H5a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7a2 2 0 0 0-2-2h-4"/>
|
||||||
@ -52,7 +49,6 @@ const tools = data.tools;
|
|||||||
KI befragen
|
KI befragen
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Contribution Button -->
|
|
||||||
<a href="/contribute" class="btn" style="padding: 0.75rem 1.5rem; background-color: var(--color-warning); color: white; border-color: var(--color-warning);" data-contribute-button="new">
|
<a href="/contribute" class="btn" style="padding: 0.75rem 1.5rem; background-color: var(--color-warning); color: white; border-color: var(--color-warning);" data-contribute-button="new">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
|
||||||
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||||
@ -75,15 +71,12 @@ const tools = data.tools;
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Filters Section -->
|
|
||||||
<section id="filters-section" style="padding: 2rem 0;">
|
<section id="filters-section" style="padding: 2rem 0;">
|
||||||
<ToolFilters data={data} />
|
<ToolFilters data={data} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- AI Query Interface -->
|
|
||||||
<AIQueryInterface />
|
<AIQueryInterface />
|
||||||
|
|
||||||
<!-- Tools Grid -->
|
|
||||||
<section id="tools-grid" style="padding-bottom: 2rem;">
|
<section id="tools-grid" style="padding-bottom: 2rem;">
|
||||||
<div class="grid-auto-fit" id="tools-container">
|
<div class="grid-auto-fit" id="tools-container">
|
||||||
{tools.map((tool: any) => (
|
{tools.map((tool: any) => (
|
||||||
@ -91,21 +84,17 @@ const tools = data.tools;
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- No results message -->
|
|
||||||
<div id="no-results" style="display: none; text-align: center; padding: 4rem 0;">
|
<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>
|
<p class="text-muted" style="font-size: 1.125rem;">No tools found matching your criteria.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Matrix View -->
|
|
||||||
<ToolMatrix data={data} />
|
<ToolMatrix data={data} />
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
<script define:vars={{ toolsData: data.tools }}>
|
<script define:vars={{ toolsData: data.tools }}>
|
||||||
// Store tools data globally
|
|
||||||
window.toolsData = toolsData;
|
window.toolsData = toolsData;
|
||||||
|
|
||||||
// Handle view changes and filtering
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const toolsContainer = document.getElementById('tools-container');
|
const toolsContainer = document.getElementById('tools-container');
|
||||||
const toolsGrid = document.getElementById('tools-grid');
|
const toolsGrid = document.getElementById('tools-grid');
|
||||||
@ -115,7 +104,6 @@ const tools = data.tools;
|
|||||||
const noResults = document.getElementById('no-results');
|
const noResults = document.getElementById('no-results');
|
||||||
const aiQueryBtn = document.getElementById('ai-query-btn');
|
const aiQueryBtn = document.getElementById('ai-query-btn');
|
||||||
|
|
||||||
// Guard against null elements
|
|
||||||
if (!toolsContainer || !toolsGrid || !matrixContainer || !noResults || !aiInterface || !filtersSection) {
|
if (!toolsContainer || !toolsGrid || !matrixContainer || !noResults || !aiInterface || !filtersSection) {
|
||||||
console.error('Required DOM elements not found');
|
console.error('Required DOM elements not found');
|
||||||
return;
|
return;
|
||||||
@ -124,7 +112,6 @@ const tools = data.tools;
|
|||||||
if (aiQueryBtn) {
|
if (aiQueryBtn) {
|
||||||
aiQueryBtn.addEventListener('click', async () => {
|
aiQueryBtn.addEventListener('click', async () => {
|
||||||
if (typeof window.requireClientAuth === 'function') {
|
if (typeof window.requireClientAuth === 'function') {
|
||||||
// ENHANCED: Use AI-specific authentication
|
|
||||||
await window.requireClientAuth(() => switchToView('ai'), `${window.location.pathname}?view=ai`, 'ai');
|
await window.requireClientAuth(() => switchToView('ai'), `${window.location.pathname}?view=ai`, 'ai');
|
||||||
} else {
|
} else {
|
||||||
console.warn('[AUTH] requireClientAuth not available');
|
console.warn('[AUTH] requireClientAuth not available');
|
||||||
@ -133,21 +120,17 @@ const tools = data.tools;
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to switch between different views
|
|
||||||
function switchToView(view) {
|
function switchToView(view) {
|
||||||
// Hide all views first
|
|
||||||
toolsGrid.style.display = 'none';
|
toolsGrid.style.display = 'none';
|
||||||
matrixContainer.style.display = 'none';
|
matrixContainer.style.display = 'none';
|
||||||
aiInterface.style.display = 'none';
|
aiInterface.style.display = 'none';
|
||||||
filtersSection.style.display = 'none';
|
filtersSection.style.display = 'none';
|
||||||
|
|
||||||
// Update view toggle buttons
|
|
||||||
const viewToggles = document.querySelectorAll('.view-toggle');
|
const viewToggles = document.querySelectorAll('.view-toggle');
|
||||||
viewToggles.forEach(btn => {
|
viewToggles.forEach(btn => {
|
||||||
btn.classList.toggle('active', btn.getAttribute('data-view') === view);
|
btn.classList.toggle('active', btn.getAttribute('data-view') === view);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show appropriate view and manage filter visibility
|
|
||||||
switch (view) {
|
switch (view) {
|
||||||
case 'ai':
|
case 'ai':
|
||||||
aiInterface.style.display = 'block';
|
aiInterface.style.display = 'block';
|
||||||
@ -166,20 +149,18 @@ const tools = data.tools;
|
|||||||
filtersSection.style.display = 'block';
|
filtersSection.style.display = 'block';
|
||||||
showFilterControls();
|
showFilterControls();
|
||||||
break;
|
break;
|
||||||
default: // grid
|
default:
|
||||||
toolsGrid.style.display = 'block';
|
toolsGrid.style.display = 'block';
|
||||||
filtersSection.style.display = 'block';
|
filtersSection.style.display = 'block';
|
||||||
showFilterControls();
|
showFilterControls();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear URL parameters after switching
|
|
||||||
if (window.location.search) {
|
if (window.location.search) {
|
||||||
window.history.replaceState({}, '', window.location.pathname);
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions for filter control visibility
|
|
||||||
function hideFilterControls() {
|
function hideFilterControls() {
|
||||||
const elements = [
|
const elements = [
|
||||||
'.domain-phase-container',
|
'.domain-phase-container',
|
||||||
@ -215,24 +196,16 @@ const tools = data.tools;
|
|||||||
checkboxWrappers.forEach(wrapper => wrapper.style.display = 'flex');
|
checkboxWrappers.forEach(wrapper => wrapper.style.display = 'flex');
|
||||||
}
|
}
|
||||||
|
|
||||||
// REMOVED: createToolSlug function - now using window.createToolSlug
|
|
||||||
// REMOVED: findTool function - now using window.findToolByIdentifier
|
|
||||||
|
|
||||||
// Navigation functions for sharing
|
|
||||||
window.navigateToGrid = function(toolName) {
|
window.navigateToGrid = function(toolName) {
|
||||||
console.log('Navigating to grid for tool:', toolName);
|
console.log('Navigating to grid for tool:', toolName);
|
||||||
|
|
||||||
// Switch to grid view first
|
|
||||||
switchToView('grid');
|
switchToView('grid');
|
||||||
|
|
||||||
// Wait for view switch, then find and scroll to tool
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Clear any filters first
|
|
||||||
if (window.clearAllFilters) {
|
if (window.clearAllFilters) {
|
||||||
window.clearAllFilters();
|
window.clearAllFilters();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for filters to clear and re-render
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const toolCards = document.querySelectorAll('.tool-card');
|
const toolCards = document.querySelectorAll('.tool-card');
|
||||||
let targetCard = null;
|
let targetCard = null;
|
||||||
@ -240,7 +213,6 @@ const tools = data.tools;
|
|||||||
toolCards.forEach(card => {
|
toolCards.forEach(card => {
|
||||||
const cardTitle = card.querySelector('h3');
|
const cardTitle = card.querySelector('h3');
|
||||||
if (cardTitle) {
|
if (cardTitle) {
|
||||||
// Clean title text (remove icons and extra spaces)
|
|
||||||
const titleText = cardTitle.textContent?.replace(/[^\w\s\-\.]/g, '').trim();
|
const titleText = cardTitle.textContent?.replace(/[^\w\s\-\.]/g, '').trim();
|
||||||
if (titleText === toolName) {
|
if (titleText === toolName) {
|
||||||
targetCard = card;
|
targetCard = card;
|
||||||
@ -268,29 +240,23 @@ const tools = data.tools;
|
|||||||
window.navigateToMatrix = function(toolName) {
|
window.navigateToMatrix = function(toolName) {
|
||||||
console.log('Navigating to matrix for tool:', toolName);
|
console.log('Navigating to matrix for tool:', toolName);
|
||||||
|
|
||||||
// Switch to matrix view
|
|
||||||
switchToView('matrix');
|
switchToView('matrix');
|
||||||
|
|
||||||
// Wait for view switch and matrix to render
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const toolChips = document.querySelectorAll('.tool-chip');
|
const toolChips = document.querySelectorAll('.tool-chip');
|
||||||
let firstMatch = null;
|
let firstMatch = null;
|
||||||
let matchCount = 0;
|
let matchCount = 0;
|
||||||
|
|
||||||
toolChips.forEach(chip => {
|
toolChips.forEach(chip => {
|
||||||
// Clean the chip text (remove emoji and extra spaces)
|
|
||||||
const chipText = chip.textContent?.replace(/📖/g, '').replace(/[^\w\s\-\.]/g, '').trim();
|
const chipText = chip.textContent?.replace(/📖/g, '').replace(/[^\w\s\-\.]/g, '').trim();
|
||||||
if (chipText === toolName) {
|
if (chipText === toolName) {
|
||||||
// Highlight this occurrence
|
|
||||||
chip.style.animation = 'highlight-flash 2s ease-out';
|
chip.style.animation = 'highlight-flash 2s ease-out';
|
||||||
matchCount++;
|
matchCount++;
|
||||||
|
|
||||||
// Remember the first match for scrolling
|
|
||||||
if (!firstMatch) {
|
if (!firstMatch) {
|
||||||
firstMatch = chip;
|
firstMatch = chip;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up animation after it completes
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
chip.style.animation = '';
|
chip.style.animation = '';
|
||||||
}, 8000);
|
}, 8000);
|
||||||
@ -306,7 +272,6 @@ const tools = data.tools;
|
|||||||
}, 500);
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle URL parameters on page load
|
|
||||||
function handleSharedURL() {
|
function handleSharedURL() {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const toolParam = urlParams.get('tool');
|
const toolParam = urlParams.get('tool');
|
||||||
@ -314,25 +279,21 @@ const tools = data.tools;
|
|||||||
const modalParam = urlParams.get('modal');
|
const modalParam = urlParams.get('modal');
|
||||||
|
|
||||||
if (!toolParam) {
|
if (!toolParam) {
|
||||||
// Check for AI view parameter
|
|
||||||
if (viewParam === 'ai') {
|
if (viewParam === 'ai') {
|
||||||
switchToView('ai');
|
switchToView('ai');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the tool by name or slug using global function
|
|
||||||
const tool = window.findToolByIdentifier(window.toolsData, toolParam);
|
const tool = window.findToolByIdentifier(window.toolsData, toolParam);
|
||||||
if (!tool) {
|
if (!tool) {
|
||||||
console.warn('Shared tool not found:', toolParam);
|
console.warn('Shared tool not found:', toolParam);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear URL parameters to avoid re-triggering
|
|
||||||
const cleanUrl = window.location.protocol + "//" + window.location.host + window.location.pathname;
|
const cleanUrl = window.location.protocol + "//" + window.location.host + window.location.pathname;
|
||||||
window.history.replaceState({}, document.title, cleanUrl);
|
window.history.replaceState({}, document.title, cleanUrl);
|
||||||
|
|
||||||
// Handle different view types
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
switch (viewParam) {
|
switch (viewParam) {
|
||||||
case 'grid':
|
case 'grid':
|
||||||
@ -354,7 +315,6 @@ const tools = data.tools;
|
|||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ENHANCED: New filtering logic using show/hide pattern
|
|
||||||
window.addEventListener('toolsFiltered', (event) => {
|
window.addEventListener('toolsFiltered', (event) => {
|
||||||
const filtered = event.detail;
|
const filtered = event.detail;
|
||||||
const currentView = document.querySelector('.view-toggle.active')?.getAttribute('data-view');
|
const currentView = document.querySelector('.view-toggle.active')?.getAttribute('data-view');
|
||||||
@ -363,7 +323,6 @@ const tools = data.tools;
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all existing tool cards
|
|
||||||
const allToolCards = document.querySelectorAll('.tool-card');
|
const allToolCards = document.querySelectorAll('.tool-card');
|
||||||
const filteredNames = new Set(filtered.map(tool => tool.name.toLowerCase()));
|
const filteredNames = new Set(filtered.map(tool => tool.name.toLowerCase()));
|
||||||
|
|
||||||
@ -379,7 +338,6 @@ const tools = data.tools;
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show/hide no results message
|
|
||||||
if (visibleCount === 0) {
|
if (visibleCount === 0) {
|
||||||
noResults.style.display = 'block';
|
noResults.style.display = 'block';
|
||||||
} else {
|
} else {
|
||||||
@ -387,16 +345,13 @@ const tools = data.tools;
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle view changes
|
|
||||||
window.addEventListener('viewChanged', (event) => {
|
window.addEventListener('viewChanged', (event) => {
|
||||||
const view = event.detail;
|
const view = event.detail;
|
||||||
switchToView(view);
|
switchToView(view);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Make switchToView available globally
|
|
||||||
window.switchToAIView = () => switchToView('ai');
|
window.switchToAIView = () => switchToView('ai');
|
||||||
|
|
||||||
// Initialize URL handling
|
|
||||||
handleSharedURL();
|
handleSharedURL();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
@ -4,21 +4,17 @@ import { getCollection } from 'astro:content';
|
|||||||
import { getToolsData } from '../utils/dataService.js';
|
import { getToolsData } from '../utils/dataService.js';
|
||||||
import ContributionButton from '../components/ContributionButton.astro';
|
import ContributionButton from '../components/ContributionButton.astro';
|
||||||
|
|
||||||
// Load tools data and knowledgebase articles
|
|
||||||
const data = await getToolsData();
|
const data = await getToolsData();
|
||||||
const allKnowledgebaseEntries = await getCollection('knowledgebase', (entry) => {
|
const allKnowledgebaseEntries = await getCollection('knowledgebase', (entry) => {
|
||||||
// Only include published articles
|
|
||||||
return entry.data.published !== false;
|
return entry.data.published !== false;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create unified knowledgebase entries with optional tool association
|
|
||||||
const knowledgebaseEntries = allKnowledgebaseEntries.map((entry) => {
|
const knowledgebaseEntries = allKnowledgebaseEntries.map((entry) => {
|
||||||
const associatedTool = entry.data.tool_name
|
const associatedTool = entry.data.tool_name
|
||||||
? data.tools.find((tool: any) => tool.name === entry.data.tool_name)
|
? data.tools.find((tool: any) => tool.name === entry.data.tool_name)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Article metadata
|
|
||||||
slug: entry.slug,
|
slug: entry.slug,
|
||||||
title: entry.data.title,
|
title: entry.data.title,
|
||||||
description: entry.data.description,
|
description: entry.data.description,
|
||||||
@ -28,13 +24,11 @@ const knowledgebaseEntries = allKnowledgebaseEntries.map((entry) => {
|
|||||||
categories: entry.data.categories || [],
|
categories: entry.data.categories || [],
|
||||||
tags: entry.data.tags || [],
|
tags: entry.data.tags || [],
|
||||||
|
|
||||||
// Tool association (optional)
|
|
||||||
tool_name: entry.data.tool_name,
|
tool_name: entry.data.tool_name,
|
||||||
related_tools: entry.data.related_tools || [],
|
related_tools: entry.data.related_tools || [],
|
||||||
associatedTool,
|
associatedTool,
|
||||||
|
|
||||||
// Derived properties for consistency with existing UI
|
name: entry.data.title,
|
||||||
name: entry.data.title, // For search compatibility
|
|
||||||
type: associatedTool?.type || 'article',
|
type: associatedTool?.type || 'article',
|
||||||
icon: associatedTool?.icon || '📖',
|
icon: associatedTool?.icon || '📖',
|
||||||
platforms: associatedTool?.platforms || [],
|
platforms: associatedTool?.platforms || [],
|
||||||
@ -44,13 +38,11 @@ const knowledgebaseEntries = allKnowledgebaseEntries.map((entry) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort alphabetically by title
|
|
||||||
knowledgebaseEntries.sort((a: any, b: any) => a.title.localeCompare(b.title));
|
knowledgebaseEntries.sort((a: any, b: any) => a.title.localeCompare(b.title));
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title="Knowledgebase" description="Extended documentation and insights for DFIR tools">
|
<BaseLayout title="Knowledgebase" description="Extended documentation and insights for DFIR tools">
|
||||||
<section style="padding: 2rem 0;">
|
<section style="padding: 2rem 0;">
|
||||||
<!-- Header -->
|
|
||||||
<div style="text-align: center; margin-bottom: 3rem; padding: 2rem; background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%); border-radius: 1rem; border: 1px solid var(--color-border);">
|
<div style="text-align: center; margin-bottom: 3rem; padding: 2rem; background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%); border-radius: 1rem; border: 1px solid var(--color-border);">
|
||||||
<h1 style="margin-bottom: 1rem; font-size: 2.5rem; color: var(--color-primary);">Knowledgebase</h1>
|
<h1 style="margin-bottom: 1rem; font-size: 2.5rem; color: var(--color-primary);">Knowledgebase</h1>
|
||||||
<p style="font-size: 1.25rem; color: var(--color-text-secondary); margin-bottom: 1.125rem;">
|
<p style="font-size: 1.25rem; color: var(--color-text-secondary); margin-bottom: 1.125rem;">
|
||||||
@ -60,7 +52,6 @@ knowledgebaseEntries.sort((a: any, b: any) => a.title.localeCompare(b.title));
|
|||||||
Praktische Erfahrungen, Konfigurationshinweise und Lektionen aus der Praxis
|
Praktische Erfahrungen, Konfigurationshinweise und Lektionen aus der Praxis
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!--contribution button -->
|
|
||||||
<div style="display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap;">
|
<div style="display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap;">
|
||||||
<ContributionButton type="write" variant="primary" text="Artikel schreiben" style="padding: 0.75rem 1.5rem;" />
|
<ContributionButton type="write" variant="primary" text="Artikel schreiben" style="padding: 0.75rem 1.5rem;" />
|
||||||
<a href="#kb-entries" class="btn btn-secondary" style="padding: 0.75rem 1.5rem;">
|
<a href="#kb-entries" class="btn btn-secondary" style="padding: 0.75rem 1.5rem;">
|
||||||
@ -73,7 +64,6 @@ knowledgebaseEntries.sort((a: any, b: any) => a.title.localeCompare(b.title));
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search -->
|
|
||||||
<div style="margin-bottom: 2rem;">
|
<div style="margin-bottom: 2rem;">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -83,14 +73,12 @@ knowledgebaseEntries.sort((a: any, b: any) => a.title.localeCompare(b.title));
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Articles Count -->
|
|
||||||
<div style="text-align: center; margin-bottom: 2rem;">
|
<div style="text-align: center; margin-bottom: 2rem;">
|
||||||
<p class="text-muted" style="font-size: 0.875rem;">
|
<p class="text-muted" style="font-size: 0.875rem;">
|
||||||
<span id="visible-count">{knowledgebaseEntries.length}</span> von {knowledgebaseEntries.length} Einträgen
|
<span id="visible-count">{knowledgebaseEntries.length}</span> von {knowledgebaseEntries.length} Einträgen
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Knowledgebase Entries -->
|
|
||||||
<div style="max-width: 1000px; margin: 0 auto;">
|
<div style="max-width: 1000px; margin: 0 auto;">
|
||||||
{knowledgebaseEntries.length === 0 ? (
|
{knowledgebaseEntries.length === 0 ? (
|
||||||
<div class="card" style="text-align: center; padding: 3rem;">
|
<div class="card" style="text-align: center; padding: 3rem;">
|
||||||
@ -134,7 +122,6 @@ knowledgebaseEntries.sort((a: any, b: any) => a.title.localeCompare(b.title));
|
|||||||
{entry.title}
|
{entry.title}
|
||||||
</h3>
|
</h3>
|
||||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||||
<!-- Type indicator badges -->
|
|
||||||
{isStandalone && <span class="badge" style="background-color: var(--color-accent); color: white;">Artikel</span>}
|
{isStandalone && <span class="badge" style="background-color: var(--color-accent); color: white;">Artikel</span>}
|
||||||
{isConcept && <span class="badge" style="background-color: var(--color-concept); color: white;">Konzept</span>}
|
{isConcept && <span class="badge" style="background-color: var(--color-concept); color: white;">Konzept</span>}
|
||||||
{isMethod && <span class="badge" style="background-color: var(--color-method); color: white;">Methode</span>}
|
{isMethod && <span class="badge" style="background-color: var(--color-method); color: white;">Methode</span>}
|
||||||
@ -143,19 +130,16 @@ knowledgebaseEntries.sort((a: any, b: any) => a.title.localeCompare(b.title));
|
|||||||
{hasValidProjectUrl && <span class="badge badge-primary">CC24-Server</span>}
|
{hasValidProjectUrl && <span class="badge badge-primary">CC24-Server</span>}
|
||||||
{hasAssociatedTool && entry.associatedTool.license !== 'Proprietary' && !isMethod && !isConcept && <span class="badge badge-success">Open Source</span>}
|
{hasAssociatedTool && entry.associatedTool.license !== 'Proprietary' && !isMethod && !isConcept && <span class="badge badge-success">Open Source</span>}
|
||||||
|
|
||||||
<!-- Difficulty indicator -->
|
|
||||||
{entry.difficulty && (
|
{entry.difficulty && (
|
||||||
<span class="badge" style="background-color: var(--color-text-secondary); color: white; font-size: 0.75rem;">
|
<span class="badge" style="background-color: var(--color-text-secondary); color: white; font-size: 0.75rem;">
|
||||||
{entry.difficulty}
|
{entry.difficulty}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<!-- Knowledge Base indicator -->
|
|
||||||
<span class="badge badge-error">📖</span>
|
<span class="badge badge-error">📖</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action buttons -->
|
|
||||||
<div style="display: flex; gap: 0.5rem; align-items: center; flex-shrink: 0;">
|
<div style="display: flex; gap: 0.5rem; align-items: center; flex-shrink: 0;">
|
||||||
<a href={`/knowledgebase/${entry.slug}`} class="btn btn-primary" style="font-size: 0.8125rem;">
|
<a href={`/knowledgebase/${entry.slug}`} class="btn btn-primary" style="font-size: 0.8125rem;">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
|
||||||
@ -166,19 +150,15 @@ knowledgebaseEntries.sort((a: any, b: any) => a.title.localeCompare(b.title));
|
|||||||
Artikel öffnen
|
Artikel öffnen
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Edit button for knowledgebase articles -->
|
|
||||||
<ContributionButton type="edit" toolName={entry.tool_name || entry.title} variant="secondary" text="Edit" style="font-size: 0.8125rem; padding: 0.5rem 0.75rem;" />
|
<ContributionButton type="edit" toolName={entry.tool_name || entry.title} variant="secondary" text="Edit" style="font-size: 0.8125rem; padding: 0.5rem 0.75rem;" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
|
||||||
<p style="margin: 1rem 0; color: var(--color-text-secondary); line-height: 1.5;">
|
<p style="margin: 1rem 0; color: var(--color-text-secondary); line-height: 1.5;">
|
||||||
{entry.description}
|
{entry.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Metadata and Tags -->
|
|
||||||
<div style="display: flex; gap: 1rem; flex-wrap: wrap; align-items: center; margin-top: 1rem;">
|
<div style="display: flex; gap: 1rem; flex-wrap: wrap; align-items: center; margin-top: 1rem;">
|
||||||
<!-- Tags -->
|
|
||||||
{entry.tags && entry.tags.length > 0 && (
|
{entry.tags && entry.tags.length > 0 && (
|
||||||
<div style="display: flex; flex-wrap: wrap; gap: 0.25rem;">
|
<div style="display: flex; flex-wrap: wrap; gap: 0.25rem;">
|
||||||
{entry.tags.map((tag: string) => (
|
{entry.tags.map((tag: string) => (
|
||||||
@ -187,14 +167,12 @@ knowledgebaseEntries.sort((a: any, b: any) => a.title.localeCompare(b.title));
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<!-- Categories -->
|
|
||||||
{entry.categories && entry.categories.length > 0 && (
|
{entry.categories && entry.categories.length > 0 && (
|
||||||
<div style="font-size: 0.8125rem; color: var(--color-text-secondary);">
|
<div style="font-size: 0.8125rem; color: var(--color-text-secondary);">
|
||||||
<strong>Kategorien:</strong> {entry.categories.join(', ')}
|
<strong>Kategorien:</strong> {entry.categories.join(', ')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<!-- Tool-specific metadata (only if associated with tool) -->
|
|
||||||
{hasAssociatedTool && entry.phases && entry.phases.length > 0 && (
|
{hasAssociatedTool && entry.phases && entry.phases.length > 0 && (
|
||||||
<div style="font-size: 0.8125rem; color: var(--color-text-secondary);">
|
<div style="font-size: 0.8125rem; color: var(--color-text-secondary);">
|
||||||
<strong>Phasen:</strong> {entry.phases.join(', ')}
|
<strong>Phasen:</strong> {entry.phases.join(', ')}
|
||||||
@ -207,14 +185,12 @@ knowledgebaseEntries.sort((a: any, b: any) => a.title.localeCompare(b.title));
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<!-- Related tools for standalone articles -->
|
|
||||||
{isStandalone && entry.related_tools && entry.related_tools.length > 0 && (
|
{isStandalone && entry.related_tools && entry.related_tools.length > 0 && (
|
||||||
<div style="font-size: 0.8125rem; color: var(--color-text-secondary);">
|
<div style="font-size: 0.8125rem; color: var(--color-text-secondary);">
|
||||||
<strong>Verwandte Tools:</strong> {entry.related_tools.join(', ')}
|
<strong>Verwandte Tools:</strong> {entry.related_tools.join(', ')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<!-- Author and date -->
|
|
||||||
<div style="font-size: 0.8125rem; color: var(--color-text-secondary); margin-left: auto;">
|
<div style="font-size: 0.8125rem; color: var(--color-text-secondary); margin-left: auto;">
|
||||||
<strong>Autor:</strong> {entry.author} • <strong>Aktualisiert:</strong> {entry.last_updated.toLocaleDateString('de-DE')}
|
<strong>Autor:</strong> {entry.author} • <strong>Aktualisiert:</strong> {entry.last_updated.toLocaleDateString('de-DE')}
|
||||||
</div>
|
</div>
|
||||||
@ -226,14 +202,12 @@ knowledgebaseEntries.sort((a: any, b: any) => a.title.localeCompare(b.title));
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- No Results -->
|
|
||||||
<div id="no-kb-results" class="card" style="text-align: center; padding: 3rem; display: none;">
|
<div id="no-kb-results" class="card" style="text-align: center; padding: 3rem; display: none;">
|
||||||
<h3 style="color: var(--color-text-secondary); margin-bottom: 0.5rem;">Keine Ergebnisse gefunden</h3>
|
<h3 style="color: var(--color-text-secondary); margin-bottom: 0.5rem;">Keine Ergebnisse gefunden</h3>
|
||||||
<p class="text-muted">Versuchen Sie es mit anderen Suchbegriffen.</p>
|
<p class="text-muted">Versuchen Sie es mit anderen Suchbegriffen.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Floating Action Button -->
|
|
||||||
<div id="fab-container" style="position: fixed; bottom: 2rem; right: 2rem; z-index: 100; display: none;">
|
<div id="fab-container" style="position: fixed; bottom: 2rem; right: 2rem; z-index: 100; display: none;">
|
||||||
<ContributionButton
|
<ContributionButton
|
||||||
type="write"
|
type="write"
|
||||||
@ -246,7 +220,6 @@ knowledgebaseEntries.sort((a: any, b: any) => a.title.localeCompare(b.title));
|
|||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Enhanced knowledgebase functionality with search
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const searchInput = document.getElementById('kb-search') as HTMLInputElement | null;
|
const searchInput = document.getElementById('kb-search') as HTMLInputElement | null;
|
||||||
const entries = document.querySelectorAll('.kb-entry') as NodeListOf<HTMLElement>;
|
const entries = document.querySelectorAll('.kb-entry') as NodeListOf<HTMLElement>;
|
||||||
@ -278,13 +251,11 @@ knowledgebaseEntries.sort((a: any, b: any) => a.title.localeCompare(b.title));
|
|||||||
|
|
||||||
updateVisibleCount(visibleEntries);
|
updateVisibleCount(visibleEntries);
|
||||||
|
|
||||||
// Show/hide no results message
|
|
||||||
if (noResults) {
|
if (noResults) {
|
||||||
noResults.style.display = visibleEntries === 0 && searchTerm.length > 0 ? 'block' : 'none';
|
noResults.style.display = visibleEntries === 0 && searchTerm.length > 0 ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search functionality
|
|
||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
searchInput.addEventListener('input', (e) => {
|
searchInput.addEventListener('input', (e) => {
|
||||||
const target = e.target as HTMLInputElement;
|
const target = e.target as HTMLInputElement;
|
||||||
@ -292,17 +263,14 @@ knowledgebaseEntries.sort((a: any, b: any) => a.title.localeCompare(b.title));
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show floating action button on scroll (optional enhancement)
|
|
||||||
let lastScrollY = window.scrollY;
|
let lastScrollY = window.scrollY;
|
||||||
const fabContainer = document.getElementById('fab-container');
|
const fabContainer = document.getElementById('fab-container');
|
||||||
|
|
||||||
window.addEventListener('scroll', () => {
|
window.addEventListener('scroll', () => {
|
||||||
if (fabContainer) {
|
if (fabContainer) {
|
||||||
if (window.scrollY > 200 && window.scrollY < lastScrollY) {
|
if (window.scrollY > 200 && window.scrollY < lastScrollY) {
|
||||||
// Scrolling up and past threshold
|
|
||||||
fabContainer.style.display = 'block';
|
fabContainer.style.display = 'block';
|
||||||
} else {
|
} else {
|
||||||
// Scrolling down or at top
|
|
||||||
fabContainer.style.display = 'none';
|
fabContainer.style.display = 'none';
|
||||||
}
|
}
|
||||||
lastScrollY = window.scrollY;
|
lastScrollY = window.scrollY;
|
||||||
|
@ -3,7 +3,6 @@ import { getCollection } from 'astro:content';
|
|||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
import { getToolsData } from '../../utils/dataService.js';
|
import { getToolsData } from '../../utils/dataService.js';
|
||||||
|
|
||||||
// Prerender these pages at build time
|
|
||||||
export const prerender = true;
|
export const prerender = true;
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
@ -21,33 +20,26 @@ export async function getStaticPaths() {
|
|||||||
|
|
||||||
const { entry }: { entry: any } = Astro.props;
|
const { entry }: { entry: any } = Astro.props;
|
||||||
|
|
||||||
// Render the content
|
|
||||||
const { Content } = await entry.render();
|
const { Content } = await entry.render();
|
||||||
|
|
||||||
// Load tools data to get the tool details
|
|
||||||
const data = await getToolsData();
|
const data = await getToolsData();
|
||||||
|
|
||||||
// UPGRADED: Handle optional tool association
|
|
||||||
const primaryTool = entry.data.tool_name
|
const primaryTool = entry.data.tool_name
|
||||||
? data.tools.find((t: any) => t.name === entry.data.tool_name)
|
? data.tools.find((t: any) => t.name === entry.data.tool_name)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// UPGRADED: Handle multiple related tools
|
|
||||||
const relatedTools = entry.data.related_tools
|
const relatedTools = entry.data.related_tools
|
||||||
? entry.data.related_tools.map((toolName: string) =>
|
? entry.data.related_tools.map((toolName: string) =>
|
||||||
data.tools.find((t: any) => t.name === toolName)
|
data.tools.find((t: any) => t.name === toolName)
|
||||||
).filter(Boolean)
|
).filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// UPGRADED: Use primary tool or first related tool for styling, fallback to generic
|
|
||||||
const displayTool = primaryTool || relatedTools[0];
|
const displayTool = primaryTool || relatedTools[0];
|
||||||
|
|
||||||
// UPGRADED: Don't redirect - show article even without tool association
|
|
||||||
if (!displayTool && !entry.data.tool_name && relatedTools.length === 0) {
|
if (!displayTool && !entry.data.tool_name && relatedTools.length === 0) {
|
||||||
console.log(`Standalone knowledgebase article: ${entry.slug}`);
|
console.log(`Standalone knowledgebase article: ${entry.slug}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine styling based on tool type or fallback to generic
|
|
||||||
const isMethod = displayTool?.type === 'method';
|
const isMethod = displayTool?.type === 'method';
|
||||||
const isConcept = displayTool?.type === 'concept';
|
const isConcept = displayTool?.type === 'concept';
|
||||||
const isStandalone = !displayTool;
|
const isStandalone = !displayTool;
|
||||||
@ -59,7 +51,6 @@ const hasValidProjectUrl = displayTool && displayTool.projectUrl !== undefined &
|
|||||||
|
|
||||||
<BaseLayout title={entry.data.title} description={entry.data.description}>
|
<BaseLayout title={entry.data.title} description={entry.data.description}>
|
||||||
<article style="max-width: 900px; margin: 0 auto;">
|
<article style="max-width: 900px; margin: 0 auto;">
|
||||||
<!-- Header -->
|
|
||||||
<header style="margin-bottom: 2rem; padding: 2rem; background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%); border-radius: 1rem; border: 1px solid var(--color-border);">
|
<header style="margin-bottom: 2rem; padding: 2rem; background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%); border-radius: 1rem; border: 1px solid var(--color-border);">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 1rem;">
|
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 1rem;">
|
||||||
<div style="flex: 1;">
|
<div style="flex: 1;">
|
||||||
@ -73,7 +64,6 @@ const hasValidProjectUrl = displayTool && displayTool.projectUrl !== undefined &
|
|||||||
</div>
|
</div>
|
||||||
<div style="display: flex; flex-direction: column; gap: 0.5rem; align-items: end;">
|
<div style="display: flex; flex-direction: column; gap: 0.5rem; align-items: end;">
|
||||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||||
<!-- UPGRADED: Conditional badges based on tool type or standalone -->
|
|
||||||
{isStandalone ? (
|
{isStandalone ? (
|
||||||
<span class="badge" style="background-color: var(--color-accent); color: white;">Artikel</span>
|
<span class="badge" style="background-color: var(--color-accent); color: white;">Artikel</span>
|
||||||
) : (
|
) : (
|
||||||
@ -90,9 +80,7 @@ const hasValidProjectUrl = displayTool && displayTool.projectUrl !== undefined &
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- UPGRADED: Flexible metadata section -->
|
|
||||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--color-border);">
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--color-border);">
|
||||||
<!-- Difficulty (always shown if present) -->
|
|
||||||
{entry.data.difficulty && (
|
{entry.data.difficulty && (
|
||||||
<div>
|
<div>
|
||||||
<strong style="font-size: 0.875rem; color: var(--color-text-secondary);">Schwierigkeit</strong>
|
<strong style="font-size: 0.875rem; color: var(--color-text-secondary);">Schwierigkeit</strong>
|
||||||
@ -100,19 +88,16 @@ const hasValidProjectUrl = displayTool && displayTool.projectUrl !== undefined &
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<!-- Last Updated (always shown) -->
|
|
||||||
<div>
|
<div>
|
||||||
<strong style="font-size: 0.875rem; color: var(--color-text-secondary);">Letztes Update</strong>
|
<strong style="font-size: 0.875rem; color: var(--color-text-secondary);">Letztes Update</strong>
|
||||||
<p style="margin: 0; font-size: 0.9375rem;">{entry.data.last_updated.toLocaleDateString('de-DE')}</p>
|
<p style="margin: 0; font-size: 0.9375rem;">{entry.data.last_updated.toLocaleDateString('de-DE')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Author (always shown) -->
|
|
||||||
<div>
|
<div>
|
||||||
<strong style="font-size: 0.875rem; color: var(--color-text-secondary);">Autor</strong>
|
<strong style="font-size: 0.875rem; color: var(--color-text-secondary);">Autor</strong>
|
||||||
<p style="margin: 0; font-size: 0.9375rem;">{entry.data.author}</p>
|
<p style="margin: 0; font-size: 0.9375rem;">{entry.data.author}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- UPGRADED: Show article type -->
|
|
||||||
<div>
|
<div>
|
||||||
<strong style="font-size: 0.875rem; color: var(--color-text-secondary);">Typ</strong>
|
<strong style="font-size: 0.875rem; color: var(--color-text-secondary);">Typ</strong>
|
||||||
<p style="margin: 0; font-size: 0.9375rem;">
|
<p style="margin: 0; font-size: 0.9375rem;">
|
||||||
@ -123,7 +108,6 @@ const hasValidProjectUrl = displayTool && displayTool.projectUrl !== undefined &
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- UPGRADED: Categories (if present) -->
|
|
||||||
{entry.data.categories && entry.data.categories.length > 0 && (
|
{entry.data.categories && entry.data.categories.length > 0 && (
|
||||||
<div style="grid-column: 1 / -1;">
|
<div style="grid-column: 1 / -1;">
|
||||||
<strong style="font-size: 0.875rem; color: var(--color-text-secondary);">Kategorien</strong>
|
<strong style="font-size: 0.875rem; color: var(--color-text-secondary);">Kategorien</strong>
|
||||||
@ -137,7 +121,6 @@ const hasValidProjectUrl = displayTool && displayTool.projectUrl !== undefined &
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Navigation -->
|
|
||||||
<nav style="margin-bottom: 2rem; position: relative; z-index: 50;">
|
<nav style="margin-bottom: 2rem; position: relative; z-index: 50;">
|
||||||
<a href="/knowledgebase" class="btn btn-secondary">
|
<a href="/knowledgebase" class="btn btn-secondary">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
|
||||||
@ -147,14 +130,12 @@ const hasValidProjectUrl = displayTool && displayTool.projectUrl !== undefined &
|
|||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<div class="card" style="padding: 2rem;">
|
<div class="card" style="padding: 2rem;">
|
||||||
<div class="kb-content markdown-content" style="line-height: 1.7;">
|
<div class="kb-content markdown-content" style="line-height: 1.7;">
|
||||||
<Content />
|
<Content />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- UPGRADED: Flexible Tool Actions Section -->
|
|
||||||
<div class="card" style="margin-top: 2rem; background-color: var(--color-bg-secondary);">
|
<div class="card" style="margin-top: 2rem; background-color: var(--color-bg-secondary);">
|
||||||
<h3 style="margin: 0 0 1rem 0; color: var(--color-text);">
|
<h3 style="margin: 0 0 1rem 0; color: var(--color-text);">
|
||||||
{isStandalone ? 'Verwandte Aktionen' : 'Tool-Aktionen'}
|
{isStandalone ? 'Verwandte Aktionen' : 'Tool-Aktionen'}
|
||||||
@ -162,7 +143,6 @@ const hasValidProjectUrl = displayTool && displayTool.projectUrl !== undefined &
|
|||||||
|
|
||||||
<div style="display: flex; gap: 1rem; flex-wrap: wrap;">
|
<div style="display: flex; gap: 1rem; flex-wrap: wrap;">
|
||||||
{isStandalone ? (
|
{isStandalone ? (
|
||||||
<!-- UPGRADED: Standalone article actions -->
|
|
||||||
<a href="/knowledgebase" class="btn btn-primary">
|
<a href="/knowledgebase" class="btn btn-primary">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
@ -174,7 +154,6 @@ const hasValidProjectUrl = displayTool && displayTool.projectUrl !== undefined &
|
|||||||
Weitere Artikel
|
Weitere Artikel
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<!-- UPGRADED: Tool-specific actions (existing logic) -->
|
|
||||||
<>
|
<>
|
||||||
{isConcept ? (
|
{isConcept ? (
|
||||||
<a href={displayTool.url} target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="background-color: var(--color-concept); border-color: var(--color-concept);">
|
<a href={displayTool.url} target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="background-color: var(--color-concept); border-color: var(--color-concept);">
|
||||||
@ -219,7 +198,6 @@ const hasValidProjectUrl = displayTool && displayTool.projectUrl !== undefined &
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<!-- UPGRADED: Show related tools if present -->
|
|
||||||
{relatedTools.length > 0 && relatedTools.length > (primaryTool ? 1 : 0) && (
|
{relatedTools.length > 0 && relatedTools.length > (primaryTool ? 1 : 0) && (
|
||||||
<div style="margin-left: auto;">
|
<div style="margin-left: auto;">
|
||||||
<details style="position: relative;">
|
<details style="position: relative;">
|
||||||
@ -247,7 +225,6 @@ const hasValidProjectUrl = displayTool && displayTool.projectUrl !== undefined &
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<!-- Always show return to main page -->
|
|
||||||
<a href="/" class="btn btn-secondary">
|
<a href="/" class="btn btn-secondary">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
|
||||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||||
|
@ -3,10 +3,8 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||||||
import { getToolsData } from '../utils/dataService.js';
|
import { getToolsData } from '../utils/dataService.js';
|
||||||
|
|
||||||
|
|
||||||
// Load tools data to get server-based services
|
|
||||||
const data = await getToolsData();
|
const data = await getToolsData();
|
||||||
|
|
||||||
// Filter for hosted services based on projectUrl presence
|
|
||||||
const hostedServices = data.tools.filter((tool: any) => {
|
const hostedServices = data.tools.filter((tool: any) => {
|
||||||
return tool.projectUrl !== undefined &&
|
return tool.projectUrl !== undefined &&
|
||||||
tool.projectUrl !== null &&
|
tool.projectUrl !== null &&
|
||||||
@ -22,14 +20,12 @@ const hostedServices = data.tools.filter((tool: any) => {
|
|||||||
Live-Monitoring zum Onlinestatus der Dienste.
|
Live-Monitoring zum Onlinestatus der Dienste.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Service Status Grid -->
|
|
||||||
<div class="grid grid-cols-2 gap-4" style="margin-bottom: 3rem;">
|
<div class="grid grid-cols-2 gap-4" style="margin-bottom: 3rem;">
|
||||||
{hostedServices.map((service: any) => (
|
{hostedServices.map((service: any) => (
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||||
<h3 style="margin: 0;">{service.name}</h3>
|
<h3 style="margin: 0;">{service.name}</h3>
|
||||||
<div id={`status-${service.name.toLowerCase().replace(/\s+/g, '-')}`}>
|
<div id={`status-${service.name.toLowerCase().replace(/\s+/g, '-')}`}>
|
||||||
<!-- Status badge will be inserted here -->
|
|
||||||
<span class="badge badge-warning">Loading...</span>
|
<span class="badge badge-warning">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -43,41 +39,28 @@ const hostedServices = data.tools.filter((tool: any) => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Uptime Kuma Embed
|
|
||||||
<div class="card" style="padding: 0; overflow: hidden;">
|
|
||||||
<iframe
|
|
||||||
src="https://status.mikoshi.de/status/cc24-hub?embed=true"
|
|
||||||
style="width: 100%; height: 600px; border: none;"
|
|
||||||
title="Uptime Kuma Status Page"
|
|
||||||
></iframe>
|
|
||||||
</div>-->
|
|
||||||
</section>
|
</section>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
<script define:vars={{ hostedServices }}>
|
<script define:vars={{ hostedServices }}>
|
||||||
// Load status badges for each service
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
hostedServices.forEach(service => {
|
hostedServices.forEach(service => {
|
||||||
const statusElement = document.getElementById(`status-${service.name.toLowerCase().replace(/\s+/g, '-')}`);
|
const statusElement = document.getElementById(`status-${service.name.toLowerCase().replace(/\s+/g, '-')}`);
|
||||||
|
|
||||||
if (statusElement && service.statusUrl) {
|
if (statusElement && service.statusUrl) {
|
||||||
// Create image element
|
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.src = service.statusUrl;
|
img.src = service.statusUrl;
|
||||||
img.alt = `Status for ${service.name}`;
|
img.alt = `Status for ${service.name}`;
|
||||||
img.style.height = '20px';
|
img.style.height = '20px';
|
||||||
img.style.verticalAlign = 'middle';
|
img.style.verticalAlign = 'middle';
|
||||||
|
|
||||||
// Handle load error
|
|
||||||
img.onerror = function() {
|
img.onerror = function() {
|
||||||
statusElement.innerHTML = '<span class="badge badge-warning">Unknown</span>';
|
statusElement.innerHTML = '<span class="badge badge-warning">Unknown</span>';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Replace loading badge with image
|
|
||||||
statusElement.innerHTML = '';
|
statusElement.innerHTML = '';
|
||||||
statusElement.appendChild(img);
|
statusElement.appendChild(img);
|
||||||
} else if (statusElement) {
|
} else if (statusElement) {
|
||||||
// No status URL available
|
|
||||||
statusElement.innerHTML = '<span class="badge badge-warning">No Status</span>';
|
statusElement.innerHTML = '<span class="badge badge-warning">No Status</span>';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -1,14 +1,10 @@
|
|||||||
// src/utils/api.ts
|
// src/utils/api.ts
|
||||||
|
|
||||||
// Standard JSON headers for all API responses
|
|
||||||
const JSON_HEADERS = {
|
const JSON_HEADERS = {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
|
||||||
* Base function to create consistent API responses
|
|
||||||
* All other response helpers use this internally
|
|
||||||
*/
|
|
||||||
export function createAPIResponse(data: any, status: number = 200, additionalHeaders?: Record<string, string>): Response {
|
export function createAPIResponse(data: any, status: number = 200, additionalHeaders?: Record<string, string>): Response {
|
||||||
const headers = additionalHeaders
|
const headers = additionalHeaders
|
||||||
? { ...JSON_HEADERS, ...additionalHeaders }
|
? { ...JSON_HEADERS, ...additionalHeaders }
|
||||||
@ -20,9 +16,6 @@ export function createAPIResponse(data: any, status: number = 200, additionalHea
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Success responses (2xx status codes)
|
|
||||||
*/
|
|
||||||
export const apiResponse = {
|
export const apiResponse = {
|
||||||
// 200 - Success with data
|
// 200 - Success with data
|
||||||
success: (data: any = { success: true }): Response =>
|
success: (data: any = { success: true }): Response =>
|
||||||
@ -37,9 +30,6 @@ export const apiResponse = {
|
|||||||
createAPIResponse(data, 202)
|
createAPIResponse(data, 202)
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Client error responses (4xx status codes)
|
|
||||||
*/
|
|
||||||
export const apiError = {
|
export const apiError = {
|
||||||
// 400 - Bad Request
|
// 400 - Bad Request
|
||||||
badRequest: (message: string = 'Bad request', details?: string[]): Response =>
|
badRequest: (message: string = 'Bad request', details?: string[]): Response =>
|
||||||
@ -74,9 +64,6 @@ export const apiError = {
|
|||||||
createAPIResponse({ success: false, error: message }, 429)
|
createAPIResponse({ success: false, error: message }, 429)
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Server error responses (5xx status codes)
|
|
||||||
*/
|
|
||||||
export const apiServerError = {
|
export const apiServerError = {
|
||||||
// 500 - Internal Server Error
|
// 500 - Internal Server Error
|
||||||
internal: (message: string = 'Internal server error'): Response =>
|
internal: (message: string = 'Internal server error'): Response =>
|
||||||
@ -95,9 +82,6 @@ export const apiServerError = {
|
|||||||
createAPIResponse({ success: false, error: message }, 504)
|
createAPIResponse({ success: false, error: message }, 504)
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Specialized response helpers for common patterns
|
|
||||||
*/
|
|
||||||
export const apiSpecial = {
|
export const apiSpecial = {
|
||||||
// JSON parsing error
|
// JSON parsing error
|
||||||
invalidJSON: (): Response =>
|
invalidJSON: (): Response =>
|
||||||
|
@ -5,17 +5,14 @@ import { config } from 'dotenv';
|
|||||||
import { SignJWT, jwtVerify, type JWTPayload } from 'jose';
|
import { SignJWT, jwtVerify, type JWTPayload } from 'jose';
|
||||||
import { serialize, parse as parseCookie } from 'cookie';
|
import { serialize, parse as parseCookie } from 'cookie';
|
||||||
|
|
||||||
// Load environment variables
|
|
||||||
config();
|
config();
|
||||||
|
|
||||||
// JWT session constants
|
|
||||||
const SECRET_KEY = new TextEncoder().encode(
|
const SECRET_KEY = new TextEncoder().encode(
|
||||||
process.env.AUTH_SECRET ||
|
process.env.AUTH_SECRET ||
|
||||||
'cc24-hub-default-secret-key-change-in-production'
|
'cc24-hub-default-secret-key-change-in-production'
|
||||||
);
|
);
|
||||||
const SESSION_DURATION = 6 * 60 * 60; // 6 hours in seconds
|
const SESSION_DURATION = 6 * 60 * 60; // 6 hours in seconds
|
||||||
|
|
||||||
// Types
|
|
||||||
export interface SessionData {
|
export interface SessionData {
|
||||||
userId: string;
|
userId: string;
|
||||||
email: string;
|
email: string;
|
||||||
@ -45,7 +42,6 @@ export interface AuthStateData {
|
|||||||
returnTo: string;
|
returnTo: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Environment variables - use runtime access for server-side
|
|
||||||
function getEnv(key: string): string {
|
function getEnv(key: string): string {
|
||||||
const value = process.env[key];
|
const value = process.env[key];
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@ -54,7 +50,6 @@ function getEnv(key: string): string {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session management functions
|
|
||||||
export function getSessionFromRequest(request: Request): string | null {
|
export function getSessionFromRequest(request: Request): string | null {
|
||||||
const cookieHeader = request.headers.get('cookie');
|
const cookieHeader = request.headers.get('cookie');
|
||||||
console.log('[DEBUG] Cookie header:', cookieHeader ? 'present' : 'missing');
|
console.log('[DEBUG] Cookie header:', cookieHeader ? 'present' : 'missing');
|
||||||
@ -74,7 +69,6 @@ export async function verifySession(sessionToken: string): Promise<SessionData |
|
|||||||
const { payload } = await jwtVerify(sessionToken, SECRET_KEY);
|
const { payload } = await jwtVerify(sessionToken, SECRET_KEY);
|
||||||
console.log('[DEBUG] JWT verification successful, payload keys:', Object.keys(payload));
|
console.log('[DEBUG] JWT verification successful, payload keys:', Object.keys(payload));
|
||||||
|
|
||||||
// Validate payload structure and cast properly
|
|
||||||
if (
|
if (
|
||||||
typeof payload.userId === 'string' &&
|
typeof payload.userId === 'string' &&
|
||||||
typeof payload.email === 'string' &&
|
typeof payload.email === 'string' &&
|
||||||
@ -133,7 +127,6 @@ export function createSessionCookie(sessionToken: string): string {
|
|||||||
return cookie;
|
return cookie;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authentication utility functions
|
|
||||||
export function getUserEmail(userInfo: UserInfo): string {
|
export function getUserEmail(userInfo: UserInfo): string {
|
||||||
return userInfo.email || userInfo.preferred_username || 'unknown@example.com';
|
return userInfo.email || userInfo.preferred_username || 'unknown@example.com';
|
||||||
}
|
}
|
||||||
@ -143,12 +136,10 @@ export function logAuthEvent(event: string, details?: any): void {
|
|||||||
console.log(`[AUTH ${timestamp}] ${event}`, details ? JSON.stringify(details) : '');
|
console.log(`[AUTH ${timestamp}] ${event}`, details ? JSON.stringify(details) : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate random state for CSRF protection
|
|
||||||
export function generateState(): string {
|
export function generateState(): string {
|
||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate OIDC authorization URL
|
|
||||||
export function generateAuthUrl(state: string): string {
|
export function generateAuthUrl(state: string): string {
|
||||||
const oidcEndpoint = getEnv('OIDC_ENDPOINT');
|
const oidcEndpoint = getEnv('OIDC_ENDPOINT');
|
||||||
const clientId = getEnv('OIDC_CLIENT_ID');
|
const clientId = getEnv('OIDC_CLIENT_ID');
|
||||||
@ -165,7 +156,6 @@ export function generateAuthUrl(state: string): string {
|
|||||||
return `${oidcEndpoint}/apps/oidc/authorize?${params.toString()}`;
|
return `${oidcEndpoint}/apps/oidc/authorize?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exchange authorization code for tokens
|
|
||||||
export async function exchangeCodeForTokens(code: string): Promise<{ access_token: string }> {
|
export async function exchangeCodeForTokens(code: string): Promise<{ access_token: string }> {
|
||||||
const oidcEndpoint = getEnv('OIDC_ENDPOINT');
|
const oidcEndpoint = getEnv('OIDC_ENDPOINT');
|
||||||
const clientId = getEnv('OIDC_CLIENT_ID');
|
const clientId = getEnv('OIDC_CLIENT_ID');
|
||||||
@ -194,7 +184,6 @@ export async function exchangeCodeForTokens(code: string): Promise<{ access_toke
|
|||||||
return await response.json();
|
return await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user info from OIDC provider
|
|
||||||
export async function getUserInfo(accessToken: string): Promise<UserInfo> {
|
export async function getUserInfo(accessToken: string): Promise<UserInfo> {
|
||||||
const oidcEndpoint = getEnv('OIDC_ENDPOINT');
|
const oidcEndpoint = getEnv('OIDC_ENDPOINT');
|
||||||
|
|
||||||
@ -213,7 +202,6 @@ export async function getUserInfo(accessToken: string): Promise<UserInfo> {
|
|||||||
return await response.json();
|
return await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse and validate auth state from cookies
|
|
||||||
export function parseAuthState(request: Request): {
|
export function parseAuthState(request: Request): {
|
||||||
isValid: boolean;
|
isValid: boolean;
|
||||||
stateData: AuthStateData | null;
|
stateData: AuthStateData | null;
|
||||||
@ -239,7 +227,6 @@ export function parseAuthState(request: Request): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify state parameter against stored state
|
|
||||||
export function verifyAuthState(request: Request, receivedState: string): {
|
export function verifyAuthState(request: Request, receivedState: string): {
|
||||||
isValid: boolean;
|
isValid: boolean;
|
||||||
stateData: AuthStateData | null;
|
stateData: AuthStateData | null;
|
||||||
|
@ -6,7 +6,7 @@ import { z } from 'zod';
|
|||||||
const ToolSchema = z.object({
|
const ToolSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
icon: z.string().optional().nullable(),
|
icon: z.string().optional().nullable(),
|
||||||
type: z.enum(['software', 'method', 'concept']), // Make this more explicit
|
type: z.enum(['software', 'method', 'concept']),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
domains: z.array(z.string()).optional().nullable().default([]),
|
domains: z.array(z.string()).optional().nullable().default([]),
|
||||||
phases: z.array(z.string()).optional().nullable().default([]),
|
phases: z.array(z.string()).optional().nullable().default([]),
|
||||||
@ -15,7 +15,6 @@ const ToolSchema = z.object({
|
|||||||
url: z.string(),
|
url: z.string(),
|
||||||
license: z.string().optional().nullable(),
|
license: z.string().optional().nullable(),
|
||||||
tags: z.array(z.string()).default([]),
|
tags: z.array(z.string()).default([]),
|
||||||
// Optional fields that can be null, undefined, or empty
|
|
||||||
projectUrl: z.string().optional().nullable(),
|
projectUrl: z.string().optional().nullable(),
|
||||||
knowledgebase: z.boolean().optional().nullable(),
|
knowledgebase: z.boolean().optional().nullable(),
|
||||||
statusUrl: z.string().optional().nullable(),
|
statusUrl: z.string().optional().nullable(),
|
||||||
@ -51,7 +50,7 @@ interface ToolsData {
|
|||||||
|
|
||||||
interface CompressedToolsData extends Omit<ToolsData, 'tools'> {
|
interface CompressedToolsData extends Omit<ToolsData, 'tools'> {
|
||||||
tools: any[];
|
tools: any[];
|
||||||
concepts: any[]; // NEW: Add concepts for AI background knowledge
|
concepts: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let cachedData: ToolsData | null = null;
|
let cachedData: ToolsData | null = null;
|
||||||
@ -59,7 +58,6 @@ let cachedRandomizedData: ToolsData | null = null;
|
|||||||
let cachedCompressedData: CompressedToolsData | null = null;
|
let cachedCompressedData: CompressedToolsData | null = null;
|
||||||
let lastRandomizationDate: string | null = null;
|
let lastRandomizationDate: string | null = null;
|
||||||
|
|
||||||
// Create a seeded random number generator
|
|
||||||
function seededRandom(seed: number): () => number {
|
function seededRandom(seed: number): () => number {
|
||||||
let x = Math.sin(seed) * 10000;
|
let x = Math.sin(seed) * 10000;
|
||||||
return function() {
|
return function() {
|
||||||
@ -68,14 +66,12 @@ function seededRandom(seed: number): () => number {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get today's date as seed + process start time for consistency within day/session
|
|
||||||
function getDailySeed(): number {
|
function getDailySeed(): number {
|
||||||
const today = new Date().toDateString();
|
const today = new Date().toDateString();
|
||||||
const processStart = process.uptime();
|
const processStart = process.uptime();
|
||||||
return today.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) + Math.floor(processStart);
|
return today.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) + Math.floor(processStart);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fisher-Yates shuffle with seeded random
|
|
||||||
function shuffleArray<T>(array: T[], randomFn: () => number): T[] {
|
function shuffleArray<T>(array: T[], randomFn: () => number): T[] {
|
||||||
const shuffled = [...array];
|
const shuffled = [...array];
|
||||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||||
@ -85,7 +81,6 @@ function shuffleArray<T>(array: T[], randomFn: () => number): T[] {
|
|||||||
return shuffled;
|
return shuffled;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load raw data from YAML
|
|
||||||
async function loadRawData(): Promise<ToolsData> {
|
async function loadRawData(): Promise<ToolsData> {
|
||||||
if (!cachedData) {
|
if (!cachedData) {
|
||||||
const yamlPath = path.join(process.cwd(), 'src/data/tools.yaml');
|
const yamlPath = path.join(process.cwd(), 'src/data/tools.yaml');
|
||||||
@ -102,17 +97,14 @@ async function loadRawData(): Promise<ToolsData> {
|
|||||||
return cachedData;
|
return cachedData;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get tools data with randomized tool order (daily seed)
|
|
||||||
export async function getToolsData(): Promise<ToolsData> {
|
export async function getToolsData(): Promise<ToolsData> {
|
||||||
const today = new Date().toDateString();
|
const today = new Date().toDateString();
|
||||||
|
|
||||||
// Check if we need to re-randomize (new day or first load)
|
|
||||||
if (!cachedRandomizedData || lastRandomizationDate !== today) {
|
if (!cachedRandomizedData || lastRandomizationDate !== today) {
|
||||||
const rawData = await loadRawData();
|
const rawData = await loadRawData();
|
||||||
const seed = getDailySeed();
|
const seed = getDailySeed();
|
||||||
const randomFn = seededRandom(seed);
|
const randomFn = seededRandom(seed);
|
||||||
|
|
||||||
// Randomize tools array while keeping other data intact
|
|
||||||
const randomizedTools = shuffleArray(rawData.tools, randomFn);
|
const randomizedTools = shuffleArray(rawData.tools, randomFn);
|
||||||
|
|
||||||
cachedRandomizedData = {
|
cachedRandomizedData = {
|
||||||
@ -122,27 +114,23 @@ export async function getToolsData(): Promise<ToolsData> {
|
|||||||
|
|
||||||
lastRandomizationDate = today;
|
lastRandomizationDate = today;
|
||||||
|
|
||||||
// Clear compressed cache when we re-randomize
|
|
||||||
cachedCompressedData = null;
|
cachedCompressedData = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return cachedRandomizedData;
|
return cachedRandomizedData;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get compressed data for AI (excludes concepts from tools, but includes them separately for background knowledge)
|
|
||||||
export async function getCompressedToolsDataForAI(): Promise<CompressedToolsData> {
|
export async function getCompressedToolsDataForAI(): Promise<CompressedToolsData> {
|
||||||
if (!cachedCompressedData) {
|
if (!cachedCompressedData) {
|
||||||
const data = await getToolsData();
|
const data = await getToolsData();
|
||||||
|
|
||||||
// Separate tools and concepts
|
|
||||||
const compressedTools = data.tools
|
const compressedTools = data.tools
|
||||||
.filter(tool => tool.type !== 'concept') // Exclude concepts from tool recommendations
|
.filter(tool => tool.type !== 'concept')
|
||||||
.map(tool => {
|
.map(tool => {
|
||||||
const { projectUrl, statusUrl, ...compressedTool } = tool;
|
const { projectUrl, statusUrl, ...compressedTool } = tool;
|
||||||
return compressedTool;
|
return compressedTool;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Extract concepts for background knowledge - keep essential fields only
|
|
||||||
const concepts = data.tools
|
const concepts = data.tools
|
||||||
.filter(tool => tool.type === 'concept')
|
.filter(tool => tool.type === 'concept')
|
||||||
.map(concept => {
|
.map(concept => {
|
||||||
@ -162,7 +150,6 @@ export async function getCompressedToolsDataForAI(): Promise<CompressedToolsData
|
|||||||
return cachedCompressedData;
|
return cachedCompressedData;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force cache refresh (useful for development)
|
|
||||||
export function clearCache(): void {
|
export function clearCache(): void {
|
||||||
cachedData = null;
|
cachedData = null;
|
||||||
cachedRandomizedData = null;
|
cachedRandomizedData = null;
|
||||||
|
@ -115,7 +115,6 @@ export class GitContributionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private generateYAML(tool: any): string {
|
private generateYAML(tool: any): string {
|
||||||
// Clean tool object
|
|
||||||
const cleanTool: any = {
|
const cleanTool: any = {
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
type: tool.type,
|
type: tool.type,
|
||||||
@ -126,7 +125,6 @@ export class GitContributionManager {
|
|||||||
url: tool.url
|
url: tool.url
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add optional fields
|
|
||||||
if (tool.icon) cleanTool.icon = tool.icon;
|
if (tool.icon) cleanTool.icon = tool.icon;
|
||||||
if (tool.platforms?.length) cleanTool.platforms = tool.platforms;
|
if (tool.platforms?.length) cleanTool.platforms = tool.platforms;
|
||||||
if (tool.license) cleanTool.license = tool.license;
|
if (tool.license) cleanTool.license = tool.license;
|
||||||
@ -191,7 +189,6 @@ export class GitContributionManager {
|
|||||||
|
|
||||||
const issueData = await response.json();
|
const issueData = await response.json();
|
||||||
|
|
||||||
// Extract issue URL
|
|
||||||
switch (this.config.provider) {
|
switch (this.config.provider) {
|
||||||
case 'gitea':
|
case 'gitea':
|
||||||
case 'github':
|
case 'github':
|
||||||
@ -246,7 +243,6 @@ export class GitContributionManager {
|
|||||||
|
|
||||||
const issueData = await response.json();
|
const issueData = await response.json();
|
||||||
|
|
||||||
// Extract issue URL
|
|
||||||
switch (this.config.provider) {
|
switch (this.config.provider) {
|
||||||
case 'gitea':
|
case 'gitea':
|
||||||
case 'github':
|
case 'github':
|
||||||
|
@ -28,7 +28,7 @@ interface FileValidation {
|
|||||||
export class NextcloudUploader {
|
export class NextcloudUploader {
|
||||||
private config: NextcloudConfig;
|
private config: NextcloudConfig;
|
||||||
private allowedTypes: Set<string>;
|
private allowedTypes: Set<string>;
|
||||||
private maxFileSize: number; // in bytes
|
private maxFileSize: number;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.config = {
|
this.config = {
|
||||||
@ -39,13 +39,9 @@ export class NextcloudUploader {
|
|||||||
publicBaseUrl: process.env.NEXTCLOUD_PUBLIC_URL || ''
|
publicBaseUrl: process.env.NEXTCLOUD_PUBLIC_URL || ''
|
||||||
};
|
};
|
||||||
|
|
||||||
// Allowed file types for knowledge base
|
|
||||||
this.allowedTypes = new Set([
|
this.allowedTypes = new Set([
|
||||||
// Images
|
|
||||||
'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
|
'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
|
||||||
// Videos
|
|
||||||
'video/mp4', 'video/webm', 'video/ogg', 'video/avi', 'video/mov',
|
'video/mp4', 'video/webm', 'video/ogg', 'video/avi', 'video/mov',
|
||||||
// Documents
|
|
||||||
'application/pdf',
|
'application/pdf',
|
||||||
'application/msword',
|
'application/msword',
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
@ -53,18 +49,13 @@ export class NextcloudUploader {
|
|||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
'application/vnd.ms-powerpoint',
|
'application/vnd.ms-powerpoint',
|
||||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
// Text files
|
|
||||||
'text/plain', 'text/csv', 'application/json',
|
'text/plain', 'text/csv', 'application/json',
|
||||||
// Archives (for tool downloads)
|
|
||||||
'application/zip', 'application/x-tar', 'application/gzip'
|
'application/zip', 'application/x-tar', 'application/gzip'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
this.maxFileSize = 50 * 1024 * 1024; // 50MB
|
this.maxFileSize = 50 * 1024 * 1024; // 50MB
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if Nextcloud upload is properly configured
|
|
||||||
*/
|
|
||||||
isConfigured(): boolean {
|
isConfigured(): boolean {
|
||||||
return !!(this.config.endpoint &&
|
return !!(this.config.endpoint &&
|
||||||
this.config.username &&
|
this.config.username &&
|
||||||
@ -72,11 +63,7 @@ export class NextcloudUploader {
|
|||||||
this.config.publicBaseUrl);
|
this.config.publicBaseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate file before upload
|
|
||||||
*/
|
|
||||||
private validateFile(file: File): FileValidation {
|
private validateFile(file: File): FileValidation {
|
||||||
// Check file size
|
|
||||||
if (file.size > this.maxFileSize) {
|
if (file.size > this.maxFileSize) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
@ -84,7 +71,6 @@ export class NextcloudUploader {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check file type
|
|
||||||
if (!this.allowedTypes.has(file.type)) {
|
if (!this.allowedTypes.has(file.type)) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
@ -92,7 +78,6 @@ export class NextcloudUploader {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanitize filename
|
|
||||||
const sanitizedName = this.sanitizeFilename(file.name);
|
const sanitizedName = this.sanitizeFilename(file.name);
|
||||||
if (!sanitizedName) {
|
if (!sanitizedName) {
|
||||||
return {
|
return {
|
||||||
@ -107,18 +92,13 @@ export class NextcloudUploader {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize filename for safe storage
|
|
||||||
*/
|
|
||||||
private sanitizeFilename(filename: string): string {
|
private sanitizeFilename(filename: string): string {
|
||||||
// Remove or replace unsafe characters
|
|
||||||
const sanitized = filename
|
const sanitized = filename
|
||||||
.replace(/[^a-zA-Z0-9._-]/g, '_') // Replace unsafe chars with underscore
|
.replace(/[^a-zA-Z0-9._-]/g, '_') // Replace unsafe chars with underscore
|
||||||
.replace(/_{2,}/g, '_') // Replace multiple underscores with single
|
.replace(/_{2,}/g, '_') // Replace multiple underscores with single
|
||||||
.replace(/^_|_$/g, '') // Remove leading/trailing underscores
|
.replace(/^_|_$/g, '') // Remove leading/trailing underscores
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
|
|
||||||
// Ensure reasonable length
|
|
||||||
if (sanitized.length > 100) {
|
if (sanitized.length > 100) {
|
||||||
const ext = path.extname(sanitized);
|
const ext = path.extname(sanitized);
|
||||||
const base = path.basename(sanitized, ext).substring(0, 90);
|
const base = path.basename(sanitized, ext).substring(0, 90);
|
||||||
@ -128,9 +108,6 @@ export class NextcloudUploader {
|
|||||||
return sanitized;
|
return sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate unique filename to prevent conflicts
|
|
||||||
*/
|
|
||||||
private generateUniqueFilename(originalName: string): string {
|
private generateUniqueFilename(originalName: string): string {
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const randomId = crypto.randomBytes(4).toString('hex');
|
const randomId = crypto.randomBytes(4).toString('hex');
|
||||||
@ -140,9 +117,6 @@ export class NextcloudUploader {
|
|||||||
return `${timestamp}_${randomId}_${base}${ext}`;
|
return `${timestamp}_${randomId}_${base}${ext}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload file to Nextcloud
|
|
||||||
*/
|
|
||||||
async uploadFile(file: File, category: string = 'general'): Promise<UploadResult> {
|
async uploadFile(file: File, category: string = 'general'): Promise<UploadResult> {
|
||||||
try {
|
try {
|
||||||
if (!this.isConfigured()) {
|
if (!this.isConfigured()) {
|
||||||
@ -152,7 +126,6 @@ export class NextcloudUploader {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate file
|
|
||||||
const validation = this.validateFile(file);
|
const validation = this.validateFile(file);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return {
|
return {
|
||||||
@ -161,22 +134,17 @@ export class NextcloudUploader {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate unique filename
|
|
||||||
const uniqueFilename = this.generateUniqueFilename(validation.sanitizedName!);
|
const uniqueFilename = this.generateUniqueFilename(validation.sanitizedName!);
|
||||||
|
|
||||||
// Create category-based path
|
|
||||||
const categoryPath = this.sanitizeFilename(category);
|
const categoryPath = this.sanitizeFilename(category);
|
||||||
const remotePath = `${this.config.uploadPath}/${categoryPath}/${uniqueFilename}`;
|
const remotePath = `${this.config.uploadPath}/${categoryPath}/${uniqueFilename}`;
|
||||||
|
|
||||||
// **FIX: Ensure directory exists before upload**
|
|
||||||
const dirPath = `${this.config.uploadPath}/${categoryPath}`;
|
const dirPath = `${this.config.uploadPath}/${categoryPath}`;
|
||||||
await this.ensureDirectoryExists(dirPath);
|
await this.ensureDirectoryExists(dirPath);
|
||||||
|
|
||||||
// Convert file to buffer
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
// Upload to Nextcloud via WebDAV
|
|
||||||
const uploadUrl = `${this.config.endpoint}/remote.php/dav/files/${this.config.username}${remotePath}`;
|
const uploadUrl = `${this.config.endpoint}/remote.php/dav/files/${this.config.username}${remotePath}`;
|
||||||
|
|
||||||
const response = await fetch(uploadUrl, {
|
const response = await fetch(uploadUrl, {
|
||||||
@ -193,7 +161,6 @@ export class NextcloudUploader {
|
|||||||
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
|
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate public URL
|
|
||||||
const publicUrl = await this.createPublicLink(remotePath);
|
const publicUrl = await this.createPublicLink(remotePath);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -214,7 +181,6 @@ export class NextcloudUploader {
|
|||||||
|
|
||||||
private async ensureDirectoryExists(dirPath: string): Promise<void> {
|
private async ensureDirectoryExists(dirPath: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Split path and create each directory level
|
|
||||||
const parts = dirPath.split('/').filter(part => part);
|
const parts = dirPath.split('/').filter(part => part);
|
||||||
let currentPath = '';
|
let currentPath = '';
|
||||||
|
|
||||||
@ -230,7 +196,7 @@ export class NextcloudUploader {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 201 = created, 405 = already exists, both are fine
|
// 201 = created, 405 = already exists
|
||||||
if (response.status !== 201 && response.status !== 405) {
|
if (response.status !== 201 && response.status !== 405) {
|
||||||
console.warn(`Directory creation failed: ${response.status} for ${currentPath}`);
|
console.warn(`Directory creation failed: ${response.status} for ${currentPath}`);
|
||||||
}
|
}
|
||||||
@ -238,22 +204,17 @@ export class NextcloudUploader {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to ensure directory exists:', error);
|
console.warn('Failed to ensure directory exists:', error);
|
||||||
// Don't fail upload for directory creation issues
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a public share link for the uploaded file
|
|
||||||
*/
|
|
||||||
private async createPublicLink(remotePath: string): Promise<string> {
|
private async createPublicLink(remotePath: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
// Use Nextcloud's share API to create public link
|
|
||||||
const shareUrl = `${this.config.endpoint}/ocs/v2.php/apps/files_sharing/api/v1/shares`;
|
const shareUrl = `${this.config.endpoint}/ocs/v2.php/apps/files_sharing/api/v1/shares`;
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('path', remotePath);
|
formData.append('path', remotePath);
|
||||||
formData.append('shareType', '3'); // Public link
|
formData.append('shareType', '3');
|
||||||
formData.append('permissions', '1'); // Read only
|
formData.append('permissions', '1');
|
||||||
|
|
||||||
const response = await fetch(shareUrl, {
|
const response = await fetch(shareUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -269,25 +230,19 @@ export class NextcloudUploader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
// Parse XML response to extract share URL
|
|
||||||
const urlMatch = text.match(/<url>(.*?)<\/url>/);
|
const urlMatch = text.match(/<url>(.*?)<\/url>/);
|
||||||
if (urlMatch) {
|
if (urlMatch) {
|
||||||
return urlMatch[1];
|
return urlMatch[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to direct URL construction
|
|
||||||
return `${this.config.publicBaseUrl}${remotePath}`;
|
return `${this.config.publicBaseUrl}${remotePath}`;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to create public link, using direct URL:', error);
|
console.warn('Failed to create public link, using direct URL:', error);
|
||||||
// Fallback to direct URL
|
|
||||||
return `${this.config.publicBaseUrl}${remotePath}`;
|
return `${this.config.publicBaseUrl}${remotePath}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete file from Nextcloud
|
|
||||||
*/
|
|
||||||
async deleteFile(remotePath: string): Promise<{ success: boolean; error?: string }> {
|
async deleteFile(remotePath: string): Promise<{ success: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
if (!this.isConfigured()) {
|
if (!this.isConfigured()) {
|
||||||
@ -318,9 +273,6 @@ export class NextcloudUploader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check Nextcloud connectivity and authentication
|
|
||||||
*/
|
|
||||||
async testConnection(): Promise<{ success: boolean; error?: string; details?: any }> {
|
async testConnection(): Promise<{ success: boolean; error?: string; details?: any }> {
|
||||||
try {
|
try {
|
||||||
if (!this.isConfigured()) {
|
if (!this.isConfigured()) {
|
||||||
@ -336,7 +288,6 @@ export class NextcloudUploader {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test with a simple WebDAV request
|
|
||||||
const testUrl = `${this.config.endpoint}/remote.php/dav/files/${this.config.username}/`;
|
const testUrl = `${this.config.endpoint}/remote.php/dav/files/${this.config.username}/`;
|
||||||
|
|
||||||
const response = await fetch(testUrl, {
|
const response = await fetch(testUrl, {
|
||||||
@ -368,9 +319,6 @@ export class NextcloudUploader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get file information from Nextcloud
|
|
||||||
*/
|
|
||||||
async getFileInfo(remotePath: string): Promise<{ success: boolean; info?: any; error?: string }> {
|
async getFileInfo(remotePath: string): Promise<{ success: boolean; info?: any; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const propfindUrl = `${this.config.endpoint}/remote.php/dav/files/${this.config.username}${remotePath}`;
|
const propfindUrl = `${this.config.endpoint}/remote.php/dav/files/${this.config.username}${remotePath}`;
|
||||||
@ -385,7 +333,6 @@ export class NextcloudUploader {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
// Parse basic file info from WebDAV response
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
info: {
|
info: {
|
||||||
@ -417,7 +364,6 @@ export class NextcloudUploader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convenience functions for easy usage
|
|
||||||
export async function uploadToNextcloud(file: File, category: string = 'general'): Promise<UploadResult> {
|
export async function uploadToNextcloud(file: File, category: string = 'general'): Promise<UploadResult> {
|
||||||
const uploader = new NextcloudUploader();
|
const uploader = new NextcloudUploader();
|
||||||
return await uploader.uploadFile(file, category);
|
return await uploader.uploadFile(file, category);
|
||||||
|
@ -1,21 +1,11 @@
|
|||||||
// src/utils/rateLimitedQueue.ts
|
// src/utils/rateLimitedQueue.ts
|
||||||
// ------------------------------------------------------------
|
|
||||||
// Enhanced FIFO queue with status tracking for visual feedback
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
|
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
/**
|
|
||||||
* Delay (in **milliseconds**) between two consecutive API calls.
|
|
||||||
* Defaults to **2000 ms** (2 seconds) when not set or invalid.
|
|
||||||
*/
|
|
||||||
const RATE_LIMIT_DELAY_MS = Number.parseInt(process.env.AI_RATE_LIMIT_DELAY_MS ?? "2000", 10) || 2000;
|
const RATE_LIMIT_DELAY_MS = Number.parseInt(process.env.AI_RATE_LIMIT_DELAY_MS ?? "2000", 10) || 2000;
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal task type with ID tracking for status updates
|
|
||||||
*/
|
|
||||||
export type Task<T = unknown> = () => Promise<T>;
|
export type Task<T = unknown> = () => Promise<T>;
|
||||||
|
|
||||||
interface QueuedTask {
|
interface QueuedTask {
|
||||||
@ -28,7 +18,7 @@ export interface QueueStatus {
|
|||||||
queueLength: number;
|
queueLength: number;
|
||||||
isProcessing: boolean;
|
isProcessing: boolean;
|
||||||
estimatedWaitTime: number; // in milliseconds
|
estimatedWaitTime: number; // in milliseconds
|
||||||
currentPosition?: number; // position of specific request
|
currentPosition?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
class RateLimitedQueue {
|
class RateLimitedQueue {
|
||||||
@ -37,10 +27,6 @@ class RateLimitedQueue {
|
|||||||
private delayMs = RATE_LIMIT_DELAY_MS;
|
private delayMs = RATE_LIMIT_DELAY_MS;
|
||||||
private lastProcessedAt = 0;
|
private lastProcessedAt = 0;
|
||||||
|
|
||||||
/**
|
|
||||||
* Schedule a task with ID tracking. Returns a Promise that resolves/rejects
|
|
||||||
* with the task result once the queue reaches it.
|
|
||||||
*/
|
|
||||||
add<T>(task: Task<T>, taskId?: string): Promise<T> {
|
add<T>(task: Task<T>, taskId?: string): Promise<T> {
|
||||||
const id = taskId || this.generateTaskId();
|
const id = taskId || this.generateTaskId();
|
||||||
|
|
||||||
@ -61,23 +47,17 @@ class RateLimitedQueue {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current queue status for visual feedback
|
|
||||||
*/
|
|
||||||
getStatus(taskId?: string): QueueStatus {
|
getStatus(taskId?: string): QueueStatus {
|
||||||
const queueLength = this.queue.length;
|
const queueLength = this.queue.length;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// Calculate estimated wait time
|
|
||||||
let estimatedWaitTime = 0;
|
let estimatedWaitTime = 0;
|
||||||
if (queueLength > 0) {
|
if (queueLength > 0) {
|
||||||
if (this.processing) {
|
if (this.processing) {
|
||||||
// Time since last request + remaining delay + queue length * delay
|
|
||||||
const timeSinceLastRequest = now - this.lastProcessedAt;
|
const timeSinceLastRequest = now - this.lastProcessedAt;
|
||||||
const remainingDelay = Math.max(0, this.delayMs - timeSinceLastRequest);
|
const remainingDelay = Math.max(0, this.delayMs - timeSinceLastRequest);
|
||||||
estimatedWaitTime = remainingDelay + (queueLength - 1) * this.delayMs;
|
estimatedWaitTime = remainingDelay + (queueLength - 1) * this.delayMs;
|
||||||
} else {
|
} else {
|
||||||
// Queue will start immediately, so just queue length * delay
|
|
||||||
estimatedWaitTime = queueLength * this.delayMs;
|
estimatedWaitTime = queueLength * this.delayMs;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -88,35 +68,25 @@ class RateLimitedQueue {
|
|||||||
estimatedWaitTime
|
estimatedWaitTime
|
||||||
};
|
};
|
||||||
|
|
||||||
// Find position of specific task if ID provided
|
|
||||||
if (taskId) {
|
if (taskId) {
|
||||||
const position = this.queue.findIndex(item => item.id === taskId);
|
const position = this.queue.findIndex(item => item.id === taskId);
|
||||||
if (position >= 0) {
|
if (position >= 0) {
|
||||||
status.currentPosition = position + 1; // 1-based indexing for user display
|
status.currentPosition = position + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Change the delay at runtime
|
|
||||||
*/
|
|
||||||
setDelay(ms: number): void {
|
setDelay(ms: number): void {
|
||||||
if (!Number.isFinite(ms) || ms < 0) return;
|
if (!Number.isFinite(ms) || ms < 0) return;
|
||||||
this.delayMs = ms;
|
this.delayMs = ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current delay setting
|
|
||||||
*/
|
|
||||||
getDelay(): number {
|
getDelay(): number {
|
||||||
return this.delayMs;
|
return this.delayMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------
|
|
||||||
// Internal helpers
|
|
||||||
// ---------------------------------------
|
|
||||||
private async process(): Promise<void> {
|
private async process(): Promise<void> {
|
||||||
if (this.processing) return;
|
if (this.processing) return;
|
||||||
this.processing = true;
|
this.processing = true;
|
||||||
@ -128,7 +98,6 @@ class RateLimitedQueue {
|
|||||||
this.lastProcessedAt = Date.now();
|
this.lastProcessedAt = Date.now();
|
||||||
await next.task();
|
await next.task();
|
||||||
|
|
||||||
// Wait before the next one (only if there are more tasks)
|
|
||||||
if (this.queue.length > 0) {
|
if (this.queue.length > 0) {
|
||||||
await new Promise((r) => setTimeout(r, this.delayMs));
|
await new Promise((r) => setTimeout(r, this.delayMs));
|
||||||
}
|
}
|
||||||
@ -142,21 +111,12 @@ class RateLimitedQueue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
// Export singleton instance and convenience functions
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
const queue = new RateLimitedQueue();
|
const queue = new RateLimitedQueue();
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper for convenience: `enqueueApiCall(() => fetch(...), 'optional-id')`.
|
|
||||||
*/
|
|
||||||
export function enqueueApiCall<T>(task: Task<T>, taskId?: string): Promise<T> {
|
export function enqueueApiCall<T>(task: Task<T>, taskId?: string): Promise<T> {
|
||||||
return queue.add(task, taskId);
|
return queue.add(task, taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current queue status for visual feedback
|
|
||||||
*/
|
|
||||||
export function getQueueStatus(taskId?: string): QueueStatus {
|
export function getQueueStatus(taskId?: string): QueueStatus {
|
||||||
return queue.getStatus(taskId);
|
return queue.getStatus(taskId);
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user