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)
|
# User rate limiting (queries per minute)
|
||||||
AI_RATE_LIMIT_MAX_REQUESTS=4
|
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
|
# 🎛️ PERFORMANCE TUNING - SENSIBLE DEFAULTS PROVIDED
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from 'astro/config';
|
||||||
import node from '@astrojs/node';
|
import node from '@astrojs/node';
|
||||||
|
import { remarkVideoPlugin } from './src/utils/remarkVideoPlugin.ts';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
output: 'server',
|
output: 'server',
|
||||||
@ -7,6 +8,13 @@ export default defineConfig({
|
|||||||
mode: 'standalone'
|
mode: 'standalone'
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
markdown: {
|
||||||
|
remarkPlugins: [
|
||||||
|
remarkVideoPlugin
|
||||||
|
],
|
||||||
|
extendDefaultPlugins: true
|
||||||
|
},
|
||||||
|
|
||||||
build: {
|
build: {
|
||||||
assets: '_astro'
|
assets: '_astro'
|
||||||
},
|
},
|
||||||
@ -16,4 +24,4 @@ export default defineConfig({
|
|||||||
host: true
|
host: true
|
||||||
},
|
},
|
||||||
allowImportingTsExtensions: true
|
allowImportingTsExtensions: true
|
||||||
});
|
});
|
@ -11,6 +11,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/node": "^9.3.0",
|
"@astrojs/node": "^9.3.0",
|
||||||
|
"@aws-sdk/client-s3": "^3.864.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.864.0",
|
||||||
"astro": "^5.12.3",
|
"astro": "^5.12.3",
|
||||||
"cookie": "^1.0.2",
|
"cookie": "^1.0.2",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
|
@ -193,7 +193,6 @@ domains.forEach((domain: any) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script define:vars={{ toolsData: tools, domainAgnosticSoftware, domainAgnosticTools }}>
|
<script define:vars={{ toolsData: tools, domainAgnosticSoftware, domainAgnosticTools }}>
|
||||||
// Ensure isToolHosted is available
|
|
||||||
if (!window.isToolHosted) {
|
if (!window.isToolHosted) {
|
||||||
window.isToolHosted = function(tool) {
|
window.isToolHosted = function(tool) {
|
||||||
return tool.projectUrl !== undefined &&
|
return tool.projectUrl !== undefined &&
|
||||||
@ -765,14 +764,12 @@ domains.forEach((domain: any) => {
|
|||||||
hideToolDetails('both');
|
hideToolDetails('both');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register all functions globally
|
|
||||||
window.showToolDetails = showToolDetails;
|
window.showToolDetails = showToolDetails;
|
||||||
window.hideToolDetails = hideToolDetails;
|
window.hideToolDetails = hideToolDetails;
|
||||||
window.hideAllToolDetails = hideAllToolDetails;
|
window.hideAllToolDetails = hideAllToolDetails;
|
||||||
window.toggleDomainAgnosticSection = toggleDomainAgnosticSection;
|
window.toggleDomainAgnosticSection = toggleDomainAgnosticSection;
|
||||||
window.showShareDialog = showShareDialog;
|
window.showShareDialog = showShareDialog;
|
||||||
|
|
||||||
// Register matrix-prefixed versions for delegation
|
|
||||||
window.matrixShowToolDetails = showToolDetails;
|
window.matrixShowToolDetails = showToolDetails;
|
||||||
window.matrixHideToolDetails = hideToolDetails;
|
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"
|
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!
|
> **⚠️ 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
|
advanced_topics: true
|
||||||
review_status: "published"
|
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!
|
> **⚠️ 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) {
|
} catch (error) {
|
||||||
console.error('Failed to load utility functions:', error);
|
console.error('Failed to load utility functions:', error);
|
||||||
|
|
||||||
// Provide fallback implementations
|
|
||||||
(window as any).createToolSlug = (toolName: string) => {
|
(window as any).createToolSlug = (toolName: string) => {
|
||||||
if (!toolName || typeof toolName !== 'string') return '';
|
if (!toolName || typeof toolName !== 'string') return '';
|
||||||
return toolName.toLowerCase().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
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;
|
(window as any).prioritizeSearchResults = prioritizeSearchResults;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
// CRITICAL: Load utility functions FIRST before any URL handling
|
|
||||||
await loadUtilityFunctions();
|
await loadUtilityFunctions();
|
||||||
|
|
||||||
const THEME_KEY = 'dfir-theme';
|
const THEME_KEY = 'dfir-theme';
|
||||||
@ -173,32 +171,31 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
|
|||||||
getStoredTheme
|
getStoredTheme
|
||||||
};
|
};
|
||||||
|
|
||||||
(window as any).showToolDetails = function(toolName: string, modalType: string = 'primary') {
|
(window as any).showToolDetails = function(toolName: string, modalType: string = 'primary') {
|
||||||
|
let attempts = 0;
|
||||||
let attempts = 0;
|
const maxAttempts = 50;
|
||||||
const maxAttempts = 50;
|
|
||||||
|
|
||||||
const tryDelegate = () => {
|
|
||||||
const matrixShowToolDetails = (window as any).matrixShowToolDetails;
|
|
||||||
|
|
||||||
if (matrixShowToolDetails && typeof matrixShowToolDetails === 'function') {
|
const tryDelegate = () => {
|
||||||
return matrixShowToolDetails(toolName, modalType);
|
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;
|
tryDelegate();
|
||||||
if (directShowToolDetails && typeof directShowToolDetails === 'function') {
|
|
||||||
return directShowToolDetails(toolName, modalType);
|
|
||||||
}
|
|
||||||
|
|
||||||
attempts++;
|
|
||||||
if (attempts < maxAttempts) {
|
|
||||||
setTimeout(tryDelegate, 100);
|
|
||||||
} else {
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
tryDelegate();
|
|
||||||
};
|
|
||||||
|
|
||||||
(window as any).hideToolDetails = function(modalType: string = 'both') {
|
(window as any).hideToolDetails = function(modalType: string = 'both') {
|
||||||
const matrixHideToolDetails = (window as any).matrixHideToolDetails;
|
const matrixHideToolDetails = (window as any).matrixHideToolDetails;
|
||||||
@ -229,7 +226,7 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
|
|||||||
authRequired: data.aiAuthRequired,
|
authRequired: data.aiAuthRequired,
|
||||||
expires: data.expires
|
expires: data.expires
|
||||||
};
|
};
|
||||||
case 'gatedcontent': // ADD THIS CASE
|
case 'gatedcontent':
|
||||||
return {
|
return {
|
||||||
authenticated: data.gatedContentAuthenticated,
|
authenticated: data.gatedContentAuthenticated,
|
||||||
authRequired: data.gatedContentAuthRequired,
|
authRequired: data.gatedContentAuthRequired,
|
||||||
@ -353,6 +350,36 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
|
|||||||
};
|
};
|
||||||
initAIButton();
|
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>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
|
// src/pages/api/auth/login.ts
|
||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { generateAuthUrl, generateState, logAuthEvent } from '../../../utils/auth.js';
|
import { generateAuthUrl, generateState, logAuthEvent } from '../../../utils/auth.js';
|
||||||
|
import { serialize } from 'cookie';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
@ -8,14 +10,27 @@ export const GET: APIRoute = async ({ url, redirect }) => {
|
|||||||
const state = generateState();
|
const state = generateState();
|
||||||
const authUrl = generateAuthUrl(state);
|
const authUrl = generateAuthUrl(state);
|
||||||
|
|
||||||
console.log('Generated auth URL:', authUrl);
|
console.log('[AUTH] Generated auth URL:', authUrl);
|
||||||
|
|
||||||
const returnTo = url.searchParams.get('returnTo') || '/';
|
const returnTo = url.searchParams.get('returnTo') || '/';
|
||||||
|
|
||||||
logAuthEvent('Login initiated', { returnTo, authUrl });
|
logAuthEvent('Login initiated', { returnTo, authUrl });
|
||||||
|
|
||||||
const stateData = JSON.stringify({ state, returnTo });
|
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, {
|
return new Response(null, {
|
||||||
status: 302,
|
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 type { APIRoute } from 'astro';
|
||||||
import {
|
import {
|
||||||
verifyAuthState,
|
verifyAuthState,
|
||||||
@ -7,7 +7,7 @@ import {
|
|||||||
createSessionWithCookie,
|
createSessionWithCookie,
|
||||||
logAuthEvent
|
logAuthEvent
|
||||||
} from '../../../utils/auth.js';
|
} 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;
|
export const prerender = false;
|
||||||
|
|
||||||
@ -30,9 +30,15 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
|
|
||||||
const stateVerification = verifyAuthState(request, state);
|
const stateVerification = verifyAuthState(request, state);
|
||||||
if (!stateVerification.isValid || !stateVerification.stateData) {
|
if (!stateVerification.isValid || !stateVerification.stateData) {
|
||||||
|
logAuthEvent('State verification failed', {
|
||||||
|
error: stateVerification.error,
|
||||||
|
hasStateData: !!stateVerification.stateData
|
||||||
|
});
|
||||||
return apiError.badRequest(stateVerification.error || 'Invalid state parameter');
|
return apiError.badRequest(stateVerification.error || 'Invalid state parameter');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[AUTH] State verification successful, exchanging code for tokens');
|
||||||
|
|
||||||
const tokens = await exchangeCodeForTokens(code);
|
const tokens = await exchangeCodeForTokens(code);
|
||||||
const userInfo = await getUserInfo(tokens.access_token);
|
const userInfo = await getUserInfo(tokens.access_token);
|
||||||
|
|
||||||
@ -43,6 +49,12 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
email: sessionResult.userEmail
|
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();
|
const responseHeaders = new Headers();
|
||||||
responseHeaders.set('Content-Type', 'application/json');
|
responseHeaders.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
@ -51,7 +63,7 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
|
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
redirectTo: stateVerification.stateData.returnTo
|
redirectTo: redirectUrl
|
||||||
}), {
|
}), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: responseHeaders
|
headers: responseHeaders
|
||||||
|
@ -9,16 +9,16 @@ export const GET: APIRoute = async ({ request }) => {
|
|||||||
return await handleAPIRequest(async () => {
|
return await handleAPIRequest(async () => {
|
||||||
const contributionAuth = await withAPIAuth(request, 'contributions');
|
const contributionAuth = await withAPIAuth(request, 'contributions');
|
||||||
const aiAuth = await withAPIAuth(request, 'ai');
|
const aiAuth = await withAPIAuth(request, 'ai');
|
||||||
const gatedContentAuth = await withAPIAuth(request, 'gatedcontent'); // ADDED
|
const gatedContentAuth = await withAPIAuth(request, 'gatedcontent');
|
||||||
|
|
||||||
return apiResponse.success({
|
return apiResponse.success({
|
||||||
authenticated: contributionAuth.authenticated || aiAuth.authenticated || gatedContentAuth.authenticated,
|
authenticated: contributionAuth.authenticated || aiAuth.authenticated || gatedContentAuth.authenticated,
|
||||||
contributionAuthRequired: contributionAuth.authRequired,
|
contributionAuthRequired: contributionAuth.authRequired,
|
||||||
aiAuthRequired: aiAuth.authRequired,
|
aiAuthRequired: aiAuth.authRequired,
|
||||||
gatedContentAuthRequired: gatedContentAuth.authRequired, // ADDED
|
gatedContentAuthRequired: gatedContentAuth.authRequired,
|
||||||
contributionAuthenticated: contributionAuth.authenticated,
|
contributionAuthenticated: contributionAuth.authenticated,
|
||||||
aiAuthenticated: aiAuth.authenticated,
|
aiAuthenticated: aiAuth.authenticated,
|
||||||
gatedContentAuthenticated: gatedContentAuth.authenticated, // ADDED
|
gatedContentAuthenticated: gatedContentAuth.authenticated,
|
||||||
expires: contributionAuth.session?.exp ? new Date(contributionAuth.session.exp * 1000).toISOString() : null
|
expires: contributionAuth.session?.exp ? new Date(contributionAuth.session.exp * 1000).toISOString() : null
|
||||||
});
|
});
|
||||||
}, 'Status check failed');
|
}, '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 type { APIRoute } from 'astro';
|
||||||
import { withAPIAuth } from '../../../utils/auth.js';
|
import { withAPIAuth } from '../../../utils/auth.js';
|
||||||
import { apiResponse, apiError, apiServerError, handleAPIRequest } from '../../../utils/api.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 type { APIRoute } from 'astro';
|
||||||
import { withAPIAuth } from '../../../utils/auth.js';
|
import { withAPIAuth } from '../../../utils/auth.js';
|
||||||
import { apiResponse, apiError, apiServerError, apiSpecial, handleAPIRequest } from '../../../utils/api.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 {
|
function preprocessFormData(body: any): any {
|
||||||
// Handle comma-separated strings from autocomplete inputs
|
|
||||||
if (body.tool) {
|
if (body.tool) {
|
||||||
// Handle tags
|
|
||||||
if (typeof body.tool.tags === 'string') {
|
if (typeof body.tool.tags === 'string') {
|
||||||
body.tool.tags = body.tool.tags.split(',').map((t: string) => t.trim()).filter(Boolean);
|
body.tool.tags = body.tool.tags.split(',').map((t: string) => t.trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle related concepts
|
|
||||||
if (body.tool.relatedConcepts) {
|
if (body.tool.relatedConcepts) {
|
||||||
if (typeof body.tool.relatedConcepts === 'string') {
|
if (typeof body.tool.relatedConcepts === 'string') {
|
||||||
body.tool.related_concepts = body.tool.relatedConcepts.split(',').map((t: string) => t.trim()).filter(Boolean);
|
body.tool.related_concepts = body.tool.relatedConcepts.split(',').map((t: string) => t.trim()).filter(Boolean);
|
||||||
} else {
|
} else {
|
||||||
body.tool.related_concepts = body.tool.relatedConcepts;
|
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 (body.tool.relatedSoftware) {
|
||||||
if (typeof body.tool.relatedSoftware === 'string') {
|
if (typeof body.tool.relatedSoftware === 'string') {
|
||||||
body.tool.related_software = body.tool.relatedSoftware.split(',').map((t: string) => t.trim()).filter(Boolean);
|
body.tool.related_software = body.tool.relatedSoftware.split(',').map((t: string) => t.trim()).filter(Boolean);
|
||||||
} else {
|
} else {
|
||||||
body.tool.related_software = body.tool.relatedSoftware;
|
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) {
|
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);
|
console.log('[VALIDATION] Related concepts provided:', tool.related_concepts);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tool.related_software && tool.related_software.length > 0) {
|
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);
|
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');
|
const { embeddingsService } = await import('../../../utils/embeddings.js');
|
||||||
|
|
||||||
if (!embeddingsService.isEnabled()) {
|
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 editTool = editToolName ? existingTools.find(tool => tool.name === editToolName) : null;
|
||||||
const isEdit = !!editTool;
|
const isEdit = !!editTool;
|
||||||
|
|
||||||
// Extract data for autocomplete
|
|
||||||
const allTags = [...new Set(existingTools.flatMap(tool => tool.tags || []))].sort();
|
const allTags = [...new Set(existingTools.flatMap(tool => tool.tags || []))].sort();
|
||||||
const allSoftwareAndMethods = existingTools
|
const allSoftwareAndMethods = existingTools
|
||||||
.filter(tool => tool.type === 'software' || tool.type === 'method')
|
.filter(tool => tool.type === 'software' || tool.type === 'method')
|
||||||
@ -300,7 +299,6 @@ const allConcepts = existingTools
|
|||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
<script define:vars={{ isEdit, editTool, domains, phases, domainAgnosticSoftware, allTags, allSoftwareAndMethods, allConcepts }}>
|
<script define:vars={{ isEdit, editTool, domains, phases, domainAgnosticSoftware, allTags, allSoftwareAndMethods, allConcepts }}>
|
||||||
// Consolidated Autocomplete Functionality - inlined to avoid module loading issues
|
|
||||||
class AutocompleteManager {
|
class AutocompleteManager {
|
||||||
constructor(inputElement, dataSource, options = {}) {
|
constructor(inputElement, dataSource, options = {}) {
|
||||||
this.input = inputElement;
|
this.input = inputElement;
|
||||||
@ -337,7 +335,6 @@ class AutocompleteManager {
|
|||||||
this.dropdown = document.createElement('div');
|
this.dropdown = document.createElement('div');
|
||||||
this.dropdown.className = 'autocomplete-dropdown';
|
this.dropdown.className = 'autocomplete-dropdown';
|
||||||
|
|
||||||
// Insert dropdown after input
|
|
||||||
this.input.parentNode.style.position = 'relative';
|
this.input.parentNode.style.position = 'relative';
|
||||||
this.input.parentNode.insertBefore(this.dropdown, this.input.nextSibling);
|
this.input.parentNode.insertBefore(this.dropdown, this.input.nextSibling);
|
||||||
}
|
}
|
||||||
@ -358,7 +355,6 @@ class AutocompleteManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.input.addEventListener('blur', (e) => {
|
this.input.addEventListener('blur', (e) => {
|
||||||
// Delay to allow click events on dropdown items
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!this.dropdown.contains(document.activeElement)) {
|
if (!this.dropdown.contains(document.activeElement)) {
|
||||||
this.hideDropdown();
|
this.hideDropdown();
|
||||||
@ -450,7 +446,6 @@ class AutocompleteManager {
|
|||||||
})
|
})
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
// Bind click events
|
|
||||||
this.dropdown.querySelectorAll('.autocomplete-option').forEach((option, index) => {
|
this.dropdown.querySelectorAll('.autocomplete-option').forEach((option, index) => {
|
||||||
option.addEventListener('click', () => {
|
option.addEventListener('click', () => {
|
||||||
this.selectItem(this.filteredData[index]);
|
this.selectItem(this.filteredData[index]);
|
||||||
@ -484,7 +479,6 @@ class AutocompleteManager {
|
|||||||
this.hideDropdown();
|
this.hideDropdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger change event
|
|
||||||
this.input.dispatchEvent(new CustomEvent('autocomplete:select', {
|
this.input.dispatchEvent(new CustomEvent('autocomplete:select', {
|
||||||
detail: { item, text, selectedItems: Array.from(this.selectedItems) }
|
detail: { item, text, selectedItems: Array.from(this.selectedItems) }
|
||||||
}));
|
}));
|
||||||
@ -510,7 +504,6 @@ class AutocompleteManager {
|
|||||||
`)
|
`)
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
// Bind remove events
|
|
||||||
this.selectedContainer.querySelectorAll('.autocomplete-remove').forEach(btn => {
|
this.selectedContainer.querySelectorAll('.autocomplete-remove').forEach(btn => {
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -636,7 +629,6 @@ class ContributionForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setupAutocomplete() {
|
setupAutocomplete() {
|
||||||
// Tags autocomplete
|
|
||||||
if (this.elements.tagsInput && this.elements.tagsHidden) {
|
if (this.elements.tagsInput && this.elements.tagsHidden) {
|
||||||
const tagsManager = new AutocompleteManager(this.elements.tagsInput, allTags, {
|
const tagsManager = new AutocompleteManager(this.elements.tagsInput, allTags, {
|
||||||
allowMultiple: true,
|
allowMultiple: true,
|
||||||
@ -644,7 +636,6 @@ class ContributionForm {
|
|||||||
placeholder: 'Beginne zu tippen, um Tags hinzuzufügen...'
|
placeholder: 'Beginne zu tippen, um Tags hinzuzufügen...'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set initial values if editing
|
|
||||||
if (this.editTool?.tags) {
|
if (this.editTool?.tags) {
|
||||||
tagsManager.setSelectedItems(this.editTool.tags);
|
tagsManager.setSelectedItems(this.editTool.tags);
|
||||||
}
|
}
|
||||||
@ -652,7 +643,6 @@ class ContributionForm {
|
|||||||
this.autocompleteManagers.set('tags', tagsManager);
|
this.autocompleteManagers.set('tags', tagsManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Related concepts autocomplete
|
|
||||||
if (this.elements.relatedConceptsInput && this.elements.relatedConceptsHidden) {
|
if (this.elements.relatedConceptsInput && this.elements.relatedConceptsHidden) {
|
||||||
const conceptsManager = new AutocompleteManager(this.elements.relatedConceptsInput, allConcepts, {
|
const conceptsManager = new AutocompleteManager(this.elements.relatedConceptsInput, allConcepts, {
|
||||||
allowMultiple: true,
|
allowMultiple: true,
|
||||||
@ -660,7 +650,6 @@ class ContributionForm {
|
|||||||
placeholder: 'Beginne zu tippen, um Konzepte zu finden...'
|
placeholder: 'Beginne zu tippen, um Konzepte zu finden...'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set initial values if editing
|
|
||||||
if (this.editTool?.related_concepts) {
|
if (this.editTool?.related_concepts) {
|
||||||
conceptsManager.setSelectedItems(this.editTool.related_concepts);
|
conceptsManager.setSelectedItems(this.editTool.related_concepts);
|
||||||
}
|
}
|
||||||
@ -668,7 +657,6 @@ class ContributionForm {
|
|||||||
this.autocompleteManagers.set('relatedConcepts', conceptsManager);
|
this.autocompleteManagers.set('relatedConcepts', conceptsManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Related software autocomplete
|
|
||||||
if (this.elements.relatedSoftwareInput && this.elements.relatedSoftwareHidden) {
|
if (this.elements.relatedSoftwareInput && this.elements.relatedSoftwareHidden) {
|
||||||
const softwareManager = new AutocompleteManager(this.elements.relatedSoftwareInput, allSoftwareAndMethods, {
|
const softwareManager = new AutocompleteManager(this.elements.relatedSoftwareInput, allSoftwareAndMethods, {
|
||||||
allowMultiple: true,
|
allowMultiple: true,
|
||||||
@ -676,7 +664,6 @@ class ContributionForm {
|
|||||||
placeholder: 'Beginne zu tippen, um Software/Methoden zu finden...'
|
placeholder: 'Beginne zu tippen, um Software/Methoden zu finden...'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set initial values if editing
|
|
||||||
if (this.editTool?.related_software) {
|
if (this.editTool?.related_software) {
|
||||||
softwareManager.setSelectedItems(this.editTool.related_software);
|
softwareManager.setSelectedItems(this.editTool.related_software);
|
||||||
}
|
}
|
||||||
@ -684,7 +671,6 @@ class ContributionForm {
|
|||||||
this.autocompleteManagers.set('relatedSoftware', softwareManager);
|
this.autocompleteManagers.set('relatedSoftware', softwareManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for autocomplete changes to update YAML preview
|
|
||||||
Object.values(this.autocompleteManagers).forEach(manager => {
|
Object.values(this.autocompleteManagers).forEach(manager => {
|
||||||
if (manager.input) {
|
if (manager.input) {
|
||||||
manager.input.addEventListener('autocomplete:select', () => {
|
manager.input.addEventListener('autocomplete:select', () => {
|
||||||
@ -726,14 +712,10 @@ class ContributionForm {
|
|||||||
updateFieldVisibility() {
|
updateFieldVisibility() {
|
||||||
const type = this.elements.typeSelect.value;
|
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';
|
this.elements.softwareFields.style.display = type === 'software' ? 'block' : 'none';
|
||||||
|
|
||||||
// Always show relations - all tool types can have relationships
|
|
||||||
this.elements.relationsFields.style.display = 'block';
|
this.elements.relationsFields.style.display = 'block';
|
||||||
|
|
||||||
// Only mark platform/license as required for software
|
|
||||||
if (this.elements.platformsRequired) {
|
if (this.elements.platformsRequired) {
|
||||||
this.elements.platformsRequired.style.display = type === 'software' ? 'inline' : 'none';
|
this.elements.platformsRequired.style.display = type === 'software' ? 'inline' : 'none';
|
||||||
}
|
}
|
||||||
@ -741,7 +723,6 @@ class ContributionForm {
|
|||||||
this.elements.licenseRequired.style.display = type === 'software' ? 'inline' : 'none';
|
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 conceptsSection = document.getElementById('related-concepts-section');
|
||||||
const softwareSection = document.getElementById('related-software-section');
|
const softwareSection = document.getElementById('related-software-section');
|
||||||
if (conceptsSection) conceptsSection.style.display = 'block';
|
if (conceptsSection) conceptsSection.style.display = 'block';
|
||||||
@ -806,19 +787,16 @@ class ContributionForm {
|
|||||||
tool.knowledgebase = true;
|
tool.knowledgebase = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle tags from autocomplete
|
|
||||||
const tagsValue = this.elements.tagsHidden?.value || '';
|
const tagsValue = this.elements.tagsHidden?.value || '';
|
||||||
if (tagsValue) {
|
if (tagsValue) {
|
||||||
tool.tags = tagsValue.split(',').map(t => t.trim()).filter(Boolean);
|
tool.tags = tagsValue.split(',').map(t => t.trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle related concepts from autocomplete
|
|
||||||
const relatedConceptsValue = this.elements.relatedConceptsHidden?.value || '';
|
const relatedConceptsValue = this.elements.relatedConceptsHidden?.value || '';
|
||||||
if (relatedConceptsValue) {
|
if (relatedConceptsValue) {
|
||||||
tool.related_concepts = relatedConceptsValue.split(',').map(t => t.trim()).filter(Boolean);
|
tool.related_concepts = relatedConceptsValue.split(',').map(t => t.trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle related software from autocomplete
|
|
||||||
const relatedSoftwareValue = this.elements.relatedSoftwareHidden?.value || '';
|
const relatedSoftwareValue = this.elements.relatedSoftwareHidden?.value || '';
|
||||||
if (relatedSoftwareValue) {
|
if (relatedSoftwareValue) {
|
||||||
tool.related_software = relatedSoftwareValue.split(',').map(t => t.trim()).filter(Boolean);
|
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 || '';
|
const tagsValue = this.elements.tagsHidden?.value || '';
|
||||||
if (tagsValue) {
|
if (tagsValue) {
|
||||||
submission.tool.tags = tagsValue.split(',').map(t => t.trim()).filter(Boolean);
|
submission.tool.tags = tagsValue.split(',').map(t => t.trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle related concepts from autocomplete
|
|
||||||
const relatedConceptsValue = this.elements.relatedConceptsHidden?.value || '';
|
const relatedConceptsValue = this.elements.relatedConceptsHidden?.value || '';
|
||||||
if (relatedConceptsValue) {
|
if (relatedConceptsValue) {
|
||||||
submission.tool.related_concepts = relatedConceptsValue.split(',').map(t => t.trim()).filter(Boolean);
|
submission.tool.related_concepts = relatedConceptsValue.split(',').map(t => t.trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle related software from autocomplete
|
|
||||||
const relatedSoftwareValue = this.elements.relatedSoftwareHidden?.value || '';
|
const relatedSoftwareValue = this.elements.relatedSoftwareHidden?.value || '';
|
||||||
if (relatedSoftwareValue) {
|
if (relatedSoftwareValue) {
|
||||||
submission.tool.related_software = relatedSoftwareValue.split(',').map(t => t.trim()).filter(Boolean);
|
submission.tool.related_software = relatedSoftwareValue.split(',').map(t => t.trim()).filter(Boolean);
|
||||||
@ -1072,7 +1047,6 @@ class ContributionForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
// Clean up autocomplete managers
|
|
||||||
this.autocompleteManagers.forEach(manager => {
|
this.autocompleteManagers.forEach(manager => {
|
||||||
manager.destroy();
|
manager.destroy();
|
||||||
});
|
});
|
||||||
|
@ -686,8 +686,6 @@ if (aiAuthRequired) {
|
|||||||
window.switchToAIView = () => switchToView('ai');
|
window.switchToAIView = () => switchToView('ai');
|
||||||
window.switchToView = switchToView;
|
window.switchToView = switchToView;
|
||||||
|
|
||||||
// CRITICAL: Handle shared URLs AFTER everything is set up
|
|
||||||
// Increased timeout to ensure all components and utility functions are loaded
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
handleSharedURL();
|
handleSharedURL();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
@ -10,7 +10,6 @@ const allKnowledgebaseEntries = await getCollection('knowledgebase', (entry) =>
|
|||||||
return entry.data.published !== false;
|
return entry.data.published !== false;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if gated content authentication is enabled globally
|
|
||||||
const gatedContentAuthEnabled = isGatedContentAuthRequired();
|
const gatedContentAuthEnabled = isGatedContentAuthRequired();
|
||||||
|
|
||||||
const knowledgebaseEntries = allKnowledgebaseEntries.map((entry) => {
|
const knowledgebaseEntries = allKnowledgebaseEntries.map((entry) => {
|
||||||
@ -27,8 +26,7 @@ const knowledgebaseEntries = allKnowledgebaseEntries.map((entry) => {
|
|||||||
difficulty: entry.data.difficulty,
|
difficulty: entry.data.difficulty,
|
||||||
categories: entry.data.categories || [],
|
categories: entry.data.categories || [],
|
||||||
tags: entry.data.tags || [],
|
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,
|
tool_name: entry.data.tool_name,
|
||||||
related_tools: entry.data.related_tools || [],
|
related_tools: entry.data.related_tools || [],
|
||||||
associatedTool,
|
associatedTool,
|
||||||
@ -45,7 +43,6 @@ const knowledgebaseEntries = allKnowledgebaseEntries.map((entry) => {
|
|||||||
|
|
||||||
knowledgebaseEntries.sort((a: any, b: any) => a.title.localeCompare(b.title));
|
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 gatedCount = knowledgebaseEntries.filter(entry => entry.gated_content).length;
|
||||||
const publicCount = knowledgebaseEntries.length - gatedCount;
|
const publicCount = knowledgebaseEntries.length - gatedCount;
|
||||||
---
|
---
|
||||||
|
@ -21,7 +21,6 @@ export async function getStaticPaths() {
|
|||||||
|
|
||||||
const { entry }: { entry: any } = Astro.props;
|
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 isGatedContent = entry.data.gated_content === true;
|
||||||
const gatedContentAuthRequired = isGatedContentAuthRequired();
|
const gatedContentAuthRequired = isGatedContentAuthRequired();
|
||||||
const requiresAuth = isGatedContent && gatedContentAuthRequired;
|
const requiresAuth = isGatedContent && gatedContentAuthRequired;
|
||||||
@ -62,24 +61,28 @@ const currentUrl = Astro.url.href;
|
|||||||
<BaseLayout title={entry.data.title} description={entry.data.description}>
|
<BaseLayout title={entry.data.title} description={entry.data.description}>
|
||||||
{requiresAuth && (
|
{requiresAuth && (
|
||||||
<script define:vars={{ requiresAuth, articleTitle: entry.data.title }}>
|
<script define:vars={{ requiresAuth, articleTitle: entry.data.title }}>
|
||||||
// Client-side authentication check for gated content
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
if (!requiresAuth) return;
|
if (!requiresAuth) return;
|
||||||
|
|
||||||
console.log('[GATED CONTENT] Checking client-side auth for: ' + articleTitle);
|
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 contentArea = document.querySelector('.article-content');
|
||||||
const sidebar = document.querySelector('.article-sidebar');
|
const sidebar = document.querySelector('.article-sidebar');
|
||||||
|
|
||||||
|
|
||||||
if (contentArea) {
|
if (contentArea) {
|
||||||
contentArea.style.display = 'none';
|
contentArea.style.display = 'none';
|
||||||
}
|
}
|
||||||
// DON'T hide the sidebar container - just prevent TOC generation
|
|
||||||
//if (sidebar) {
|
if (authSuccess) {
|
||||||
//sidebar.innerHTML = ''; // Clear any content instead of hiding
|
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 {
|
try {
|
||||||
const response = await fetch('/api/auth/status');
|
const response = await fetch('/api/auth/status');
|
||||||
@ -93,7 +96,6 @@ const currentUrl = Astro.url.href;
|
|||||||
if (authRequired && !isAuthenticated) {
|
if (authRequired && !isAuthenticated) {
|
||||||
console.log('[GATED CONTENT] Access denied - showing auth required message: ' + articleTitle);
|
console.log('[GATED CONTENT] Access denied - showing auth required message: ' + articleTitle);
|
||||||
|
|
||||||
// Show authentication required message (no auto-redirect)
|
|
||||||
if (contentArea) {
|
if (contentArea) {
|
||||||
const loginUrl = '/api/auth/login?returnTo=' + encodeURIComponent(window.location.href);
|
const loginUrl = '/api/auth/login?returnTo=' + encodeURIComponent(window.location.href);
|
||||||
contentArea.innerHTML = [
|
contentArea.innerHTML = [
|
||||||
@ -121,11 +123,9 @@ const currentUrl = Astro.url.href;
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('[GATED CONTENT] Access granted for: ' + articleTitle);
|
console.log('[GATED CONTENT] Access granted for: ' + articleTitle);
|
||||||
// Show content for authenticated users
|
|
||||||
if (contentArea) {
|
if (contentArea) {
|
||||||
contentArea.style.display = 'block';
|
contentArea.style.display = 'block';
|
||||||
}
|
}
|
||||||
// Let TOC generate normally for authenticated users
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (typeof generateTOCContent === 'function') {
|
if (typeof generateTOCContent === 'function') {
|
||||||
generateTOCContent();
|
generateTOCContent();
|
||||||
@ -134,7 +134,6 @@ const currentUrl = Astro.url.href;
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[GATED CONTENT] Auth check failed:', error);
|
console.error('[GATED CONTENT] Auth check failed:', error);
|
||||||
// On error, show auth required message
|
|
||||||
if (requiresAuth && contentArea) {
|
if (requiresAuth && contentArea) {
|
||||||
const loginUrl = '/api/auth/login?returnTo=' + encodeURIComponent(window.location.href);
|
const loginUrl = '/api/auth/login?returnTo=' + encodeURIComponent(window.location.href);
|
||||||
contentArea.innerHTML = [
|
contentArea.innerHTML = [
|
||||||
@ -402,29 +401,10 @@ const currentUrl = Astro.url.href;
|
|||||||
}
|
}
|
||||||
|
|
||||||
function generateSidebarTOC() {
|
function generateSidebarTOC() {
|
||||||
// NEW: Don't generate TOC for gated content that requires auth
|
|
||||||
if (requiresAuth) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For non-gated content, generate TOC normally
|
|
||||||
generateTOCContent();
|
generateTOCContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -530,17 +510,14 @@ const currentUrl = Astro.url.href;
|
|||||||
pre.dataset.copyEnhanced = 'true';
|
pre.dataset.copyEnhanced = 'true';
|
||||||
pre.style.position ||= 'relative';
|
pre.style.position ||= 'relative';
|
||||||
|
|
||||||
// Try to find an existing copy button we can reuse
|
|
||||||
let btn =
|
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]');
|
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')) {
|
if (btn && !btn.classList.contains('copy-btn')) {
|
||||||
btn.classList.add('copy-btn');
|
btn.classList.add('copy-btn');
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no button at all, create one
|
|
||||||
if (!btn) {
|
if (!btn) {
|
||||||
btn = document.createElement('button');
|
btn = document.createElement('button');
|
||||||
btn.type = 'button';
|
btn.type = 'button';
|
||||||
@ -555,7 +532,6 @@ const currentUrl = Astro.url.href;
|
|||||||
pre.appendChild(btn);
|
pre.appendChild(btn);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If there is a SECOND old button lingering (top-left in your case), hide it
|
|
||||||
const possibleOldButtons = pre.querySelectorAll(
|
const possibleOldButtons = pre.querySelectorAll(
|
||||||
'.btn-copy, .copy-button, .code-copy, .copy-code, button[aria-label*="copy" i]'
|
'.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';
|
if (b !== btn) b.style.display = 'none';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Success pill
|
|
||||||
if (!pre.querySelector('.copied-pill')) {
|
if (!pre.querySelector('.copied-pill')) {
|
||||||
const pill = document.createElement('div');
|
const pill = document.createElement('div');
|
||||||
pill.className = 'copied-pill';
|
pill.className = 'copied-pill';
|
||||||
@ -571,7 +546,6 @@ const currentUrl = Astro.url.href;
|
|||||||
pre.appendChild(pill);
|
pre.appendChild(pill);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Screen reader live region
|
|
||||||
if (!pre.querySelector('.sr-live')) {
|
if (!pre.querySelector('.sr-live')) {
|
||||||
const live = document.createElement('div');
|
const live = document.createElement('div');
|
||||||
live.className = 'sr-live';
|
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', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// existing:
|
|
||||||
calculateReadingTime();
|
calculateReadingTime();
|
||||||
generateSidebarTOC();
|
generateSidebarTOC();
|
||||||
// new/updated:
|
|
||||||
enhanceCodeCopy();
|
enhanceCodeCopy();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
@ -688,3 +688,245 @@
|
|||||||
/* Expand content */
|
/* Expand content */
|
||||||
.article-main { max-width: 100% !important; }
|
.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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: AI selection of tools for completion
|
|
||||||
const selectionPrompt = AI_PROMPTS.generatePhaseCompletionPrompt(originalQuery, phase, phaseTools, phaseConcepts);
|
const selectionPrompt = AI_PROMPTS.generatePhaseCompletionPrompt(originalQuery, phase, phaseTools, phaseConcepts);
|
||||||
const selectionResult = await this.callMicroTaskAI(selectionPrompt, context, 800);
|
const selectionResult = await this.callMicroTaskAI(selectionPrompt, context, 800);
|
||||||
|
|
||||||
@ -1108,7 +1107,6 @@ class ImprovedMicroTaskAIPipeline {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Generate detailed reasoning for each selected tool
|
|
||||||
for (const tool of validTools) {
|
for (const tool of validTools) {
|
||||||
console.log('[AI-PIPELINE] Generating reasoning for phase completion tool:', tool.name);
|
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 type { AstroGlobal } from 'astro';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { config } from 'dotenv';
|
import { config } from 'dotenv';
|
||||||
@ -390,12 +390,10 @@ export function getAuthRequirementForContext(context: AuthContextType): boolean
|
|||||||
return getAuthRequirement(context);
|
return getAuthRequirement(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
// NEW: Helper function to check if gated content requires authentication
|
|
||||||
export function isGatedContentAuthRequired(): boolean {
|
export function isGatedContentAuthRequired(): boolean {
|
||||||
return getAuthRequirement('gatedcontent');
|
return getAuthRequirement('gatedcontent');
|
||||||
}
|
}
|
||||||
|
|
||||||
// NEW: Check if specific content should be gated
|
|
||||||
export function shouldGateContent(isGatedContent: boolean): boolean {
|
export function shouldGateContent(isGatedContent: boolean): boolean {
|
||||||
return isGatedContent && isGatedContentAuthRequired();
|
return isGatedContent && isGatedContentAuthRequired();
|
||||||
}
|
}
|
@ -1,5 +1,5 @@
|
|||||||
// src/utils/clientUtils.ts
|
// src/utils/clientUtils.ts
|
||||||
// Client-side utilities that mirror server-side toolHelpers.ts
|
|
||||||
|
|
||||||
export function createToolSlug(toolName: string): string {
|
export function createToolSlug(toolName: string): string {
|
||||||
if (!toolName || typeof toolName !== 'string') {
|
if (!toolName || typeof toolName !== 'string') {
|
||||||
@ -8,10 +8,10 @@ export function createToolSlug(toolName: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return toolName.toLowerCase()
|
return toolName.toLowerCase()
|
||||||
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
.replace(/\s+/g, '-')
|
||||||
.replace(/-+/g, '-') // Remove duplicate hyphens
|
.replace(/-+/g, '-')
|
||||||
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
|
.replace(/^-|-$/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findToolByIdentifier(tools: any[], identifier: string): any | undefined {
|
export function findToolByIdentifier(tools: any[], identifier: string): any | undefined {
|
||||||
@ -30,7 +30,6 @@ export function isToolHosted(tool: any): boolean {
|
|||||||
tool.projectUrl.trim() !== "";
|
tool.projectUrl.trim() !== "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Consolidated Autocomplete Functionality
|
|
||||||
interface AutocompleteOptions {
|
interface AutocompleteOptions {
|
||||||
minLength?: number;
|
minLength?: number;
|
||||||
maxResults?: number;
|
maxResults?: number;
|
||||||
@ -97,7 +96,6 @@ export class AutocompleteManager {
|
|||||||
display: none;
|
display: none;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Insert dropdown after input
|
|
||||||
const parentElement = this.input.parentNode as HTMLElement;
|
const parentElement = this.input.parentNode as HTMLElement;
|
||||||
parentElement.style.position = 'relative';
|
parentElement.style.position = 'relative';
|
||||||
parentElement.insertBefore(this.dropdown, this.input.nextSibling);
|
parentElement.insertBefore(this.dropdown, this.input.nextSibling);
|
||||||
@ -119,7 +117,6 @@ export class AutocompleteManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.input.addEventListener('blur', () => {
|
this.input.addEventListener('blur', () => {
|
||||||
// Delay to allow click events on dropdown items
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const activeElement = document.activeElement;
|
const activeElement = document.activeElement;
|
||||||
if (!activeElement || !this.dropdown.contains(activeElement)) {
|
if (!activeElement || !this.dropdown.contains(activeElement)) {
|
||||||
@ -226,7 +223,6 @@ export class AutocompleteManager {
|
|||||||
})
|
})
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
// Bind click events
|
|
||||||
this.dropdown.querySelectorAll('.autocomplete-option').forEach((option, index) => {
|
this.dropdown.querySelectorAll('.autocomplete-option').forEach((option, index) => {
|
||||||
option.addEventListener('click', () => {
|
option.addEventListener('click', () => {
|
||||||
this.selectItem(this.filteredData[index]);
|
this.selectItem(this.filteredData[index]);
|
||||||
@ -260,7 +256,6 @@ export class AutocompleteManager {
|
|||||||
this.hideDropdown();
|
this.hideDropdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger change event
|
|
||||||
this.input.dispatchEvent(new CustomEvent('autocomplete:select', {
|
this.input.dispatchEvent(new CustomEvent('autocomplete:select', {
|
||||||
detail: { item, text, selectedItems: Array.from(this.selectedItems) }
|
detail: { item, text, selectedItems: Array.from(this.selectedItems) }
|
||||||
}));
|
}));
|
||||||
@ -307,7 +302,6 @@ export class AutocompleteManager {
|
|||||||
`)
|
`)
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
// Bind remove events
|
|
||||||
this.selectedContainer.querySelectorAll('.autocomplete-remove').forEach(btn => {
|
this.selectedContainer.querySelectorAll('.autocomplete-remove').forEach(btn => {
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
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 {
|
export interface Tool {
|
||||||
name: string;
|
name: string;
|
||||||
type?: 'software' | 'method' | 'concept';
|
type?: 'software' | 'method' | 'concept';
|
||||||
@ -13,31 +15,8 @@ export interface Tool {
|
|||||||
related_concepts?: string[];
|
related_concepts?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createToolSlug(toolName: string): string {
|
export {
|
||||||
if (!toolName || typeof toolName !== 'string') {
|
createToolSlug,
|
||||||
console.warn('[toolHelpers] Invalid toolName provided to createToolSlug:', toolName);
|
findToolByIdentifier,
|
||||||
return '';
|
isToolHosted
|
||||||
}
|
} from './clientUtils.js';
|
||||||
|
|
||||||
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() !== "";
|
|
||||||
}
|
|
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