finalize knowledgebase commit
This commit is contained in:
		
							parent
							
								
									6892aaf7de
								
							
						
					
					
						commit
						fbe8d4d4e1
					
				@ -1,20 +1,20 @@
 | 
			
		||||
// src/pages/api/contribute/knowledgebase.ts (UPDATED - Using consolidated API responses)
 | 
			
		||||
// src/pages/api/contribute/knowledgebase.ts - SIMPLIFIED: Issues only, minimal validation
 | 
			
		||||
import type { APIRoute } from 'astro';
 | 
			
		||||
import { withAPIAuth } from '../../../utils/auth.js';
 | 
			
		||||
import { apiResponse, apiError, apiServerError, apiSpecial, handleAPIRequest } from '../../../utils/api.js';
 | 
			
		||||
import { apiResponse, apiError, apiServerError, handleAPIRequest } from '../../../utils/api.js';
 | 
			
		||||
import { GitContributionManager } from '../../../utils/gitContributions.js';
 | 
			
		||||
import { z } from 'zod';
 | 
			
		||||
 | 
			
		||||
export const prerender = false;
 | 
			
		||||
 | 
			
		||||
// Simplified schema for document-based contributions
 | 
			
		||||
// Simple schema - all fields optional except for having some content
 | 
			
		||||
const KnowledgebaseContributionSchema = z.object({
 | 
			
		||||
  toolName: z.string().min(1),
 | 
			
		||||
  title: z.string().min(1),
 | 
			
		||||
  description: z.string().min(1),
 | 
			
		||||
  content: z.string().default(''),
 | 
			
		||||
  externalLink: z.string().url().optional().or(z.literal('')),
 | 
			
		||||
  difficulty: z.enum(['novice', 'beginner', 'intermediate', 'advanced', 'expert']),
 | 
			
		||||
  toolName: z.string().optional().nullable().transform(val => val || undefined),
 | 
			
		||||
  title: z.string().optional().nullable().transform(val => val || undefined),
 | 
			
		||||
  description: z.string().optional().nullable().transform(val => val || undefined),
 | 
			
		||||
  content: z.string().optional().nullable().transform(val => val || undefined),
 | 
			
		||||
  externalLink: z.string().url().optional().nullable().catch(undefined),
 | 
			
		||||
  difficulty: z.enum(['novice', 'beginner', 'intermediate', 'advanced', 'expert']).optional().nullable().catch(undefined),
 | 
			
		||||
  categories: z.string().transform(str => {
 | 
			
		||||
    try { return JSON.parse(str); } catch { return []; }
 | 
			
		||||
  }).pipe(z.array(z.string()).default([])),
 | 
			
		||||
@ -23,32 +23,27 @@ const KnowledgebaseContributionSchema = z.object({
 | 
			
		||||
  }).pipe(z.array(z.string()).default([])),
 | 
			
		||||
  uploadedFiles: z.string().transform(str => {
 | 
			
		||||
    try { return JSON.parse(str); } catch { return []; }
 | 
			
		||||
  }).pipe(z.array(z.any()).default([]))
 | 
			
		||||
  }).pipe(z.array(z.any()).default([])),
 | 
			
		||||
  reason: z.string().optional().nullable().transform(val => val || undefined)
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
interface KnowledgebaseContributionData {
 | 
			
		||||
  type: 'add';
 | 
			
		||||
  article: {
 | 
			
		||||
    toolName: string;
 | 
			
		||||
    title: string;
 | 
			
		||||
    description: string;
 | 
			
		||||
    content: string;
 | 
			
		||||
    externalLink?: string;
 | 
			
		||||
    difficulty: string;
 | 
			
		||||
    categories: string[];
 | 
			
		||||
    tags: string[];
 | 
			
		||||
    uploadedFiles: any[];
 | 
			
		||||
  };
 | 
			
		||||
  metadata: {
 | 
			
		||||
    submitter: string;
 | 
			
		||||
    reason?: string;
 | 
			
		||||
  };
 | 
			
		||||
  toolName?: string;
 | 
			
		||||
  title?: string;
 | 
			
		||||
  description?: string;
 | 
			
		||||
  content?: string;
 | 
			
		||||
  externalLink?: string;
 | 
			
		||||
  difficulty?: string;
 | 
			
		||||
  categories: string[];
 | 
			
		||||
  tags: string[];
 | 
			
		||||
  uploadedFiles: any[];
 | 
			
		||||
  reason?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Rate limiting
 | 
			
		||||
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
 | 
			
		||||
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour
 | 
			
		||||
const RATE_LIMIT_MAX = 5; // Max 5 submissions per hour per user
 | 
			
		||||
const RATE_LIMIT_MAX = 3; // Max 3 submissions per hour per user
 | 
			
		||||
 | 
			
		||||
function checkRateLimit(userEmail: string): boolean {
 | 
			
		||||
  const now = Date.now();
 | 
			
		||||
@ -67,205 +62,23 @@ function checkRateLimit(userEmail: string): boolean {
 | 
			
		||||
  return true;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function validateKnowledgebaseData(data: any): { valid: boolean; errors?: string[] } {
 | 
			
		||||
  // Only check that they provided SOMETHING
 | 
			
		||||
  const hasContent = data.content?.trim().length > 0;
 | 
			
		||||
  const hasLink = data.externalLink?.trim().length > 0;
 | 
			
		||||
  const hasFiles = data.uploadedFiles?.length > 0;
 | 
			
		||||
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 hasLink = (data.externalLink ?? '').trim().length > 0;
 | 
			
		||||
  const hasFiles = Array.isArray(data.uploadedFiles) && data.uploadedFiles.length > 0;
 | 
			
		||||
  const hasTitle = (data.title ?? '').trim().length > 0;
 | 
			
		||||
  const hasDescription = (data.description ?? '').trim().length > 0;
 | 
			
		||||
  
 | 
			
		||||
  if (!hasContent && !hasLink && !hasFiles) {
 | 
			
		||||
    return { valid: false, errors: ['Must provide content, link, or files'] };
 | 
			
		||||
  if (!hasContent && !hasLink && !hasFiles && !hasTitle && !hasDescription) {
 | 
			
		||||
    return { 
 | 
			
		||||
      valid: false, 
 | 
			
		||||
      errors: ['Please provide at least a title, description, content, external link, or upload files'] 
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  return { valid: true }; // That's it - maximum freedom
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function generateArticleSlug(title: string, toolName: string): string {
 | 
			
		||||
  const baseSlug = title.toLowerCase()
 | 
			
		||||
    .replace(/[^a-z0-9\s-]/g, '')
 | 
			
		||||
    .replace(/\s+/g, '-')
 | 
			
		||||
    .replace(/-+/g, '-')
 | 
			
		||||
    .replace(/^-|-$/g, '');
 | 
			
		||||
  
 | 
			
		||||
  const toolSlug = toolName.toLowerCase()
 | 
			
		||||
    .replace(/[^a-z0-9\s-]/g, '')
 | 
			
		||||
    .replace(/\s+/g, '-')
 | 
			
		||||
    .replace(/-+/g, '-')
 | 
			
		||||
    .replace(/^-|-$/g, '');
 | 
			
		||||
    
 | 
			
		||||
  return `${toolSlug}-${baseSlug}`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function generateMarkdownContent(article: any): string {
 | 
			
		||||
  const now = new Date();
 | 
			
		||||
  
 | 
			
		||||
  // Generate frontmatter
 | 
			
		||||
  const frontmatter = {
 | 
			
		||||
    title: article.title,
 | 
			
		||||
    tool_name: article.toolName,
 | 
			
		||||
    description: article.description,
 | 
			
		||||
    last_updated: now.toISOString().split('T')[0],
 | 
			
		||||
    author: 'CC24-Team',
 | 
			
		||||
    difficulty: article.difficulty,
 | 
			
		||||
    categories: article.categories,
 | 
			
		||||
    tags: article.tags,
 | 
			
		||||
    review_status: 'pending_review'
 | 
			
		||||
  };
 | 
			
		||||
  
 | 
			
		||||
  const frontmatterYaml = Object.entries(frontmatter)
 | 
			
		||||
    .map(([key, value]) => {
 | 
			
		||||
      if (Array.isArray(value)) {
 | 
			
		||||
        return `${key}: [${value.map(v => `"${v}"`).join(', ')}]`;
 | 
			
		||||
      } else {
 | 
			
		||||
        return `${key}: "${value}"`;
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
    .join('\n');
 | 
			
		||||
 | 
			
		||||
  // Generate content sections
 | 
			
		||||
  let content = `---\n${frontmatterYaml}\n---\n\n`;
 | 
			
		||||
  content += `# ${article.title}\n\n`;
 | 
			
		||||
  content += `${article.description}\n\n`;
 | 
			
		||||
  
 | 
			
		||||
  // Add user content if provided
 | 
			
		||||
  if (article.content && article.content.trim().length > 0) {
 | 
			
		||||
    content += `## Content\n\n${article.content}\n\n`;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  // Add external link if provided
 | 
			
		||||
  if (article.externalLink && article.externalLink.trim().length > 0) {
 | 
			
		||||
    content += `## External Resources\n\n`;
 | 
			
		||||
    content += `- [External Documentation](${article.externalLink})\n\n`;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  // Add uploaded files section
 | 
			
		||||
  if (article.uploadedFiles && article.uploadedFiles.length > 0) {
 | 
			
		||||
    content += `## Uploaded Files\n\n`;
 | 
			
		||||
    article.uploadedFiles.forEach((file: any) => {
 | 
			
		||||
      const fileType = file.name.toLowerCase();
 | 
			
		||||
      let icon = '📎';
 | 
			
		||||
      if (fileType.includes('.pdf')) icon = '📄';
 | 
			
		||||
      else if (fileType.match(/\.(png|jpg|jpeg|gif|webp)$/)) icon = '🖼️';
 | 
			
		||||
      else if (fileType.match(/\.(mp4|webm|mov|avi)$/)) icon = '🎥';
 | 
			
		||||
      else if (fileType.match(/\.(doc|docx)$/)) icon = '📝';
 | 
			
		||||
      else if (fileType.match(/\.(zip|tar|gz)$/)) icon = '📦';
 | 
			
		||||
      
 | 
			
		||||
      content += `- ${icon} [${file.name}](${file.url})\n`;
 | 
			
		||||
    });
 | 
			
		||||
    content += '\n';
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  content += `---\n\n`;
 | 
			
		||||
  content += `*This article was contributed via the CC24-Hub knowledge base submission system.*\n`;
 | 
			
		||||
  
 | 
			
		||||
  return content;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Extended GitContributionManager for knowledgebase
 | 
			
		||||
class KnowledgebaseGitManager extends GitContributionManager {
 | 
			
		||||
  async submitKnowledgebaseContribution(data: KnowledgebaseContributionData): Promise<{success: boolean, message: string, prUrl?: string, branchName?: string}> {
 | 
			
		||||
    const branchName = `kb-add-${Date.now()}`;
 | 
			
		||||
    
 | 
			
		||||
    try {
 | 
			
		||||
      // Create branch
 | 
			
		||||
      await this.createBranch(branchName);
 | 
			
		||||
      
 | 
			
		||||
      // Generate file content
 | 
			
		||||
      const slug = generateArticleSlug(data.article.title, data.article.toolName);
 | 
			
		||||
      const markdownContent = generateMarkdownContent(data.article);
 | 
			
		||||
      
 | 
			
		||||
      // Write article file
 | 
			
		||||
      const articlePath = `src/content/knowledgebase/${slug}.md`;
 | 
			
		||||
      await this.writeFile(articlePath, markdownContent);
 | 
			
		||||
      
 | 
			
		||||
      // Commit changes
 | 
			
		||||
      const commitMessage = `Add knowledgebase contribution: ${data.article.title}
 | 
			
		||||
 | 
			
		||||
Contributed by: ${data.metadata.submitter}
 | 
			
		||||
Tool: ${data.article.toolName}
 | 
			
		||||
Type: Document-based contribution
 | 
			
		||||
Difficulty: ${data.article.difficulty}
 | 
			
		||||
Categories: ${data.article.categories.join(', ')}
 | 
			
		||||
Files: ${data.article.uploadedFiles.length} uploaded
 | 
			
		||||
${data.article.externalLink ? `External Link: ${data.article.externalLink}` : ''}
 | 
			
		||||
 | 
			
		||||
${data.metadata.reason ? `Reason: ${data.metadata.reason}` : ''}`;
 | 
			
		||||
      
 | 
			
		||||
      await this.commitChanges(commitMessage);
 | 
			
		||||
      
 | 
			
		||||
      // Push branch
 | 
			
		||||
      await this.pushBranch(branchName);
 | 
			
		||||
      
 | 
			
		||||
      // Create pull request
 | 
			
		||||
      const prUrl = await this.createPullRequest(
 | 
			
		||||
        branchName,
 | 
			
		||||
        `Add KB Article: ${data.article.title}`,
 | 
			
		||||
        this.generateKnowledgebasePRDescription(data)
 | 
			
		||||
      );
 | 
			
		||||
      
 | 
			
		||||
      return {
 | 
			
		||||
        success: true,
 | 
			
		||||
        message: 'Knowledge base article submitted successfully',
 | 
			
		||||
        prUrl,
 | 
			
		||||
        branchName
 | 
			
		||||
      };
 | 
			
		||||
      
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      // Cleanup on failure
 | 
			
		||||
      try {
 | 
			
		||||
        await this.deleteBranch(branchName);
 | 
			
		||||
      } catch (cleanupError) {
 | 
			
		||||
        console.error('Failed to cleanup branch:', cleanupError);
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      throw error;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private generateKnowledgebasePRDescription(data: KnowledgebaseContributionData): string {
 | 
			
		||||
    return `## Knowledge Base Article: ${data.article.title}
 | 
			
		||||
 | 
			
		||||
**Tool:** ${data.article.toolName}  
 | 
			
		||||
**Submitted by:** ${data.metadata.submitter}  
 | 
			
		||||
**Difficulty:** ${data.article.difficulty}
 | 
			
		||||
 | 
			
		||||
### Article Details
 | 
			
		||||
- **Description:** ${data.article.description}
 | 
			
		||||
- **Categories:** ${data.article.categories.length > 0 ? data.article.categories.join(', ') : 'None'}
 | 
			
		||||
- **Tags:** ${data.article.tags.length > 0 ? data.article.tags.join(', ') : 'None'}
 | 
			
		||||
- **Content:** ${data.article.content && data.article.content.trim().length > 0 ? `~${data.article.content.split(/\s+/).length} words` : 'Document/link-based'}
 | 
			
		||||
- **Uploaded Files:** ${data.article.uploadedFiles.length} files
 | 
			
		||||
${data.article.externalLink ? `- **External Link:** ${data.article.externalLink}` : ''}
 | 
			
		||||
 | 
			
		||||
${data.metadata.reason ? `### Reason for Contribution
 | 
			
		||||
${data.metadata.reason}
 | 
			
		||||
 | 
			
		||||
` : ''}### Content Overview
 | 
			
		||||
${data.article.content && data.article.content.trim().length > 0 ? `
 | 
			
		||||
**Provided Content:**
 | 
			
		||||
${data.article.content.substring(0, 200)}${data.article.content.length > 200 ? '...' : ''}
 | 
			
		||||
` : ''}
 | 
			
		||||
${data.article.uploadedFiles.length > 0 ? `
 | 
			
		||||
**Uploaded Files:**
 | 
			
		||||
${data.article.uploadedFiles.map((file: any) => `- ${file.name} (${file.url})`).join('\n')}
 | 
			
		||||
` : ''}
 | 
			
		||||
 | 
			
		||||
### Review Checklist
 | 
			
		||||
- [ ] Article content is accurate and helpful
 | 
			
		||||
- [ ] All uploaded files are accessible and appropriate
 | 
			
		||||
- [ ] External links are valid and safe
 | 
			
		||||
- [ ] Categories and tags are relevant
 | 
			
		||||
- [ ] Difficulty level is appropriate
 | 
			
		||||
- [ ] Content is well-organized and clear
 | 
			
		||||
- [ ] No sensitive or inappropriate content
 | 
			
		||||
- [ ] Proper attribution for external sources
 | 
			
		||||
 | 
			
		||||
### Files Changed
 | 
			
		||||
- \`src/content/knowledgebase/${generateArticleSlug(data.article.title, data.article.toolName)}.md\` (new article)
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
*This contribution was submitted via the CC24-Hub document-based knowledge base system.*`;
 | 
			
		||||
  }
 | 
			
		||||
  return { valid: true };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const POST: APIRoute = async ({ request }) => {
 | 
			
		||||
@ -288,12 +101,12 @@ export const POST: APIRoute = async ({ request }) => {
 | 
			
		||||
    try {
 | 
			
		||||
      formData = await request.formData();
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      return apiSpecial.invalidJSON();
 | 
			
		||||
      return apiError.badRequest('Invalid form data');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const rawData = Object.fromEntries(formData);
 | 
			
		||||
 | 
			
		||||
    // Validate request data
 | 
			
		||||
    // Validate and sanitize data
 | 
			
		||||
    let validatedData;
 | 
			
		||||
    try {
 | 
			
		||||
      validatedData = KnowledgebaseContributionSchema.parse(rawData);
 | 
			
		||||
@ -308,38 +121,39 @@ export const POST: APIRoute = async ({ request }) => {
 | 
			
		||||
      return apiError.badRequest('Invalid request data');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Additional validation
 | 
			
		||||
    const kbValidation = validateKnowledgebaseData(validatedData);
 | 
			
		||||
    if (!kbValidation.valid) {
 | 
			
		||||
      return apiError.validation('Content validation failed', kbValidation.errors);
 | 
			
		||||
    // Basic content validation
 | 
			
		||||
    const contentValidation = validateKnowledgebaseData(validatedData);
 | 
			
		||||
    if (!contentValidation.valid) {
 | 
			
		||||
      return apiError.validation('Content validation failed', contentValidation.errors);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Prepare contribution data
 | 
			
		||||
    const contributionData: KnowledgebaseContributionData = {
 | 
			
		||||
      type: 'add',
 | 
			
		||||
      article: validatedData,
 | 
			
		||||
      metadata: {
 | 
			
		||||
        submitter: userEmail,
 | 
			
		||||
        reason: rawData.reason as string || undefined
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // Submit contribution via Git
 | 
			
		||||
    const gitManager = new KnowledgebaseGitManager();
 | 
			
		||||
    const result = await gitManager.submitKnowledgebaseContribution(contributionData);
 | 
			
		||||
 | 
			
		||||
    if (result.success) {
 | 
			
		||||
      console.log(`[KB CONTRIBUTION] "${validatedData.title}" for ${validatedData.toolName} by ${userEmail} - PR: ${result.prUrl}`);
 | 
			
		||||
      
 | 
			
		||||
      return apiResponse.created({
 | 
			
		||||
        message: result.message,
 | 
			
		||||
        prUrl: result.prUrl,
 | 
			
		||||
        branchName: result.branchName
 | 
			
		||||
    // Submit as issue via Git
 | 
			
		||||
    try {
 | 
			
		||||
      const gitManager = new GitContributionManager();
 | 
			
		||||
      const result = await gitManager.submitKnowledgebaseContribution({
 | 
			
		||||
        ...validatedData,
 | 
			
		||||
        submitter: userEmail
 | 
			
		||||
      });
 | 
			
		||||
    } else {
 | 
			
		||||
      console.error(`[KB CONTRIBUTION FAILED] "${validatedData.title}" by ${userEmail}: ${result.message}`);
 | 
			
		||||
 | 
			
		||||
      return apiServerError.internal(`Contribution failed: ${result.message}`);
 | 
			
		||||
      if (result.success) {
 | 
			
		||||
        console.log(`[KB CONTRIBUTION] "${validatedData.title || 'Article'}" by ${userEmail} - Issue: ${result.issueUrl}`);
 | 
			
		||||
        
 | 
			
		||||
        return apiResponse.created({
 | 
			
		||||
          success: true,
 | 
			
		||||
          message: result.message,
 | 
			
		||||
          issueUrl: result.issueUrl,
 | 
			
		||||
          issueNumber: result.issueNumber
 | 
			
		||||
        });
 | 
			
		||||
      } else {
 | 
			
		||||
        console.error(`[KB CONTRIBUTION FAILED] "${validatedData.title || 'Article'}" by ${userEmail}: ${result.message}`);
 | 
			
		||||
        
 | 
			
		||||
        return apiServerError.internal(`Contribution failed: ${result.message}`);
 | 
			
		||||
      }
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error(`[KB GIT ERROR] "${validatedData.title || 'Article'}" by ${userEmail}:`, error);
 | 
			
		||||
      
 | 
			
		||||
      const errorMessage = error instanceof Error ? error.message : 'Git operation failed';
 | 
			
		||||
      return apiServerError.internal(`Submission failed: ${errorMessage}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
  }, 'Knowledgebase contribution processing failed');
 | 
			
		||||
 | 
			
		||||
@ -45,7 +45,7 @@ const ContributionRequestSchema = z.object({
 | 
			
		||||
// Rate limiting
 | 
			
		||||
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
 | 
			
		||||
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour
 | 
			
		||||
const RATE_LIMIT_MAX = 5; // 5 contributions per hour per user
 | 
			
		||||
const RATE_LIMIT_MAX = 15; // 15 contributions per hour per user
 | 
			
		||||
 | 
			
		||||
function checkRateLimit(userId: string): boolean {
 | 
			
		||||
  const now = Date.now();
 | 
			
		||||
 | 
			
		||||
@ -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;">
 | 
			
		||||
    
 | 
			
		||||
      <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>
 | 
			
		||||
    <!-- 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>
 | 
			
		||||
 | 
			
		||||
          <!-- 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>
 | 
			
		||||
 | 
			
		||||
        <!-- Content -->
 | 
			
		||||
        <div class="form-section">
 | 
			
		||||
          <h3 class="section-title">Content</h3>
 | 
			
		||||
          
 | 
			
		||||
          <!-- Article Content -->
 | 
			
		||||
          <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 -->
 | 
			
		||||
        <div class="form-section">
 | 
			
		||||
          <h3 class="section-title">Upload Files</h3>
 | 
			
		||||
          
 | 
			
		||||
          <!-- File Upload Section -->
 | 
			
		||||
          <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 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 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;
 | 
			
		||||
 | 
			
		||||
    if (!elements.submitBtn || !elements.form) {
 | 
			
		||||
      console.log('[KB FORM DEBUG] Submission blocked - form missing');
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    this.isSubmitting = true;
 | 
			
		||||
    
 | 
			
		||||
    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>
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
// src/utils/gitContributions.ts - MINIMAL: Issues only, no filesystem/git operations
 | 
			
		||||
// src/utils/gitContributions.ts
 | 
			
		||||
import { dump } from 'js-yaml';
 | 
			
		||||
 | 
			
		||||
export interface ContributionData {
 | 
			
		||||
@ -35,6 +35,20 @@ export interface GitOperationResult {
 | 
			
		||||
  issueNumber?: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface KnowledgebaseContribution {
 | 
			
		||||
  toolName?: string;
 | 
			
		||||
  title?: string;
 | 
			
		||||
  description?: string;
 | 
			
		||||
  content?: string;
 | 
			
		||||
  externalLink?: string;
 | 
			
		||||
  difficulty?: string;
 | 
			
		||||
  categories?: string[];
 | 
			
		||||
  tags?: string[];
 | 
			
		||||
  uploadedFiles?: { name: string; url: string }[];
 | 
			
		||||
  submitter: string;
 | 
			
		||||
  reason?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface GitConfig {
 | 
			
		||||
  provider: 'gitea' | 'github' | 'gitlab';
 | 
			
		||||
  apiEndpoint: string;
 | 
			
		||||
@ -86,6 +100,20 @@ export class GitContributionManager {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async submitKnowledgebaseContribution(data: KnowledgebaseContribution): Promise<GitOperationResult> {
 | 
			
		||||
    try {
 | 
			
		||||
      const issueUrl = await this.createKnowledgebaseIssue(data);
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        success: true,
 | 
			
		||||
        message: 'Knowledge base article submitted as issue',
 | 
			
		||||
        issueUrl
 | 
			
		||||
      };
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      throw new Error(`KB issue creation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private generateYAML(tool: any): string {
 | 
			
		||||
    // Clean tool object
 | 
			
		||||
    const cleanTool: any = {
 | 
			
		||||
@ -175,6 +203,61 @@ export class GitContributionManager {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async createKnowledgebaseIssue(data: KnowledgebaseContribution): Promise<string> {
 | 
			
		||||
    const title = `Knowledge Base: ${data.title || data.toolName || 'New Article'}`;
 | 
			
		||||
    const body = this.generateKnowledgebaseIssueBody(data);
 | 
			
		||||
 | 
			
		||||
    let apiUrl: string;
 | 
			
		||||
    let requestBody: any;
 | 
			
		||||
 | 
			
		||||
    switch (this.config.provider) {
 | 
			
		||||
      case 'gitea':
 | 
			
		||||
        apiUrl = `${this.config.apiEndpoint}/repos/${this.config.repoOwner}/${this.config.repoName}/issues`;
 | 
			
		||||
        requestBody = { title, body };
 | 
			
		||||
        break;
 | 
			
		||||
      
 | 
			
		||||
      case 'github':
 | 
			
		||||
        apiUrl = `${this.config.apiEndpoint}/repos/${this.config.repoOwner}/${this.config.repoName}/issues`;
 | 
			
		||||
        requestBody = { title, body };
 | 
			
		||||
        break;
 | 
			
		||||
        
 | 
			
		||||
      case 'gitlab':
 | 
			
		||||
        apiUrl = `${this.config.apiEndpoint}/projects/${encodeURIComponent(this.config.repoOwner + '/' + this.config.repoName)}/issues`;
 | 
			
		||||
        requestBody = { title, description: body };
 | 
			
		||||
        break;
 | 
			
		||||
        
 | 
			
		||||
      default:
 | 
			
		||||
        throw new Error(`Unsupported git provider: ${this.config.provider}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const response = await fetch(apiUrl, {
 | 
			
		||||
      method: 'POST',
 | 
			
		||||
      headers: {
 | 
			
		||||
        'Authorization': `Bearer ${this.config.apiToken}`,
 | 
			
		||||
        'Content-Type': 'application/json'
 | 
			
		||||
      },
 | 
			
		||||
      body: JSON.stringify(requestBody)
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (!response.ok) {
 | 
			
		||||
      const errorText = await response.text();
 | 
			
		||||
      throw new Error(`HTTP ${response.status}: ${errorText}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const issueData = await response.json();
 | 
			
		||||
    
 | 
			
		||||
    // Extract issue URL
 | 
			
		||||
    switch (this.config.provider) {
 | 
			
		||||
      case 'gitea':
 | 
			
		||||
      case 'github':
 | 
			
		||||
        return issueData.html_url || issueData.url;
 | 
			
		||||
      case 'gitlab':
 | 
			
		||||
        return issueData.web_url;
 | 
			
		||||
      default:
 | 
			
		||||
        throw new Error('Unknown provider response format');
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private generateIssueBody(data: ContributionData, toolYaml: string): string {
 | 
			
		||||
    return `## ${data.type === 'add' ? 'Add' : 'Update'} Tool: ${data.tool.name}
 | 
			
		||||
 | 
			
		||||
@ -210,4 +293,103 @@ ${data.metadata.reason}
 | 
			
		||||
---
 | 
			
		||||
*Submitted via CC24-Hub contribution form*`;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private generateKnowledgebaseIssueBody(data: KnowledgebaseContribution): string {
 | 
			
		||||
    const sections: string[] = [];
 | 
			
		||||
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    /* Header                                                             */
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    sections.push(`## Knowledge Base Article: ${data.title ?? 'Untitled'}`);
 | 
			
		||||
    sections.push('');
 | 
			
		||||
    sections.push(`**Submitted by:** ${data.submitter}`);
 | 
			
		||||
    if (data.toolName)   sections.push(`**Related Tool:** ${data.toolName}`);
 | 
			
		||||
    if (data.difficulty) sections.push(`**Difficulty:** ${data.difficulty}`);
 | 
			
		||||
    sections.push('');
 | 
			
		||||
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    /* Description                                                        */
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    if (data.description) {
 | 
			
		||||
      sections.push('### Description');
 | 
			
		||||
      sections.push(data.description);
 | 
			
		||||
      sections.push('');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    /* Content                                                            */
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    if (data.content) {
 | 
			
		||||
      sections.push('### Article Content');
 | 
			
		||||
      sections.push('```markdown');
 | 
			
		||||
      sections.push(data.content);
 | 
			
		||||
      sections.push('```');
 | 
			
		||||
      sections.push('');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    /* External resources                                                 */
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    if (data.externalLink) {
 | 
			
		||||
      sections.push('### External Resource');
 | 
			
		||||
      sections.push(`- [External Documentation](${data.externalLink})`);
 | 
			
		||||
      sections.push('');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    /* Uploaded files                                                     */
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    if (Array.isArray(data.uploadedFiles) && data.uploadedFiles.length) {
 | 
			
		||||
      sections.push('### Uploaded Files');
 | 
			
		||||
      data.uploadedFiles.forEach((file) => {
 | 
			
		||||
        const fileType = file.name.toLowerCase();
 | 
			
		||||
        let icon = '📎';
 | 
			
		||||
 | 
			
		||||
        if (fileType.endsWith('.pdf'))                       icon = '📄';
 | 
			
		||||
        else if (/(png|jpe?g|gif|webp)$/.test(fileType))     icon = '🖼️';
 | 
			
		||||
        else if (/(mp4|webm|mov|avi)$/.test(fileType))       icon = '🎥';
 | 
			
		||||
        else if (/(docx?)$/.test(fileType))                  icon = '📝';
 | 
			
		||||
        else if (/(zip|tar|gz)$/.test(fileType))             icon = '📦';
 | 
			
		||||
 | 
			
		||||
        sections.push(`- ${icon} [${file.name}](${file.url})`);
 | 
			
		||||
      });
 | 
			
		||||
      sections.push('');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    /* Categories & Tags                                                  */
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    const hasCategories = Array.isArray(data.categories) && data.categories.length > 0;
 | 
			
		||||
    const hasTags       = Array.isArray(data.tags)       && data.tags.length > 0;
 | 
			
		||||
 | 
			
		||||
    if (hasCategories || hasTags) {
 | 
			
		||||
      sections.push('### Metadata');
 | 
			
		||||
      if (hasCategories) sections.push(`**Categories:** ${data.categories!.join(', ')}`);
 | 
			
		||||
      if (hasTags)       sections.push(`**Tags:** ${data.tags!.join(', ')}`);
 | 
			
		||||
      sections.push('');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    /* Reason                                                             */
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    if (data.reason) {
 | 
			
		||||
      sections.push('### Reason for Contribution');
 | 
			
		||||
      sections.push(data.reason);
 | 
			
		||||
      sections.push('');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    /* Footer                                                             */
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    sections.push('### For Maintainers');
 | 
			
		||||
    sections.push('1. Review the content for quality and accuracy');
 | 
			
		||||
    sections.push('2. Create the appropriate markdown file in `src/content/knowledgebase/`');
 | 
			
		||||
    sections.push('3. Process any uploaded files as needed');
 | 
			
		||||
    sections.push('4. Close this issue when the article is published');
 | 
			
		||||
    sections.push('');
 | 
			
		||||
    sections.push('---');
 | 
			
		||||
    sections.push('*Submitted via CC24-Hub knowledge base contribution form*');
 | 
			
		||||
 | 
			
		||||
    return sections.join('\n');
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -1,332 +0,0 @@
 | 
			
		||||
// src/utils/markdown.ts
 | 
			
		||||
// Simple markdown parser for client-side preview functionality
 | 
			
		||||
// Note: For production, consider using a proper markdown library like marked or markdown-it
 | 
			
		||||
 | 
			
		||||
export interface MarkdownParseOptions {
 | 
			
		||||
  sanitize?: boolean;
 | 
			
		||||
  breaks?: boolean;
 | 
			
		||||
  linkTarget?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class SimpleMarkdownParser {
 | 
			
		||||
  private options: MarkdownParseOptions;
 | 
			
		||||
 | 
			
		||||
  constructor(options: MarkdownParseOptions = {}) {
 | 
			
		||||
    this.options = {
 | 
			
		||||
      sanitize: true,
 | 
			
		||||
      breaks: true,
 | 
			
		||||
      linkTarget: '_blank',
 | 
			
		||||
      ...options
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Parse markdown to HTML
 | 
			
		||||
   */
 | 
			
		||||
  parse(markdown: string): string {
 | 
			
		||||
    if (!markdown || markdown.trim().length === 0) {
 | 
			
		||||
      return '';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let html = markdown;
 | 
			
		||||
 | 
			
		||||
    // Handle code blocks first (to prevent processing content inside them)
 | 
			
		||||
    html = this.parseCodeBlocks(html);
 | 
			
		||||
    
 | 
			
		||||
    // Parse headers
 | 
			
		||||
    html = this.parseHeaders(html);
 | 
			
		||||
    
 | 
			
		||||
    // Parse bold and italic
 | 
			
		||||
    html = this.parseEmphasis(html);
 | 
			
		||||
    
 | 
			
		||||
    // Parse links and images
 | 
			
		||||
    html = this.parseLinksAndImages(html);
 | 
			
		||||
    
 | 
			
		||||
    // Parse inline code
 | 
			
		||||
    html = this.parseInlineCode(html);
 | 
			
		||||
    
 | 
			
		||||
    // Parse lists
 | 
			
		||||
    html = this.parseLists(html);
 | 
			
		||||
    
 | 
			
		||||
    // Parse blockquotes
 | 
			
		||||
    html = this.parseBlockquotes(html);
 | 
			
		||||
    
 | 
			
		||||
    // Parse horizontal rules
 | 
			
		||||
    html = this.parseHorizontalRules(html);
 | 
			
		||||
    
 | 
			
		||||
    // Parse line breaks and paragraphs
 | 
			
		||||
    html = this.parseLineBreaks(html);
 | 
			
		||||
    
 | 
			
		||||
    // Sanitize if needed
 | 
			
		||||
    if (this.options.sanitize) {
 | 
			
		||||
      html = this.sanitizeHtml(html);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return html.trim();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private parseCodeBlocks(html: string): string {
 | 
			
		||||
    // Replace code blocks with placeholders to protect them
 | 
			
		||||
    const codeBlocks: string[] = [];
 | 
			
		||||
    
 | 
			
		||||
    // Match ```code``` blocks
 | 
			
		||||
    html = html.replace(/```([\s\S]*?)```/g, (match, code) => {
 | 
			
		||||
      const index = codeBlocks.length;
 | 
			
		||||
      const lang = code.split('\n')[0].trim();
 | 
			
		||||
      const content = code.includes('\n') ? code.substring(code.indexOf('\n') + 1) : code;
 | 
			
		||||
      
 | 
			
		||||
      codeBlocks.push(`<pre><code class="language-${this.escapeHtml(lang)}">${this.escapeHtml(content.trim())}</code></pre>`);
 | 
			
		||||
      return `__CODEBLOCK_${index}__`;
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    // Restore code blocks at the end
 | 
			
		||||
    codeBlocks.forEach((block, index) => {
 | 
			
		||||
      html = html.replace(`__CODEBLOCK_${index}__`, block);
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    return html;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private parseHeaders(html: string): string {
 | 
			
		||||
    // H1-H6 headers
 | 
			
		||||
    for (let i = 6; i >= 1; i--) {
 | 
			
		||||
      const headerRegex = new RegExp(`^#{${i}}\\s+(.+)$`, 'gm');
 | 
			
		||||
      html = html.replace(headerRegex, `<h${i}>$1</h${i}>`);
 | 
			
		||||
    }
 | 
			
		||||
    return html;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private parseEmphasis(html: string): string {
 | 
			
		||||
    // Bold: **text** or __text__
 | 
			
		||||
    html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
 | 
			
		||||
    html = html.replace(/__(.*?)__/g, '<strong>$1</strong>');
 | 
			
		||||
    
 | 
			
		||||
    // Italic: *text* or _text_
 | 
			
		||||
    html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
 | 
			
		||||
    html = html.replace(/_(.*?)_/g, '<em>$1</em>');
 | 
			
		||||
    
 | 
			
		||||
    return html;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private parseLinksAndImages(html: string): string {
 | 
			
		||||
    const linkTarget = this.options.linkTarget ? ` target="${this.options.linkTarget}" rel="noopener noreferrer"` : '';
 | 
			
		||||
    
 | 
			
		||||
    // Images: 
 | 
			
		||||
    html = html.replace(/!\[([^\]]*)\]\(([^)]*)\)/g, 
 | 
			
		||||
      '<img src="$2" alt="$1" style="max-width: 100%; height: auto; border-radius: 0.25rem; margin: 0.5rem 0;" />');
 | 
			
		||||
    
 | 
			
		||||
    // Links: [text](url)
 | 
			
		||||
    html = html.replace(/\[([^\]]*)\]\(([^)]*)\)/g, 
 | 
			
		||||
      `<a href="$2"${linkTarget}>$1</a>`);
 | 
			
		||||
    
 | 
			
		||||
    return html;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private parseInlineCode(html: string): string {
 | 
			
		||||
    // Inline code: `code`
 | 
			
		||||
    html = html.replace(/`([^`]*)`/g, '<code>$1</code>');
 | 
			
		||||
    return html;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private parseLists(html: string): string {
 | 
			
		||||
    // Unordered lists
 | 
			
		||||
    html = html.replace(/^[\s]*[-*+]\s+(.+)$/gm, '<li>$1</li>');
 | 
			
		||||
    
 | 
			
		||||
    // Ordered lists
 | 
			
		||||
    html = html.replace(/^[\s]*\d+\.\s+(.+)$/gm, '<li>$1</li>');
 | 
			
		||||
    
 | 
			
		||||
    // Wrap consecutive list items in ul/ol
 | 
			
		||||
    html = html.replace(/(<li>.*<\/li>)/s, (match) => {
 | 
			
		||||
      // Simple approach: assume unordered list
 | 
			
		||||
      return `<ul>${match}</ul>`;
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    return html;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private parseBlockquotes(html: string): string {
 | 
			
		||||
    // Blockquotes: > text
 | 
			
		||||
    html = html.replace(/^>\s+(.+)$/gm, '<blockquote>$1</blockquote>');
 | 
			
		||||
    
 | 
			
		||||
    // Merge consecutive blockquotes
 | 
			
		||||
    html = html.replace(/(<\/blockquote>)\s*(<blockquote>)/g, ' ');
 | 
			
		||||
    
 | 
			
		||||
    return html;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private parseHorizontalRules(html: string): string {
 | 
			
		||||
    // Horizontal rules: --- or ***
 | 
			
		||||
    html = html.replace(/^[-*]{3,}$/gm, '<hr>');
 | 
			
		||||
    return html;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private parseLineBreaks(html: string): string {
 | 
			
		||||
    if (!this.options.breaks) {
 | 
			
		||||
      return html;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Split into paragraphs (double line breaks)
 | 
			
		||||
    const paragraphs = html.split(/\n\s*\n/);
 | 
			
		||||
    
 | 
			
		||||
    const processedParagraphs = paragraphs.map(paragraph => {
 | 
			
		||||
      const trimmed = paragraph.trim();
 | 
			
		||||
      
 | 
			
		||||
      // Skip if already wrapped in HTML tag
 | 
			
		||||
      if (trimmed.startsWith('<') && trimmed.endsWith('>')) {
 | 
			
		||||
        return trimmed;
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      // Single line breaks become <br>
 | 
			
		||||
      const withBreaks = trimmed.replace(/\n/g, '<br>');
 | 
			
		||||
      
 | 
			
		||||
      // Wrap in paragraph if not empty and not already a block element
 | 
			
		||||
      if (withBreaks && !this.isBlockElement(withBreaks)) {
 | 
			
		||||
        return `<p>${withBreaks}</p>`;
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      return withBreaks;
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    return processedParagraphs.filter(p => p.trim()).join('\n\n');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private isBlockElement(html: string): boolean {
 | 
			
		||||
    const blockTags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'div', 'ul', 'ol', 'li', 'blockquote', 'pre', 'hr'];
 | 
			
		||||
    return blockTags.some(tag => html.startsWith(`<${tag}`));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private sanitizeHtml(html: string): string {
 | 
			
		||||
    // Very basic HTML sanitization - for production use a proper library
 | 
			
		||||
    const allowedTags = [
 | 
			
		||||
      'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
 | 
			
		||||
      'p', 'br', 'strong', 'em', 'code', 'pre',
 | 
			
		||||
      'a', 'img', 'ul', 'ol', 'li', 'blockquote', 'hr'
 | 
			
		||||
    ];
 | 
			
		||||
    
 | 
			
		||||
    // Remove script tags and event handlers
 | 
			
		||||
    html = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
 | 
			
		||||
    html = html.replace(/\bon\w+\s*=\s*"[^"]*"/gi, '');
 | 
			
		||||
    html = html.replace(/\bon\w+\s*=\s*'[^']*'/gi, '');
 | 
			
		||||
    html = html.replace(/javascript:/gi, '');
 | 
			
		||||
    
 | 
			
		||||
    // This is a very basic sanitizer - for production use a proper library like DOMPurify
 | 
			
		||||
    return html;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private escapeHtml(text: string): string {
 | 
			
		||||
    const div = document.createElement('div');
 | 
			
		||||
    div.textContent = text;
 | 
			
		||||
    return div.innerHTML;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Extract plain text from markdown (for word/character counting)
 | 
			
		||||
   */
 | 
			
		||||
  extractText(markdown: string): string {
 | 
			
		||||
    // Remove markdown syntax and return plain text
 | 
			
		||||
    let text = markdown;
 | 
			
		||||
    
 | 
			
		||||
    // Remove code blocks
 | 
			
		||||
    text = text.replace(/```[\s\S]*?```/g, '');
 | 
			
		||||
    
 | 
			
		||||
    // Remove inline code
 | 
			
		||||
    text = text.replace(/`[^`]*`/g, '');
 | 
			
		||||
    
 | 
			
		||||
    // Remove images
 | 
			
		||||
    text = text.replace(/!\[[^\]]*\]\([^)]*\)/g, '');
 | 
			
		||||
    
 | 
			
		||||
    // Remove links but keep text
 | 
			
		||||
    text = text.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1');
 | 
			
		||||
    
 | 
			
		||||
    // Remove headers
 | 
			
		||||
    text = text.replace(/^#{1,6}\s+/gm, '');
 | 
			
		||||
    
 | 
			
		||||
    // Remove emphasis
 | 
			
		||||
    text = text.replace(/\*\*(.*?)\*\*/g, '$1');
 | 
			
		||||
    text = text.replace(/\*(.*?)\*/g, '$1');
 | 
			
		||||
    text = text.replace(/__(.*?)__/g, '$1');
 | 
			
		||||
    text = text.replace(/_(.*?)_/g, '$1');
 | 
			
		||||
    
 | 
			
		||||
    // Remove blockquotes
 | 
			
		||||
    text = text.replace(/^>\s+/gm, '');
 | 
			
		||||
    
 | 
			
		||||
    // Remove list markers
 | 
			
		||||
    text = text.replace(/^[\s]*[-*+]\s+/gm, '');
 | 
			
		||||
    text = text.replace(/^[\s]*\d+\.\s+/gm, '');
 | 
			
		||||
    
 | 
			
		||||
    // Remove horizontal rules
 | 
			
		||||
    text = text.replace(/^[-*]{3,}$/gm, '');
 | 
			
		||||
    
 | 
			
		||||
    // Clean up whitespace
 | 
			
		||||
    text = text.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim();
 | 
			
		||||
    
 | 
			
		||||
    return text;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Count words in markdown text
 | 
			
		||||
   */
 | 
			
		||||
  countWords(markdown: string): number {
 | 
			
		||||
    const plainText = this.extractText(markdown);
 | 
			
		||||
    if (!plainText.trim()) return 0;
 | 
			
		||||
    return plainText.trim().split(/\s+/).length;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Count characters in markdown text
 | 
			
		||||
   */
 | 
			
		||||
  countCharacters(markdown: string): number {
 | 
			
		||||
    return this.extractText(markdown).length;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Generate table of contents from headers
 | 
			
		||||
   */
 | 
			
		||||
  generateTOC(markdown: string): Array<{level: number, text: string, anchor: string}> {
 | 
			
		||||
    const headers: Array<{level: number, text: string, anchor: string}> = [];
 | 
			
		||||
    const lines = markdown.split('\n');
 | 
			
		||||
    
 | 
			
		||||
    lines.forEach(line => {
 | 
			
		||||
      const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
 | 
			
		||||
      if (headerMatch) {
 | 
			
		||||
        const level = headerMatch[1].length;
 | 
			
		||||
        const text = headerMatch[2].trim();
 | 
			
		||||
        const anchor = text.toLowerCase()
 | 
			
		||||
          .replace(/[^a-z0-9\s-]/g, '')
 | 
			
		||||
          .replace(/\s+/g, '-')
 | 
			
		||||
          .replace(/-+/g, '-')
 | 
			
		||||
          .replace(/^-|-$/g, '');
 | 
			
		||||
        
 | 
			
		||||
        headers.push({ level, text, anchor });
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    return headers;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Convenience functions for global use
 | 
			
		||||
export function parseMarkdown(markdown: string, options?: MarkdownParseOptions): string {
 | 
			
		||||
  const parser = new SimpleMarkdownParser(options);
 | 
			
		||||
  return parser.parse(markdown);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function extractTextFromMarkdown(markdown: string): string {
 | 
			
		||||
  const parser = new SimpleMarkdownParser();
 | 
			
		||||
  return parser.extractText(markdown);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function countWordsInMarkdown(markdown: string): number {
 | 
			
		||||
  const parser = new SimpleMarkdownParser();
 | 
			
		||||
  return parser.countWords(markdown);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function countCharactersInMarkdown(markdown: string): number {
 | 
			
		||||
  const parser = new SimpleMarkdownParser();
 | 
			
		||||
  return parser.countCharacters(markdown);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function generateMarkdownTOC(markdown: string): Array<{level: number, text: string, anchor: string}> {
 | 
			
		||||
  const parser = new SimpleMarkdownParser();
 | 
			
		||||
  return parser.generateTOC(markdown);
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user