480 lines
16 KiB
Plaintext
480 lines
16 KiB
Plaintext
---
|
||
// src/pages/contribute/knowledgebase.astro - SIMPLIFIED: Issues only, minimal validation
|
||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||
import { withAuth } from '../../utils/auth.js';
|
||
import { getToolsData } from '../../utils/dataService.js';
|
||
|
||
export const prerender = false;
|
||
|
||
// Check authentication
|
||
const authResult = await withAuth(Astro);
|
||
if (authResult instanceof Response) {
|
||
return authResult;
|
||
}
|
||
|
||
const { authenticated, userEmail, userId } = authResult;
|
||
|
||
// Load tools for reference (optional dropdown)
|
||
const data = await getToolsData();
|
||
const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.name));
|
||
---
|
||
|
||
<BaseLayout title="Contribute Knowledge Base Article">
|
||
<div class="container" style="max-width: 900px; margin: 0 auto; padding: 2rem 1rem;">
|
||
|
||
<!-- Header -->
|
||
<div 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>
|
||
|
||
<!-- 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>
|
||
|
||
<div class="form-group">
|
||
<label for="title" class="form-label">Article Title (Optional)</label>
|
||
<input
|
||
type="text"
|
||
id="title"
|
||
name="title"
|
||
maxlength="100"
|
||
placeholder="Clear, descriptive title for your article"
|
||
class="form-input"
|
||
/>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<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"
|
||
class="form-input"
|
||
></textarea>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Content -->
|
||
<div class="form-section">
|
||
<h3 class="section-title">Content</h3>
|
||
|
||
<div class="form-group">
|
||
<label for="content" class="form-label">Article Content (Optional)</label>
|
||
<textarea
|
||
id="content"
|
||
name="content"
|
||
rows="8"
|
||
placeholder="Provide your content, documentation, tutorial steps, or notes here..."
|
||
class="form-input"
|
||
></textarea>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="external-link" class="form-label">External Link (Optional)</label>
|
||
<input
|
||
type="url"
|
||
id="external-link"
|
||
name="externalLink"
|
||
placeholder="https://example.com/documentation"
|
||
class="form-input"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- File Upload -->
|
||
<div class="form-section">
|
||
<h3 class="section-title">Upload Files</h3>
|
||
|
||
<div class="form-group">
|
||
<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,.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>Click to select files or drag & drop</p>
|
||
<small>PDFs, documents, images, archives, etc.</small>
|
||
</div>
|
||
</div>
|
||
<div id="file-list" class="file-list" style="display: none;">
|
||
<h5>Selected Files</h5>
|
||
<div id="files-container"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 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>
|
||
|
||
<div class="form-group">
|
||
<label for="reason" class="form-label">Reason for Contribution (Optional)</label>
|
||
<textarea
|
||
id="reason"
|
||
name="reason"
|
||
rows="3"
|
||
placeholder="Why are you submitting this article? What problem does it solve?"
|
||
class="form-input"
|
||
></textarea>
|
||
</div>
|
||
</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:rgba(0,0,0,.5); z-index:1000; align-items:center; justify-content:center;">
|
||
<div class="card" style="max-width:500px; width:90%; margin:2rem; text-align:center;">
|
||
<div style="font-size:3rem; margin-bottom:1rem;">✅</div>
|
||
<h3 style="margin-bottom:1rem;">Article Submitted!</h3>
|
||
<p id="success-message" style="margin-bottom:1.5rem;">
|
||
Your knowledge‑base article has been submitted as an issue for review by maintainers.
|
||
</p>
|
||
<div style="display:flex; gap:1rem; justify-content:center;">
|
||
<a id="issue-link" href="#" target="_blank" class="btn btn-primary" style="display:none;">View Issue</a>
|
||
<a href="/" class="btn btn-secondary">Back to Home</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Message Container -->
|
||
<div id="message-container" class="message-container"></div>
|
||
</div>
|
||
</BaseLayout>
|
||
|
||
<script>
|
||
interface UploadedFile {
|
||
id: string;
|
||
file: File;
|
||
name: string;
|
||
uploaded: boolean;
|
||
url?: string;
|
||
}
|
||
|
||
declare global {
|
||
interface Window {
|
||
removeFile: (fileId: string) => void;
|
||
}
|
||
}
|
||
|
||
class KnowledgebaseForm {
|
||
private uploadedFiles: UploadedFile[] = [];
|
||
private isSubmitting = false;
|
||
private elements: Record<string, HTMLElement | null> = {};
|
||
|
||
constructor() {
|
||
this.init();
|
||
}
|
||
|
||
private init() {
|
||
// Get elements
|
||
this.elements = {
|
||
form: document.getElementById('kb-form'),
|
||
submitBtn: document.getElementById('submit-btn'),
|
||
submitText: document.getElementById('submit-text'),
|
||
submitSpinner: document.getElementById('submit-spinner'),
|
||
fileInput: document.getElementById('file-input'),
|
||
uploadArea: document.getElementById('upload-area'),
|
||
fileList: document.getElementById('file-list'),
|
||
filesContainer: document.getElementById('files-container'),
|
||
successModal: document.getElementById('success-modal')
|
||
};
|
||
|
||
if (!this.elements.form || !this.elements.submitBtn) {
|
||
console.error('[KB FORM] Critical elements missing');
|
||
return;
|
||
}
|
||
|
||
this.setupEventListeners();
|
||
this.setupFileUpload();
|
||
}
|
||
|
||
private setupEventListeners() {
|
||
// Form submission
|
||
this.elements.form?.addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
if (!this.isSubmitting) {
|
||
this.handleSubmit();
|
||
}
|
||
});
|
||
}
|
||
|
||
private setupFileUpload() {
|
||
if (!this.elements.fileInput || !this.elements.uploadArea) return;
|
||
|
||
this.elements.uploadArea.addEventListener('click', () => {
|
||
(this.elements.fileInput as HTMLInputElement)?.click();
|
||
});
|
||
|
||
this.elements.uploadArea.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
this.elements.uploadArea?.classList.add('drag-over');
|
||
});
|
||
|
||
this.elements.uploadArea.addEventListener('dragleave', () => {
|
||
this.elements.uploadArea?.classList.remove('drag-over');
|
||
});
|
||
|
||
this.elements.uploadArea.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
this.elements.uploadArea?.classList.remove('drag-over');
|
||
if (e.dataTransfer?.files) {
|
||
this.handleFiles(Array.from(e.dataTransfer.files));
|
||
}
|
||
});
|
||
|
||
this.elements.fileInput.addEventListener('change', (e) => {
|
||
const target = e.target as HTMLInputElement;
|
||
if (target?.files) {
|
||
this.handleFiles(Array.from(target.files));
|
||
}
|
||
});
|
||
}
|
||
|
||
private handleFiles(files: File[]) {
|
||
files.forEach(file => {
|
||
const fileId = Date.now() + '-' + Math.random().toString(36).substr(2, 9);
|
||
const newFile: UploadedFile = {
|
||
id: fileId,
|
||
file,
|
||
name: file.name,
|
||
uploaded: false
|
||
};
|
||
this.uploadedFiles.push(newFile);
|
||
this.uploadFile(fileId);
|
||
});
|
||
this.renderFileList();
|
||
}
|
||
|
||
private async uploadFile(fileId: string) {
|
||
const fileItem = this.uploadedFiles.find(f => f.id === fileId);
|
||
if (!fileItem) return;
|
||
|
||
const formData = new FormData();
|
||
formData.append('file', fileItem.file);
|
||
formData.append('type', 'knowledgebase');
|
||
|
||
try {
|
||
const response = await fetch('/api/upload/media', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
|
||
if (response.ok) {
|
||
const result = await response.json();
|
||
fileItem.uploaded = true;
|
||
fileItem.url = result.url;
|
||
this.renderFileList();
|
||
} else {
|
||
throw new Error('Upload failed');
|
||
}
|
||
} catch (error) {
|
||
this.showMessage('error', `Failed to upload ${fileItem.name}`);
|
||
this.removeFile(fileId);
|
||
}
|
||
}
|
||
|
||
private removeFile(fileId: string) {
|
||
this.uploadedFiles = this.uploadedFiles.filter(f => f.id !== fileId);
|
||
this.renderFileList();
|
||
}
|
||
|
||
private renderFileList() {
|
||
if (!this.elements.filesContainer || !this.elements.fileList) return;
|
||
|
||
if (this.uploadedFiles.length > 0) {
|
||
(this.elements.fileList as HTMLElement).style.display = 'block';
|
||
(this.elements.filesContainer as HTMLElement).innerHTML = this.uploadedFiles.map(file => `
|
||
<div class="file-item">
|
||
<div class="file-info">
|
||
<strong>${file.name}</strong>
|
||
<div class="file-meta">
|
||
${(file.file.size / 1024 / 1024).toFixed(2)} MB
|
||
${file.uploaded ?
|
||
'<span class="file-status success">✓ Uploaded</span>' :
|
||
'<span class="file-status pending">⏳ Uploading...</span>'
|
||
}
|
||
</div>
|
||
</div>
|
||
<button type="button" onclick="window.removeFile('${file.id}')" class="btn btn-danger btn-small">Remove</button>
|
||
</div>
|
||
`).join('');
|
||
} else {
|
||
(this.elements.fileList as HTMLElement).style.display = 'none';
|
||
}
|
||
}
|
||
|
||
private async handleSubmit() {
|
||
if (this.isSubmitting) return;
|
||
|
||
this.isSubmitting = true;
|
||
|
||
// Update UI
|
||
(this.elements.submitBtn as HTMLButtonElement).disabled = true;
|
||
(this.elements.submitText as HTMLElement).textContent = 'Submitting...';
|
||
(this.elements.submitSpinner as HTMLElement).style.display = 'inline';
|
||
|
||
try {
|
||
const formData = new FormData(this.elements.form as HTMLFormElement);
|
||
|
||
// Process categories and tags
|
||
const categoriesValue = (formData.get('categories') as string) || '';
|
||
const tagsValue = (formData.get('tags') as string) || '';
|
||
|
||
const categories = categoriesValue.split(',').map(s => s.trim()).filter(s => s);
|
||
const tags = tagsValue.split(',').map(s => s.trim()).filter(s => s);
|
||
formData.set('categories', JSON.stringify(categories));
|
||
formData.set('tags', JSON.stringify(tags));
|
||
|
||
// Add uploaded files
|
||
formData.set('uploadedFiles', JSON.stringify(this.uploadedFiles.filter(f => f.uploaded)));
|
||
|
||
const response = await fetch('/api/contribute/knowledgebase', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
this.showSuccess(result);
|
||
} else {
|
||
throw new Error(result.error || 'Submission failed');
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('[KB FORM] Submission error:', error);
|
||
this.showMessage('error', 'Submission failed. Please try again.');
|
||
} finally {
|
||
this.isSubmitting = false;
|
||
(this.elements.submitBtn as HTMLButtonElement).disabled = false;
|
||
(this.elements.submitText as HTMLElement).textContent = 'Submit Article';
|
||
(this.elements.submitSpinner as HTMLElement).style.display = 'none';
|
||
}
|
||
}
|
||
|
||
private showSuccess(result: any) {
|
||
const successMessage = document.getElementById('success-message');
|
||
const issueLink = document.getElementById('issue-link') as HTMLAnchorElement;
|
||
|
||
if (successMessage) {
|
||
successMessage.textContent = 'Your knowledge base article has been submitted as an issue for review by maintainers.';
|
||
}
|
||
|
||
if (result.issueUrl && issueLink) {
|
||
issueLink.href = result.issueUrl;
|
||
issueLink.style.display = 'inline-flex';
|
||
}
|
||
|
||
(this.elements.successModal as HTMLElement).style.display = 'flex';
|
||
|
||
// Reset form
|
||
(this.elements.form as HTMLFormElement).reset();
|
||
this.uploadedFiles = [];
|
||
this.renderFileList();
|
||
}
|
||
|
||
private showMessage(type: 'success' | 'error' | 'warning', message: string) {
|
||
const container = document.getElementById('message-container');
|
||
if (!container) return;
|
||
|
||
const messageEl = document.createElement('div');
|
||
messageEl.className = `message message-${type}`;
|
||
messageEl.textContent = message;
|
||
|
||
container.appendChild(messageEl);
|
||
setTimeout(() => messageEl.remove(), 5000);
|
||
}
|
||
|
||
// Public method for file removal
|
||
public removeFileById(fileId: string) {
|
||
this.removeFile(fileId);
|
||
}
|
||
}
|
||
|
||
// Global instance
|
||
let formInstance: KnowledgebaseForm;
|
||
|
||
// Global function for file removal
|
||
window.removeFile = (fileId: string) => {
|
||
if (formInstance) {
|
||
formInstance.removeFileById(fileId);
|
||
}
|
||
};
|
||
|
||
// Initialize form
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
formInstance = new KnowledgebaseForm();
|
||
});
|
||
</script> |