fix contrib mechanic
This commit is contained in:
		
							parent
							
								
									2fcc84991a
								
							
						
					
					
						commit
						4cc3e2c830
					
				@ -5,6 +5,7 @@ import '../styles/global.css';
 | 
			
		||||
import '../styles/auditTrail.css';
 | 
			
		||||
import '../styles/knowledgebase.css';
 | 
			
		||||
import '../styles/palette.css';
 | 
			
		||||
import '../styles/autocomplete.css';
 | 
			
		||||
 | 
			
		||||
export interface Props {
 | 
			
		||||
  title: string;
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
// src/pages/api/contribute/tool.ts (UPDATED - Using consolidated API responses)
 | 
			
		||||
// src/pages/api/contribute/tool.ts (UPDATED - Using consolidated API responses + related_software)
 | 
			
		||||
import type { APIRoute } from 'astro';
 | 
			
		||||
import { withAPIAuth } from '../../../utils/auth.js';
 | 
			
		||||
import { apiResponse, apiError, apiServerError, apiSpecial, handleAPIRequest } from '../../../utils/api.js';
 | 
			
		||||
@ -27,6 +27,7 @@ const ContributionToolSchema = z.object({
 | 
			
		||||
  knowledgebase: z.boolean().optional().nullable(),
 | 
			
		||||
  'domain-agnostic-software': z.array(z.string()).optional().nullable(),
 | 
			
		||||
  related_concepts: z.array(z.string()).optional().nullable(),
 | 
			
		||||
  related_software: z.array(z.string()).optional().nullable(),
 | 
			
		||||
  tags: z.array(z.string()).default([]),
 | 
			
		||||
  statusUrl: z.string().url('Must be a valid URL').optional().nullable()
 | 
			
		||||
});
 | 
			
		||||
@ -80,6 +81,38 @@ function sanitizeInput(obj: any): any {
 | 
			
		||||
  return obj;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function preprocessFormData(body: any): any {
 | 
			
		||||
  // Handle comma-separated strings from autocomplete inputs
 | 
			
		||||
  if (body.tool) {
 | 
			
		||||
    // Handle tags
 | 
			
		||||
    if (typeof body.tool.tags === 'string') {
 | 
			
		||||
      body.tool.tags = body.tool.tags.split(',').map((t: string) => t.trim()).filter(Boolean);
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Handle related concepts
 | 
			
		||||
    if (body.tool.relatedConcepts) {
 | 
			
		||||
      if (typeof body.tool.relatedConcepts === 'string') {
 | 
			
		||||
        body.tool.related_concepts = body.tool.relatedConcepts.split(',').map((t: string) => t.trim()).filter(Boolean);
 | 
			
		||||
      } else {
 | 
			
		||||
        body.tool.related_concepts = body.tool.relatedConcepts;
 | 
			
		||||
      }
 | 
			
		||||
      delete body.tool.relatedConcepts; // Remove the original key
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Handle related software
 | 
			
		||||
    if (body.tool.relatedSoftware) {
 | 
			
		||||
      if (typeof body.tool.relatedSoftware === 'string') {
 | 
			
		||||
        body.tool.related_software = body.tool.relatedSoftware.split(',').map((t: string) => t.trim()).filter(Boolean);
 | 
			
		||||
      } else {
 | 
			
		||||
        body.tool.related_software = body.tool.relatedSoftware;
 | 
			
		||||
      }
 | 
			
		||||
      delete body.tool.relatedSoftware; // Remove the original key
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  return body;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function validateToolData(tool: any, action: string): Promise<{ valid: boolean; errors: string[] }> {
 | 
			
		||||
  const errors: string[] = [];
 | 
			
		||||
  
 | 
			
		||||
@ -109,6 +142,17 @@ async function validateToolData(tool: any, action: string): Promise<{ valid: boo
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Validate related items exist (optional validation - could be enhanced)
 | 
			
		||||
    if (tool.related_concepts && tool.related_concepts.length > 0) {
 | 
			
		||||
      // Could validate that referenced concepts actually exist
 | 
			
		||||
      console.log('[VALIDATION] Related concepts provided:', tool.related_concepts);
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    if (tool.related_software && tool.related_software.length > 0) {
 | 
			
		||||
      // Could validate that referenced software actually exists
 | 
			
		||||
      console.log('[VALIDATION] Related software provided:', tool.related_software);
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    return { valid: errors.length === 0, errors };
 | 
			
		||||
    
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
@ -143,6 +187,9 @@ export const POST: APIRoute = async ({ request }) => {
 | 
			
		||||
      return apiSpecial.invalidJSON();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Preprocess form data to handle autocomplete inputs
 | 
			
		||||
    body = preprocessFormData(body);
 | 
			
		||||
    
 | 
			
		||||
    const sanitizedBody = sanitizeInput(body);
 | 
			
		||||
 | 
			
		||||
    let validatedData;
 | 
			
		||||
@ -153,6 +200,7 @@ export const POST: APIRoute = async ({ request }) => {
 | 
			
		||||
        const errorMessages = error.errors.map(err => 
 | 
			
		||||
          `${err.path.join('.')}: ${err.message}`
 | 
			
		||||
        );
 | 
			
		||||
        console.log('[VALIDATION] Zod validation errors:', errorMessages);
 | 
			
		||||
        return apiError.validation('Validation failed', errorMessages);
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
@ -174,6 +222,16 @@ export const POST: APIRoute = async ({ request }) => {
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    console.log('[CONTRIBUTION] Processing contribution:', {
 | 
			
		||||
      type: contributionData.type,
 | 
			
		||||
      toolName: contributionData.tool.name,
 | 
			
		||||
      toolType: contributionData.tool.type,
 | 
			
		||||
      submitter: userEmail,
 | 
			
		||||
      hasRelatedConcepts: !!(contributionData.tool.related_concepts?.length),
 | 
			
		||||
      hasRelatedSoftware: !!(contributionData.tool.related_software?.length),
 | 
			
		||||
      tagsCount: contributionData.tool.tags?.length || 0
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      const gitManager = new GitContributionManager();
 | 
			
		||||
      const result = await gitManager.submitContribution(contributionData);
 | 
			
		||||
 | 
			
		||||
@ -22,6 +22,17 @@ const existingTools = data.tools;
 | 
			
		||||
const editToolName = Astro.url.searchParams.get('edit');
 | 
			
		||||
const editTool = editToolName ? existingTools.find(tool => tool.name === editToolName) : null;
 | 
			
		||||
const isEdit = !!editTool;
 | 
			
		||||
 | 
			
		||||
// Extract data for autocomplete
 | 
			
		||||
const allTags = [...new Set(existingTools.flatMap(tool => tool.tags || []))].sort();
 | 
			
		||||
const allSoftwareAndMethods = existingTools
 | 
			
		||||
  .filter(tool => tool.type === 'software' || tool.type === 'method')
 | 
			
		||||
  .map(tool => tool.name)
 | 
			
		||||
  .sort();
 | 
			
		||||
const allConcepts = existingTools
 | 
			
		||||
  .filter(tool => tool.type === 'concept')
 | 
			
		||||
  .map(tool => tool.name)
 | 
			
		||||
  .sort();
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<BaseLayout title={isEdit ? `Edit ${editTool?.name}` : 'Contribute Tool'}>
 | 
			
		||||
@ -194,16 +205,27 @@ const isEdit = !!editTool;
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div id="concepts-fields" style="border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 2rem; display: none;">
 | 
			
		||||
          <h3 style="margin: 0 0 1.5rem 0; color: var(--color-primary); border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem;">Konzepte im Zusammenhang</h3>
 | 
			
		||||
          <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0.5rem;">
 | 
			
		||||
            {existingTools.filter(tool => tool.type === 'concept').map(concept => (
 | 
			
		||||
              <label class="checkbox-wrapper">
 | 
			
		||||
                <input type="checkbox" name="relatedConcepts" value={concept.name} 
 | 
			
		||||
                       checked={editTool?.related_concepts?.includes(concept.name)} />
 | 
			
		||||
                <span>{concept.name}</span>
 | 
			
		||||
              </label>
 | 
			
		||||
            ))}
 | 
			
		||||
        <div id="relations-fields" style="border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 2rem; display: none;">
 | 
			
		||||
          <h3 style="margin: 0 0 1.5rem 0; color: var(--color-primary); border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem;">Verwandte Tools & Konzepte</h3>
 | 
			
		||||
          
 | 
			
		||||
          <div style="display: grid; gap: 1.5rem;">
 | 
			
		||||
            <div id="related-concepts-section">
 | 
			
		||||
              <label for="related-concepts-input" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">Verwandte Konzepte</label>
 | 
			
		||||
              <input type="text" id="related-concepts-input" placeholder="Beginne zu tippen, um Konzepte zu finden..." />
 | 
			
		||||
              <input type="hidden" id="related-concepts-hidden" name="relatedConcepts" value={editTool?.related_concepts?.join(', ') || ''} />
 | 
			
		||||
              <small style="display: block; margin-top: 0.25rem; color: var(--color-text-secondary); font-size: 0.8125rem;">
 | 
			
		||||
                Konzepte, die mit diesem Tool/Methode in Verbindung stehen
 | 
			
		||||
              </small>
 | 
			
		||||
            </div>
 | 
			
		||||
            
 | 
			
		||||
            <div id="related-software-section">
 | 
			
		||||
              <label for="related-software-input" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">Verwandte Software & Methoden</label>
 | 
			
		||||
              <input type="text" id="related-software-input" placeholder="Beginne zu tippen, um Software/Methoden zu finden..." />
 | 
			
		||||
              <input type="hidden" id="related-software-hidden" name="relatedSoftware" value={editTool?.related_software?.join(', ') || ''} />
 | 
			
		||||
              <small style="display: block; margin-top: 0.25rem; color: var(--color-text-secondary); font-size: 0.8125rem;">
 | 
			
		||||
                Software oder Methoden, die oft zusammen mit diesem Tool verwendet werden
 | 
			
		||||
              </small>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
@ -211,9 +233,12 @@ const isEdit = !!editTool;
 | 
			
		||||
          <h3 style="margin: 0 0 1.5rem 0; color: var(--color-primary); border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem;">Zusatzinfos</h3>
 | 
			
		||||
          
 | 
			
		||||
          <div style="margin-bottom: 1.5rem;">
 | 
			
		||||
            <label for="tags" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">Tags</label>
 | 
			
		||||
            <input type="text" id="tags" name="tags" value={editTool?.tags?.join(', ') || ''} 
 | 
			
		||||
                   placeholder="Komma-getrennt: Passende Begriffe, nach denen ihr suchen würdet." />
 | 
			
		||||
            <label for="tags-input" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">Tags</label>
 | 
			
		||||
            <input type="text" id="tags-input" placeholder="Beginne zu tippen, um Tags hinzuzufügen..." />
 | 
			
		||||
            <input type="hidden" id="tags-hidden" name="tags" value={editTool?.tags?.join(', ') || ''} />
 | 
			
		||||
            <small style="display: block; margin-top: 0.25rem; color: var(--color-text-secondary); font-size: 0.8125rem;">
 | 
			
		||||
              Passende Begriffe, nach denen ihr suchen würdet
 | 
			
		||||
            </small>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div style="margin-bottom: 1.5rem;">
 | 
			
		||||
@ -274,7 +299,275 @@ const isEdit = !!editTool;
 | 
			
		||||
  </div>
 | 
			
		||||
</BaseLayout>
 | 
			
		||||
 | 
			
		||||
<script define:vars={{ isEdit, editTool, domains, phases, domainAgnosticSoftware }}>
 | 
			
		||||
<script define:vars={{ isEdit, editTool, domains, phases, domainAgnosticSoftware, allTags, allSoftwareAndMethods, allConcepts }}>
 | 
			
		||||
// Consolidated Autocomplete Functionality - inlined to avoid module loading issues
 | 
			
		||||
class AutocompleteManager {
 | 
			
		||||
  constructor(inputElement, dataSource, options = {}) {
 | 
			
		||||
    this.input = inputElement;
 | 
			
		||||
    this.dataSource = dataSource;
 | 
			
		||||
    this.options = {
 | 
			
		||||
      minLength: 1,
 | 
			
		||||
      maxResults: 10,
 | 
			
		||||
      placeholder: 'Type to search...',
 | 
			
		||||
      allowMultiple: false,
 | 
			
		||||
      separator: ', ',
 | 
			
		||||
      filterFunction: this.defaultFilter.bind(this),
 | 
			
		||||
      renderFunction: this.defaultRender.bind(this),
 | 
			
		||||
      ...options
 | 
			
		||||
    };
 | 
			
		||||
    
 | 
			
		||||
    this.isOpen = false;
 | 
			
		||||
    this.selectedIndex = -1;
 | 
			
		||||
    this.filteredData = [];
 | 
			
		||||
    this.selectedItems = new Set();
 | 
			
		||||
    
 | 
			
		||||
    this.init();
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  init() {
 | 
			
		||||
    this.createDropdown();
 | 
			
		||||
    this.bindEvents();
 | 
			
		||||
    
 | 
			
		||||
    if (this.options.allowMultiple) {
 | 
			
		||||
      this.initMultipleMode();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  createDropdown() {
 | 
			
		||||
    this.dropdown = document.createElement('div');
 | 
			
		||||
    this.dropdown.className = 'autocomplete-dropdown';
 | 
			
		||||
    
 | 
			
		||||
    // Insert dropdown after input
 | 
			
		||||
    this.input.parentNode.style.position = 'relative';
 | 
			
		||||
    this.input.parentNode.insertBefore(this.dropdown, this.input.nextSibling);
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  bindEvents() {
 | 
			
		||||
    this.input.addEventListener('input', (e) => {
 | 
			
		||||
      this.handleInput(e.target.value);
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    this.input.addEventListener('keydown', (e) => {
 | 
			
		||||
      this.handleKeydown(e);
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    this.input.addEventListener('focus', () => {
 | 
			
		||||
      if (this.input.value.length >= this.options.minLength) {
 | 
			
		||||
        this.showDropdown();
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    this.input.addEventListener('blur', (e) => {
 | 
			
		||||
      // Delay to allow click events on dropdown items
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        if (!this.dropdown.contains(document.activeElement)) {
 | 
			
		||||
          this.hideDropdown();
 | 
			
		||||
        }
 | 
			
		||||
      }, 150);
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    document.addEventListener('click', (e) => {
 | 
			
		||||
      if (!this.input.contains(e.target) && !this.dropdown.contains(e.target)) {
 | 
			
		||||
        this.hideDropdown();
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  initMultipleMode() {
 | 
			
		||||
    this.selectedContainer = document.createElement('div');
 | 
			
		||||
    this.selectedContainer.className = 'autocomplete-selected';
 | 
			
		||||
    
 | 
			
		||||
    this.input.parentNode.insertBefore(this.selectedContainer, this.input);
 | 
			
		||||
    this.updateSelectedDisplay();
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  handleInput(value) {
 | 
			
		||||
    if (value.length >= this.options.minLength) {
 | 
			
		||||
      this.filteredData = this.options.filterFunction(value);
 | 
			
		||||
      this.selectedIndex = -1;
 | 
			
		||||
      this.renderDropdown();
 | 
			
		||||
      this.showDropdown();
 | 
			
		||||
    } else {
 | 
			
		||||
      this.hideDropdown();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  handleKeydown(e) {
 | 
			
		||||
    if (!this.isOpen) return;
 | 
			
		||||
    
 | 
			
		||||
    switch (e.key) {
 | 
			
		||||
      case 'ArrowDown':
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
        this.selectedIndex = Math.min(this.selectedIndex + 1, this.filteredData.length - 1);
 | 
			
		||||
        this.updateHighlight();
 | 
			
		||||
        break;
 | 
			
		||||
        
 | 
			
		||||
      case 'ArrowUp':
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
        this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
 | 
			
		||||
        this.updateHighlight();
 | 
			
		||||
        break;
 | 
			
		||||
        
 | 
			
		||||
      case 'Enter':
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
        if (this.selectedIndex >= 0) {
 | 
			
		||||
          this.selectItem(this.filteredData[this.selectedIndex]);
 | 
			
		||||
        }
 | 
			
		||||
        break;
 | 
			
		||||
        
 | 
			
		||||
      case 'Escape':
 | 
			
		||||
        this.hideDropdown();
 | 
			
		||||
        break;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  defaultFilter(query) {
 | 
			
		||||
    const searchTerm = query.toLowerCase();
 | 
			
		||||
    return this.dataSource
 | 
			
		||||
      .filter(item => {
 | 
			
		||||
        const text = typeof item === 'string' ? item : item.name || item.label || item.toString();
 | 
			
		||||
        return text.toLowerCase().includes(searchTerm) && 
 | 
			
		||||
               (!this.options.allowMultiple || !this.selectedItems.has(text));
 | 
			
		||||
      })
 | 
			
		||||
      .slice(0, this.options.maxResults);
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  defaultRender(item) {
 | 
			
		||||
    const text = typeof item === 'string' ? item : item.name || item.label || item.toString();
 | 
			
		||||
    return `<div class="autocomplete-item">${this.escapeHtml(text)}</div>`;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  renderDropdown() {
 | 
			
		||||
    if (this.filteredData.length === 0) {
 | 
			
		||||
      this.dropdown.innerHTML = '<div class="autocomplete-no-results">No results found</div>';
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    this.dropdown.innerHTML = this.filteredData
 | 
			
		||||
      .map((item, index) => {
 | 
			
		||||
        const content = this.options.renderFunction(item);
 | 
			
		||||
        return `<div class="autocomplete-option" data-index="${index}">${content}</div>`;
 | 
			
		||||
      })
 | 
			
		||||
      .join('');
 | 
			
		||||
    
 | 
			
		||||
    // Bind click events
 | 
			
		||||
    this.dropdown.querySelectorAll('.autocomplete-option').forEach((option, index) => {
 | 
			
		||||
      option.addEventListener('click', () => {
 | 
			
		||||
        this.selectItem(this.filteredData[index]);
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      option.addEventListener('mouseenter', () => {
 | 
			
		||||
        this.selectedIndex = index;
 | 
			
		||||
        this.updateHighlight();
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  updateHighlight() {
 | 
			
		||||
    this.dropdown.querySelectorAll('.autocomplete-option').forEach((option, index) => {
 | 
			
		||||
      option.style.backgroundColor = index === this.selectedIndex 
 | 
			
		||||
        ? 'var(--color-bg-secondary)' 
 | 
			
		||||
        : 'transparent';
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  selectItem(item) {
 | 
			
		||||
    const text = typeof item === 'string' ? item : item.name || item.label || item.toString();
 | 
			
		||||
    
 | 
			
		||||
    if (this.options.allowMultiple) {
 | 
			
		||||
      this.selectedItems.add(text);
 | 
			
		||||
      this.updateSelectedDisplay();
 | 
			
		||||
      this.updateInputValue();
 | 
			
		||||
      this.input.value = '';
 | 
			
		||||
    } else {
 | 
			
		||||
      this.input.value = text;
 | 
			
		||||
      this.hideDropdown();
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Trigger change event
 | 
			
		||||
    this.input.dispatchEvent(new CustomEvent('autocomplete:select', {
 | 
			
		||||
      detail: { item, text, selectedItems: Array.from(this.selectedItems) }
 | 
			
		||||
    }));
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  removeItem(text) {
 | 
			
		||||
    if (this.options.allowMultiple) {
 | 
			
		||||
      this.selectedItems.delete(text);
 | 
			
		||||
      this.updateSelectedDisplay();
 | 
			
		||||
      this.updateInputValue();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  updateSelectedDisplay() {
 | 
			
		||||
    if (!this.options.allowMultiple || !this.selectedContainer) return;
 | 
			
		||||
    
 | 
			
		||||
    this.selectedContainer.innerHTML = Array.from(this.selectedItems)
 | 
			
		||||
      .map(item => `
 | 
			
		||||
        <span class="autocomplete-tag">
 | 
			
		||||
          ${this.escapeHtml(item)}
 | 
			
		||||
          <button type="button" class="autocomplete-remove" data-item="${this.escapeHtml(item)}">×</button>
 | 
			
		||||
        </span>
 | 
			
		||||
      `)
 | 
			
		||||
      .join('');
 | 
			
		||||
    
 | 
			
		||||
    // Bind remove events
 | 
			
		||||
    this.selectedContainer.querySelectorAll('.autocomplete-remove').forEach(btn => {
 | 
			
		||||
      btn.addEventListener('click', (e) => {
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
        this.removeItem(btn.getAttribute('data-item'));
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  updateInputValue() {
 | 
			
		||||
    if (this.options.allowMultiple && this.options.hiddenInput) {
 | 
			
		||||
      this.options.hiddenInput.value = Array.from(this.selectedItems).join(this.options.separator);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  showDropdown() {
 | 
			
		||||
    this.dropdown.style.display = 'block';
 | 
			
		||||
    this.isOpen = true;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  hideDropdown() {
 | 
			
		||||
    this.dropdown.style.display = 'none';
 | 
			
		||||
    this.isOpen = false;
 | 
			
		||||
    this.selectedIndex = -1;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  escapeHtml(text) {
 | 
			
		||||
    const div = document.createElement('div');
 | 
			
		||||
    div.textContent = text;
 | 
			
		||||
    return div.innerHTML;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  setDataSource(newDataSource) {
 | 
			
		||||
    this.dataSource = newDataSource;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  getSelectedItems() {
 | 
			
		||||
    return Array.from(this.selectedItems);
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  setSelectedItems(items) {
 | 
			
		||||
    this.selectedItems = new Set(items);
 | 
			
		||||
    if (this.options.allowMultiple) {
 | 
			
		||||
      this.updateSelectedDisplay();
 | 
			
		||||
      this.updateInputValue();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  destroy() {
 | 
			
		||||
    if (this.dropdown && this.dropdown.parentNode) {
 | 
			
		||||
      this.dropdown.parentNode.removeChild(this.dropdown);
 | 
			
		||||
    }
 | 
			
		||||
    if (this.selectedContainer && this.selectedContainer.parentNode) {
 | 
			
		||||
      this.selectedContainer.parentNode.removeChild(this.selectedContainer);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
console.log('[FORM] Script loaded, initializing...');
 | 
			
		||||
 | 
			
		||||
class ContributionForm {
 | 
			
		||||
@ -282,7 +575,8 @@ class ContributionForm {
 | 
			
		||||
    this.isEdit = isEdit;
 | 
			
		||||
    this.editTool = editTool;
 | 
			
		||||
    this.elements = {};
 | 
			
		||||
    this.isSubmitting = false; 
 | 
			
		||||
    this.isSubmitting = false;
 | 
			
		||||
    this.autocompleteManagers = new Map();
 | 
			
		||||
    this.init();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -303,14 +597,20 @@ class ContributionForm {
 | 
			
		||||
      yamlPreview: document.getElementById('yaml-preview'),
 | 
			
		||||
      successModal: document.getElementById('success-modal'),
 | 
			
		||||
      softwareFields: document.getElementById('software-fields'),
 | 
			
		||||
      conceptsFields: document.getElementById('concepts-fields'),
 | 
			
		||||
      relationsFields: document.getElementById('relations-fields'),
 | 
			
		||||
      descriptionCount: document.getElementById('description-count'),
 | 
			
		||||
      reasonCount: document.getElementById('reason-count'),
 | 
			
		||||
      validationErrors: document.getElementById('validation-errors'),
 | 
			
		||||
      errorList: document.getElementById('error-list'),
 | 
			
		||||
      platformsRequired: document.getElementById('platforms-required'),
 | 
			
		||||
      licenseRequired: document.getElementById('license-required'),
 | 
			
		||||
      licenseInput: document.getElementById('license')
 | 
			
		||||
      licenseInput: document.getElementById('license'),
 | 
			
		||||
      tagsInput: document.getElementById('tags-input'),
 | 
			
		||||
      tagsHidden: document.getElementById('tags-hidden'),
 | 
			
		||||
      relatedConceptsInput: document.getElementById('related-concepts-input'),
 | 
			
		||||
      relatedConceptsHidden: document.getElementById('related-concepts-hidden'),
 | 
			
		||||
      relatedSoftwareInput: document.getElementById('related-software-input'),
 | 
			
		||||
      relatedSoftwareHidden: document.getElementById('related-software-hidden')
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    if (!this.elements.form || !this.elements.submitBtn) {
 | 
			
		||||
@ -327,6 +627,7 @@ class ContributionForm {
 | 
			
		||||
 | 
			
		||||
    console.log('[FORM] Setting up handlers...');
 | 
			
		||||
    this.setupEventListeners();
 | 
			
		||||
    this.setupAutocomplete();
 | 
			
		||||
    this.updateFieldVisibility();
 | 
			
		||||
    this.setupCharacterCounters();
 | 
			
		||||
    this.updateYAMLPreview();
 | 
			
		||||
@ -334,6 +635,65 @@ class ContributionForm {
 | 
			
		||||
    console.log('[FORM] Initialization complete!');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setupAutocomplete() {
 | 
			
		||||
    // Tags autocomplete
 | 
			
		||||
    if (this.elements.tagsInput && this.elements.tagsHidden) {
 | 
			
		||||
      const tagsManager = new AutocompleteManager(this.elements.tagsInput, allTags, {
 | 
			
		||||
        allowMultiple: true,
 | 
			
		||||
        hiddenInput: this.elements.tagsHidden,
 | 
			
		||||
        placeholder: 'Beginne zu tippen, um Tags hinzuzufügen...'
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      // Set initial values if editing
 | 
			
		||||
      if (this.editTool?.tags) {
 | 
			
		||||
        tagsManager.setSelectedItems(this.editTool.tags);
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      this.autocompleteManagers.set('tags', tagsManager);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Related concepts autocomplete
 | 
			
		||||
    if (this.elements.relatedConceptsInput && this.elements.relatedConceptsHidden) {
 | 
			
		||||
      const conceptsManager = new AutocompleteManager(this.elements.relatedConceptsInput, allConcepts, {
 | 
			
		||||
        allowMultiple: true,
 | 
			
		||||
        hiddenInput: this.elements.relatedConceptsHidden,
 | 
			
		||||
        placeholder: 'Beginne zu tippen, um Konzepte zu finden...'
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      // Set initial values if editing
 | 
			
		||||
      if (this.editTool?.related_concepts) {
 | 
			
		||||
        conceptsManager.setSelectedItems(this.editTool.related_concepts);
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      this.autocompleteManagers.set('relatedConcepts', conceptsManager);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Related software autocomplete
 | 
			
		||||
    if (this.elements.relatedSoftwareInput && this.elements.relatedSoftwareHidden) {
 | 
			
		||||
      const softwareManager = new AutocompleteManager(this.elements.relatedSoftwareInput, allSoftwareAndMethods, {
 | 
			
		||||
        allowMultiple: true,
 | 
			
		||||
        hiddenInput: this.elements.relatedSoftwareHidden,
 | 
			
		||||
        placeholder: 'Beginne zu tippen, um Software/Methoden zu finden...'
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      // Set initial values if editing
 | 
			
		||||
      if (this.editTool?.related_software) {
 | 
			
		||||
        softwareManager.setSelectedItems(this.editTool.related_software);
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      this.autocompleteManagers.set('relatedSoftware', softwareManager);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Listen for autocomplete changes to update YAML preview
 | 
			
		||||
    Object.values(this.autocompleteManagers).forEach(manager => {
 | 
			
		||||
      if (manager.input) {
 | 
			
		||||
        manager.input.addEventListener('autocomplete:select', () => {
 | 
			
		||||
          this.updateYAMLPreview();
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setupEventListeners() {
 | 
			
		||||
    this.elements.typeSelect.addEventListener('change', () => {
 | 
			
		||||
      this.updateFieldVisibility();
 | 
			
		||||
@ -363,203 +723,231 @@ class ContributionForm {
 | 
			
		||||
    console.log('[FORM] Event listeners attached');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
updateFieldVisibility() {
 | 
			
		||||
  const type = this.elements.typeSelect.value;
 | 
			
		||||
  
 | 
			
		||||
  this.elements.softwareFields.style.display = 'none';
 | 
			
		||||
  this.elements.conceptsFields.style.display = 'none';
 | 
			
		||||
  
 | 
			
		||||
  if (this.elements.platformsRequired) this.elements.platformsRequired.style.display = 'none';
 | 
			
		||||
  if (this.elements.licenseRequired) this.elements.licenseRequired.style.display = 'none';
 | 
			
		||||
 | 
			
		||||
  if (type === 'software') {
 | 
			
		||||
    this.elements.softwareFields.style.display = 'block';
 | 
			
		||||
    this.elements.conceptsFields.style.display = 'block';
 | 
			
		||||
    if (this.elements.platformsRequired) this.elements.platformsRequired.style.display = 'inline';
 | 
			
		||||
    if (this.elements.licenseRequired) this.elements.licenseRequired.style.display = 'inline';
 | 
			
		||||
  } else if (type === 'method') {
 | 
			
		||||
    this.elements.conceptsFields.style.display = 'block';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  console.log('[FORM] Field visibility updated for type:', type);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
setupCharacterCounters() {
 | 
			
		||||
  const counters = [
 | 
			
		||||
    { element: this.elements.descriptionTextarea, counter: this.elements.descriptionCount, max: 1000 },
 | 
			
		||||
    { element: this.elements.reasonTextarea, counter: this.elements.reasonCount, max: 500 }
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  counters.forEach(({ element, counter, max }) => {
 | 
			
		||||
    if (element && counter) {
 | 
			
		||||
      const updateCounter = () => {
 | 
			
		||||
        const count = element.value.length;
 | 
			
		||||
        counter.textContent = count;
 | 
			
		||||
        counter.style.color = count > max * 0.9 ? 'var(--color-warning)' : 'var(--color-text-secondary)';
 | 
			
		||||
      };
 | 
			
		||||
      
 | 
			
		||||
      element.addEventListener('input', updateCounter);
 | 
			
		||||
      updateCounter();
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
updateYAMLPreview() {
 | 
			
		||||
  if (!this.elements.yamlPreview) return;
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    const formData = new FormData(this.elements.form);
 | 
			
		||||
  updateFieldVisibility() {
 | 
			
		||||
    const type = this.elements.typeSelect.value;
 | 
			
		||||
    
 | 
			
		||||
    const tool = {
 | 
			
		||||
      name: formData.get('name') || 'Tool Name',
 | 
			
		||||
      type: formData.get('type') || 'software',
 | 
			
		||||
      description: formData.get('description') || 'Tool description',
 | 
			
		||||
      domains: formData.getAll('domains'),
 | 
			
		||||
      phases: formData.getAll('phases'),
 | 
			
		||||
      skillLevel: formData.get('skillLevel') || 'intermediate',
 | 
			
		||||
      url: formData.get('url') || 'https://example.com'
 | 
			
		||||
    };
 | 
			
		||||
    this.elements.softwareFields.style.display = 'none';
 | 
			
		||||
    this.elements.relationsFields.style.display = 'none';
 | 
			
		||||
    
 | 
			
		||||
    if (this.elements.platformsRequired) this.elements.platformsRequired.style.display = 'none';
 | 
			
		||||
    if (this.elements.licenseRequired) this.elements.licenseRequired.style.display = 'none';
 | 
			
		||||
 | 
			
		||||
    if (formData.get('icon')) {
 | 
			
		||||
      tool.icon = formData.get('icon');
 | 
			
		||||
    if (type === 'software') {
 | 
			
		||||
      this.elements.softwareFields.style.display = 'block';
 | 
			
		||||
      this.elements.relationsFields.style.display = 'block';
 | 
			
		||||
      if (this.elements.platformsRequired) this.elements.platformsRequired.style.display = 'inline';
 | 
			
		||||
      if (this.elements.licenseRequired) this.elements.licenseRequired.style.display = 'inline';
 | 
			
		||||
    } else if (type === 'method') {
 | 
			
		||||
      this.elements.relationsFields.style.display = 'block';
 | 
			
		||||
    } else if (type === 'concept') {
 | 
			
		||||
      // Concepts can only relate to software/methods, not other concepts
 | 
			
		||||
      this.elements.relationsFields.style.display = 'block';
 | 
			
		||||
      // Hide concepts section for concept types
 | 
			
		||||
      const conceptsSection = document.getElementById('related-concepts-section');
 | 
			
		||||
      const softwareSection = document.getElementById('related-software-section');
 | 
			
		||||
      if (conceptsSection) conceptsSection.style.display = 'none';
 | 
			
		||||
      if (softwareSection) softwareSection.style.display = 'block';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (tool.type === 'software') {
 | 
			
		||||
      tool.platforms = formData.getAll('platforms');
 | 
			
		||||
      tool.license = formData.get('license') || 'Unknown';
 | 
			
		||||
      if (formData.get('accessType')) {
 | 
			
		||||
        tool.accessType = formData.get('accessType');
 | 
			
		||||
    // Show appropriate relation sections
 | 
			
		||||
    if (type !== 'concept') {
 | 
			
		||||
      const conceptsSection = document.getElementById('related-concepts-section');
 | 
			
		||||
      const softwareSection = document.getElementById('related-software-section');
 | 
			
		||||
      if (conceptsSection) conceptsSection.style.display = 'block';
 | 
			
		||||
      if (softwareSection) softwareSection.style.display = 'block';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    console.log('[FORM] Field visibility updated for type:', type);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setupCharacterCounters() {
 | 
			
		||||
    const counters = [
 | 
			
		||||
      { element: this.elements.descriptionTextarea, counter: this.elements.descriptionCount, max: 1000 },
 | 
			
		||||
      { element: this.elements.reasonTextarea, counter: this.elements.reasonCount, max: 500 }
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    counters.forEach(({ element, counter, max }) => {
 | 
			
		||||
      if (element && counter) {
 | 
			
		||||
        const updateCounter = () => {
 | 
			
		||||
          const count = element.value.length;
 | 
			
		||||
          counter.textContent = count;
 | 
			
		||||
          counter.style.color = count > max * 0.9 ? 'var(--color-warning)' : 'var(--color-text-secondary)';
 | 
			
		||||
        };
 | 
			
		||||
        
 | 
			
		||||
        element.addEventListener('input', updateCounter);
 | 
			
		||||
        updateCounter();
 | 
			
		||||
      }
 | 
			
		||||
      const domainAgnostic = formData.getAll('domainAgnostic');
 | 
			
		||||
      if (domainAgnostic.length > 0) {
 | 
			
		||||
        tool['domain-agnostic-software'] = domainAgnostic;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (formData.has('knowledgebase')) {
 | 
			
		||||
      tool.knowledgebase = true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const tags = formData.get('tags');
 | 
			
		||||
    if (tags) {
 | 
			
		||||
      tool.tags = tags.split(',').map(t => t.trim()).filter(Boolean);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const relatedConcepts = formData.getAll('relatedConcepts');
 | 
			
		||||
    if (relatedConcepts.length > 0) {
 | 
			
		||||
      tool.related_concepts = relatedConcepts;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const yaml = this.generateYAML(tool);
 | 
			
		||||
    this.elements.yamlPreview.textContent = yaml;
 | 
			
		||||
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('[FORM] YAML preview error:', error);
 | 
			
		||||
    this.elements.yamlPreview.textContent = '# Error generating preview';
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
generateYAML(tool) {
 | 
			
		||||
  const lines = [];
 | 
			
		||||
  
 | 
			
		||||
  lines.push(`name: "${tool.name}"`);
 | 
			
		||||
  if (tool.icon) lines.push(`icon: "${tool.icon}"`);
 | 
			
		||||
  lines.push(`type: ${tool.type}`);
 | 
			
		||||
  lines.push(`description: "${tool.description}"`);
 | 
			
		||||
  lines.push(`domains: [${tool.domains.map(d => `"${d}"`).join(', ')}]`);
 | 
			
		||||
  lines.push(`phases: [${tool.phases.map(p => `"${p}"`).join(', ')}]`);
 | 
			
		||||
  lines.push(`skillLevel: ${tool.skillLevel}`);
 | 
			
		||||
  lines.push(`url: "${tool.url}"`);
 | 
			
		||||
  
 | 
			
		||||
  if (tool.platforms && tool.platforms.length > 0) {
 | 
			
		||||
    lines.push(`platforms: [${tool.platforms.map(p => `"${p}"`).join(', ')}]`);
 | 
			
		||||
  }
 | 
			
		||||
  if (tool.license) lines.push(`license: "${tool.license}"`);
 | 
			
		||||
  if (tool.accessType) lines.push(`accessType: ${tool.accessType}`);
 | 
			
		||||
  if (tool['domain-agnostic-software']) {
 | 
			
		||||
    lines.push(`domain-agnostic-software: [${tool['domain-agnostic-software'].map(c => `"${c}"`).join(', ')}]`);
 | 
			
		||||
  }
 | 
			
		||||
  if (tool.knowledgebase) lines.push(`knowledgebase: true`);
 | 
			
		||||
  if (tool.tags && tool.tags.length > 0) {
 | 
			
		||||
    lines.push(`tags: [${tool.tags.map(t => `"${t}"`).join(', ')}]`);
 | 
			
		||||
  }
 | 
			
		||||
  if (tool.related_concepts && tool.related_concepts.length > 0) {
 | 
			
		||||
    lines.push(`related_concepts: [${tool.related_concepts.map(c => `"${c}"`).join(', ')}]`);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return lines.join('\n');
 | 
			
		||||
}
 | 
			
		||||
  updateYAMLPreview() {
 | 
			
		||||
    if (!this.elements.yamlPreview) return;
 | 
			
		||||
 | 
			
		||||
validateForm() {
 | 
			
		||||
  const errors = [];
 | 
			
		||||
  const formData = new FormData(this.elements.form);
 | 
			
		||||
 | 
			
		||||
  const name = formData.get('name')?.trim();
 | 
			
		||||
  if (!name) {
 | 
			
		||||
    errors.push('Tool name is required');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const description = formData.get('description')?.trim();
 | 
			
		||||
  if (!description) {
 | 
			
		||||
    errors.push('Description is required');
 | 
			
		||||
  } else if (description.length < 10) {
 | 
			
		||||
    errors.push('Description must be at least 10 characters long');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const skillLevel = formData.get('skillLevel');
 | 
			
		||||
  if (!skillLevel) {
 | 
			
		||||
    errors.push('Skill level is required');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const type = formData.get('type');
 | 
			
		||||
  if (!type) {
 | 
			
		||||
    errors.push('Type is required');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const url = formData.get('url')?.trim();
 | 
			
		||||
  if (!url) {
 | 
			
		||||
    errors.push('Primary URL is required');
 | 
			
		||||
  } else {
 | 
			
		||||
    try {
 | 
			
		||||
      new URL(url);
 | 
			
		||||
    } catch {
 | 
			
		||||
      errors.push('Primary URL must be a valid URL');
 | 
			
		||||
      const formData = new FormData(this.elements.form);
 | 
			
		||||
      
 | 
			
		||||
      const tool = {
 | 
			
		||||
        name: formData.get('name') || 'Tool Name',
 | 
			
		||||
        type: formData.get('type') || 'software',
 | 
			
		||||
        description: formData.get('description') || 'Tool description',
 | 
			
		||||
        domains: formData.getAll('domains'),
 | 
			
		||||
        phases: formData.getAll('phases'),
 | 
			
		||||
        skillLevel: formData.get('skillLevel') || 'intermediate',
 | 
			
		||||
        url: formData.get('url') || 'https://example.com'
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      if (formData.get('icon')) {
 | 
			
		||||
        tool.icon = formData.get('icon');
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (tool.type === 'software') {
 | 
			
		||||
        tool.platforms = formData.getAll('platforms');
 | 
			
		||||
        tool.license = formData.get('license') || 'Unknown';
 | 
			
		||||
        if (formData.get('accessType')) {
 | 
			
		||||
          tool.accessType = formData.get('accessType');
 | 
			
		||||
        }
 | 
			
		||||
        const domainAgnostic = formData.getAll('domainAgnostic');
 | 
			
		||||
        if (domainAgnostic.length > 0) {
 | 
			
		||||
          tool['domain-agnostic-software'] = domainAgnostic;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (formData.has('knowledgebase')) {
 | 
			
		||||
        tool.knowledgebase = true;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Handle tags from autocomplete
 | 
			
		||||
      const tagsValue = this.elements.tagsHidden?.value || '';
 | 
			
		||||
      if (tagsValue) {
 | 
			
		||||
        tool.tags = tagsValue.split(',').map(t => t.trim()).filter(Boolean);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Handle related concepts from autocomplete
 | 
			
		||||
      const relatedConceptsValue = this.elements.relatedConceptsHidden?.value || '';
 | 
			
		||||
      if (relatedConceptsValue) {
 | 
			
		||||
        tool.related_concepts = relatedConceptsValue.split(',').map(t => t.trim()).filter(Boolean);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Handle related software from autocomplete
 | 
			
		||||
      const relatedSoftwareValue = this.elements.relatedSoftwareHidden?.value || '';
 | 
			
		||||
      if (relatedSoftwareValue) {
 | 
			
		||||
        tool.related_software = relatedSoftwareValue.split(',').map(t => t.trim()).filter(Boolean);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const yaml = this.generateYAML(tool);
 | 
			
		||||
      this.elements.yamlPreview.textContent = yaml;
 | 
			
		||||
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error('[FORM] YAML preview error:', error);
 | 
			
		||||
      this.elements.yamlPreview.textContent = '# Error generating preview';
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (type === 'software') {
 | 
			
		||||
    const platforms = formData.getAll('platforms');
 | 
			
		||||
    if (platforms.length === 0) {
 | 
			
		||||
      errors.push('At least one platform is required for software');
 | 
			
		||||
  generateYAML(tool) {
 | 
			
		||||
    const lines = [];
 | 
			
		||||
    
 | 
			
		||||
    lines.push(`name: "${tool.name}"`);
 | 
			
		||||
    if (tool.icon) lines.push(`icon: "${tool.icon}"`);
 | 
			
		||||
    lines.push(`type: ${tool.type}`);
 | 
			
		||||
    lines.push(`description: "${tool.description}"`);
 | 
			
		||||
    lines.push(`domains: [${tool.domains.map(d => `"${d}"`).join(', ')}]`);
 | 
			
		||||
    lines.push(`phases: [${tool.phases.map(p => `"${p}"`).join(', ')}]`);
 | 
			
		||||
    lines.push(`skillLevel: ${tool.skillLevel}`);
 | 
			
		||||
    lines.push(`url: "${tool.url}"`);
 | 
			
		||||
    
 | 
			
		||||
    if (tool.platforms && tool.platforms.length > 0) {
 | 
			
		||||
      lines.push(`platforms: [${tool.platforms.map(p => `"${p}"`).join(', ')}]`);
 | 
			
		||||
    }
 | 
			
		||||
    if (tool.license) lines.push(`license: "${tool.license}"`);
 | 
			
		||||
    if (tool.accessType) lines.push(`accessType: ${tool.accessType}`);
 | 
			
		||||
    if (tool['domain-agnostic-software']) {
 | 
			
		||||
      lines.push(`domain-agnostic-software: [${tool['domain-agnostic-software'].map(c => `"${c}"`).join(', ')}]`);
 | 
			
		||||
    }
 | 
			
		||||
    if (tool.knowledgebase) lines.push(`knowledgebase: true`);
 | 
			
		||||
    if (tool.tags && tool.tags.length > 0) {
 | 
			
		||||
      lines.push(`tags: [${tool.tags.map(t => `"${t}"`).join(', ')}]`);
 | 
			
		||||
    }
 | 
			
		||||
    if (tool.related_concepts && tool.related_concepts.length > 0) {
 | 
			
		||||
      lines.push(`related_concepts: [${tool.related_concepts.map(c => `"${c}"`).join(', ')}]`);
 | 
			
		||||
    }
 | 
			
		||||
    if (tool.related_software && tool.related_software.length > 0) {
 | 
			
		||||
      lines.push(`related_software: [${tool.related_software.map(s => `"${s}"`).join(', ')}]`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const license = formData.get('license')?.trim();
 | 
			
		||||
    if (!license) {
 | 
			
		||||
      errors.push('License is required for software');
 | 
			
		||||
    return lines.join('\n');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  validateForm() {
 | 
			
		||||
    const errors = [];
 | 
			
		||||
    const formData = new FormData(this.elements.form);
 | 
			
		||||
 | 
			
		||||
    const name = formData.get('name')?.trim();
 | 
			
		||||
    if (!name) {
 | 
			
		||||
      errors.push('Tool name is required');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const description = formData.get('description')?.trim();
 | 
			
		||||
    if (!description) {
 | 
			
		||||
      errors.push('Description is required');
 | 
			
		||||
    } else if (description.length < 10) {
 | 
			
		||||
      errors.push('Description must be at least 10 characters long');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const skillLevel = formData.get('skillLevel');
 | 
			
		||||
    if (!skillLevel) {
 | 
			
		||||
      errors.push('Skill level is required');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const type = formData.get('type');
 | 
			
		||||
    if (!type) {
 | 
			
		||||
      errors.push('Type is required');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const url = formData.get('url')?.trim();
 | 
			
		||||
    if (!url) {
 | 
			
		||||
      errors.push('Primary URL is required');
 | 
			
		||||
    } else {
 | 
			
		||||
      try {
 | 
			
		||||
        new URL(url);
 | 
			
		||||
      } catch {
 | 
			
		||||
        errors.push('Primary URL must be a valid URL');
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (type === 'software') {
 | 
			
		||||
      const platforms = formData.getAll('platforms');
 | 
			
		||||
      if (platforms.length === 0) {
 | 
			
		||||
        errors.push('At least one platform is required for software');
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const license = formData.get('license')?.trim();
 | 
			
		||||
      if (!license) {
 | 
			
		||||
        errors.push('License is required for software');
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return errors;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return errors;
 | 
			
		||||
}
 | 
			
		||||
  showValidationErrors(errors) {
 | 
			
		||||
    if (errors.length === 0) {
 | 
			
		||||
      this.elements.validationErrors.style.display = 'none';
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
showValidationErrors(errors) {
 | 
			
		||||
  if (errors.length === 0) {
 | 
			
		||||
    this.elements.validationErrors.style.display = 'none';
 | 
			
		||||
    return;
 | 
			
		||||
    this.elements.errorList.innerHTML = '';
 | 
			
		||||
    
 | 
			
		||||
    errors.forEach(error => {
 | 
			
		||||
      const li = document.createElement('li');
 | 
			
		||||
      li.textContent = error;
 | 
			
		||||
      this.elements.errorList.appendChild(li);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    this.elements.validationErrors.style.display = 'block';
 | 
			
		||||
    
 | 
			
		||||
    this.elements.validationErrors.scrollIntoView({ behavior: 'smooth', block: 'center' });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  this.elements.errorList.innerHTML = '';
 | 
			
		||||
  
 | 
			
		||||
  errors.forEach(error => {
 | 
			
		||||
    const li = document.createElement('li');
 | 
			
		||||
    li.textContent = error;
 | 
			
		||||
    this.elements.errorList.appendChild(li);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  this.elements.validationErrors.style.display = 'block';
 | 
			
		||||
  
 | 
			
		||||
  this.elements.validationErrors.scrollIntoView({ behavior: 'smooth', block: 'center' });
 | 
			
		||||
}
 | 
			
		||||
  async handleSubmit() {
 | 
			
		||||
    console.log('[FORM] Submit handler called!');
 | 
			
		||||
 | 
			
		||||
@ -597,14 +985,32 @@ showValidationErrors(errors) {
 | 
			
		||||
          phases: formData.getAll('phases'),
 | 
			
		||||
          skillLevel: formData.get('skillLevel'),
 | 
			
		||||
          url: formData.get('url'),
 | 
			
		||||
          tags: formData.get('tags') ? 
 | 
			
		||||
            formData.get('tags').split(',').map(t => t.trim()).filter(Boolean) : []
 | 
			
		||||
          tags: []
 | 
			
		||||
        },
 | 
			
		||||
        metadata: {
 | 
			
		||||
          reason: formData.get('reason') || ''
 | 
			
		||||
          reason: formData.get('reason') || '',
 | 
			
		||||
          contact: formData.get('contact') || ''
 | 
			
		||||
        }
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      // Handle tags from autocomplete
 | 
			
		||||
      const tagsValue = this.elements.tagsHidden?.value || '';
 | 
			
		||||
      if (tagsValue) {
 | 
			
		||||
        submission.tool.tags = tagsValue.split(',').map(t => t.trim()).filter(Boolean);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Handle related concepts from autocomplete
 | 
			
		||||
      const relatedConceptsValue = this.elements.relatedConceptsHidden?.value || '';
 | 
			
		||||
      if (relatedConceptsValue) {
 | 
			
		||||
        submission.tool.related_concepts = relatedConceptsValue.split(',').map(t => t.trim()).filter(Boolean);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Handle related software from autocomplete
 | 
			
		||||
      const relatedSoftwareValue = this.elements.relatedSoftwareHidden?.value || '';
 | 
			
		||||
      if (relatedSoftwareValue) {
 | 
			
		||||
        submission.tool.related_software = relatedSoftwareValue.split(',').map(t => t.trim()).filter(Boolean);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (formData.get('icon')) submission.tool.icon = formData.get('icon');
 | 
			
		||||
      if (formData.has('knowledgebase')) submission.tool.knowledgebase = true;
 | 
			
		||||
 | 
			
		||||
@ -620,13 +1026,6 @@ showValidationErrors(errors) {
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (submission.tool.type !== 'concept') {
 | 
			
		||||
        const related = formData.getAll('relatedConcepts');
 | 
			
		||||
        if (related.length > 0) {
 | 
			
		||||
          submission.tool.related_concepts = related;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      console.log('[FORM] Sending submission:', submission);
 | 
			
		||||
 | 
			
		||||
      const response = await fetch('/api/contribute/tool', {
 | 
			
		||||
@ -681,6 +1080,14 @@ showValidationErrors(errors) {
 | 
			
		||||
      timeout = setTimeout(() => func.apply(this, args), wait);
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  destroy() {
 | 
			
		||||
    // Clean up autocomplete managers
 | 
			
		||||
    this.autocompleteManagers.forEach(manager => {
 | 
			
		||||
      manager.destroy();
 | 
			
		||||
    });
 | 
			
		||||
    this.autocompleteManagers.clear();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function initializeForm() {
 | 
			
		||||
@ -706,4 +1113,5 @@ if (document.readyState === 'loading') {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
console.log('[FORM] Script loaded successfully');
 | 
			
		||||
</script>
 | 
			
		||||
</script>
 | 
			
		||||
</BaseLayout>
 | 
			
		||||
							
								
								
									
										121
									
								
								src/styles/autocomplete.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								src/styles/autocomplete.css
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,121 @@
 | 
			
		||||
/* ============================================================================
 | 
			
		||||
   AUTOCOMPLETE COMPONENT STYLES
 | 
			
		||||
   ============================================================================ */
 | 
			
		||||
 | 
			
		||||
.autocomplete-dropdown {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  top: 100%;
 | 
			
		||||
  left: 0;
 | 
			
		||||
  right: 0;
 | 
			
		||||
  background: var(--color-bg);
 | 
			
		||||
  border: 1px solid var(--color-border);
 | 
			
		||||
  border-radius: 0.375rem;
 | 
			
		||||
  box-shadow: var(--shadow-lg);
 | 
			
		||||
  max-height: 200px;
 | 
			
		||||
  overflow-y: auto;
 | 
			
		||||
  z-index: 1000;
 | 
			
		||||
  display: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.autocomplete-option {
 | 
			
		||||
  padding: 0.5rem;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  border-bottom: 1px solid var(--color-border-light);
 | 
			
		||||
  transition: background-color 0.15s ease;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.autocomplete-option:last-child {
 | 
			
		||||
  border-bottom: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.autocomplete-option:hover {
 | 
			
		||||
  background-color: var(--color-bg-secondary);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.autocomplete-item {
 | 
			
		||||
  font-size: 0.875rem;
 | 
			
		||||
  color: var(--color-text);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.autocomplete-no-results {
 | 
			
		||||
  padding: 0.75rem;
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  color: var(--color-text-secondary);
 | 
			
		||||
  font-size: 0.875rem;
 | 
			
		||||
  font-style: italic;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.autocomplete-selected {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-wrap: wrap;
 | 
			
		||||
  gap: 0.5rem;
 | 
			
		||||
  margin-bottom: 0.5rem;
 | 
			
		||||
  min-height: 1.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.autocomplete-tag {
 | 
			
		||||
  background-color: var(--color-primary);
 | 
			
		||||
  color: white;
 | 
			
		||||
  padding: 0.25rem 0.5rem;
 | 
			
		||||
  border-radius: 0.25rem;
 | 
			
		||||
  font-size: 0.875rem;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  gap: 0.25rem;
 | 
			
		||||
  animation: fadeInScale 0.2s ease-out;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.autocomplete-remove {
 | 
			
		||||
  background: none;
 | 
			
		||||
  border: none;
 | 
			
		||||
  color: white;
 | 
			
		||||
  font-weight: bold;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  padding: 0;
 | 
			
		||||
  width: 1rem;
 | 
			
		||||
  height: 1rem;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
  border-radius: 50%;
 | 
			
		||||
  transition: background-color 0.15s ease;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.autocomplete-remove:hover {
 | 
			
		||||
  background-color: rgba(255, 255, 255, 0.2);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Animation for tag appearance */
 | 
			
		||||
@keyframes fadeInScale {
 | 
			
		||||
  from {
 | 
			
		||||
    opacity: 0;
 | 
			
		||||
    transform: scale(0.8);
 | 
			
		||||
  }
 | 
			
		||||
  to {
 | 
			
		||||
    opacity: 1;
 | 
			
		||||
    transform: scale(1);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Ensure autocomplete container has relative positioning */
 | 
			
		||||
.autocomplete-container {
 | 
			
		||||
  position: relative;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Custom scrollbar for autocomplete dropdown */
 | 
			
		||||
.autocomplete-dropdown::-webkit-scrollbar {
 | 
			
		||||
  width: 6px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.autocomplete-dropdown::-webkit-scrollbar-track {
 | 
			
		||||
  background: var(--color-bg-secondary);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.autocomplete-dropdown::-webkit-scrollbar-thumb {
 | 
			
		||||
  background: var(--color-border);
 | 
			
		||||
  border-radius: 3px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.autocomplete-dropdown::-webkit-scrollbar-thumb:hover {
 | 
			
		||||
  background: var(--color-text-secondary);
 | 
			
		||||
}
 | 
			
		||||
@ -1,7 +1,7 @@
 | 
			
		||||
// src/utils/clientUtils.js
 | 
			
		||||
// src/utils/clientUtils.ts
 | 
			
		||||
// Client-side utilities that mirror server-side toolHelpers.ts
 | 
			
		||||
 | 
			
		||||
export function createToolSlug(toolName) {
 | 
			
		||||
export function createToolSlug(toolName: string): string {
 | 
			
		||||
  if (!toolName || typeof toolName !== 'string') {
 | 
			
		||||
    console.warn('[toolHelpers] Invalid toolName provided to createToolSlug:', toolName);
 | 
			
		||||
    return '';
 | 
			
		||||
@ -14,18 +14,353 @@ export function createToolSlug(toolName) {
 | 
			
		||||
    .replace(/^-|-$/g, '');           // Remove leading/trailing hyphens
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function findToolByIdentifier(tools, identifier) {
 | 
			
		||||
export function findToolByIdentifier(tools: any[], identifier: string): any | undefined {
 | 
			
		||||
  if (!identifier || !Array.isArray(tools)) return undefined;
 | 
			
		||||
  
 | 
			
		||||
  return tools.find(tool => 
 | 
			
		||||
  return tools.find((tool: any) => 
 | 
			
		||||
    tool.name === identifier || 
 | 
			
		||||
    createToolSlug(tool.name) === identifier.toLowerCase()
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function isToolHosted(tool) {
 | 
			
		||||
export function isToolHosted(tool: any): boolean {
 | 
			
		||||
  return tool.projectUrl !== undefined && 
 | 
			
		||||
         tool.projectUrl !== null && 
 | 
			
		||||
         tool.projectUrl !== "" && 
 | 
			
		||||
         tool.projectUrl.trim() !== "";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Consolidated Autocomplete Functionality
 | 
			
		||||
interface AutocompleteOptions {
 | 
			
		||||
  minLength?: number;
 | 
			
		||||
  maxResults?: number;
 | 
			
		||||
  placeholder?: string;
 | 
			
		||||
  allowMultiple?: boolean;
 | 
			
		||||
  separator?: string;
 | 
			
		||||
  filterFunction?: (query: string) => any[];
 | 
			
		||||
  renderFunction?: (item: any) => string;
 | 
			
		||||
  hiddenInput?: HTMLInputElement;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AutocompleteManager {
 | 
			
		||||
  public input: HTMLInputElement;
 | 
			
		||||
  public dataSource: any[];
 | 
			
		||||
  public options: AutocompleteOptions;
 | 
			
		||||
  public isOpen: boolean = false;
 | 
			
		||||
  public selectedIndex: number = -1;
 | 
			
		||||
  public filteredData: any[] = [];
 | 
			
		||||
  public selectedItems: Set<string> = new Set();
 | 
			
		||||
  public dropdown!: HTMLElement;
 | 
			
		||||
  public selectedContainer!: HTMLElement;
 | 
			
		||||
 | 
			
		||||
  constructor(inputElement: HTMLInputElement, dataSource: any[], options: AutocompleteOptions = {}) {
 | 
			
		||||
    this.input = inputElement;
 | 
			
		||||
    this.dataSource = dataSource;
 | 
			
		||||
    this.options = {
 | 
			
		||||
      minLength: 1,
 | 
			
		||||
      maxResults: 10,
 | 
			
		||||
      placeholder: 'Type to search...',
 | 
			
		||||
      allowMultiple: false,
 | 
			
		||||
      separator: ', ',
 | 
			
		||||
      filterFunction: this.defaultFilter.bind(this),
 | 
			
		||||
      renderFunction: this.defaultRender.bind(this),
 | 
			
		||||
      ...options
 | 
			
		||||
    };
 | 
			
		||||
    
 | 
			
		||||
    this.init();
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  init(): void {
 | 
			
		||||
    this.createDropdown();
 | 
			
		||||
    this.bindEvents();
 | 
			
		||||
    
 | 
			
		||||
    if (this.options.allowMultiple) {
 | 
			
		||||
      this.initMultipleMode();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  createDropdown(): void {
 | 
			
		||||
    this.dropdown = document.createElement('div');
 | 
			
		||||
    this.dropdown.className = 'autocomplete-dropdown';
 | 
			
		||||
    this.dropdown.style.cssText = `
 | 
			
		||||
      position: absolute;
 | 
			
		||||
      top: 100%;
 | 
			
		||||
      left: 0;
 | 
			
		||||
      right: 0;
 | 
			
		||||
      background: var(--color-bg);
 | 
			
		||||
      border: 1px solid var(--color-border);
 | 
			
		||||
      border-radius: 0.375rem;
 | 
			
		||||
      box-shadow: var(--shadow-lg);
 | 
			
		||||
      max-height: 200px;
 | 
			
		||||
      overflow-y: auto;
 | 
			
		||||
      z-index: 1000;
 | 
			
		||||
      display: none;
 | 
			
		||||
    `;
 | 
			
		||||
    
 | 
			
		||||
    // Insert dropdown after input
 | 
			
		||||
    const parentElement = this.input.parentNode as HTMLElement;
 | 
			
		||||
    parentElement.style.position = 'relative';
 | 
			
		||||
    parentElement.insertBefore(this.dropdown, this.input.nextSibling);
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  bindEvents(): void {
 | 
			
		||||
    this.input.addEventListener('input', (e) => {
 | 
			
		||||
      this.handleInput((e.target as HTMLInputElement).value);
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    this.input.addEventListener('keydown', (e) => {
 | 
			
		||||
      this.handleKeydown(e);
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    this.input.addEventListener('focus', () => {
 | 
			
		||||
      if (this.input.value.length >= (this.options.minLength || 1)) {
 | 
			
		||||
        this.showDropdown();
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    this.input.addEventListener('blur', () => {
 | 
			
		||||
      // Delay to allow click events on dropdown items
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        const activeElement = document.activeElement;
 | 
			
		||||
        if (!activeElement || !this.dropdown.contains(activeElement)) {
 | 
			
		||||
          this.hideDropdown();
 | 
			
		||||
        }
 | 
			
		||||
      }, 150);
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    document.addEventListener('click', (e) => {
 | 
			
		||||
      const target = e.target as Node;
 | 
			
		||||
      if (!this.input.contains(target) && !this.dropdown.contains(target)) {
 | 
			
		||||
        this.hideDropdown();
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  initMultipleMode(): void {
 | 
			
		||||
    this.selectedContainer = document.createElement('div');
 | 
			
		||||
    this.selectedContainer.className = 'autocomplete-selected';
 | 
			
		||||
    this.selectedContainer.style.cssText = `
 | 
			
		||||
      display: flex;
 | 
			
		||||
      flex-wrap: wrap;
 | 
			
		||||
      gap: 0.5rem;
 | 
			
		||||
      margin-bottom: 0.5rem;
 | 
			
		||||
      min-height: 1.5rem;
 | 
			
		||||
    `;
 | 
			
		||||
    
 | 
			
		||||
    const parentElement = this.input.parentNode as HTMLElement;
 | 
			
		||||
    parentElement.insertBefore(this.selectedContainer, this.input);
 | 
			
		||||
    this.updateSelectedDisplay();
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  handleInput(value: string): void {
 | 
			
		||||
    if (value.length >= (this.options.minLength || 1)) {
 | 
			
		||||
      this.filteredData = this.options.filterFunction!(value);
 | 
			
		||||
      this.selectedIndex = -1;
 | 
			
		||||
      this.renderDropdown();
 | 
			
		||||
      this.showDropdown();
 | 
			
		||||
    } else {
 | 
			
		||||
      this.hideDropdown();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  handleKeydown(e: KeyboardEvent): void {
 | 
			
		||||
    if (!this.isOpen) return;
 | 
			
		||||
    
 | 
			
		||||
    switch (e.key) {
 | 
			
		||||
      case 'ArrowDown':
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
        this.selectedIndex = Math.min(this.selectedIndex + 1, this.filteredData.length - 1);
 | 
			
		||||
        this.updateHighlight();
 | 
			
		||||
        break;
 | 
			
		||||
        
 | 
			
		||||
      case 'ArrowUp':
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
        this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
 | 
			
		||||
        this.updateHighlight();
 | 
			
		||||
        break;
 | 
			
		||||
        
 | 
			
		||||
      case 'Enter':
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
        if (this.selectedIndex >= 0) {
 | 
			
		||||
          this.selectItem(this.filteredData[this.selectedIndex]);
 | 
			
		||||
        }
 | 
			
		||||
        break;
 | 
			
		||||
        
 | 
			
		||||
      case 'Escape':
 | 
			
		||||
        this.hideDropdown();
 | 
			
		||||
        break;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  defaultFilter(query: string): any[] {
 | 
			
		||||
    const searchTerm = query.toLowerCase();
 | 
			
		||||
    return this.dataSource
 | 
			
		||||
      .filter(item => {
 | 
			
		||||
        const text = typeof item === 'string' ? item : item.name || item.label || item.toString();
 | 
			
		||||
        return text.toLowerCase().includes(searchTerm) && 
 | 
			
		||||
               (!this.options.allowMultiple || !this.selectedItems.has(text));
 | 
			
		||||
      })
 | 
			
		||||
      .slice(0, this.options.maxResults || 10);
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  defaultRender(item: any): string {
 | 
			
		||||
    const text = typeof item === 'string' ? item : item.name || item.label || item.toString();
 | 
			
		||||
    return `<div class="autocomplete-item">${this.escapeHtml(text)}</div>`;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  renderDropdown(): void {
 | 
			
		||||
    if (this.filteredData.length === 0) {
 | 
			
		||||
      this.dropdown.innerHTML = '<div class="autocomplete-no-results">No results found</div>';
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    this.dropdown.innerHTML = this.filteredData
 | 
			
		||||
      .map((item, index) => {
 | 
			
		||||
        const content = this.options.renderFunction!(item);
 | 
			
		||||
        return `<div class="autocomplete-option" data-index="${index}" style="
 | 
			
		||||
          padding: 0.5rem;
 | 
			
		||||
          cursor: pointer;
 | 
			
		||||
          border-bottom: 1px solid var(--color-border-light);
 | 
			
		||||
          transition: background-color 0.15s ease;
 | 
			
		||||
        ">${content}</div>`;
 | 
			
		||||
      })
 | 
			
		||||
      .join('');
 | 
			
		||||
    
 | 
			
		||||
    // Bind click events
 | 
			
		||||
    this.dropdown.querySelectorAll('.autocomplete-option').forEach((option, index) => {
 | 
			
		||||
      option.addEventListener('click', () => {
 | 
			
		||||
        this.selectItem(this.filteredData[index]);
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      option.addEventListener('mouseenter', () => {
 | 
			
		||||
        this.selectedIndex = index;
 | 
			
		||||
        this.updateHighlight();
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  updateHighlight(): void {
 | 
			
		||||
    this.dropdown.querySelectorAll('.autocomplete-option').forEach((option, index) => {
 | 
			
		||||
      (option as HTMLElement).style.backgroundColor = index === this.selectedIndex 
 | 
			
		||||
        ? 'var(--color-bg-secondary)' 
 | 
			
		||||
        : 'transparent';
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  selectItem(item: any): void {
 | 
			
		||||
    const text = typeof item === 'string' ? item : item.name || item.label || item.toString();
 | 
			
		||||
    
 | 
			
		||||
    if (this.options.allowMultiple) {
 | 
			
		||||
      this.selectedItems.add(text);
 | 
			
		||||
      this.updateSelectedDisplay();
 | 
			
		||||
      this.updateInputValue();
 | 
			
		||||
      this.input.value = '';
 | 
			
		||||
    } else {
 | 
			
		||||
      this.input.value = text;
 | 
			
		||||
      this.hideDropdown();
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Trigger change event
 | 
			
		||||
    this.input.dispatchEvent(new CustomEvent('autocomplete:select', {
 | 
			
		||||
      detail: { item, text, selectedItems: Array.from(this.selectedItems) }
 | 
			
		||||
    }));
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  removeItem(text: string): void {
 | 
			
		||||
    if (this.options.allowMultiple) {
 | 
			
		||||
      this.selectedItems.delete(text);
 | 
			
		||||
      this.updateSelectedDisplay();
 | 
			
		||||
      this.updateInputValue();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  updateSelectedDisplay(): void {
 | 
			
		||||
    if (!this.options.allowMultiple || !this.selectedContainer) return;
 | 
			
		||||
    
 | 
			
		||||
    this.selectedContainer.innerHTML = Array.from(this.selectedItems)
 | 
			
		||||
      .map(item => `
 | 
			
		||||
        <span class="autocomplete-tag" style="
 | 
			
		||||
          background-color: var(--color-primary);
 | 
			
		||||
          color: white;
 | 
			
		||||
          padding: 0.25rem 0.5rem;
 | 
			
		||||
          border-radius: 0.25rem;
 | 
			
		||||
          font-size: 0.875rem;
 | 
			
		||||
          display: flex;
 | 
			
		||||
          align-items: center;
 | 
			
		||||
          gap: 0.25rem;
 | 
			
		||||
        ">
 | 
			
		||||
          ${this.escapeHtml(item)}
 | 
			
		||||
          <button type="button" class="autocomplete-remove" data-item="${this.escapeHtml(item)}" style="
 | 
			
		||||
            background: none;
 | 
			
		||||
            border: none;
 | 
			
		||||
            color: white;
 | 
			
		||||
            font-weight: bold;
 | 
			
		||||
            cursor: pointer;
 | 
			
		||||
            padding: 0;
 | 
			
		||||
            width: 1rem;
 | 
			
		||||
            height: 1rem;
 | 
			
		||||
            display: flex;
 | 
			
		||||
            align-items: center;
 | 
			
		||||
            justify-content: center;
 | 
			
		||||
          ">×</button>
 | 
			
		||||
        </span>
 | 
			
		||||
      `)
 | 
			
		||||
      .join('');
 | 
			
		||||
    
 | 
			
		||||
    // Bind remove events
 | 
			
		||||
    this.selectedContainer.querySelectorAll('.autocomplete-remove').forEach(btn => {
 | 
			
		||||
      btn.addEventListener('click', (e) => {
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
        this.removeItem((btn as HTMLElement).getAttribute('data-item')!);
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  updateInputValue(): void {
 | 
			
		||||
    if (this.options.allowMultiple && this.options.hiddenInput) {
 | 
			
		||||
      this.options.hiddenInput.value = Array.from(this.selectedItems).join(this.options.separator || ', ');
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  showDropdown(): void {
 | 
			
		||||
    this.dropdown.style.display = 'block';
 | 
			
		||||
    this.isOpen = true;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  hideDropdown(): void {
 | 
			
		||||
    this.dropdown.style.display = 'none';
 | 
			
		||||
    this.isOpen = false;
 | 
			
		||||
    this.selectedIndex = -1;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  escapeHtml(text: string): string {
 | 
			
		||||
    const div = document.createElement('div');
 | 
			
		||||
    div.textContent = text;
 | 
			
		||||
    return div.innerHTML;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  setDataSource(newDataSource: any[]): void {
 | 
			
		||||
    this.dataSource = newDataSource;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  getSelectedItems(): string[] {
 | 
			
		||||
    return Array.from(this.selectedItems);
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  setSelectedItems(items: string[]): void {
 | 
			
		||||
    this.selectedItems = new Set(items);
 | 
			
		||||
    if (this.options.allowMultiple) {
 | 
			
		||||
      this.updateSelectedDisplay();
 | 
			
		||||
      this.updateInputValue();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  destroy(): void {
 | 
			
		||||
    if (this.dropdown && this.dropdown.parentNode) {
 | 
			
		||||
      this.dropdown.parentNode.removeChild(this.dropdown);
 | 
			
		||||
    }
 | 
			
		||||
    if (this.selectedContainer && this.selectedContainer.parentNode) {
 | 
			
		||||
      this.selectedContainer.parentNode.removeChild(this.selectedContainer);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -5,22 +5,23 @@ export interface ContributionData {
 | 
			
		||||
  type: 'add' | 'edit';
 | 
			
		||||
  tool: {
 | 
			
		||||
    name: string;
 | 
			
		||||
    icon?: string;
 | 
			
		||||
    icon?: string | null;
 | 
			
		||||
    type: 'software' | 'method' | 'concept';
 | 
			
		||||
    description: string;
 | 
			
		||||
    domains: string[];
 | 
			
		||||
    phases: string[];
 | 
			
		||||
    platforms: string[];
 | 
			
		||||
    skillLevel: string;
 | 
			
		||||
    accessType?: string;
 | 
			
		||||
    accessType?: string | null;
 | 
			
		||||
    url: string;
 | 
			
		||||
    projectUrl?: string;
 | 
			
		||||
    license?: string;
 | 
			
		||||
    knowledgebase?: boolean;
 | 
			
		||||
    'domain-agnostic-software'?: string[];
 | 
			
		||||
    related_concepts?: string[];
 | 
			
		||||
    projectUrl?: string | null;
 | 
			
		||||
    license?: string | null;
 | 
			
		||||
    knowledgebase?: boolean | null;
 | 
			
		||||
    'domain-agnostic-software'?: string[] | null;
 | 
			
		||||
    related_concepts?: string[] | null;
 | 
			
		||||
    related_software?: string[] | null;
 | 
			
		||||
    tags: string[];
 | 
			
		||||
    statusUrl?: string;
 | 
			
		||||
    statusUrl?: string | null;
 | 
			
		||||
  };
 | 
			
		||||
  metadata: {
 | 
			
		||||
    submitter: string;
 | 
			
		||||
@ -134,6 +135,7 @@ export class GitContributionManager {
 | 
			
		||||
    if (tool.projectUrl) cleanTool.projectUrl = tool.projectUrl;
 | 
			
		||||
    if (tool.knowledgebase) cleanTool.knowledgebase = tool.knowledgebase;
 | 
			
		||||
    if (tool.related_concepts?.length) cleanTool.related_concepts = tool.related_concepts;
 | 
			
		||||
    if (tool.related_software?.length) cleanTool.related_software = tool.related_software;
 | 
			
		||||
    if (tool.tags?.length) cleanTool.tags = tool.tags;
 | 
			
		||||
    if (tool['domain-agnostic-software']?.length) {
 | 
			
		||||
      cleanTool['domain-agnostic-software'] = tool['domain-agnostic-software'];
 | 
			
		||||
@ -272,6 +274,8 @@ ${data.tool.platforms?.length ? `- **Platforms:** ${data.tool.platforms.join(',
 | 
			
		||||
${data.tool.license ? `- **License:** ${data.tool.license}` : ''}
 | 
			
		||||
${data.tool.domains?.length ? `- **Domains:** ${data.tool.domains.join(', ')}` : ''}
 | 
			
		||||
${data.tool.phases?.length ? `- **Phases:** ${data.tool.phases.join(', ')}` : ''}
 | 
			
		||||
${data.tool.related_concepts?.length ? `- **Related Concepts:** ${data.tool.related_concepts.join(', ')}` : ''}
 | 
			
		||||
${data.tool.related_software?.length ? `- **Related Software:** ${data.tool.related_software.join(', ')}` : ''}
 | 
			
		||||
 | 
			
		||||
${data.metadata.reason ? `### Reason
 | 
			
		||||
${data.metadata.reason}
 | 
			
		||||
@ -299,9 +303,6 @@ ${data.metadata.contact}
 | 
			
		||||
  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}`);
 | 
			
		||||
@ -310,18 +311,12 @@ ${data.metadata.contact}
 | 
			
		||||
    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');
 | 
			
		||||
@ -330,18 +325,12 @@ ${data.metadata.contact}
 | 
			
		||||
      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) => {
 | 
			
		||||
@ -359,9 +348,6 @@ ${data.metadata.contact}
 | 
			
		||||
      sections.push('');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    /* Categories & Tags                                                  */
 | 
			
		||||
    /* ------------------------------------------------------------------ */
 | 
			
		||||
    const hasCategories = Array.isArray(data.categories) && data.categories.length > 0;
 | 
			
		||||
    const hasTags       = Array.isArray(data.tags)       && data.tags.length > 0;
 | 
			
		||||
 | 
			
		||||
@ -372,18 +358,12 @@ ${data.metadata.contact}
 | 
			
		||||
      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/`');
 | 
			
		||||
@ -395,4 +375,4 @@ ${data.metadata.contact}
 | 
			
		||||
 | 
			
		||||
    return sections.join('\n');
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user