finalize knowledgebase commit

This commit is contained in:
overcuriousity
2025-07-25 23:23:38 +02:00
parent 6892aaf7de
commit fbe8d4d4e1
5 changed files with 521 additions and 849 deletions

View File

@@ -1,5 +1,5 @@
---
// src/pages/contribute/knowledgebase.astro
// src/pages/contribute/knowledgebase.astro - SIMPLIFIED: Issues only, minimal validation
import BaseLayout from '../../layouts/BaseLayout.astro';
import { withAuth } from '../../utils/auth.js';
import { getToolsData } from '../../utils/dataService.js';
@@ -9,39 +9,60 @@ export const prerender = false;
// Check authentication
const authResult = await withAuth(Astro);
if (authResult instanceof Response) {
return authResult; // Redirect to login
return authResult;
}
const { authenticated, userEmail, userId } = authResult;
// Load tools for reference (optional dropdown)
const data = await getToolsData();
const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.name));
---
<BaseLayout title="Contribute Knowledge Base Article" description="Submit articles and documentation for the CC24-Hub knowledge base">
<main>
<div class="container">
<div class="page-header">
<h1>Submit Knowledge Base Article</h1>
<p>Upload documents, provide links, or submit articles for review by maintainers.</p>
</div>
<BaseLayout title="Contribute Knowledge Base Article">
<div class="container" style="max-width: 900px; margin: 0 auto; padding: 2rem 1rem;">
<!-- Header -->
<div class="hero-section">
<h1>Submit Knowledge Base Article</h1>
<p>Share documentation, tutorials, or insights about DFIR tools and methods. Your contribution will be submitted as an issue for maintainer review.</p>
{userEmail && <p><strong>Submitting as:</strong> {userEmail}</p>}
</div>
<div class="card">
<form id="kb-form" style="padding: 2rem;">
<!-- Tool Selection -->
<div class="form-group">
<label for="tool-name" class="form-label">Related Tool</label>
<select id="tool-name" name="toolName" class="form-input">
<option value="">Select a tool</option>
{sortedTools.map(tool => (
<option value={tool.name}>{tool.name} ({tool.type})</option>
))}
</select>
<!-- Main Form -->
<div class="card">
<form id="kb-form" novalidate>
<!-- Basic Information -->
<div class="form-section">
<h3 class="section-title">Basic Information</h3>
<div class="form-grid-2">
<div class="form-group">
<label for="tool-name" class="form-label">Related Tool (Optional)</label>
<select id="tool-name" name="toolName" class="form-input">
<option value="">Select a tool...</option>
{sortedTools.map(tool => (
<option value={tool.name}>{tool.name} ({tool.type})</option>
))}
</select>
</div>
<div class="form-group">
<label for="difficulty" class="form-label">Difficulty Level (Optional)</label>
<select id="difficulty" name="difficulty" class="form-input">
<option value="">Select difficulty...</option>
<option value="novice">Novice</option>
<option value="beginner">Beginner</option>
<option value="intermediate">Intermediate</option>
<option value="advanced">Advanced</option>
<option value="expert">Expert</option>
</select>
</div>
</div>
<!-- Article Title -->
<div class="form-group">
<label for="title" class="form-label">Article Title</label>
<label for="title" class="form-label">Article Title (Optional)</label>
<input
type="text"
id="title"
@@ -52,36 +73,36 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
/>
</div>
<!-- Description -->
<div class="form-group">
<label for="description" class="form-label">Description</label>
<label for="description" class="form-label">Description (Optional)</label>
<textarea
id="description"
name="description"
maxlength="300"
rows="3"
placeholder="Brief summary of what this article covers (20-300 characters)"
placeholder="Brief summary of what this article covers"
class="form-input"
></textarea>
<small class="form-help">This will be shown in search results and article listings.</small>
</div>
</div>
<!-- Article Content -->
<!-- Content -->
<div class="form-section">
<h3 class="section-title">Content</h3>
<div class="form-group">
<label for="content" class="form-label">Article Content</label>
<label for="content" class="form-label">Article Content (Optional)</label>
<textarea
id="content"
name="content"
rows="8"
placeholder="Provide article content, notes, or instructions. You can also upload documents below instead."
placeholder="Provide your content, documentation, tutorial steps, or notes here..."
class="form-input"
></textarea>
<small class="form-help">Optional if you're uploading documents. Use this for additional context or instructions.</small>
</div>
<!-- External Link -->
<div class="form-group">
<label for="external-link" class="form-label">External Link</label>
<label for="external-link" class="form-label">External Link (Optional)</label>
<input
type="url"
id="external-link"
@@ -89,72 +110,66 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
placeholder="https://example.com/documentation"
class="form-input"
/>
<small class="form-help">Link to external documentation, tutorials, or resources.</small>
</div>
</div>
<!-- File Upload Section -->
<!-- File Upload -->
<div class="form-section">
<h3 class="section-title">Upload Files</h3>
<div class="form-group">
<label class="form-label">Upload Documents</label>
<label class="form-label">Documents, Images, Videos (Optional)</label>
<div class="upload-area" id="upload-area">
<input type="file" id="file-input" multiple accept=".pdf,.doc,.docx,.txt,.md,.zip,.png,.jpg,.jpeg,.gif,.mp4" style="display: none;">
<input type="file" id="file-input" multiple accept=".pdf,.doc,.docx,.txt,.md,.zip,.png,.jpg,.jpeg,.gif,.mp4,.webm" style="display: none;">
<div class="upload-placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
<p style="margin: 0.5rem 0 0 0;">Click to select files or drag & drop</p>
<p>Click to select files or drag & drop</p>
<small>PDFs, documents, images, archives, etc.</small>
</div>
</div>
<div id="file-list" style="margin-top: 1rem; display: none;">
<div id="file-list" class="file-list" style="display: none;">
<h5>Selected Files</h5>
<div id="files-container"></div>
</div>
</div>
</div>
<!-- Difficulty Level -->
<div class="form-group">
<label for="difficulty" class="form-label">Difficulty Level</label>
<select id="difficulty" name="difficulty" class="form-input">
<option value="">Select difficulty</option>
<option value="novice">Novice - No prior experience needed</option>
<option value="beginner">Beginner - Basic familiarity helpful</option>
<option value="intermediate">Intermediate - Some experience required</option>
<option value="advanced">Advanced - Significant experience needed</option>
<option value="expert">Expert - Deep technical knowledge required</option>
</select>
<!-- Additional Information -->
<div class="form-section">
<h3 class="section-title">Additional Information</h3>
<div class="form-grid-2">
<div class="form-group">
<label for="categories" class="form-label">Categories (Optional)</label>
<input
type="text"
id="categories"
name="categories"
placeholder="setup, configuration, troubleshooting"
class="form-input"
/>
<small class="form-help">Comma-separated categories</small>
</div>
<div class="form-group">
<label for="tags" class="form-label">Tags (Optional)</label>
<input
type="text"
id="tags"
name="tags"
placeholder="installation, docker, linux, windows"
class="form-input"
/>
<small class="form-help">Comma-separated tags</small>
</div>
</div>
<!-- Categories -->
<div class="form-group">
<label for="categories" class="form-label">Categories</label>
<input
type="text"
id="categories"
name="categories"
placeholder="setup, configuration, troubleshooting"
class="form-input"
/>
<small class="form-help">Comma-separated categories that describe the article type.</small>
</div>
<!-- Tags -->
<div class="form-group">
<label for="tags" class="form-label">Tags</label>
<input
type="text"
id="tags"
name="tags"
placeholder="installation, docker, linux, windows"
class="form-input"
/>
<small class="form-help">Comma-separated tags for better searchability.</small>
</div>
<!-- Reason for Contribution -->
<div class="form-group">
<label for="reason" class="form-label">Reason for Contribution</label>
<label for="reason" class="form-label">Reason for Contribution (Optional)</label>
<textarea
id="reason"
name="reason"
@@ -163,139 +178,131 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
class="form-input"
></textarea>
</div>
</div>
<!-- Submit Button -->
<div style="display: flex; gap: 1rem; margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--color-border);">
<button type="submit" id="submit-btn" class="btn btn-accent" style="flex: 1;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
</svg>
Submit Article
</button>
</div>
</form>
</div>
<!-- Submit Button -->
<div class="form-actions">
<a href="/" class="btn btn-secondary">Cancel</a>
<button type="submit" id="submit-btn" class="btn btn-accent">
<span id="submit-text">Submit Article</span>
<span id="submit-spinner" style="display: none;">⏳</span>
</button>
</div>
</form>
</div>
<!-- Success Modal -->
<div id="success-modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: 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;">
<div style="text-align: center;">
<div style="background-color: var(--color-accent); color: white; width: 64px; height: 64px; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 1rem;">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20,6 9,17 4,12"/>
</svg>
</div>
<h3 style="margin-bottom: 1rem;">Article Submitted!</h3>
<p id="success-message" style="margin-bottom: 1.5rem; line-height: 1.5;"></p>
<div style="display: flex; gap: 1rem; justify-content: center;">
<a id="pr-link" href="#" target="_blank" class="btn btn-primary" style="display: none;">View Pull Request</a>
<a href="/" class="btn btn-secondary">Back to Home</a>
</div>
<div id="success-modal"
style="display:none; position:fixed; top:0; left:0; width:100%; height:100%;
background:rgba(0,0,0,.5); z-index:1000; align-items:center; justify-content:center;">
<div class="card" style="max-width:500px; width:90%; margin:2rem; text-align:center;">
<div style="font-size:3rem; margin-bottom:1rem;">✅</div>
<h3 style="margin-bottom:1rem;">Article Submitted!</h3>
<p id="success-message" style="margin-bottom:1.5rem;">
Your knowledgebase article has been submitted as an issue for review by maintainers.
</p>
<div style="display:flex; gap:1rem; justify-content:center;">
<a id="issue-link" href="#" target="_blank" class="btn btn-primary" style="display:none;">View Issue</a>
<a href="/" class="btn btn-secondary">Back to Home</a>
</div>
</div>
</div>
<!-- Message Display -->
<div id="message-container" style="position: fixed; top: 20px; right: 20px; z-index: 1000;"></div>
</main>
<!-- Message Container -->
<div id="message-container" class="message-container"></div>
</div>
</BaseLayout>
<script>
// FIXED: Properly typed interfaces for TypeScript compliance
interface UploadedFile {
id: string;
file: File;
name: string;
uploaded: boolean;
url?: string;
interface UploadedFile {
id: string;
file: File;
name: string;
uploaded: boolean;
url?: string;
}
declare global {
interface Window {
removeFile: (fileId: string) => void;
}
}
class KnowledgebaseForm {
private uploadedFiles: UploadedFile[] = [];
private isSubmitting = false;
private elements: Record<string, HTMLElement | null> = {};
constructor() {
this.init();
}
// Extend Window interface for global functions
declare global {
interface Window {
removeFile: (fileId: string) => void;
private init() {
// Get elements
this.elements = {
form: document.getElementById('kb-form'),
submitBtn: document.getElementById('submit-btn'),
submitText: document.getElementById('submit-text'),
submitSpinner: document.getElementById('submit-spinner'),
fileInput: document.getElementById('file-input'),
uploadArea: document.getElementById('upload-area'),
fileList: document.getElementById('file-list'),
filesContainer: document.getElementById('files-container'),
successModal: document.getElementById('success-modal')
};
if (!this.elements.form || !this.elements.submitBtn) {
console.error('[KB FORM] Critical elements missing');
return;
}
this.setupEventListeners();
this.setupFileUpload();
}
// FIXED: State management with proper typing
let uploadedFiles: UploadedFile[] = [];
// FIXED: Properly typed element selection with specific HTML element types
const elements = {
form: document.getElementById('kb-form') as HTMLFormElement | null,
submitBtn: document.getElementById('submit-btn') as HTMLButtonElement | null,
fileInput: document.getElementById('file-input') as HTMLInputElement | null,
uploadArea: document.getElementById('upload-area') as HTMLElement | null,
fileList: document.getElementById('file-list') as HTMLElement | null,
filesContainer: document.getElementById('files-container') as HTMLElement | null
};
// Check for critical elements
const criticalElements: Array<keyof typeof elements> = ['form', 'submitBtn'];
const missingElements = criticalElements.filter(key => !elements[key]);
if (missingElements.length > 0) {
console.error('[KB FORM ERROR] Missing critical elements:', missingElements);
} else {
console.log('[KB FORM DEBUG] All critical elements found, initializing form');
}
function validateForm(): boolean {
return true; // Always return true - no validation
}
// Update submit button state with null checks
function updateSubmitButton(): void {
if (elements.submitBtn) {
const isValid = validateForm();
elements.submitBtn.disabled = !isValid;
console.log('[KB FORM DEBUG] Button state:', isValid ? 'enabled' : 'disabled');
}
}
// File upload handling with proper null checks
function setupFileUpload(): void {
if (!elements.fileInput || !elements.uploadArea) return;
elements.uploadArea.addEventListener('click', () => {
if (elements.fileInput) {
elements.fileInput.click();
}
});
elements.uploadArea.addEventListener('dragover', (e: DragEvent) => {
private setupEventListeners() {
// Form submission
this.elements.form?.addEventListener('submit', (e) => {
e.preventDefault();
if (elements.uploadArea) {
elements.uploadArea.style.borderColor = 'var(--color-accent)';
if (!this.isSubmitting) {
this.handleSubmit();
}
});
elements.uploadArea.addEventListener('dragleave', () => {
if (elements.uploadArea) {
elements.uploadArea.style.borderColor = 'var(--color-border)';
}
}
private setupFileUpload() {
if (!this.elements.fileInput || !this.elements.uploadArea) return;
this.elements.uploadArea.addEventListener('click', () => {
(this.elements.fileInput as HTMLInputElement)?.click();
});
elements.uploadArea.addEventListener('drop', (e: DragEvent) => {
this.elements.uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
if (elements.uploadArea) {
elements.uploadArea.style.borderColor = 'var(--color-border)';
}
this.elements.uploadArea?.classList.add('drag-over');
});
this.elements.uploadArea.addEventListener('dragleave', () => {
this.elements.uploadArea?.classList.remove('drag-over');
});
this.elements.uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
this.elements.uploadArea?.classList.remove('drag-over');
if (e.dataTransfer?.files) {
handleFiles(Array.from(e.dataTransfer.files));
this.handleFiles(Array.from(e.dataTransfer.files));
}
});
elements.fileInput.addEventListener('change', (e: Event) => {
this.elements.fileInput.addEventListener('change', (e) => {
const target = e.target as HTMLInputElement;
if (target?.files) {
handleFiles(Array.from(target.files));
this.handleFiles(Array.from(target.files));
}
});
}
function handleFiles(files: File[]): void {
private handleFiles(files: File[]) {
files.forEach(file => {
const fileId = Date.now() + '-' + Math.random().toString(36).substr(2, 9);
const newFile: UploadedFile = {
@@ -304,15 +311,14 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
name: file.name,
uploaded: false
};
uploadedFiles.push(newFile);
uploadFile(fileId);
this.uploadedFiles.push(newFile);
this.uploadFile(fileId);
});
renderFileList();
updateSubmitButton();
this.renderFileList();
}
async function uploadFile(fileId: string): Promise<void> {
const fileItem = uploadedFiles.find(f => f.id === fileId);
private async uploadFile(fileId: string) {
const fileItem = this.uploadedFiles.find(f => f.id === fileId);
if (!fileItem) return;
const formData = new FormData();
@@ -329,63 +335,60 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
const result = await response.json();
fileItem.uploaded = true;
fileItem.url = result.url;
renderFileList();
this.renderFileList();
} else {
throw new Error('Upload failed');
}
} catch (error) {
showMessage('error', `Failed to upload ${fileItem.name}`);
removeFile(fileId);
this.showMessage('error', `Failed to upload ${fileItem.name}`);
this.removeFile(fileId);
}
}
function removeFile(fileId: string): void {
uploadedFiles = uploadedFiles.filter(f => f.id !== fileId);
renderFileList();
updateSubmitButton();
private removeFile(fileId: string) {
this.uploadedFiles = this.uploadedFiles.filter(f => f.id !== fileId);
this.renderFileList();
}
function renderFileList(): void {
if (!elements.filesContainer || !elements.fileList) return;
private renderFileList() {
if (!this.elements.filesContainer || !this.elements.fileList) return;
if (uploadedFiles.length > 0) {
elements.fileList.style.display = 'block';
elements.filesContainer.innerHTML = uploadedFiles.map(file => `
<div class="file-item" style="display: flex; align-items: center; gap: 1rem; padding: 0.5rem; border: 1px solid var(--color-border); border-radius: 0.25rem; margin-bottom: 0.5rem;">
<div style="flex: 1;">
if (this.uploadedFiles.length > 0) {
(this.elements.fileList as HTMLElement).style.display = 'block';
(this.elements.filesContainer as HTMLElement).innerHTML = this.uploadedFiles.map(file => `
<div class="file-item">
<div class="file-info">
<strong>${file.name}</strong>
<div style="font-size: 0.875rem; color: var(--color-text-secondary);">
<div class="file-meta">
${(file.file.size / 1024 / 1024).toFixed(2)} MB
${file.uploaded ?
'<span style="color: var(--color-success);">✓ Uploaded</span>' :
'<span style="color: var(--color-warning);">⏳ Uploading...</span>'
'<span class="file-status success">✓ Uploaded</span>' :
'<span class="file-status pending">⏳ Uploading...</span>'
}
</div>
</div>
<button type="button" onclick="window.removeFile('${file.id}')" class="btn btn-small" style="background: var(--color-danger); color: white;">Remove</button>
<button type="button" onclick="window.removeFile('${file.id}')" class="btn btn-danger btn-small">Remove</button>
</div>
`).join('');
} else {
elements.fileList.style.display = 'none';
(this.elements.fileList as HTMLElement).style.display = 'none';
}
}
async function handleSubmit(e: Event): Promise<void> {
e.preventDefault();
console.log('[KB FORM DEBUG] Form submitted');
private async handleSubmit() {
if (this.isSubmitting) return;
this.isSubmitting = true;
if (!elements.submitBtn || !elements.form) {
console.log('[KB FORM DEBUG] Submission blocked - form missing');
return;
}
elements.submitBtn.classList.add('loading');
elements.submitBtn.innerHTML = '⏳ Submitting...';
// Update UI
(this.elements.submitBtn as HTMLButtonElement).disabled = true;
(this.elements.submitText as HTMLElement).textContent = 'Submitting...';
(this.elements.submitSpinner as HTMLElement).style.display = 'inline';
try {
const formData = new FormData(elements.form);
const formData = new FormData(this.elements.form as HTMLFormElement);
// Process categories and tags with proper null handling
// Process categories and tags
const categoriesValue = (formData.get('categories') as string) || '';
const tagsValue = (formData.get('tags') as string) || '';
@@ -395,78 +398,83 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
formData.set('tags', JSON.stringify(tags));
// Add uploaded files
formData.set('uploadedFiles', JSON.stringify(uploadedFiles.filter(f => f.uploaded)));
formData.set('uploadedFiles', JSON.stringify(this.uploadedFiles.filter(f => f.uploaded)));
console.log('[KB FORM DEBUG] Submitting to API...');
const response = await fetch('/api/contribute/knowledgebase', {
method: 'POST',
body: formData
});
console.log('[KB FORM DEBUG] Response status:', response.status);
const result = await response.json();
console.log('[KB FORM DEBUG] Response data:', result);
if (result.success) {
// Show success modal
const successModal = document.getElementById('success-modal');
const successMessage = document.getElementById('success-message');
const prLink = document.getElementById('pr-link') as HTMLAnchorElement;
if (successModal && successMessage) {
successMessage.textContent = 'Your knowledge base article has been submitted successfully and will be reviewed by the maintainers.';
if (result.prUrl && prLink) {
prLink.href = result.prUrl;
prLink.style.display = 'inline-flex';
}
successModal.style.display = 'flex';
}
// Reset form with proper typing
if (elements.form) {
elements.form.reset();
}
uploadedFiles = [];
renderFileList();
updateSubmitButton();
this.showSuccess(result);
} else {
showMessage('error', result.error || 'Submission failed');
throw new Error(result.error || 'Submission failed');
}
} catch (error) {
console.error('[KB FORM ERROR] Submission error:', error);
showMessage('error', 'An error occurred during submission');
console.error('[KB FORM] Submission error:', error);
this.showMessage('error', 'Submission failed. Please try again.');
} finally {
if (elements.submitBtn) {
elements.submitBtn.classList.remove('loading');
elements.submitBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/></svg> Submit Article';
}
this.isSubmitting = false;
(this.elements.submitBtn as HTMLButtonElement).disabled = false;
(this.elements.submitText as HTMLElement).textContent = 'Submit Article';
(this.elements.submitSpinner as HTMLElement).style.display = 'none';
}
}
function showMessage(type: 'success' | 'error' | 'warning', message: string): void {
private showSuccess(result: any) {
const successMessage = document.getElementById('success-message');
const issueLink = document.getElementById('issue-link') as HTMLAnchorElement;
if (successMessage) {
successMessage.textContent = 'Your knowledge base article has been submitted as an issue for review by maintainers.';
}
if (result.issueUrl && issueLink) {
issueLink.href = result.issueUrl;
issueLink.style.display = 'inline-flex';
}
(this.elements.successModal as HTMLElement).style.display = 'flex';
// Reset form
(this.elements.form as HTMLFormElement).reset();
this.uploadedFiles = [];
this.renderFileList();
}
private showMessage(type: 'success' | 'error' | 'warning', message: string) {
const container = document.getElementById('message-container');
if (!container) return;
const messageEl = document.createElement('div');
messageEl.className = `message message-${type}`;
messageEl.style.cssText = `
padding: 1rem; margin-bottom: 0.5rem; border-radius: 0.25rem;
background-color: var(--color-${type === 'error' ? 'danger' : type === 'warning' ? 'warning' : 'success'});
color: white; animation: slideIn 0.3s ease;
`;
messageEl.textContent = message;
container.appendChild(messageEl);
setTimeout(() => messageEl.remove(), 5000);
}
// Make removeFile available globally with proper typing
window.removeFile = removeFile;
// Public method for file removal
public removeFileById(fileId: string) {
this.removeFile(fileId);
}
}
// Initialize
setupFileUpload();
console.log('[KB FORM DEBUG] Form initialization complete');
// Global instance
let formInstance: KnowledgebaseForm;
// Global function for file removal
window.removeFile = (fileId: string) => {
if (formInstance) {
formInstance.removeFileById(fileId);
}
};
// Initialize form
document.addEventListener('DOMContentLoaded', () => {
formInstance = new KnowledgebaseForm();
});
</script>