iterate on contrib

This commit is contained in:
overcuriousity
2025-07-23 21:06:39 +02:00
parent f4acf39ca7
commit 3d42fcef79
12 changed files with 3559 additions and 477 deletions

View File

@@ -1,12 +1,29 @@
---
// src/pages/contribute/index.astro
// src/pages/contribute/index.astro - Updated for Phase 3
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getAuthContext, requireAuth } from '../../utils/serverAuth.js';
import { getSessionFromRequest, verifySession } from '../../utils/auth.js';
export const prerender = false;
// Check authentication
const authContext = await getAuthContext(Astro);
const authRedirect = requireAuth(authContext, Astro.url.toString());
if (authRedirect) return authRedirect;
const authRequired = import.meta.env.AUTHENTICATION_NECESSARY !== 'false';
let isAuthenticated = false;
let userEmail = '';
if (authRequired) {
const sessionToken = getSessionFromRequest(Astro.request);
if (sessionToken) {
const session = await verifySession(sessionToken);
if (session) {
isAuthenticated = true;
userEmail = session.email;
}
}
if (!isAuthenticated) {
return Astro.redirect('/auth/login');
}
}
---
<BaseLayout title="Contribute" description="Contribute tools, methods, concepts, and knowledge articles to CC24-Guide">
@@ -26,6 +43,11 @@ if (authRedirect) return authRedirect;
Help expand our DFIR knowledge base by contributing tools, methods, concepts, and detailed articles.
All contributions are reviewed before being merged into the main database.
</p>
{userEmail && (
<p style="margin-top: 1rem; opacity: 0.8; font-size: 0.9rem;">
Logged in as: <strong>{userEmail}</strong>
</p>
)}
</div>
<!-- Contribution Options -->
@@ -126,6 +148,12 @@ if (authRedirect) return authRedirect;
</svg>
Report Issue
</a>
<a href="/api/contribute/health" class="btn btn-secondary" target="_blank">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
</svg>
System Health
</a>
</div>
</div>
</div>
@@ -143,16 +171,18 @@ if (authRedirect) return authRedirect;
<li>Use clear, professional language</li>
<li>Include relevant tags and categorization</li>
<li>Verify all URLs and links work correctly</li>
<li>Test installation and configuration steps</li>
</ul>
</div>
<div>
<h4 style="margin-bottom: 0.75rem; color: var(--color-accent);">Review Process</h4>
<ul style="margin: 0; padding-left: 1.5rem; line-height: 1.6;">
<li>All contributions create pull requests</li>
<li>Maintainers review within 48-72 hours</li>
<li>Feedback provided for requested changes</li>
<li>Approved changes merged automatically</li>
<li>All contributions are submitted as pull requests</li>
<li>Automated validation checks run on submissions</li>
<li>Manual review by CC24 team members</li>
<li>Feedback provided through PR comments</li>
<li>Merge after approval and testing</li>
</ul>
</div>
@@ -160,34 +190,116 @@ if (authRedirect) return authRedirect;
<h4 style="margin-bottom: 0.75rem; color: var(--color-warning);">Best Practices</h4>
<ul style="margin: 0; padding-left: 1.5rem; line-height: 1.6;">
<li>Search existing entries before adding duplicates</li>
<li>Include rationale for new additions</li>
<li>Follow existing categorization patterns</li>
<li>Test tools/methods before recommending</li>
<li>Use consistent naming and categorization</li>
<li>Provide detailed descriptions and use cases</li>
<li>Include screenshots for complex procedures</li>
<li>Credit original sources and authors</li>
</ul>
</div>
</div>
</div>
<!-- Statistics -->
<div style="text-align: center; padding: 1.5rem; background-color: var(--color-bg-secondary); border-radius: 0.75rem;">
<p class="text-muted" style="margin: 0; font-size: 0.9375rem;">
<strong>Community Contributions:</strong> Help us maintain the most comprehensive DFIR resource available.
<br>
Your contributions are credited and help the entire forensics community.
</p>
<!-- System Status -->
<div class="card" style="background-color: var(--color-bg-secondary);">
<h3 style="margin-bottom: 1rem; color: var(--color-text);">System Status</h3>
<div id="system-status" style="display: flex; align-items: center; gap: 1rem;">
<div style="width: 12px; height: 12px; background-color: var(--color-text-secondary); border-radius: 50%; animation: pulse 2s infinite;"></div>
<span style="color: var(--color-text-secondary);">Checking system health...</span>
</div>
<div style="margin-top: 1rem; font-size: 0.875rem; color: var(--color-text-secondary);">
<p style="margin: 0;">
<strong>Features Available:</strong> Tool contributions, knowledgebase articles, media uploads
</p>
<p style="margin: 0.5rem 0 0 0;">
<strong>Storage:</strong> Local + Nextcloud (if configured) |
<strong>Authentication:</strong> {authRequired ? 'Required' : 'Disabled'} |
<strong>Rate Limiting:</strong> Active
</p>
</div>
</div>
</section>
</BaseLayout>
<style>
.card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
@media (width <= 768px) {
div[style*="grid-template-columns: 2fr 1fr"] {
grid-template-columns: 1fr !important;
<style>
.card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
}
}
</style>
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.loading {
animation: pulse 2s infinite;
}
@media (max-width: 768px) {
div[style*="grid-template-columns: 2fr 1fr"] {
grid-template-columns: 1fr !important;
gap: 1rem !important;
}
div[style*="grid-template-columns: repeat(auto-fit, minmax(300px, 1fr))"] {
grid-template-columns: 1fr !important;
}
h1 {
font-size: 2rem !important;
}
}
</style>
<script>
// Check system health on page load
document.addEventListener('DOMContentLoaded', async function() {
const statusEl = document.getElementById('system-status');
if (!statusEl) return;
try {
const response = await fetch('/api/contribute/health');
const health = await response.json();
let statusColor = 'var(--color-success)';
let statusText = 'All systems operational';
if (health.overall === 'warning') {
statusColor = 'var(--color-warning)';
statusText = `${health.summary.warnings} warning(s) detected`;
} else if (health.overall === 'error') {
statusColor = 'var(--color-error)';
statusText = `${health.summary.errors} error(s) detected`;
}
statusEl.innerHTML = `
<div style="width: 12px; height: 12px; background-color: ${statusColor}; border-radius: 50%;"></div>
<span style="color: var(--color-text);">${statusText}</span>
<a href="/api/contribute/health" target="_blank" style="color: var(--color-primary); text-decoration: underline; font-size: 0.875rem;">View Details</a>
`;
} catch (error) {
console.error('Health check failed:', error);
statusEl.innerHTML = `
<div style="width: 12px; height: 12px; background-color: var(--color-error); border-radius: 50%;"></div>
<span style="color: var(--color-error);">Health check failed</span>
`;
}
});
// Add hover effects for cards
document.querySelectorAll('.card[onclick]').forEach((card) => {
const cardEl = card as HTMLElement;
cardEl.addEventListener('mouseenter', function() {
this.style.transform = 'translateY(-2px)';
this.style.boxShadow = '0 8px 32px rgba(0, 0, 0, 0.12)';
});
cardEl.addEventListener('mouseleave', function() {
this.style.transform = 'translateY(0)';
this.style.boxShadow = '';
});
});
</script>
</BaseLayout>

View File

@@ -0,0 +1,958 @@
---
// src/pages/contribute/knowledgebase.astro
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getSessionFromRequest, verifySession } from '../../utils/auth.js';
import { getToolsData } from '../../utils/dataService.js';
export const prerender = false;
// Check authentication
const authRequired = import.meta.env.AUTHENTICATION_NECESSARY !== 'false';
let isAuthenticated = false;
let userEmail = '';
if (authRequired) {
const sessionToken = getSessionFromRequest(Astro.request);
if (sessionToken) {
const session = await verifySession(sessionToken);
if (session) {
isAuthenticated = true;
userEmail = session.email;
}
}
if (!isAuthenticated) {
return Astro.redirect('/auth/login');
}
}
// Load tools data for selection
const data = await getToolsData();
const tools = data.tools;
// Get edit mode parameters
const url = new URL(Astro.request.url);
const editMode = url.searchParams.get('edit');
const toolName = url.searchParams.get('tool');
// Article templates
const templates = {
installation: {
name: 'Installation Guide',
sections: ['overview', 'installation', 'configuration', 'usage_examples', 'troubleshooting'],
content: `# Installation Guide for {TOOL_NAME}
## Overview
Brief description of what {TOOL_NAME} is and what this guide covers.
## System Requirements
- Operating System:
- RAM:
- Storage:
- Dependencies:
## Installation Steps
### Step 1: Download
Instructions for downloading the tool...
### Step 2: Installation
Detailed installation instructions...
### Step 3: Initial Configuration
Basic configuration steps...
## Verification
How to verify the installation was successful...
## Troubleshooting
Common issues and solutions...
`
},
tutorial: {
name: 'Tutorial/How-to Guide',
sections: ['overview', 'usage_examples', 'best_practices'],
content: `# {TOOL_NAME} Tutorial
## Overview
What you'll learn in this tutorial...
## Prerequisites
- Required knowledge
- Tools needed
- Setup requirements
## Step-by-Step Guide
### Step 1: Getting Started
Initial setup and preparation...
### Step 2: Basic Usage
Core functionality walkthrough...
### Step 3: Advanced Features
More complex operations...
## Best Practices
- Tip 1: ...
- Tip 2: ...
- Tip 3: ...
## Next Steps
Where to go from here...
`
},
case_study: {
name: 'Case Study',
sections: ['overview', 'usage_examples', 'best_practices'],
content: `# Case Study: {TOOL_NAME} in Action
## Scenario
Description of the forensic scenario...
## Challenge
What problems needed to be solved...
## Solution Approach
How {TOOL_NAME} was used to address the challenge...
## Implementation
Detailed steps taken...
## Results
What was discovered or accomplished...
## Lessons Learned
Key takeaways and insights...
`
},
reference: {
name: 'Reference Documentation',
sections: ['overview', 'usage_examples', 'advanced_topics'],
content: `# {TOOL_NAME} Reference
## Overview
Comprehensive reference for {TOOL_NAME}...
## Command Reference
List of commands and their usage...
## Configuration Options
Available settings and parameters...
## API Reference
(If applicable) API endpoints and methods...
## Examples
Common usage examples...
`
}
};
---
<BaseLayout title="Contribute - Knowledgebase">
<section style="max-width: 1200px; margin: 0 auto;">
<!-- Header -->
<header style="margin-bottom: 2rem; text-align: center;">
<h1 style="margin-bottom: 1rem; color: var(--color-primary);">Write Knowledgebase Article</h1>
<p class="text-muted" style="font-size: 1.125rem; max-width: 600px; margin: 0 auto;">
Create detailed guides, tutorials, and documentation for forensic tools and methodologies.
</p>
{userEmail && (
<p style="margin-top: 0.5rem; font-size: 0.875rem; color: var(--color-text-secondary);">
Logged in as: <strong>{userEmail}</strong>
</p>
)}
</header>
<!-- Navigation -->
<nav style="margin-bottom: 2rem;">
<a href="/contribute" 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;">
<polyline points="15,18 9,12 15,6"></polyline>
</svg>
Back to Contribute
</a>
</nav>
<!-- Main Form -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 2rem;">
<!-- Form Section -->
<div class="card" style="padding: 2rem;">
<form id="kb-form" style="display: flex; flex-direction: column; gap: 1.5rem;">
<!-- Article Metadata -->
<h3 style="margin: 0 0 1rem 0; color: var(--color-accent); border-bottom: 2px solid var(--color-accent); padding-bottom: 0.5rem;">
Article Metadata
</h3>
<!-- Tool Selection -->
<div>
<label for="tool-select" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
Associated Tool <span style="color: var(--color-error);">*</span>
</label>
<select id="tool-select" name="toolName" required>
<option value="">Select a tool...</option>
{tools.map((tool: any) => (
<option value={tool.name} selected={toolName === tool.name}>
{tool.icon ? `${tool.icon} ` : ''}{tool.name}
</option>
))}
</select>
<div class="field-help">Choose the tool this article is about</div>
</div>
<!-- Title -->
<div>
<label for="article-title" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
Article Title <span style="color: var(--color-error);">*</span>
</label>
<input type="text" id="article-title" name="title" required minlength="5" maxlength="100"
placeholder="e.g., Installing and Configuring Wireshark" />
<div style="display: flex; justify-content: space-between; margin-top: 0.25rem;">
<div class="field-help">Clear, descriptive title for your article</div>
<div id="title-count" style="font-size: 0.75rem; color: var(--color-text-secondary);">0/100</div>
</div>
</div>
<!-- Description -->
<div>
<label for="article-description" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
Description <span style="color: var(--color-error);">*</span>
</label>
<textarea id="article-description" name="description" required rows="3" minlength="20" maxlength="300"
placeholder="Brief summary of what this article covers..."></textarea>
<div style="display: flex; justify-content: space-between; margin-top: 0.25rem;">
<div class="field-help">Brief summary for search results and listings</div>
<div id="description-count" style="font-size: 0.75rem; color: var(--color-text-secondary);">0/300</div>
</div>
</div>
<!-- Template Selection -->
<div>
<label for="template-select" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
Article Template
</label>
<select id="template-select" name="template">
<option value="">Start from scratch</option>
{Object.entries(templates).map(([key, template]) => (
<option value={key}>{template.name}</option>
))}
</select>
<div class="field-help">Choose a template to get started quickly</div>
</div>
<!-- Difficulty Level -->
<div>
<label for="difficulty-select" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
Difficulty Level <span style="color: var(--color-error);">*</span>
</label>
<select id="difficulty-select" name="difficulty" required>
<option value="">Select difficulty...</option>
<option value="novice">Novice - No prior experience needed</option>
<option value="beginner">Beginner - Basic computer skills required</option>
<option value="intermediate">Intermediate - Some forensics knowledge</option>
<option value="advanced">Advanced - Experienced practitioners</option>
<option value="expert">Expert - Deep technical expertise required</option>
</select>
</div>
<!-- Categories and Tags -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div>
<label for="categories" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
Categories
</label>
<input type="text" id="categories" name="categories"
placeholder="Installation, Tutorial, Configuration..."
title="Comma-separated categories" />
<div class="field-help">Comma-separated (e.g., Installation, Guide)</div>
</div>
<div>
<label for="tags" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
Tags
</label>
<input type="text" id="tags" name="tags"
placeholder="forensics, network, analysis..."
title="Comma-separated tags" />
<div class="field-help">Comma-separated keywords</div>
</div>
</div>
<!-- Content Sections -->
<div>
<label style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
Content Sections
</label>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-bottom: 0.5rem;">
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem;">
<input type="checkbox" name="sections" value="overview" checked>
Overview
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem;">
<input type="checkbox" name="sections" value="installation">
Installation
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem;">
<input type="checkbox" name="sections" value="configuration">
Configuration
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem;">
<input type="checkbox" name="sections" value="usage_examples" checked>
Usage Examples
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem;">
<input type="checkbox" name="sections" value="best_practices" checked>
Best Practices
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem;">
<input type="checkbox" name="sections" value="troubleshooting">
Troubleshooting
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem;">
<input type="checkbox" name="sections" value="advanced_topics">
Advanced Topics
</label>
</div>
<div class="field-help">Select which sections your article will include</div>
</div>
<!-- Media Upload Section -->
<div>
<h4 style="margin: 1rem 0 0.5rem 0; color: var(--color-text);">Media Files</h4>
<div id="media-upload" style="border: 2px dashed var(--color-border); border-radius: 0.5rem; padding: 2rem; text-align: center; background-color: var(--color-bg-secondary); cursor: pointer; transition: var(--transition-fast);">
<input type="file" id="media-input" multiple accept="image/*,video/*,.pdf,.doc,.docx" style="display: none;">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-secondary)" stroke-width="1.5" style="margin: 0 auto 1rem;">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="12" y1="11" x2="12" y2="17"/>
<polyline points="9 14 12 11 15 14"/>
</svg>
<p style="margin: 0; color: var(--color-text-secondary);">Click to upload or drag files here</p>
<p style="margin: 0.5rem 0 0 0; font-size: 0.875rem; color: var(--color-text-tertiary);">
Images, videos, PDFs, and documents
</p>
</div>
<div id="uploaded-files" style="margin-top: 1rem; display: none;">
<h5 style="margin: 0 0 0.5rem 0; color: var(--color-text);">Uploaded Files</h5>
<div id="files-list" style="display: flex; flex-direction: column; gap: 0.5rem;"></div>
</div>
</div>
<!-- Action Buttons -->
<div style="display: flex; gap: 1rem; margin-top: 2rem; border-top: 1px solid var(--color-border); padding-top: 1.5rem;">
<button type="button" id="preview-btn" class="btn btn-secondary" style="flex: 1;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
Preview
</button>
<button type="submit" id="submit-btn" class="btn btn-accent" style="flex: 2;" disabled>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21v-8H7v8"/>
<polyline points="7 3v5h8"/>
</svg>
Submit Article
</button>
</div>
</form>
</div>
<!-- Editor and Preview Section -->
<div class="card" style="padding: 2rem; display: flex; flex-direction: column;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; border-bottom: 2px solid var(--color-accent); padding-bottom: 0.5rem;">
<h3 style="margin: 0; color: var(--color-accent);">Content Editor</h3>
<div style="display: flex; gap: 0.5rem;">
<button id="editor-tab" class="btn btn-small" style="background-color: var(--color-accent); color: white;">
Editor
</button>
<button id="preview-tab" class="btn btn-small btn-secondary">
Preview
</button>
</div>
</div>
<!-- Markdown Editor -->
<div id="editor-section" style="flex: 1; display: flex; flex-direction: column;">
<div style="margin-bottom: 1rem; display: flex; gap: 0.5rem; flex-wrap: wrap;">
<button type="button" class="toolbar-btn" data-action="bold" title="Bold">
<strong>B</strong>
</button>
<button type="button" class="toolbar-btn" data-action="italic" title="Italic">
<em>I</em>
</button>
<button type="button" class="toolbar-btn" data-action="heading" title="Heading">
H1
</button>
<button type="button" class="toolbar-btn" data-action="link" title="Link">
🔗
</button>
<button type="button" class="toolbar-btn" data-action="image" title="Image">
🖼️
</button>
<button type="button" class="toolbar-btn" data-action="code" title="Code Block">
&lt;/&gt;
</button>
<button type="button" class="toolbar-btn" data-action="list" title="List">
📝
</button>
</div>
<textarea id="markdown-editor" name="content"
placeholder="Write your article content in Markdown..."
style="flex: 1; min-height: 400px; font-family: 'Courier New', monospace; font-size: 0.9rem; line-height: 1.5; border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1rem; background-color: var(--color-bg); color: var(--color-text); resize: vertical;"></textarea>
<div style="margin-top: 0.5rem; display: flex; justify-content: space-between; align-items: center; font-size: 0.875rem; color: var(--color-text-secondary);">
<div>Supports full Markdown syntax</div>
<div id="content-stats">Words: 0 | Characters: 0</div>
</div>
</div>
<!-- Preview Section -->
<div id="preview-section" style="flex: 1; display: none; flex-direction: column;">
<div id="preview-content" style="flex: 1; min-height: 400px; border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1rem; background-color: var(--color-bg); overflow-y: auto;">
<p class="text-muted" style="text-align: center; margin-top: 2rem;">Start writing to see preview...</p>
</div>
</div>
</div>
</div>
<!-- Success/Error Messages -->
<div id="form-messages" style="position: fixed; top: 1rem; right: 1rem; z-index: 1000; max-width: 400px;"></div>
</section>
<!-- Load templates as JSON for JavaScript -->
<script type="application/json" id="article-templates">
{JSON.stringify(templates)}
</script>
<style>
.toolbar-btn {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 0.25rem;
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
cursor: pointer;
transition: var(--transition-fast);
color: var(--color-text);
}
.toolbar-btn:hover {
background-color: var(--color-bg-tertiary);
border-color: var(--color-primary);
}
.toolbar-btn:active {
background-color: var(--color-primary);
color: white;
}
#media-upload:hover {
border-color: var(--color-primary);
background-color: var(--color-bg);
}
.file-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
background-color: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
font-size: 0.875rem;
}
.file-item .file-info {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
}
.file-item .file-actions {
display: flex;
gap: 0.5rem;
}
.file-item .file-actions button {
background: none;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
padding: 0.25rem;
border-radius: 0.25rem;
transition: var(--transition-fast);
}
.file-item .file-actions button:hover {
color: var(--color-primary);
background-color: var(--color-bg-secondary);
}
.loading {
opacity: 0.7;
pointer-events: none;
}
@media (max-width: 768px) {
div[style*="grid-template-columns: 1fr 1fr"] {
display: block !important;
}
div[style*="grid-template-columns: 1fr 1fr"] > * {
margin-bottom: 2rem;
}
}
</style>
<script>
// Import templates with null safety
const templatesEl = document.getElementById('article-templates');
const templates = templatesEl ? JSON.parse(templatesEl.textContent || '{}') : {};
// Form elements with null checks
const form = document.getElementById('kb-form') as HTMLFormElement | null;
const toolSelect = document.getElementById('tool-select') as HTMLSelectElement | null;
const titleInput = document.getElementById('article-title') as HTMLInputElement | null;
const descriptionInput = document.getElementById('article-description') as HTMLTextAreaElement | null;
const templateSelect = document.getElementById('template-select') as HTMLSelectElement | null;
const markdownEditor = document.getElementById('markdown-editor') as HTMLTextAreaElement | null;
const previewContent = document.getElementById('preview-content') as HTMLElement | null;
const submitBtn = document.getElementById('submit-btn') as HTMLButtonElement | null;
const editorTab = document.getElementById('editor-tab') as HTMLButtonElement | null;
const previewTab = document.getElementById('preview-tab') as HTMLButtonElement | null;
const editorSection = document.getElementById('editor-section') as HTMLElement | null;
const previewSection = document.getElementById('preview-section') as HTMLElement | null;
const mediaUpload = document.getElementById('media-upload') as HTMLElement | null;
const mediaInput = document.getElementById('media-input') as HTMLInputElement | null;
const uploadedFiles = document.getElementById('uploaded-files') as HTMLElement | null;
const filesList = document.getElementById('files-list') as HTMLElement | null;
// Character counters
const titleCount = document.getElementById('title-count') as HTMLElement | null;
const descriptionCount = document.getElementById('description-count') as HTMLElement | null;
const contentStats = document.getElementById('content-stats') as HTMLElement | null;
// Uploaded files tracking
interface UploadedFile {
id: string;
file: File;
name: string;
size: string;
type: string;
uploaded: boolean;
url: string | null;
}
let uploadedFilesList: UploadedFile[] = [];
// Initialize
document.addEventListener('DOMContentLoaded', function() {
// Character counting with null checks
if (titleInput && titleCount) {
titleInput.addEventListener('input', () => {
titleCount.textContent = `${titleInput.value.length}/100`;
validateForm();
});
}
if (descriptionInput && descriptionCount) {
descriptionInput.addEventListener('input', () => {
descriptionCount.textContent = `${descriptionInput.value.length}/300`;
validateForm();
});
}
// Content stats with null checks
if (markdownEditor && contentStats) {
markdownEditor.addEventListener('input', () => {
const content = markdownEditor.value;
const words = content.trim() ? content.trim().split(/\s+/).length : 0;
const chars = content.length;
contentStats.textContent = `Words: ${words} | Characters: ${chars}`;
validateForm();
});
}
// Template selection with null checks
if (templateSelect && markdownEditor && toolSelect) {
templateSelect.addEventListener('change', () => {
if (templateSelect.value && templates[templateSelect.value]) {
const template = templates[templateSelect.value];
const toolName = toolSelect.value || '{TOOL_NAME}';
const content = template.content.replace(/{TOOL_NAME}/g, toolName);
markdownEditor.value = content;
// Update sections checkboxes
const sectionCheckboxes = document.querySelectorAll('input[name="sections"]') as NodeListOf<HTMLInputElement>;
sectionCheckboxes.forEach(cb => {
cb.checked = template.sections.includes(cb.value);
});
markdownEditor.dispatchEvent(new Event('input'));
}
});
}
// Tool selection updates template
if (toolSelect && templateSelect) {
toolSelect.addEventListener('change', () => {
if (templateSelect.value && toolSelect.value) {
templateSelect.dispatchEvent(new Event('change'));
}
validateForm();
});
}
// Tab switching with null checks
if (editorTab && previewTab && editorSection && previewSection) {
editorTab.addEventListener('click', () => {
editorTab.style.backgroundColor = 'var(--color-accent)';
editorTab.style.color = 'white';
previewTab.style.backgroundColor = 'var(--color-bg-secondary)';
previewTab.style.color = 'var(--color-text)';
editorSection.style.display = 'flex';
previewSection.style.display = 'none';
});
previewTab.addEventListener('click', () => {
previewTab.style.backgroundColor = 'var(--color-accent)';
previewTab.style.color = 'white';
editorTab.style.backgroundColor = 'var(--color-bg-secondary)';
editorTab.style.color = 'var(--color-text)';
editorSection.style.display = 'none';
previewSection.style.display = 'flex';
updatePreview();
});
}
// Toolbar actions with null checks
document.querySelectorAll('.toolbar-btn').forEach((btn) => {
const button = btn as HTMLButtonElement;
button.addEventListener('click', () => {
const action = button.dataset.action;
if (action) {
insertMarkdown(action);
}
});
});
// Media upload with null checks
if (mediaUpload && mediaInput) {
mediaUpload.addEventListener('click', () => mediaInput.click());
mediaUpload.addEventListener('dragover', (e) => {
e.preventDefault();
mediaUpload.style.borderColor = 'var(--color-primary)';
});
mediaUpload.addEventListener('dragleave', () => {
mediaUpload.style.borderColor = 'var(--color-border)';
});
mediaUpload.addEventListener('drop', (e) => {
e.preventDefault();
mediaUpload.style.borderColor = 'var(--color-border)';
if (e.dataTransfer?.files) {
handleFiles(e.dataTransfer.files);
}
});
mediaInput.addEventListener('change', (e) => {
const target = e.target as HTMLInputElement;
if (target.files) {
handleFiles(target.files);
}
});
}
// Form submission with null checks
if (form) {
form.addEventListener('submit', handleSubmit);
}
// Initial validation
validateForm();
});
function validateForm() {
if (!toolSelect || !titleInput || !descriptionInput || !markdownEditor || !submitBtn) {
return;
}
const difficultySelect = document.getElementById('difficulty-select') as HTMLSelectElement | null;
if (!difficultySelect) return;
const isValid = toolSelect.value &&
titleInput.value.length >= 5 &&
descriptionInput.value.length >= 20 &&
difficultySelect.value &&
markdownEditor.value.trim().length >= 50;
submitBtn.disabled = !isValid;
submitBtn.style.opacity = isValid ? '1' : '0.6';
}
function insertMarkdown(action: string) {
if (!markdownEditor) return;
const editor = markdownEditor;
const start = editor.selectionStart;
const end = editor.selectionEnd;
const selectedText = editor.value.substring(start, end);
let insertText = '';
switch (action) {
case 'bold':
insertText = `**${selectedText || 'bold text'}**`;
break;
case 'italic':
insertText = `*${selectedText || 'italic text'}*`;
break;
case 'heading':
insertText = `## ${selectedText || 'Heading'}`;
break;
case 'link':
insertText = `[${selectedText || 'link text'}](url)`;
break;
case 'image':
insertText = `![${selectedText || 'alt text'}](image-url)`;
break;
case 'code':
insertText = selectedText ? `\`\`\`\n${selectedText}\n\`\`\`` : '```\ncode\n```';
break;
case 'list':
insertText = selectedText ? selectedText.split('\n').map(line => `- ${line}`).join('\n') : '- List item';
break;
}
editor.value = editor.value.substring(0, start) + insertText + editor.value.substring(end);
editor.focus();
editor.setSelectionRange(start + insertText.length, start + insertText.length);
editor.dispatchEvent(new Event('input'));
}
function updatePreview() {
if (!markdownEditor || !previewContent) return;
const content = markdownEditor.value;
if (!content.trim()) {
previewContent.innerHTML = '<p class="text-muted" style="text-align: center; margin-top: 2rem;">Start writing to see preview...</p>';
return;
}
// Simple markdown parsing (in production, use a proper markdown parser)
let html = content
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
.replace(/\*\*(.*)\*\*/gim, '<strong>$1</strong>')
.replace(/\*(.*)\*/gim, '<em>$1</em>')
.replace(/\[([^\]]*)\]\(([^\)]*)\)/gim, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
.replace(/!\[([^\]]*)\]\(([^\)]*)\)/gim, '<img src="$2" alt="$1" style="max-width: 100%; height: auto;">')
.replace(/```([\s\S]*?)```/gim, '<pre><code>$1</code></pre>')
.replace(/`([^`]*)`/gim, '<code>$1</code>')
.replace(/^\* (.*$)/gim, '<li>$1</li>')
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
.replace(/\n\n/gim, '</p><p>')
.replace(/\n/gim, '<br>');
// Wrap in paragraphs
html = '<p>' + html + '</p>';
previewContent.innerHTML = html;
}
function handleFiles(files: FileList) {
Array.from(files).forEach((file: File) => {
if (file.size > 10 * 1024 * 1024) { // 10MB limit
showMessage('error', `File ${file.name} is too large (max 10MB)`);
return;
}
const fileItem: UploadedFile = {
id: (Date.now() + Math.random()).toString(),
file: file,
name: file.name,
size: formatFileSize(file.size),
type: file.type,
uploaded: false,
url: null
};
uploadedFilesList.push(fileItem);
renderFilesList();
uploadFile(fileItem);
});
}
function renderFilesList() {
if (!uploadedFiles || !filesList) return;
if (uploadedFilesList.length > 0) {
uploadedFiles.style.display = 'block';
filesList.innerHTML = uploadedFilesList.map(file => `
<div class="file-item" data-file-id="${file.id}">
<div class="file-info">
<span>${getFileIcon(file.type)}</span>
<span style="font-weight: 500;">${file.name}</span>
<span style="color: var(--color-text-secondary);">(${file.size})</span>
${file.uploaded ? '<span style="color: var(--color-success); font-size: 0.75rem;">✓ Uploaded</span>' : '<span style="color: var(--color-warning); font-size: 0.75rem;">⏳ Uploading...</span>'}
</div>
<div class="file-actions">
${file.uploaded ? `<button onclick="insertFileReference('${file.url}', '${file.name}', '${file.type}')" title="Insert into content">📝</button>` : ''}
<button onclick="removeFile('${file.id}')" title="Remove">🗑️</button>
</div>
</div>
`).join('');
} else {
uploadedFiles.style.display = 'none';
}
}
async function uploadFile(fileItem: UploadedFile) {
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;
renderFilesList();
} else {
throw new Error('Upload failed');
}
} catch (error) {
showMessage('error', `Failed to upload ${fileItem.name}`);
removeFile(fileItem.id);
}
}
function removeFile(fileId: string) {
uploadedFilesList = uploadedFilesList.filter(f => f.id !== fileId);
renderFilesList();
}
function insertFileReference(url: string, name: string, type: string) {
if (!markdownEditor) return;
let insertText = '';
if (type.startsWith('image/')) {
insertText = `![${name}](${url})`;
} else {
insertText = `[📎 ${name}](${url})`;
}
const editor = markdownEditor;
const cursorPos = editor.selectionStart;
editor.value = editor.value.substring(0, cursorPos) + insertText + editor.value.substring(cursorPos);
editor.focus();
editor.setSelectionRange(cursorPos + insertText.length, cursorPos + insertText.length);
editor.dispatchEvent(new Event('input'));
}
function getFileIcon(type: string) {
if (type.startsWith('image/')) return '🖼️';
if (type.startsWith('video/')) return '🎥';
if (type.includes('pdf')) return '📄';
if (type.includes('document') || type.includes('word')) return '📝';
return '📎';
}
function formatFileSize(bytes: number) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
async function handleSubmit(e: Event) {
e.preventDefault();
if (!submitBtn || !form || submitBtn.disabled) return;
submitBtn.classList.add('loading');
submitBtn.innerHTML = '⏳ Submitting...';
try {
const formData = new FormData(form);
// Collect sections
const sections: Record<string, boolean> = {};
document.querySelectorAll('input[name="sections"]:checked').forEach((checkbox) => {
const cb = checkbox as HTMLInputElement;
sections[cb.value] = true;
});
formData.set('sections', JSON.stringify(sections));
// 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(uploadedFilesList.filter(f => f.uploaded)));
const response = await fetch('/api/contribute/knowledgebase', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
showMessage('success', `Article submitted successfully! <a href="${result.prUrl}" target="_blank" rel="noopener noreferrer">View Pull Request</a>`);
// Reset form or redirect
setTimeout(() => {
window.location.href = '/contribute';
}, 3000);
} else {
throw new Error(result.error || 'Submission failed');
}
} catch (error) {
console.error('Submission error:', error);
showMessage('error', `Submission failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
submitBtn.classList.remove('loading');
submitBtn.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 0.5rem;">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21v-8H7v8"/>
<polyline points="7 3v5h8"/>
</svg>
Submit Article
`;
}
}
function showMessage(type: 'success' | 'error', message: string) {
const messageEl = document.createElement('div');
messageEl.className = `card ${type === 'success' ? 'card-success' : 'card-error'}`;
messageEl.style.cssText = 'padding: 1rem; margin-bottom: 1rem; animation: slideIn 0.3s ease-out;';
messageEl.innerHTML = message;
const container = document.getElementById('form-messages');
if (container) {
container.appendChild(messageEl);
setTimeout(() => {
messageEl.remove();
}, 5000);
}
}
</script>
</BaseLayout>