Merge pull request 'videos' (#17) from videos into main
Reviewed-on: #17
This commit is contained in:
		
						commit
						4fd257cbd6
					
				
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										30
									
								
								.env.example
									
									
									
									
									
								
							
							
						
						
									
										30
									
								
								.env.example
									
									
									
									
									
								
							@ -68,6 +68,36 @@ AI_EMBEDDINGS_MODEL=mistral-embed
 | 
			
		||||
# User rate limiting (queries per minute)
 | 
			
		||||
AI_RATE_LIMIT_MAX_REQUESTS=4
 | 
			
		||||
 | 
			
		||||
# ============================================================================
 | 
			
		||||
# 🎥 VIDEO EMBEDDING - PRODUCTION CONFIGURATION
 | 
			
		||||
# ============================================================================
 | 
			
		||||
 | 
			
		||||
# Enable local caching of Nextcloud videos (highly recommended)
 | 
			
		||||
VIDEO_CACHE_ENABLED=true
 | 
			
		||||
 | 
			
		||||
# Directory for cached videos (ensure it's writable and has sufficient space)
 | 
			
		||||
# This directory will grow over time as videos are cached permanently
 | 
			
		||||
VIDEO_CACHE_DIR=./cache/videos
 | 
			
		||||
 | 
			
		||||
# Emergency cleanup threshold in MB - videos are cached indefinitely
 | 
			
		||||
# Only triggers cleanup when approaching this limit to prevent disk full
 | 
			
		||||
# Recommended: 2000MB (2GB) for small deployments, 5000MB+ for larger ones
 | 
			
		||||
VIDEO_CACHE_MAX_SIZE=2000
 | 
			
		||||
 | 
			
		||||
# Maximum individual video file size for caching in MB
 | 
			
		||||
# Videos larger than this will stream directly without caching
 | 
			
		||||
VIDEO_MAX_SIZE=200
 | 
			
		||||
 | 
			
		||||
# ============================================================================
 | 
			
		||||
# CACHING BEHAVIOR
 | 
			
		||||
# ============================================================================
 | 
			
		||||
# - Videos downloaded once, cached permanently
 | 
			
		||||
# - No time-based expiration
 | 
			
		||||
# - Dramatically improves loading times after first download
 | 
			
		||||
# - Emergency cleanup only when approaching disk space limit
 | 
			
		||||
# - Perfect for manually curated forensics training content
 | 
			
		||||
# ============================================================================
 | 
			
		||||
 | 
			
		||||
# ============================================================================
 | 
			
		||||
# 🎛️ PERFORMANCE TUNING - SENSIBLE DEFAULTS PROVIDED
 | 
			
		||||
# ============================================================================
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,6 @@
 | 
			
		||||
import { defineConfig } from 'astro/config';
 | 
			
		||||
import node from '@astrojs/node';
 | 
			
		||||
import { remarkVideoPlugin } from './src/utils/remarkVideoPlugin.ts';
 | 
			
		||||
 | 
			
		||||
export default defineConfig({
 | 
			
		||||
  output: 'server',
 | 
			
		||||
@ -7,6 +8,13 @@ export default defineConfig({
 | 
			
		||||
    mode: 'standalone'
 | 
			
		||||
  }),
 | 
			
		||||
  
 | 
			
		||||
  markdown: {
 | 
			
		||||
    remarkPlugins: [
 | 
			
		||||
      remarkVideoPlugin
 | 
			
		||||
    ],
 | 
			
		||||
    extendDefaultPlugins: true
 | 
			
		||||
  },
 | 
			
		||||
  
 | 
			
		||||
  build: {
 | 
			
		||||
    assets: '_astro'
 | 
			
		||||
  },
 | 
			
		||||
@ -16,4 +24,4 @@ export default defineConfig({
 | 
			
		||||
    host: true
 | 
			
		||||
  },
 | 
			
		||||
  allowImportingTsExtensions: true
 | 
			
		||||
});
 | 
			
		||||
});
 | 
			
		||||
@ -11,6 +11,8 @@
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@astrojs/node": "^9.3.0",
 | 
			
		||||
    "@aws-sdk/client-s3": "^3.864.0",
 | 
			
		||||
    "@aws-sdk/s3-request-presigner": "^3.864.0",
 | 
			
		||||
    "astro": "^5.12.3",
 | 
			
		||||
    "cookie": "^1.0.2",
 | 
			
		||||
    "dotenv": "^16.4.5",
 | 
			
		||||
 | 
			
		||||
@ -193,7 +193,6 @@ domains.forEach((domain: any) => {
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<script define:vars={{ toolsData: tools, domainAgnosticSoftware, domainAgnosticTools }}>
 | 
			
		||||
  // Ensure isToolHosted is available
 | 
			
		||||
  if (!window.isToolHosted) {
 | 
			
		||||
    window.isToolHosted = function(tool) {
 | 
			
		||||
      return tool.projectUrl !== undefined && 
 | 
			
		||||
@ -765,14 +764,12 @@ domains.forEach((domain: any) => {
 | 
			
		||||
    hideToolDetails('both');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Register all functions globally
 | 
			
		||||
  window.showToolDetails = showToolDetails;
 | 
			
		||||
  window.hideToolDetails = hideToolDetails;
 | 
			
		||||
  window.hideAllToolDetails = hideAllToolDetails;
 | 
			
		||||
  window.toggleDomainAgnosticSection = toggleDomainAgnosticSection;
 | 
			
		||||
  window.showShareDialog = showShareDialog;
 | 
			
		||||
 | 
			
		||||
  // Register matrix-prefixed versions for delegation
 | 
			
		||||
  window.matrixShowToolDetails = showToolDetails;
 | 
			
		||||
  window.matrixHideToolDetails = hideToolDetails;
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										46
									
								
								src/components/Video.astro
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/components/Video.astro
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,46 @@
 | 
			
		||||
---
 | 
			
		||||
// src/components/Video.astro - SIMPLE responsive video component
 | 
			
		||||
export interface Props {
 | 
			
		||||
  src: string;
 | 
			
		||||
  title?: string;
 | 
			
		||||
  controls?: boolean;
 | 
			
		||||
  autoplay?: boolean;
 | 
			
		||||
  muted?: boolean;
 | 
			
		||||
  loop?: boolean;
 | 
			
		||||
  aspectRatio?: '16:9' | '4:3' | '1:1';
 | 
			
		||||
  preload?: 'none' | 'metadata' | 'auto';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const {
 | 
			
		||||
  src,
 | 
			
		||||
  title = 'Video',
 | 
			
		||||
  controls = true,
 | 
			
		||||
  autoplay = false,
 | 
			
		||||
  muted = false,
 | 
			
		||||
  loop = false,
 | 
			
		||||
  aspectRatio = '16:9',
 | 
			
		||||
  preload = 'metadata'
 | 
			
		||||
} = Astro.props;
 | 
			
		||||
 | 
			
		||||
const aspectClass = `aspect-${aspectRatio.replace(':', '-')}`;
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<div class={`video-container ${aspectClass}`}>
 | 
			
		||||
  <video 
 | 
			
		||||
    src={src}
 | 
			
		||||
    controls={controls}
 | 
			
		||||
    autoplay={autoplay}
 | 
			
		||||
    muted={muted}
 | 
			
		||||
    loop={loop}
 | 
			
		||||
    preload={preload}
 | 
			
		||||
    style="width: 100%; height: 100%;"
 | 
			
		||||
    data-video-title={title}
 | 
			
		||||
  >
 | 
			
		||||
    <p>Your browser does not support the video element.</p>
 | 
			
		||||
  </video>
 | 
			
		||||
  {title !== 'Video' && (
 | 
			
		||||
    <div class="video-metadata">
 | 
			
		||||
      <div class="video-title">{title}</div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )}
 | 
			
		||||
</div>
 | 
			
		||||
@ -18,6 +18,7 @@ sections:
 | 
			
		||||
review_status: "published"
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<video src="https://cloud.cc24.dev/s/ZmPK86M86fWyGQk" controls title="Training Video"></video>
 | 
			
		||||
> **⚠️ Hinweis**: Dies ist ein vorläufiger, KI-generierter Knowledgebase-Eintrag. Wir freuen uns über Verbesserungen und Ergänzungen durch die Community!
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -18,6 +18,8 @@ sections:
 | 
			
		||||
  advanced_topics: true
 | 
			
		||||
review_status: "published"
 | 
			
		||||
---
 | 
			
		||||
<video src="https://cloud.cc24.dev/s/ZmPK86M86fWyGQk" controls title="Training Video"></video>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
> **⚠️ Hinweis**: Dies ist ein vorläufiger, KI-generierter Knowledgebase-Eintrag. Wir freuen uns über Verbesserungen und Ergänzungen durch die Community!
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -37,7 +37,6 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.error('Failed to load utility functions:', error);
 | 
			
		||||
        
 | 
			
		||||
        // Provide fallback implementations
 | 
			
		||||
        (window as any).createToolSlug = (toolName: string) => {
 | 
			
		||||
          if (!toolName || typeof toolName !== 'string') return '';
 | 
			
		||||
          return toolName.toLowerCase().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
 | 
			
		||||
@ -119,7 +118,6 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
 | 
			
		||||
  (window as any).prioritizeSearchResults = prioritizeSearchResults;
 | 
			
		||||
 | 
			
		||||
  document.addEventListener('DOMContentLoaded', async () => {
 | 
			
		||||
    // CRITICAL: Load utility functions FIRST before any URL handling
 | 
			
		||||
    await loadUtilityFunctions();
 | 
			
		||||
    
 | 
			
		||||
    const THEME_KEY = 'dfir-theme';
 | 
			
		||||
@ -173,32 +171,31 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
 | 
			
		||||
      getStoredTheme
 | 
			
		||||
    };
 | 
			
		||||
  
 | 
			
		||||
  (window as any).showToolDetails = function(toolName: string, modalType: string = 'primary') {
 | 
			
		||||
    
 | 
			
		||||
    let attempts = 0;
 | 
			
		||||
    const maxAttempts = 50;
 | 
			
		||||
    
 | 
			
		||||
    const tryDelegate = () => {
 | 
			
		||||
      const matrixShowToolDetails = (window as any).matrixShowToolDetails;
 | 
			
		||||
    (window as any).showToolDetails = function(toolName: string, modalType: string = 'primary') {
 | 
			
		||||
      let attempts = 0;
 | 
			
		||||
      const maxAttempts = 50;
 | 
			
		||||
      
 | 
			
		||||
      if (matrixShowToolDetails && typeof matrixShowToolDetails === 'function') {
 | 
			
		||||
        return matrixShowToolDetails(toolName, modalType);
 | 
			
		||||
      }
 | 
			
		||||
      const tryDelegate = () => {
 | 
			
		||||
        const matrixShowToolDetails = (window as any).matrixShowToolDetails;
 | 
			
		||||
        
 | 
			
		||||
        if (matrixShowToolDetails && typeof matrixShowToolDetails === 'function') {
 | 
			
		||||
          return matrixShowToolDetails(toolName, modalType);
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        const directShowToolDetails = (window as any).directShowToolDetails;
 | 
			
		||||
        if (directShowToolDetails && typeof directShowToolDetails === 'function') {
 | 
			
		||||
          return directShowToolDetails(toolName, modalType);
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        attempts++;
 | 
			
		||||
        if (attempts < maxAttempts) {
 | 
			
		||||
          setTimeout(tryDelegate, 100);
 | 
			
		||||
        } else {
 | 
			
		||||
        }
 | 
			
		||||
      };
 | 
			
		||||
      
 | 
			
		||||
      const directShowToolDetails = (window as any).directShowToolDetails;
 | 
			
		||||
      if (directShowToolDetails && typeof directShowToolDetails === 'function') {
 | 
			
		||||
        return directShowToolDetails(toolName, modalType);
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      attempts++;
 | 
			
		||||
      if (attempts < maxAttempts) {
 | 
			
		||||
        setTimeout(tryDelegate, 100);
 | 
			
		||||
      } else {
 | 
			
		||||
      }
 | 
			
		||||
      tryDelegate();
 | 
			
		||||
    };
 | 
			
		||||
    
 | 
			
		||||
    tryDelegate();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
    (window as any).hideToolDetails = function(modalType: string = 'both') {
 | 
			
		||||
      const matrixHideToolDetails = (window as any).matrixHideToolDetails;
 | 
			
		||||
@ -229,7 +226,7 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
 | 
			
		||||
              authRequired: data.aiAuthRequired,
 | 
			
		||||
              expires: data.expires
 | 
			
		||||
            };
 | 
			
		||||
            case 'gatedcontent':  // ADD THIS CASE
 | 
			
		||||
            case 'gatedcontent':
 | 
			
		||||
              return {
 | 
			
		||||
                authenticated: data.gatedContentAuthenticated,
 | 
			
		||||
                authRequired: data.gatedContentAuthRequired,
 | 
			
		||||
@ -353,6 +350,36 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
 | 
			
		||||
    };
 | 
			
		||||
    initAIButton();
 | 
			
		||||
  });
 | 
			
		||||
    document.addEventListener('DOMContentLoaded', () => {
 | 
			
		||||
      const isFirefox = navigator.userAgent.toLowerCase().includes('firefox') || 
 | 
			
		||||
                        navigator.userAgent.toLowerCase().includes('librewolf');
 | 
			
		||||
      
 | 
			
		||||
      if (isFirefox) {
 | 
			
		||||
        console.log('[VIDEO] Firefox detected - setting up error recovery');
 | 
			
		||||
        
 | 
			
		||||
        document.querySelectorAll('video').forEach(video => {
 | 
			
		||||
          let errorCount = 0;
 | 
			
		||||
          
 | 
			
		||||
          video.addEventListener('error', () => {
 | 
			
		||||
            errorCount++;
 | 
			
		||||
            console.log(`[VIDEO] Error ${errorCount} in Firefox for: ${video.getAttribute('data-video-title')}`);
 | 
			
		||||
            
 | 
			
		||||
            if (errorCount === 1 && video.src.includes('/download')) {
 | 
			
		||||
              console.log('[VIDEO] Trying /preview URL for Firefox compatibility');
 | 
			
		||||
              video.src = video.src.replace('/download', '/preview');
 | 
			
		||||
              video.load();
 | 
			
		||||
            } else if (errorCount === 1) {
 | 
			
		||||
              console.log('[VIDEO] Video failed to load in Firefox');
 | 
			
		||||
            }
 | 
			
		||||
          });
 | 
			
		||||
          
 | 
			
		||||
          video.addEventListener('loadedmetadata', () => {
 | 
			
		||||
            const title = video.getAttribute('data-video-title') || 'Video';
 | 
			
		||||
            console.log(`[VIDEO] Successfully loaded: ${title}`);
 | 
			
		||||
          });
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
</script>
 | 
			
		||||
</head>
 | 
			
		||||
<body>
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,7 @@
 | 
			
		||||
// src/pages/api/auth/login.ts 
 | 
			
		||||
import type { APIRoute } from 'astro';
 | 
			
		||||
import { generateAuthUrl, generateState, logAuthEvent } from '../../../utils/auth.js';
 | 
			
		||||
import { serialize } from 'cookie';
 | 
			
		||||
 | 
			
		||||
export const prerender = false;
 | 
			
		||||
 | 
			
		||||
@ -8,14 +10,27 @@ export const GET: APIRoute = async ({ url, redirect }) => {
 | 
			
		||||
    const state = generateState();
 | 
			
		||||
    const authUrl = generateAuthUrl(state);
 | 
			
		||||
    
 | 
			
		||||
    console.log('Generated auth URL:', authUrl);
 | 
			
		||||
    console.log('[AUTH] Generated auth URL:', authUrl);
 | 
			
		||||
    
 | 
			
		||||
    const returnTo = url.searchParams.get('returnTo') || '/';
 | 
			
		||||
    
 | 
			
		||||
    logAuthEvent('Login initiated', { returnTo, authUrl });
 | 
			
		||||
    
 | 
			
		||||
    const stateData = JSON.stringify({ state, returnTo });
 | 
			
		||||
    const stateCookie = `auth_state=${encodeURIComponent(stateData)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=600`;  
 | 
			
		||||
    
 | 
			
		||||
    const publicBaseUrl = process.env.PUBLIC_BASE_URL || '';
 | 
			
		||||
    const isProduction = process.env.NODE_ENV === 'production';
 | 
			
		||||
    const isSecure = publicBaseUrl.startsWith('https://') || isProduction;
 | 
			
		||||
    
 | 
			
		||||
    const stateCookie = serialize('auth_state', stateData, {
 | 
			
		||||
      httpOnly: true,
 | 
			
		||||
      secure: isSecure,
 | 
			
		||||
      sameSite: 'lax',
 | 
			
		||||
      maxAge: 600, // 10 minutes
 | 
			
		||||
      path: '/'
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    console.log('[AUTH] Setting auth state cookie:', stateCookie.substring(0, 50) + '...');
 | 
			
		||||
    
 | 
			
		||||
    return new Response(null, {
 | 
			
		||||
      status: 302,
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
// src/pages/api/auth/process.ts (FIXED - Proper cookie handling)
 | 
			
		||||
// src/pages/api/auth/process.ts
 | 
			
		||||
import type { APIRoute } from 'astro';
 | 
			
		||||
import { 
 | 
			
		||||
  verifyAuthState,
 | 
			
		||||
@ -7,7 +7,7 @@ import {
 | 
			
		||||
  createSessionWithCookie,
 | 
			
		||||
  logAuthEvent
 | 
			
		||||
} from '../../../utils/auth.js';
 | 
			
		||||
import { apiError, apiSpecial, apiWithHeaders, handleAPIRequest } from '../../../utils/api.js';
 | 
			
		||||
import { apiError, apiSpecial, handleAPIRequest } from '../../../utils/api.js';
 | 
			
		||||
 | 
			
		||||
export const prerender = false;
 | 
			
		||||
 | 
			
		||||
@ -30,9 +30,15 @@ export const POST: APIRoute = async ({ request }) => {
 | 
			
		||||
    
 | 
			
		||||
    const stateVerification = verifyAuthState(request, state);
 | 
			
		||||
    if (!stateVerification.isValid || !stateVerification.stateData) {
 | 
			
		||||
      logAuthEvent('State verification failed', { 
 | 
			
		||||
        error: stateVerification.error,
 | 
			
		||||
        hasStateData: !!stateVerification.stateData 
 | 
			
		||||
      });
 | 
			
		||||
      return apiError.badRequest(stateVerification.error || 'Invalid state parameter');
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    console.log('[AUTH] State verification successful, exchanging code for tokens');
 | 
			
		||||
    
 | 
			
		||||
    const tokens = await exchangeCodeForTokens(code);
 | 
			
		||||
    const userInfo = await getUserInfo(tokens.access_token);
 | 
			
		||||
    
 | 
			
		||||
@ -43,6 +49,12 @@ export const POST: APIRoute = async ({ request }) => {
 | 
			
		||||
      email: sessionResult.userEmail 
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    const returnUrl = new URL(stateVerification.stateData.returnTo, request.url);
 | 
			
		||||
    returnUrl.searchParams.set('auth', 'success');
 | 
			
		||||
    const redirectUrl = returnUrl.toString();
 | 
			
		||||
    
 | 
			
		||||
    console.log('[AUTH] Redirecting to:', redirectUrl);
 | 
			
		||||
    
 | 
			
		||||
    const responseHeaders = new Headers();
 | 
			
		||||
    responseHeaders.set('Content-Type', 'application/json');
 | 
			
		||||
    
 | 
			
		||||
@ -51,7 +63,7 @@ export const POST: APIRoute = async ({ request }) => {
 | 
			
		||||
    
 | 
			
		||||
    return new Response(JSON.stringify({ 
 | 
			
		||||
      success: true, 
 | 
			
		||||
      redirectTo: stateVerification.stateData.returnTo 
 | 
			
		||||
      redirectTo: redirectUrl
 | 
			
		||||
    }), {
 | 
			
		||||
      status: 200,
 | 
			
		||||
      headers: responseHeaders
 | 
			
		||||
 | 
			
		||||
@ -9,16 +9,16 @@ export const GET: APIRoute = async ({ request }) => {
 | 
			
		||||
  return await handleAPIRequest(async () => {
 | 
			
		||||
    const contributionAuth = await withAPIAuth(request, 'contributions');
 | 
			
		||||
    const aiAuth = await withAPIAuth(request, 'ai');
 | 
			
		||||
    const gatedContentAuth = await withAPIAuth(request, 'gatedcontent'); // ADDED
 | 
			
		||||
    const gatedContentAuth = await withAPIAuth(request, 'gatedcontent');
 | 
			
		||||
    
 | 
			
		||||
    return apiResponse.success({
 | 
			
		||||
      authenticated: contributionAuth.authenticated || aiAuth.authenticated || gatedContentAuth.authenticated,
 | 
			
		||||
      contributionAuthRequired: contributionAuth.authRequired,
 | 
			
		||||
      aiAuthRequired: aiAuth.authRequired,
 | 
			
		||||
      gatedContentAuthRequired: gatedContentAuth.authRequired, // ADDED
 | 
			
		||||
      gatedContentAuthRequired: gatedContentAuth.authRequired,
 | 
			
		||||
      contributionAuthenticated: contributionAuth.authenticated,
 | 
			
		||||
      aiAuthenticated: aiAuth.authenticated,
 | 
			
		||||
      gatedContentAuthenticated: gatedContentAuth.authenticated, // ADDED
 | 
			
		||||
      gatedContentAuthenticated: gatedContentAuth.authenticated,
 | 
			
		||||
      expires: contributionAuth.session?.exp ? new Date(contributionAuth.session.exp * 1000).toISOString() : null
 | 
			
		||||
    });
 | 
			
		||||
  }, 'Status check failed');
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
// src/pages/api/contribute/knowledgebase.ts - SIMPLIFIED: Issues only, minimal validation
 | 
			
		||||
// src/pages/api/contribute/knowledgebase.ts
 | 
			
		||||
import type { APIRoute } from 'astro';
 | 
			
		||||
import { withAPIAuth } from '../../../utils/auth.js';
 | 
			
		||||
import { apiResponse, apiError, apiServerError, handleAPIRequest } from '../../../utils/api.js';
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
// src/pages/api/contribute/tool.ts (UPDATED - Using consolidated API responses + related_software)
 | 
			
		||||
// src/pages/api/contribute/tool.ts
 | 
			
		||||
import type { APIRoute } from 'astro';
 | 
			
		||||
import { withAPIAuth } from '../../../utils/auth.js';
 | 
			
		||||
import { apiResponse, apiError, apiServerError, apiSpecial, handleAPIRequest } from '../../../utils/api.js';
 | 
			
		||||
@ -82,31 +82,27 @@ function sanitizeInput(obj: any): any {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
      delete body.tool.relatedConcepts;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // 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
 | 
			
		||||
      delete body.tool.relatedSoftware;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
@ -142,14 +138,11 @@ 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);
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
@ -35,7 +35,6 @@ export const POST: APIRoute = async ({ request }) => {
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /* --- (rest of the handler unchanged) -------------------------- */
 | 
			
		||||
    const { embeddingsService } = await import('../../../utils/embeddings.js');
 | 
			
		||||
 | 
			
		||||
    if (!embeddingsService.isEnabled()) {
 | 
			
		||||
 | 
			
		||||
@ -23,7 +23,6 @@ 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')
 | 
			
		||||
@ -300,7 +299,6 @@ const allConcepts = existingTools
 | 
			
		||||
</BaseLayout>
 | 
			
		||||
 | 
			
		||||
<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;
 | 
			
		||||
@ -337,7 +335,6 @@ class AutocompleteManager {
 | 
			
		||||
    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);
 | 
			
		||||
  }
 | 
			
		||||
@ -358,7 +355,6 @@ class AutocompleteManager {
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    this.input.addEventListener('blur', (e) => {
 | 
			
		||||
      // Delay to allow click events on dropdown items
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        if (!this.dropdown.contains(document.activeElement)) {
 | 
			
		||||
          this.hideDropdown();
 | 
			
		||||
@ -450,7 +446,6 @@ class AutocompleteManager {
 | 
			
		||||
      })
 | 
			
		||||
      .join('');
 | 
			
		||||
    
 | 
			
		||||
    // Bind click events
 | 
			
		||||
    this.dropdown.querySelectorAll('.autocomplete-option').forEach((option, index) => {
 | 
			
		||||
      option.addEventListener('click', () => {
 | 
			
		||||
        this.selectItem(this.filteredData[index]);
 | 
			
		||||
@ -484,7 +479,6 @@ class AutocompleteManager {
 | 
			
		||||
      this.hideDropdown();
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Trigger change event
 | 
			
		||||
    this.input.dispatchEvent(new CustomEvent('autocomplete:select', {
 | 
			
		||||
      detail: { item, text, selectedItems: Array.from(this.selectedItems) }
 | 
			
		||||
    }));
 | 
			
		||||
@ -510,7 +504,6 @@ class AutocompleteManager {
 | 
			
		||||
      `)
 | 
			
		||||
      .join('');
 | 
			
		||||
    
 | 
			
		||||
    // Bind remove events
 | 
			
		||||
    this.selectedContainer.querySelectorAll('.autocomplete-remove').forEach(btn => {
 | 
			
		||||
      btn.addEventListener('click', (e) => {
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
@ -636,7 +629,6 @@ class ContributionForm {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setupAutocomplete() {
 | 
			
		||||
    // Tags autocomplete
 | 
			
		||||
    if (this.elements.tagsInput && this.elements.tagsHidden) {
 | 
			
		||||
      const tagsManager = new AutocompleteManager(this.elements.tagsInput, allTags, {
 | 
			
		||||
        allowMultiple: true,
 | 
			
		||||
@ -644,7 +636,6 @@ class ContributionForm {
 | 
			
		||||
        placeholder: 'Beginne zu tippen, um Tags hinzuzufügen...'
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      // Set initial values if editing
 | 
			
		||||
      if (this.editTool?.tags) {
 | 
			
		||||
        tagsManager.setSelectedItems(this.editTool.tags);
 | 
			
		||||
      }
 | 
			
		||||
@ -652,7 +643,6 @@ class ContributionForm {
 | 
			
		||||
      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,
 | 
			
		||||
@ -660,7 +650,6 @@ class ContributionForm {
 | 
			
		||||
        placeholder: 'Beginne zu tippen, um Konzepte zu finden...'
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      // Set initial values if editing
 | 
			
		||||
      if (this.editTool?.related_concepts) {
 | 
			
		||||
        conceptsManager.setSelectedItems(this.editTool.related_concepts);
 | 
			
		||||
      }
 | 
			
		||||
@ -668,7 +657,6 @@ class ContributionForm {
 | 
			
		||||
      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,
 | 
			
		||||
@ -676,7 +664,6 @@ class ContributionForm {
 | 
			
		||||
        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);
 | 
			
		||||
      }
 | 
			
		||||
@ -684,7 +671,6 @@ class ContributionForm {
 | 
			
		||||
      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', () => {
 | 
			
		||||
@ -726,14 +712,10 @@ class ContributionForm {
 | 
			
		||||
  updateFieldVisibility() {
 | 
			
		||||
    const type = this.elements.typeSelect.value;
 | 
			
		||||
    
 | 
			
		||||
    // Only hide/show software-specific fields (platforms, license)
 | 
			
		||||
    // Relations should always be visible since all tool types can have relationships
 | 
			
		||||
    this.elements.softwareFields.style.display = type === 'software' ? 'block' : 'none';
 | 
			
		||||
    
 | 
			
		||||
    // Always show relations - all tool types can have relationships
 | 
			
		||||
    this.elements.relationsFields.style.display = 'block';
 | 
			
		||||
    
 | 
			
		||||
    // Only mark platform/license as required for software
 | 
			
		||||
    if (this.elements.platformsRequired) {
 | 
			
		||||
      this.elements.platformsRequired.style.display = type === 'software' ? 'inline' : 'none';
 | 
			
		||||
    }
 | 
			
		||||
@ -741,7 +723,6 @@ class ContributionForm {
 | 
			
		||||
      this.elements.licenseRequired.style.display = type === 'software' ? 'inline' : 'none';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Always show both relation sections - let users decide what's relevant
 | 
			
		||||
    const conceptsSection = document.getElementById('related-concepts-section');
 | 
			
		||||
    const softwareSection = document.getElementById('related-software-section');
 | 
			
		||||
    if (conceptsSection) conceptsSection.style.display = 'block';
 | 
			
		||||
@ -806,19 +787,16 @@ class ContributionForm {
 | 
			
		||||
        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);
 | 
			
		||||
@ -983,19 +961,16 @@ class ContributionForm {
 | 
			
		||||
        }
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      // 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);
 | 
			
		||||
@ -1072,7 +1047,6 @@ class ContributionForm {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  destroy() {
 | 
			
		||||
    // Clean up autocomplete managers
 | 
			
		||||
    this.autocompleteManagers.forEach(manager => {
 | 
			
		||||
      manager.destroy();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
@ -686,8 +686,6 @@ if (aiAuthRequired) {
 | 
			
		||||
    window.switchToAIView = () => switchToView('ai');
 | 
			
		||||
    window.switchToView = switchToView;
 | 
			
		||||
 | 
			
		||||
    // CRITICAL: Handle shared URLs AFTER everything is set up
 | 
			
		||||
    // Increased timeout to ensure all components and utility functions are loaded
 | 
			
		||||
    setTimeout(() => {
 | 
			
		||||
      handleSharedURL();
 | 
			
		||||
    }, 1000);
 | 
			
		||||
 | 
			
		||||
@ -10,7 +10,6 @@ const allKnowledgebaseEntries = await getCollection('knowledgebase', (entry) =>
 | 
			
		||||
  return entry.data.published !== false;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Check if gated content authentication is enabled globally
 | 
			
		||||
const gatedContentAuthEnabled = isGatedContentAuthRequired();
 | 
			
		||||
 | 
			
		||||
const knowledgebaseEntries = allKnowledgebaseEntries.map((entry) => {
 | 
			
		||||
@ -27,8 +26,7 @@ const knowledgebaseEntries = allKnowledgebaseEntries.map((entry) => {
 | 
			
		||||
    difficulty: entry.data.difficulty,
 | 
			
		||||
    categories: entry.data.categories || [],
 | 
			
		||||
    tags: entry.data.tags || [],
 | 
			
		||||
    gated_content: entry.data.gated_content || false, // NEW: Include gated content flag
 | 
			
		||||
    
 | 
			
		||||
    gated_content: entry.data.gated_content || false, 
 | 
			
		||||
    tool_name: entry.data.tool_name,
 | 
			
		||||
    related_tools: entry.data.related_tools || [],
 | 
			
		||||
    associatedTool,
 | 
			
		||||
@ -45,7 +43,6 @@ const knowledgebaseEntries = allKnowledgebaseEntries.map((entry) => {
 | 
			
		||||
 | 
			
		||||
knowledgebaseEntries.sort((a: any, b: any) => a.title.localeCompare(b.title));
 | 
			
		||||
 | 
			
		||||
// Count gated vs public articles for statistics
 | 
			
		||||
const gatedCount = knowledgebaseEntries.filter(entry => entry.gated_content).length;
 | 
			
		||||
const publicCount = knowledgebaseEntries.length - gatedCount;
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
@ -21,7 +21,6 @@ export async function getStaticPaths() {
 | 
			
		||||
 | 
			
		||||
const { entry }: { entry: any } = Astro.props;
 | 
			
		||||
 | 
			
		||||
// Check if this article is gated and if gated content auth is required globally
 | 
			
		||||
const isGatedContent = entry.data.gated_content === true;
 | 
			
		||||
const gatedContentAuthRequired = isGatedContentAuthRequired();
 | 
			
		||||
const requiresAuth = isGatedContent && gatedContentAuthRequired;
 | 
			
		||||
@ -62,24 +61,28 @@ const currentUrl = Astro.url.href;
 | 
			
		||||
<BaseLayout title={entry.data.title} description={entry.data.description}>
 | 
			
		||||
  {requiresAuth && (
 | 
			
		||||
    <script define:vars={{ requiresAuth, articleTitle: entry.data.title }}>
 | 
			
		||||
      // Client-side authentication check for gated content
 | 
			
		||||
      document.addEventListener('DOMContentLoaded', async () => {
 | 
			
		||||
        if (!requiresAuth) return;
 | 
			
		||||
        
 | 
			
		||||
        console.log('[GATED CONTENT] Checking client-side auth for: ' + articleTitle);
 | 
			
		||||
        
 | 
			
		||||
        // Hide content immediately while checking auth
 | 
			
		||||
        const urlParams = new URLSearchParams(window.location.search);
 | 
			
		||||
        const authSuccess = urlParams.get('auth') === 'success';
 | 
			
		||||
        
 | 
			
		||||
        const contentArea = document.querySelector('.article-content');
 | 
			
		||||
        const sidebar = document.querySelector('.article-sidebar');
 | 
			
		||||
 | 
			
		||||
        
 | 
			
		||||
        if (contentArea) {
 | 
			
		||||
          contentArea.style.display = 'none';
 | 
			
		||||
        }
 | 
			
		||||
        // DON'T hide the sidebar container - just prevent TOC generation
 | 
			
		||||
        //if (sidebar) {
 | 
			
		||||
          //sidebar.innerHTML = ''; // Clear any content instead of hiding
 | 
			
		||||
        //}
 | 
			
		||||
        
 | 
			
		||||
        if (authSuccess) {
 | 
			
		||||
          console.log('[GATED CONTENT] Auth success detected, waiting for session...');
 | 
			
		||||
          await new Promise(resolve => setTimeout(resolve, 1000));
 | 
			
		||||
          
 | 
			
		||||
          const cleanUrl = window.location.protocol + "//" + window.location.host + window.location.pathname;
 | 
			
		||||
          window.history.replaceState({}, document.title, cleanUrl);
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        try {
 | 
			
		||||
          const response = await fetch('/api/auth/status');
 | 
			
		||||
@ -93,7 +96,6 @@ const currentUrl = Astro.url.href;
 | 
			
		||||
          if (authRequired && !isAuthenticated) {
 | 
			
		||||
            console.log('[GATED CONTENT] Access denied - showing auth required message: ' + articleTitle);
 | 
			
		||||
            
 | 
			
		||||
            // Show authentication required message (no auto-redirect)
 | 
			
		||||
            if (contentArea) {
 | 
			
		||||
              const loginUrl = '/api/auth/login?returnTo=' + encodeURIComponent(window.location.href);
 | 
			
		||||
                contentArea.innerHTML = [
 | 
			
		||||
@ -121,11 +123,9 @@ const currentUrl = Astro.url.href;
 | 
			
		||||
            }
 | 
			
		||||
          } else {
 | 
			
		||||
            console.log('[GATED CONTENT] Access granted for: ' + articleTitle);
 | 
			
		||||
            // Show content for authenticated users
 | 
			
		||||
            if (contentArea) {
 | 
			
		||||
              contentArea.style.display = 'block';
 | 
			
		||||
            }
 | 
			
		||||
            // Let TOC generate normally for authenticated users
 | 
			
		||||
            setTimeout(() => {
 | 
			
		||||
              if (typeof generateTOCContent === 'function') {
 | 
			
		||||
                generateTOCContent();
 | 
			
		||||
@ -134,7 +134,6 @@ const currentUrl = Astro.url.href;
 | 
			
		||||
          }
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
          console.error('[GATED CONTENT] Auth check failed:', error);
 | 
			
		||||
          // On error, show auth required message
 | 
			
		||||
          if (requiresAuth && contentArea) {
 | 
			
		||||
            const loginUrl = '/api/auth/login?returnTo=' + encodeURIComponent(window.location.href);
 | 
			
		||||
            contentArea.innerHTML = [
 | 
			
		||||
@ -402,29 +401,10 @@ const currentUrl = Astro.url.href;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function generateSidebarTOC() {
 | 
			
		||||
    // NEW: Don't generate TOC for gated content that requires auth
 | 
			
		||||
    if (requiresAuth) {
 | 
			
		||||
      fetch('/api/auth/status')
 | 
			
		||||
        .then(response => response.json())
 | 
			
		||||
        .then(authStatus => {
 | 
			
		||||
          const isAuthenticated = authStatus.gatedContentAuthenticated || false;
 | 
			
		||||
          const authRequired = authStatus.gatedContentAuthRequired || false;
 | 
			
		||||
          
 | 
			
		||||
          // Only generate TOC if user is authenticated for gated content
 | 
			
		||||
          if (authRequired && !isAuthenticated) {
 | 
			
		||||
            return; // Don't generate TOC
 | 
			
		||||
          } else {
 | 
			
		||||
            generateTOCContent(); // Generate TOC for authenticated users
 | 
			
		||||
          }
 | 
			
		||||
        })
 | 
			
		||||
        .catch(() => {
 | 
			
		||||
          // On error, don't generate TOC for gated content
 | 
			
		||||
          return;
 | 
			
		||||
        });
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // For non-gated content, generate TOC normally
 | 
			
		||||
    generateTOCContent();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -530,17 +510,14 @@ const currentUrl = Astro.url.href;
 | 
			
		||||
      pre.dataset.copyEnhanced = 'true';
 | 
			
		||||
      pre.style.position ||= 'relative';
 | 
			
		||||
 | 
			
		||||
      // Try to find an existing copy button we can reuse
 | 
			
		||||
      let btn =
 | 
			
		||||
        pre.querySelector('.copy-btn') ||                         // our class
 | 
			
		||||
        pre.querySelector('.copy-btn') ||  
 | 
			
		||||
        pre.querySelector('.btn-copy, .copy-button, .code-copy, .copy-code, button[aria-label*="copy" i]');
 | 
			
		||||
 | 
			
		||||
      // If there is an "old" button that is NOT ours, prefer to reuse it by giving it our class.
 | 
			
		||||
      if (btn && !btn.classList.contains('copy-btn')) {
 | 
			
		||||
        btn.classList.add('copy-btn');
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // If no button at all, create one
 | 
			
		||||
      if (!btn) {
 | 
			
		||||
        btn = document.createElement('button');
 | 
			
		||||
        btn.type = 'button';
 | 
			
		||||
@ -555,7 +532,6 @@ const currentUrl = Astro.url.href;
 | 
			
		||||
        pre.appendChild(btn);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // If there is a SECOND old button lingering (top-left in your case), hide it
 | 
			
		||||
      const possibleOldButtons = pre.querySelectorAll(
 | 
			
		||||
        '.btn-copy, .copy-button, .code-copy, .copy-code, button[aria-label*="copy" i]'
 | 
			
		||||
      );
 | 
			
		||||
@ -563,7 +539,6 @@ const currentUrl = Astro.url.href;
 | 
			
		||||
        if (b !== btn) b.style.display = 'none';
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      // Success pill
 | 
			
		||||
      if (!pre.querySelector('.copied-pill')) {
 | 
			
		||||
        const pill = document.createElement('div');
 | 
			
		||||
        pill.className = 'copied-pill';
 | 
			
		||||
@ -571,7 +546,6 @@ const currentUrl = Astro.url.href;
 | 
			
		||||
        pre.appendChild(pill);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Screen reader live region
 | 
			
		||||
      if (!pre.querySelector('.sr-live')) {
 | 
			
		||||
        const live = document.createElement('div');
 | 
			
		||||
        live.className = 'sr-live';
 | 
			
		||||
@ -614,12 +588,13 @@ const currentUrl = Astro.url.href;
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // keep your existing DOMContentLoaded; just ensure this is called
 | 
			
		||||
  // Make generateTOCContent available globally for the auth check script
 | 
			
		||||
  window.generateTOCContent = generateTOCContent;
 | 
			
		||||
 | 
			
		||||
  // Initialize everything on page load
 | 
			
		||||
  document.addEventListener('DOMContentLoaded', () => {
 | 
			
		||||
    // existing:
 | 
			
		||||
    calculateReadingTime();
 | 
			
		||||
    generateSidebarTOC();
 | 
			
		||||
    // new/updated:
 | 
			
		||||
    enhanceCodeCopy();
 | 
			
		||||
  });
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
@ -688,3 +688,245 @@
 | 
			
		||||
  /* Expand content */
 | 
			
		||||
  .article-main { max-width: 100% !important; }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/* ==========================================================================
 | 
			
		||||
   VIDEO EMBEDDING - Add to knowledgebase.css
 | 
			
		||||
   ========================================================================== */
 | 
			
		||||
 | 
			
		||||
/* Video Container and Responsive Wrapper */
 | 
			
		||||
:where(.markdown-content) .video-container {
 | 
			
		||||
  position: relative;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  margin: 2rem 0;
 | 
			
		||||
  border-radius: var(--radius-lg, 0.75rem);
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
  background-color: var(--color-bg-tertiary, #000);
 | 
			
		||||
  box-shadow: var(--shadow-lg, 0 12px 30px rgba(0,0,0,0.16));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Responsive 16:9 aspect ratio by default */
 | 
			
		||||
:where(.markdown-content) .video-container.aspect-16-9 {
 | 
			
		||||
  aspect-ratio: 16 / 9;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:where(.markdown-content) .video-container.aspect-4-3 {
 | 
			
		||||
  aspect-ratio: 4 / 3;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:where(.markdown-content) .video-container.aspect-1-1 {
 | 
			
		||||
  aspect-ratio: 1 / 1;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Video Element Styling */
 | 
			
		||||
:where(.markdown-content) .video-container video {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  object-fit: contain;
 | 
			
		||||
  background-color: #000;
 | 
			
		||||
  border: none;
 | 
			
		||||
  outline: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Custom Video Controls Enhancement */
 | 
			
		||||
:where(.markdown-content) video::-webkit-media-controls-panel {
 | 
			
		||||
  background-color: rgba(0, 0, 0, 0.8);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:where(.markdown-content) video::-webkit-media-controls-current-time-display,
 | 
			
		||||
:where(.markdown-content) video::-webkit-media-controls-time-remaining-display {
 | 
			
		||||
  color: white;
 | 
			
		||||
  text-shadow: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Video Loading State */
 | 
			
		||||
:where(.markdown-content) .video-container .video-loading {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  top: 50%;
 | 
			
		||||
  left: 50%;
 | 
			
		||||
  transform: translate(-50%, -50%);
 | 
			
		||||
  color: var(--color-text-secondary);
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  gap: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:where(.markdown-content) .video-container .video-loading .spinner {
 | 
			
		||||
  width: 2rem;
 | 
			
		||||
  height: 2rem;
 | 
			
		||||
  border: 3px solid var(--color-border);
 | 
			
		||||
  border-top: 3px solid var(--color-primary);
 | 
			
		||||
  border-radius: 50%;
 | 
			
		||||
  animation: spin 1s linear infinite;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@keyframes spin {
 | 
			
		||||
  0% { transform: rotate(0deg); }
 | 
			
		||||
  100% { transform: rotate(360deg); }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Video Error State */
 | 
			
		||||
:where(.markdown-content) .video-container .video-error {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  top: 50%;
 | 
			
		||||
  left: 50%;
 | 
			
		||||
  transform: translate(-50%, -50%);
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  color: var(--color-error, #dc3545);
 | 
			
		||||
  padding: 2rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:where(.markdown-content) .video-container .video-error .error-icon {
 | 
			
		||||
  font-size: 3rem;
 | 
			
		||||
  margin-bottom: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Video Metadata Overlay */
 | 
			
		||||
:where(.markdown-content) .video-metadata {
 | 
			
		||||
  background-color: var(--color-bg-secondary);
 | 
			
		||||
  border: 1px solid var(--color-border);
 | 
			
		||||
  border-top: none;
 | 
			
		||||
  padding: 1rem 1.5rem;
 | 
			
		||||
  font-size: 0.875rem;
 | 
			
		||||
  color: var(--color-text-secondary);
 | 
			
		||||
  border-radius: 0 0 var(--radius-lg, 0.75rem) var(--radius-lg, 0.75rem);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:where(.markdown-content) .video-metadata .video-title {
 | 
			
		||||
  font-weight: 600;
 | 
			
		||||
  color: var(--color-text);
 | 
			
		||||
  margin-bottom: 0.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:where(.markdown-content) .video-metadata .video-info {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  gap: 1rem;
 | 
			
		||||
  flex-wrap: wrap;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:where(.markdown-content) .video-metadata .video-duration,
 | 
			
		||||
:where(.markdown-content) .video-metadata .video-size,
 | 
			
		||||
:where(.markdown-content) .video-metadata .video-format {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  gap: 0.25rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Fullscreen Support */
 | 
			
		||||
:where(.markdown-content) .video-container video:fullscreen {
 | 
			
		||||
  background-color: #000;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:where(.markdown-content) .video-container video:-webkit-full-screen {
 | 
			
		||||
  background-color: #000;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:where(.markdown-content) .video-container video:-moz-full-screen {
 | 
			
		||||
  background-color: #000;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Video Thumbnail/Poster Styling */
 | 
			
		||||
:where(.markdown-content) .video-container video[poster] {
 | 
			
		||||
  object-fit: cover;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Protected Video Overlay */
 | 
			
		||||
:where(.markdown-content) .video-container .video-protected {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  top: 0;
 | 
			
		||||
  left: 0;
 | 
			
		||||
  right: 0;
 | 
			
		||||
  bottom: 0;
 | 
			
		||||
  background-color: rgba(0, 0, 0, 0.8);
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
  color: white;
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  padding: 2rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:where(.markdown-content) .video-container .video-protected .lock-icon {
 | 
			
		||||
  font-size: 3rem;
 | 
			
		||||
  margin-bottom: 1rem;
 | 
			
		||||
  opacity: 0.8;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Responsive Design */
 | 
			
		||||
@media (max-width: 768px) {
 | 
			
		||||
  :where(.markdown-content) .video-container {
 | 
			
		||||
    margin: 1.5rem -0.5rem; /* Extend to edges on mobile */
 | 
			
		||||
    border-radius: 0;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  :where(.markdown-content) .video-metadata {
 | 
			
		||||
    padding: 0.75rem 1rem;
 | 
			
		||||
    font-size: 0.8125rem;
 | 
			
		||||
    border-radius: 0;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  :where(.markdown-content) .video-metadata .video-info {
 | 
			
		||||
    flex-direction: column;
 | 
			
		||||
    gap: 0.5rem;
 | 
			
		||||
    align-items: flex-start;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Dark Theme Adjustments */
 | 
			
		||||
[data-theme="dark"] :where(.markdown-content) .video-container {
 | 
			
		||||
  box-shadow: 0 12px 30px rgba(0,0,0,0.4);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
[data-theme="dark"] :where(.markdown-content) .video-metadata {
 | 
			
		||||
  background-color: var(--color-bg-tertiary);
 | 
			
		||||
  border-color: color-mix(in srgb, var(--color-border) 60%, transparent);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Video Caption/Description Support */
 | 
			
		||||
:where(.markdown-content) .video-caption {
 | 
			
		||||
  margin-top: 1rem;
 | 
			
		||||
  font-size: 0.9375rem;
 | 
			
		||||
  color: var(--color-text-secondary);
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  font-style: italic;
 | 
			
		||||
  line-height: 1.5;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Video Gallery Support (multiple videos) */
 | 
			
		||||
:where(.markdown-content) .video-gallery {
 | 
			
		||||
  display: grid;
 | 
			
		||||
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
 | 
			
		||||
  gap: 2rem;
 | 
			
		||||
  margin: 2rem 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:where(.markdown-content) .video-gallery .video-container {
 | 
			
		||||
  margin: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Accessibility Improvements */
 | 
			
		||||
:where(.markdown-content) .video-container video:focus {
 | 
			
		||||
  outline: 3px solid var(--color-primary);
 | 
			
		||||
  outline-offset: 3px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Print Media - Hide Videos */
 | 
			
		||||
@media print {
 | 
			
		||||
  :where(.markdown-content) .video-container {
 | 
			
		||||
    display: none !important;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  :where(.markdown-content) .video-container::after {
 | 
			
		||||
    content: "[Video: " attr(data-video-title, "Embedded Video") "]";
 | 
			
		||||
    display: block;
 | 
			
		||||
    padding: 1rem;
 | 
			
		||||
    background-color: #f5f5f5;
 | 
			
		||||
    border: 1px solid #ddd;
 | 
			
		||||
    text-align: center;
 | 
			
		||||
    font-style: italic;
 | 
			
		||||
    color: #666;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -1083,7 +1083,6 @@ class ImprovedMicroTaskAIPipeline {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      // Step 1: AI selection of tools for completion
 | 
			
		||||
      const selectionPrompt = AI_PROMPTS.generatePhaseCompletionPrompt(originalQuery, phase, phaseTools, phaseConcepts);
 | 
			
		||||
      const selectionResult = await this.callMicroTaskAI(selectionPrompt, context, 800);
 | 
			
		||||
      
 | 
			
		||||
@ -1108,7 +1107,6 @@ class ImprovedMicroTaskAIPipeline {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      // Step 2: Generate detailed reasoning for each selected tool
 | 
			
		||||
      for (const tool of validTools) {
 | 
			
		||||
        console.log('[AI-PIPELINE] Generating reasoning for phase completion tool:', tool.name);
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
// src/utils/auth.js (ENHANCED - Added gated content support)
 | 
			
		||||
// src/utils/auth.js
 | 
			
		||||
import type { AstroGlobal } from 'astro';
 | 
			
		||||
import crypto from 'crypto';
 | 
			
		||||
import { config } from 'dotenv';
 | 
			
		||||
@ -390,12 +390,10 @@ export function getAuthRequirementForContext(context: AuthContextType): boolean
 | 
			
		||||
  return getAuthRequirement(context);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// NEW: Helper function to check if gated content requires authentication
 | 
			
		||||
export function isGatedContentAuthRequired(): boolean {
 | 
			
		||||
  return getAuthRequirement('gatedcontent');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// NEW: Check if specific content should be gated
 | 
			
		||||
export function shouldGateContent(isGatedContent: boolean): boolean {
 | 
			
		||||
  return isGatedContent && isGatedContentAuthRequired();
 | 
			
		||||
}
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
// src/utils/clientUtils.ts
 | 
			
		||||
// Client-side utilities that mirror server-side toolHelpers.ts
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
export function createToolSlug(toolName: string): string {
 | 
			
		||||
  if (!toolName || typeof toolName !== 'string') {
 | 
			
		||||
@ -8,10 +8,10 @@ export function createToolSlug(toolName: string): string {
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  return toolName.toLowerCase()
 | 
			
		||||
    .replace(/[^a-z0-9\s-]/g, '')     // Remove special characters
 | 
			
		||||
    .replace(/\s+/g, '-')             // Replace spaces with hyphens
 | 
			
		||||
    .replace(/-+/g, '-')              // Remove duplicate hyphens
 | 
			
		||||
    .replace(/^-|-$/g, '');           // Remove leading/trailing hyphens
 | 
			
		||||
    .replace(/[^a-z0-9\s-]/g, '')
 | 
			
		||||
    .replace(/\s+/g, '-')
 | 
			
		||||
    .replace(/-+/g, '-')
 | 
			
		||||
    .replace(/^-|-$/g, '');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function findToolByIdentifier(tools: any[], identifier: string): any | undefined {
 | 
			
		||||
@ -30,7 +30,6 @@ export function isToolHosted(tool: any): boolean {
 | 
			
		||||
         tool.projectUrl.trim() !== "";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Consolidated Autocomplete Functionality
 | 
			
		||||
interface AutocompleteOptions {
 | 
			
		||||
  minLength?: number;
 | 
			
		||||
  maxResults?: number;
 | 
			
		||||
@ -97,7 +96,6 @@ export class AutocompleteManager {
 | 
			
		||||
      display: none;
 | 
			
		||||
    `;
 | 
			
		||||
    
 | 
			
		||||
    // Insert dropdown after input
 | 
			
		||||
    const parentElement = this.input.parentNode as HTMLElement;
 | 
			
		||||
    parentElement.style.position = 'relative';
 | 
			
		||||
    parentElement.insertBefore(this.dropdown, this.input.nextSibling);
 | 
			
		||||
@ -119,7 +117,6 @@ export class AutocompleteManager {
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    this.input.addEventListener('blur', () => {
 | 
			
		||||
      // Delay to allow click events on dropdown items
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        const activeElement = document.activeElement;
 | 
			
		||||
        if (!activeElement || !this.dropdown.contains(activeElement)) {
 | 
			
		||||
@ -226,7 +223,6 @@ export class AutocompleteManager {
 | 
			
		||||
      })
 | 
			
		||||
      .join('');
 | 
			
		||||
    
 | 
			
		||||
    // Bind click events
 | 
			
		||||
    this.dropdown.querySelectorAll('.autocomplete-option').forEach((option, index) => {
 | 
			
		||||
      option.addEventListener('click', () => {
 | 
			
		||||
        this.selectItem(this.filteredData[index]);
 | 
			
		||||
@ -260,7 +256,6 @@ export class AutocompleteManager {
 | 
			
		||||
      this.hideDropdown();
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Trigger change event
 | 
			
		||||
    this.input.dispatchEvent(new CustomEvent('autocomplete:select', {
 | 
			
		||||
      detail: { item, text, selectedItems: Array.from(this.selectedItems) }
 | 
			
		||||
    }));
 | 
			
		||||
@ -307,7 +302,6 @@ export class AutocompleteManager {
 | 
			
		||||
      `)
 | 
			
		||||
      .join('');
 | 
			
		||||
    
 | 
			
		||||
    // Bind remove events
 | 
			
		||||
    this.selectedContainer.querySelectorAll('.autocomplete-remove').forEach(btn => {
 | 
			
		||||
      btn.addEventListener('click', (e) => {
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										85
									
								
								src/utils/remarkVideoPlugin.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								src/utils/remarkVideoPlugin.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,85 @@
 | 
			
		||||
// src/utils/remarkVideoPlugin.ts
 | 
			
		||||
import { visit } from 'unist-util-visit';
 | 
			
		||||
import type { Plugin } from 'unified';
 | 
			
		||||
import type { Root } from 'hast';
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
export const remarkVideoPlugin: Plugin<[], Root> = () => {
 | 
			
		||||
  return (tree: Root) => {
 | 
			
		||||
    visit(tree, 'html', (node: any, index: number | undefined, parent: any) => {
 | 
			
		||||
      if (node.value && node.value.includes('<video') && typeof index === 'number') {
 | 
			
		||||
        
 | 
			
		||||
        const srcMatch = node.value.match(/src=["']([^"']+)["']/);
 | 
			
		||||
        const titleMatch = node.value.match(/title=["']([^"']+)["']/);
 | 
			
		||||
        
 | 
			
		||||
        if (srcMatch) {
 | 
			
		||||
          const originalSrc = srcMatch[1];
 | 
			
		||||
          const title = titleMatch?.[1] || 'Video';
 | 
			
		||||
          
 | 
			
		||||
          const finalSrc = processNextcloudUrl(originalSrc);
 | 
			
		||||
          
 | 
			
		||||
          const hasControls = node.value.includes('controls');
 | 
			
		||||
          const hasAutoplay = node.value.includes('autoplay');
 | 
			
		||||
          const hasMuted = node.value.includes('muted');
 | 
			
		||||
          const hasLoop = node.value.includes('loop');
 | 
			
		||||
          const hasPreload = node.value.match(/preload=["']([^"']+)["']/);
 | 
			
		||||
          
 | 
			
		||||
          const enhancedHTML = `
 | 
			
		||||
            <div class="video-container aspect-16-9">
 | 
			
		||||
              <video 
 | 
			
		||||
                src="${escapeHtml(finalSrc)}"
 | 
			
		||||
                ${hasControls ? 'controls' : ''}
 | 
			
		||||
                ${hasAutoplay ? 'autoplay' : ''}
 | 
			
		||||
                ${hasMuted ? 'muted' : ''}
 | 
			
		||||
                ${hasLoop ? 'loop' : ''}
 | 
			
		||||
                ${hasPreload ? `preload="${hasPreload[1]}"` : 'preload="metadata"'}
 | 
			
		||||
                style="width: 100%; height: 100%;"
 | 
			
		||||
                data-video-title="${escapeHtml(title)}"
 | 
			
		||||
                data-original-src="${escapeHtml(originalSrc)}"
 | 
			
		||||
              >
 | 
			
		||||
                <p>Your browser does not support the video element.</p>
 | 
			
		||||
              </video>
 | 
			
		||||
              ${title !== 'Video' ? `
 | 
			
		||||
                <div class="video-metadata">
 | 
			
		||||
                  <div class="video-title">${escapeHtml(title)}</div>
 | 
			
		||||
                </div>
 | 
			
		||||
              ` : ''}
 | 
			
		||||
            </div>
 | 
			
		||||
          `.trim();
 | 
			
		||||
          
 | 
			
		||||
          parent.children[index] = { type: 'html', value: enhancedHTML };
 | 
			
		||||
          
 | 
			
		||||
          console.log(`[VIDEO] Processed: ${title}`);
 | 
			
		||||
          console.log(`[VIDEO] Final URL: ${finalSrc}`);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
function processNextcloudUrl(originalUrl: string): string {
 | 
			
		||||
  if (isNextcloudShareUrl(originalUrl) && !originalUrl.includes('/download')) {
 | 
			
		||||
    const downloadUrl = `${originalUrl}/download`;
 | 
			
		||||
    console.log(`[VIDEO] Auto-added /download: ${originalUrl} → ${downloadUrl}`);
 | 
			
		||||
    return downloadUrl;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  return originalUrl;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function isNextcloudShareUrl(url: string): boolean {
 | 
			
		||||
  const pattern = /\/s\/[a-zA-Z0-9]+/;
 | 
			
		||||
  return pattern.test(url) && (url.includes('nextcloud') || url.includes('cloud.'));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function escapeHtml(unsafe: string): string {
 | 
			
		||||
  if (typeof unsafe !== 'string') return '';
 | 
			
		||||
  
 | 
			
		||||
  return unsafe
 | 
			
		||||
    .replace(/&/g, "&")
 | 
			
		||||
    .replace(/</g, "<")
 | 
			
		||||
    .replace(/>/g, ">")
 | 
			
		||||
    .replace(/"/g, """)
 | 
			
		||||
    .replace(/'/g, "'");
 | 
			
		||||
}
 | 
			
		||||
@ -1,3 +1,5 @@
 | 
			
		||||
// src/utils/toolHelpers.ts
 | 
			
		||||
 | 
			
		||||
export interface Tool {
 | 
			
		||||
  name: string;
 | 
			
		||||
  type?: 'software' | 'method' | 'concept';
 | 
			
		||||
@ -13,31 +15,8 @@ export interface Tool {
 | 
			
		||||
  related_concepts?: string[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function createToolSlug(toolName: string): string {
 | 
			
		||||
  if (!toolName || typeof toolName !== 'string') {
 | 
			
		||||
    console.warn('[toolHelpers] Invalid toolName provided to createToolSlug:', toolName);
 | 
			
		||||
    return '';
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  return toolName.toLowerCase()
 | 
			
		||||
    .replace(/[^a-z0-9\s-]/g, '')     // Remove special characters
 | 
			
		||||
    .replace(/\s+/g, '-')             // Replace spaces with hyphens
 | 
			
		||||
    .replace(/-+/g, '-')              // Remove duplicate hyphens
 | 
			
		||||
    .replace(/^-|-$/g, '');           // Remove leading/trailing hyphens
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function findToolByIdentifier(tools: Tool[], identifier: string): Tool | undefined {
 | 
			
		||||
  if (!identifier || !Array.isArray(tools)) return undefined;
 | 
			
		||||
  
 | 
			
		||||
  return tools.find(tool => 
 | 
			
		||||
    tool.name === identifier || 
 | 
			
		||||
    createToolSlug(tool.name) === identifier.toLowerCase()
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function isToolHosted(tool: Tool): boolean {
 | 
			
		||||
  return tool.projectUrl !== undefined && 
 | 
			
		||||
         tool.projectUrl !== null && 
 | 
			
		||||
         tool.projectUrl !== "" && 
 | 
			
		||||
         tool.projectUrl.trim() !== "";
 | 
			
		||||
}
 | 
			
		||||
export { 
 | 
			
		||||
  createToolSlug, 
 | 
			
		||||
  findToolByIdentifier, 
 | 
			
		||||
  isToolHosted 
 | 
			
		||||
} from './clientUtils.js';
 | 
			
		||||
							
								
								
									
										115
									
								
								src/utils/videoUtils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								src/utils/videoUtils.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,115 @@
 | 
			
		||||
// src/utils/videoUtils.ts - SIMPLIFIED - Basic utilities only
 | 
			
		||||
import 'dotenv/config';
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
export interface SimpleVideoMetadata {
 | 
			
		||||
  title?: string;
 | 
			
		||||
  description?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function getVideoMimeType(url: string): string {
 | 
			
		||||
  let extension: string | undefined;
 | 
			
		||||
  try {
 | 
			
		||||
    const pathname = new URL(url).pathname;
 | 
			
		||||
    extension = pathname.split('.').pop()?.toLowerCase();
 | 
			
		||||
  } catch {
 | 
			
		||||
    extension = url.split('?')[0].split('.').pop()?.toLowerCase();
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  const mimeTypes: Record<string, string> = {
 | 
			
		||||
    mp4: 'video/mp4',
 | 
			
		||||
    webm: 'video/webm',
 | 
			
		||||
    ogg: 'video/ogg',
 | 
			
		||||
    mov: 'video/quicktime',
 | 
			
		||||
    avi: 'video/x-msvideo',
 | 
			
		||||
    m4v: 'video/m4v',
 | 
			
		||||
    mkv: 'video/x-matroska',
 | 
			
		||||
    flv: 'video/x-flv'
 | 
			
		||||
  };
 | 
			
		||||
  
 | 
			
		||||
  return (extension && mimeTypes[extension]) || 'video/mp4';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function formatDuration(seconds: number): string {
 | 
			
		||||
  const hours = Math.floor(seconds / 3600);
 | 
			
		||||
  const minutes = Math.floor((seconds % 3600) / 60);
 | 
			
		||||
  const remainingSeconds = Math.floor(seconds % 60);
 | 
			
		||||
  
 | 
			
		||||
  if (hours > 0) {
 | 
			
		||||
    return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function formatFileSize(bytes: number): string {
 | 
			
		||||
  if (bytes < 1024) return `${bytes} B`;
 | 
			
		||||
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
 | 
			
		||||
  if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
 | 
			
		||||
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function escapeHtml(unsafe: string): string {
 | 
			
		||||
  if (typeof unsafe !== 'string') return '';
 | 
			
		||||
  
 | 
			
		||||
  return unsafe
 | 
			
		||||
    .replace(/&/g, "&")
 | 
			
		||||
    .replace(/</g, "<")
 | 
			
		||||
    .replace(/>/g, ">")
 | 
			
		||||
    .replace(/"/g, """)
 | 
			
		||||
    .replace(/'/g, "'");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function generateVideoHTML(
 | 
			
		||||
  src: string, 
 | 
			
		||||
  options: {
 | 
			
		||||
    title?: string;
 | 
			
		||||
    controls?: boolean;
 | 
			
		||||
    autoplay?: boolean;
 | 
			
		||||
    muted?: boolean;
 | 
			
		||||
    loop?: boolean;
 | 
			
		||||
    preload?: 'none' | 'metadata' | 'auto';
 | 
			
		||||
    aspectRatio?: '16:9' | '4:3' | '1:1';
 | 
			
		||||
    showMetadata?: boolean;
 | 
			
		||||
  } = {}
 | 
			
		||||
): string {
 | 
			
		||||
  const {
 | 
			
		||||
    title = 'Video',
 | 
			
		||||
    controls = true,
 | 
			
		||||
    autoplay = false,
 | 
			
		||||
    muted = false,
 | 
			
		||||
    loop = false,
 | 
			
		||||
    preload = 'metadata',
 | 
			
		||||
    aspectRatio = '16:9',
 | 
			
		||||
    showMetadata = true
 | 
			
		||||
  } = options;
 | 
			
		||||
  
 | 
			
		||||
  const aspectClass = `aspect-${aspectRatio.replace(':', '-')}`;
 | 
			
		||||
  const videoAttributes = [
 | 
			
		||||
    controls ? 'controls' : '',
 | 
			
		||||
    autoplay ? 'autoplay' : '',
 | 
			
		||||
    muted ? 'muted' : '',
 | 
			
		||||
    loop ? 'loop' : '',
 | 
			
		||||
    `preload="${preload}"`
 | 
			
		||||
  ].filter(Boolean).join(' ');
 | 
			
		||||
  
 | 
			
		||||
  const metadataHTML = showMetadata && title !== 'Video' ? `
 | 
			
		||||
    <div class="video-metadata">
 | 
			
		||||
      <div class="video-title">${escapeHtml(title)}</div>
 | 
			
		||||
    </div>
 | 
			
		||||
  ` : '';
 | 
			
		||||
  
 | 
			
		||||
  return `
 | 
			
		||||
    <div class="video-container ${aspectClass}">
 | 
			
		||||
      <video 
 | 
			
		||||
        src="${escapeHtml(src)}"
 | 
			
		||||
        ${videoAttributes}
 | 
			
		||||
        style="width: 100%; height: 100%;"
 | 
			
		||||
        data-video-title="${escapeHtml(title)}"
 | 
			
		||||
      >
 | 
			
		||||
        <p>Your browser does not support the video element.</p>
 | 
			
		||||
      </video>
 | 
			
		||||
      ${metadataHTML}
 | 
			
		||||
    </div>
 | 
			
		||||
  `.trim();
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user