From 925e7a99b6b1eb70a5e585d4e5ef1fdf22a05cb2 Mon Sep 17 00:00:00 2001 From: Monster0506 Date: Mon, 8 Dec 2025 09:26:40 -0500 Subject: [PATCH 1/6] Update icon loading --- src/lib/Footer.svelte | 18 ++++++++---------- src/lib/components/MeetingCard.svelte | 12 ++---------- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/src/lib/Footer.svelte b/src/lib/Footer.svelte index cc5ef2e..ed54254 100644 --- a/src/lib/Footer.svelte +++ b/src/lib/Footer.svelte @@ -7,15 +7,19 @@ const socialLinks = [ { name: 'Instagram', - href: 'https://www.instagram.com/hacksu/' + href: 'https://www.instagram.com/hacksu/', + icon: instagramIcon, + }, { name: 'GitHub', - href: 'https://github.com/hacksu' + href: 'https://github.com/hacksu', + icon: githubIcon, }, { name: 'Discord', - href: 'https://discord.gg/rJDdvnt' + href: 'https://discord.gg/rJDdvnt', + icon: discordIcon, } ]; @@ -30,13 +34,7 @@ class="text-white hover:text-hacksu-green transition-colors duration-200" aria-label={link.name} > - {#if link.name === 'Instagram'} - Discord - {:else if link.name === 'GitHub'} - Discord - {:else if link.name === 'Discord'} - Discord - {/if} + {link.name} {/each} diff --git a/src/lib/components/MeetingCard.svelte b/src/lib/components/MeetingCard.svelte index aef37f5..48a7f01 100644 --- a/src/lib/components/MeetingCard.svelte +++ b/src/lib/components/MeetingCard.svelte @@ -1,5 +1,6 @@ + +
+
+
+ {#if iconUrl} + {category} + {:else if initials} + {initials} + {:else} + 📁 + {/if} +
+ +

+ {displayCategory} +

+ +
+ {lessonCount} lesson{lessonCount !== 1 ? 's' : ''} +
+
+
diff --git a/src/lib/components/lessons/LessonBreadcrumbs.svelte b/src/lib/components/lessons/LessonBreadcrumbs.svelte new file mode 100644 index 0000000..f29cb97 --- /dev/null +++ b/src/lib/components/lessons/LessonBreadcrumbs.svelte @@ -0,0 +1,73 @@ + + + + + + + diff --git a/src/lib/components/lessons/LessonCard.svelte b/src/lib/components/lessons/LessonCard.svelte new file mode 100644 index 0000000..cd9d358 --- /dev/null +++ b/src/lib/components/lessons/LessonCard.svelte @@ -0,0 +1,95 @@ + + +
+
+

+ {displayName} +

+ + {#if lesson.description} +

+ {lesson.description} +

+ {/if} + +
+ Updated {formatDate(lesson.last_commit_date || lesson.updated_at)} +
+
+
diff --git a/src/lib/components/lessons/LessonsSkeleton.svelte b/src/lib/components/lessons/LessonsSkeleton.svelte new file mode 100644 index 0000000..234272c --- /dev/null +++ b/src/lib/components/lessons/LessonsSkeleton.svelte @@ -0,0 +1,181 @@ + + +
+
+

HacKSU Lessons

+

Loading lessons...

+ + +
+ +
+ + +
+ {#each Array(5) as _} +
+
+
+
+
+
+
+ +
+ {/each} +
+
+
+ + + + diff --git a/src/lib/components/lessons/SearchBar.svelte b/src/lib/components/lessons/SearchBar.svelte new file mode 100644 index 0000000..b72f936 --- /dev/null +++ b/src/lib/components/lessons/SearchBar.svelte @@ -0,0 +1,78 @@ + + +
+ + + + + +
+ + + + diff --git a/src/lib/lessons/iconify.ts b/src/lib/lessons/iconify.ts new file mode 100644 index 0000000..0faa1c1 --- /dev/null +++ b/src/lib/lessons/iconify.ts @@ -0,0 +1,126 @@ +/** + * Iconify icon mapping and utilities + * Maps technology/framework names to Iconify icon identifiers + */ + +// Common technology to Iconify icon mappings +const iconMap: Record = { + // Python frameworks + flask: 'simple-icons:flask', + django: 'simple-icons:django', + fastapi: 'simple-icons:fastapi', + 'fast-api': 'simple-icons:fastapi', + + // JavaScript frameworks + react: 'simple-icons:react', + vue: 'simple-icons:vuedotjs', + angular: 'simple-icons:angular', + express: 'simple-icons:express', + jquery: 'simple-icons:jquery', + meteor: 'simple-icons:meteor', + + // Languages + python: 'simple-icons:python', + javascript: 'simple-icons:javascript', + typescript: 'simple-icons:typescript', + java: 'simple-icons:java', + rust: 'simple-icons:rust', + go: 'simple-icons:go', + 'c++': 'simple-icons:cplusplus', + c: 'simple-icons:c', + 'c#': 'simple-icons:csharp', + ruby: 'simple-icons:ruby', + php: 'simple-icons:php', + swift: 'simple-icons:swift', + kotlin: 'simple-icons:kotlin', + + // Web technologies + html: 'simple-icons:html5', + css: 'simple-icons:css3', + sass: 'simple-icons:sass', + less: 'simple-icons:less', + + // Databases + mongodb: 'simple-icons:mongodb', + postgresql: 'simple-icons:postgresql', + mysql: 'simple-icons:mysql', + sqlite: 'simple-icons:sqlite', + redis: 'simple-icons:redis', + + // Tools + docker: 'simple-icons:docker', + kubernetes: 'simple-icons:kubernetes', + git: 'simple-icons:git', + github: 'simple-icons:github', + nodejs: 'simple-icons:nodedotjs', + npm: 'simple-icons:npm', + yarn: 'simple-icons:yarn', + + // Static site generators + nextjs: 'simple-icons:nextdotjs', + nuxt: 'simple-icons:nuxtdotjs', + gatsby: 'simple-icons:gatsby', + jekyll: 'simple-icons:jekyll', + hugo: 'simple-icons:hugo', + + // Other + graphql: 'simple-icons:graphql', + apollo: 'simple-icons:apollographql', + redux: 'simple-icons:redux', + webpack: 'simple-icons:webpack', + vite: 'simple-icons:vite', +}; + +/** + * Normalizes a name to match icon map keys + */ +function normalizeName(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); +} + +/** + * Gets the Iconify icon identifier for a given technology name + */ +export function getIconifyIcon(name: string): string | null { + const normalized = normalizeName(name); + + // Direct match + if (iconMap[normalized]) { + return iconMap[normalized]; + } + + // Try partial matches (e.g., "flask-tutorial" -> "flask") + for (const [key, icon] of Object.entries(iconMap)) { + if (normalized.includes(key) || key.includes(normalized)) { + return icon; + } + } + + return null; +} + +/** + * Gets the Iconify SVG URL for an icon identifier + */ +export function getIconifyUrl(iconId: string, color?: string): string { + const [collection, icon] = iconId.split(':'); + const colorParam = color ? `&color=${encodeURIComponent(color)}` : ''; + return `https://api.iconify.design/${collection}/${icon}.svg?width=80&height=80${colorParam}`; +} + +/** + * Gets the icon URL for a technology name, or returns null if not found + */ +export function getTechnologyIconUrl(name: string, color?: string): string | null { + const iconId = getIconifyIcon(name); + if (!iconId) { + return null; + } + return getIconifyUrl(iconId, color); +} + + diff --git a/src/lib/lessons/utils.ts b/src/lib/lessons/utils.ts new file mode 100644 index 0000000..ba839f9 --- /dev/null +++ b/src/lib/lessons/utils.ts @@ -0,0 +1,356 @@ +import type { LessonRepo } from '../../routes/api/lessons/repos/+server'; + +export interface ParsedTopic { + name: string; + level: number; + chain: string; // branch letter or '_default' +} + +export interface CategoryTree { + [key: string]: CategoryTree | LessonRepo[]; +} + +export interface GroupedItems { + categories: string[]; + lessons: LessonRepo[]; +} + +export interface SubcategoryGroup { + name: string; + lessons: LessonRepo[]; +} + +/** + * Parses a topic tag in the format: {categoryName}-{level}{branch?} + * Examples: "framework-1", "javascript-2a", "express-3a" + */ +export function parseTopic(tag: string): ParsedTopic | null { + const match = tag.match(/^(.+)-(\d+)([a-z])?$/); + if (!match) { + return null; + } + + const [, name, levelStr, branch] = match; + const level = parseInt(levelStr, 10); + const chain = branch || '_default'; + + return { name, level, chain }; +} + +/** + * Builds a nested category tree from lesson repos based on their topics + * Supports ordered, branched topic convention: {categoryName}-{level}{branch?} + * + * Examples: + * - "framework-1", "javascript-2a", "express-3a" → path: ["framework", "javascript", "express"] + * - "framework-1b", "python-2b", "flask-3b" → path: ["framework", "python", "flask"] + * + * Branches (a, b, _default) allow parallel category trees. Topics are grouped by branch, + * sorted by level, and paths are built accordingly. Lessons can appear in multiple branches + * if they have topics from different chains. + */ +export function buildCategoryTree(repos: LessonRepo[]): CategoryTree { + const tree: CategoryTree = {}; + + for (const repo of repos) { + // Use only numbered topics to build hierarchy (e.g., framework-1, javascript-2, angular-3) + const topicsByChain: Record = {}; + for (const topic of repo.topics) { + if (topic === 'lesson') continue; + const parsed = parseTopic(topic); + if (!parsed) continue; + if (!topicsByChain[parsed.chain]) topicsByChain[parsed.chain] = []; + topicsByChain[parsed.chain].push(parsed); + } + + // Skip repos without numbered topics (cannot place in hierarchy) + if (Object.keys(topicsByChain).length === 0) continue; + + for (const topics of Object.values(topicsByChain)) { + // Sort by level to determine path order + topics.sort((a, b) => a.level - b.level); + + const path = topics.map((t) => t.name); + let current = tree; + for (const segment of path) { + if (!current[segment]) current[segment] = {}; + current = current[segment] as CategoryTree; + } + + if (!Array.isArray(current)) { + if (!current['__lessons__']) current['__lessons__'] = []; + const lessons = current['__lessons__'] as LessonRepo[]; + if (!lessons.find((l) => l.id === repo.id)) lessons.push(repo); + } + } + } + + return tree; +} + +/** + * Gets items at a specific path in the category tree + */ +export function getItemsAtPath(tree: CategoryTree, path: string[]): GroupedItems { + let current: CategoryTree | LessonRepo[] = tree; + + // Navigate to the path + for (const segment of path) { + if (typeof current === 'object' && !Array.isArray(current) && current[segment]) { + current = current[segment] as CategoryTree | LessonRepo[]; + } else { + // Path doesn't exist + return { categories: [], lessons: [] }; + } + } + + // If we're at a lessons array, return it + if (Array.isArray(current)) { + return { categories: [], lessons: current }; + } + + // Extract categories and lessons from current level + const categories: string[] = []; + const lessons: LessonRepo[] = []; + + for (const [key, value] of Object.entries(current)) { + if (key === '__lessons__' && Array.isArray(value)) { + lessons.push(...value); + } else if (typeof value === 'object' && !Array.isArray(value)) { + categories.push(key); + } + } + + return { categories, lessons }; +} + +/** + * Gets items grouped by level-2 categories when at level 1 + * Returns: { [level2Category]: { categories: string[], lessons: LessonRepo[] } } + */ +export function getGroupedItemsAtLevel(repos: LessonRepo[], path: string[]): Record { + const grouped: Record = {}; + const targetLevel = path.length + 1; // The level we're looking at + + for (const repo of repos) { + // Parse topics and find matching path + const topicsByChain: Record = {}; + for (const topic of repo.topics) { + if (topic === 'lesson') continue; + const parsed = parseTopic(topic); + if (!parsed) continue; + if (!topicsByChain[parsed.chain]) topicsByChain[parsed.chain] = []; + topicsByChain[parsed.chain].push(parsed); + } + + // Check each chain to see if it matches the current path + for (const topics of Object.values(topicsByChain)) { + topics.sort((a, b) => a.level - b.level); + const topicPath = topics.map((t) => t.name); + + // Check if this chain matches the current path + const matchesPath = path.every((segment, index) => { + return topicPath[index]?.toLowerCase() === segment.toLowerCase(); + }); + + if (matchesPath && topicPath.length >= targetLevel) { + // Get the level-2 category (or level-3 if we're at level 2, etc.) + const groupKey = topicPath[targetLevel - 1]; + if (!groupKey) continue; + + if (!grouped[groupKey]) { + grouped[groupKey] = { categories: [], lessons: [] }; + } + + // If there's a next level category, add it to categories + if (topicPath.length > targetLevel) { + const nextCategory = topicPath[targetLevel]; + if (!grouped[groupKey].categories.includes(nextCategory)) { + grouped[groupKey].categories.push(nextCategory); + } + } else { + // This is a lesson at this level + if (!grouped[groupKey].lessons.find((l) => l.id === repo.id)) { + grouped[groupKey].lessons.push(repo); + } + } + } + } + } + + // Sort categories within each group + for (const group of Object.values(grouped)) { + group.categories.sort(); + } + + return grouped; +} + +/** + * Gets the subtree at a specific path + */ +export function getSubTreeAtPath(tree: CategoryTree, path: string[]): CategoryTree | null { + let current: CategoryTree | LessonRepo[] = tree; + + for (const segment of path) { + if (typeof current === 'object' && !Array.isArray(current) && current[segment]) { + current = current[segment] as CategoryTree | LessonRepo[]; + } else { + return null; + } + } + + if (Array.isArray(current)) { + return null; + } + + return current as CategoryTree; +} + +/** + * Gets lessons grouped by their immediate subcategories + * Used when viewing a category to show section headings with lessons grouped underneath + */ +export function getLessonsBySubcategory(tree: CategoryTree, path: string[]): { + directLessons: LessonRepo[]; + subcategoryGroups: SubcategoryGroup[]; +} { + const directLessons: LessonRepo[] = []; + const subcategoryGroups: SubcategoryGroup[] = []; + + // Navigate to the path + let current: CategoryTree | LessonRepo[] = tree; + for (const segment of path) { + if (typeof current === 'object' && !Array.isArray(current) && current[segment]) { + current = current[segment] as CategoryTree | LessonRepo[]; + } else { + return { directLessons: [], subcategoryGroups: [] }; + } + } + + if (Array.isArray(current)) { + return { directLessons: current, subcategoryGroups: [] }; + } + + // Get direct lessons at this level + if (current['__lessons__'] && Array.isArray(current['__lessons__'])) { + directLessons.push(...current['__lessons__']); + } + + // For each subcategory, collect all lessons from level 3 and below + for (const [categoryName, subTree] of Object.entries(current)) { + if (categoryName === '__lessons__' || Array.isArray(subTree)) { + continue; + } + + const lessons: LessonRepo[] = []; + + // Recursively collect all lessons from this subcategory + function collectLessons(node: CategoryTree | LessonRepo[]): void { + if (Array.isArray(node)) { + lessons.push(...node); + } else { + if (node['__lessons__'] && Array.isArray(node['__lessons__'])) { + lessons.push(...node['__lessons__']); + } + for (const [key, value] of Object.entries(node)) { + if (key !== '__lessons__' && typeof value === 'object' && !Array.isArray(value)) { + collectLessons(value); + } + } + } + } + + collectLessons(subTree); + + // Remove duplicates + const uniqueLessons = Array.from(new Map(lessons.map((l) => [l.id, l])).values()); + + if (uniqueLessons.length > 0) { + subcategoryGroups.push({ + name: categoryName, + lessons: uniqueLessons + }); + } + } + + // Sort subcategory groups alphabetically + subcategoryGroups.sort((a, b) => a.name.localeCompare(b.name)); + + return { directLessons, subcategoryGroups }; +} + +/** + * Counts the total number of lessons in a category (recursively) + */ +export function countLessonsInCategory(tree: CategoryTree | LessonRepo[]): number { + if (Array.isArray(tree)) { + return tree.length; + } + + // Count all lessons in this tree + let count = 0; + if (tree['__lessons__'] && Array.isArray(tree['__lessons__'])) { + count += tree['__lessons__'].length; + } + + for (const [key, value] of Object.entries(tree)) { + if (key !== '__lessons__' && typeof value === 'object' && !Array.isArray(value)) { + count += countLessonsInCategory(value); + } + } + + return count; +} + +/** + * Formats a date as "X time ago" + */ +export function formatTimeAgo(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSecs = Math.floor(diffMs / 1000); + const diffMins = Math.floor(diffSecs / 60); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + const diffWeeks = Math.floor(diffDays / 7); + const diffMonths = Math.floor(diffDays / 30); + const diffYears = Math.floor(diffDays / 365); + + if (diffSecs < 60) return 'just now'; + if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago`; + if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`; + if (diffDays < 7) return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`; + if (diffWeeks < 4) return `${diffWeeks} week${diffWeeks !== 1 ? 's' : ''} ago`; + if (diffMonths < 12) return `${diffMonths} month${diffMonths !== 1 ? 's' : ''} ago`; + return `${diffYears} year${diffYears !== 1 ? 's' : ''} ago`; +} + +/** + * Gets language color (basic mapping) + */ +export function getLanguageColor(language: string | null): string { + if (!language) return '#6b7280'; + + const colors: Record = { + JavaScript: '#f7df1e', + TypeScript: '#3178c6', + Python: '#3776ab', + Java: '#ed8b00', + 'C++': '#00599c', + C: '#a8b9cc', + Rust: '#000000', + Go: '#00add8', + Ruby: '#cc342d', + PHP: '#777bb4', + Swift: '#fa7343', + Kotlin: '#7f52ff', + HTML: '#e34c26', + CSS: '#1572b6', + Shell: '#89e051', + 'C#': '#239120' + }; + + return colors[language] || '#6b7280'; +} diff --git a/src/lib/server/redis.ts b/src/lib/server/redis.ts new file mode 100644 index 0000000..9f44732 --- /dev/null +++ b/src/lib/server/redis.ts @@ -0,0 +1,82 @@ +import { createClient } from 'redis'; +import { env } from '$env/dynamic/private'; +import { logger } from './logger'; + +let redisClient: ReturnType | null = null; +let connectionPromise: Promise | null = null; + +async function ensureConnected(): Promise> { + if (!redisClient) { + const redisUrl = env.REDIS_URL || 'redis://localhost:6379'; + + redisClient = createClient({ + url: redisUrl, + socket: { + reconnectStrategy: (retries) => { + if (retries > 10) { + logger.error('Redis reconnection failed after 10 attempts'); + return new Error('Redis reconnection failed'); + } + return Math.min(retries * 100, 3000); + } + } + }); + + redisClient.on('error', (err) => { + logger.error('Redis client error', err); + }); + + redisClient.on('connect', () => { + logger.info('Redis client connected'); + }); + + redisClient.on('reconnecting', () => { + logger.info('Redis client reconnecting...'); + }); + + connectionPromise = redisClient.connect().catch((err) => { + logger.error('Failed to connect to Redis', err); + connectionPromise = null; + throw err; + }); + } + + // Wait for connection if it's still in progress + if (connectionPromise) { + try { + await connectionPromise; + } catch (err) { + // Connection failed, but we'll still try to use the client + // (it might reconnect automatically) + } + } + + return redisClient; +} + +export async function getCachedRepos(): Promise { + try { + const client = await ensureConnected(); + const data = await client.get('lessons:repos'); + if (data) { + return JSON.parse(data); + } + return null; + } catch (err) { + logger.error('Error retrieving repos from Redis', err); + return null; + } +} + +export async function getCachedReadme(repoName: string): Promise { + try { + const client = await ensureConnected(); + const key = `lessons:readme:${repoName}`; + const content = await client.get(key); + return content || null; + } catch (err) { + logger.error(`Error retrieving README for ${repoName} from Redis`, err); + return null; + } +} + diff --git a/src/lib/utils/markdown.ts b/src/lib/utils/markdown.ts index 42245a2..da2112a 100644 --- a/src/lib/utils/markdown.ts +++ b/src/lib/utils/markdown.ts @@ -1,95 +1,95 @@ -import { marked } from 'marked'; -import DOMPurify from 'dompurify'; -import { browser } from '$app/environment'; - -// Shared sanitization config -const SANITIZE_CONFIG = { - ALLOWED_TAGS: [ - 'p', - 'br', - 'strong', - 'em', - 'u', - 's', - 'code', - 'pre', - 'a', - 'ul', - 'ol', - 'li', - 'h1', - 'h2', - 'h3', - 'h4', - 'h5', - 'h6', - 'blockquote', - 'hr', - 'img', - 'table', - 'thead', - 'tbody', - 'tr', - 'th', - 'td', - ], - ALLOWED_ATTR: ['href', 'title', 'alt', 'src', 'class'], - ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i, -}; - -// Lazy-loaded jsdom for server-side use -let jsdomModule: typeof import('jsdom') | null = null; - -function getJSDOM(): typeof import('jsdom') { - if (!jsdomModule) { - // Use require for server-side to avoid bundling issues - // eslint-disable-next-line @typescript-eslint/no-require-imports - jsdomModule = require('jsdom') as typeof import('jsdom'); - } - return jsdomModule; -} - -/** - * Renders markdown to sanitized HTML - * @param markdown - The markdown string to render - * @returns Sanitized HTML string safe for use with {@html} directive - */ -export function renderMarkdown(markdown: string): string { - if (!markdown) return ''; - - // Configure marked options - marked.setOptions({ - breaks: true, // Convert line breaks to
- gfm: true, // GitHub Flavored Markdown - }); - - // Parse markdown to HTML - const html = marked.parse(markdown) as string; - - // Sanitize HTML to prevent XSS attacks - // This is critical for user-generated content - let sanitized: string; - - if (browser) { - // Client-side: use DOMPurify directly - sanitized = DOMPurify.sanitize(html, SANITIZE_CONFIG); - } else { - // Server-side: use JSDOM for DOMPurify - // jsdom is externalized in vite.config.ts to avoid bundling - try { - const { JSDOM } = getJSDOM(); - const dom = new JSDOM(''); - const purify = DOMPurify(dom.window as unknown as Window & typeof globalThis); - sanitized = purify.sanitize(html, SANITIZE_CONFIG); - } catch (e) { - // Fallback during build: return unsanitized HTML - // This should only happen during build, not in production SSR - // In production, jsdom will be available as an external dependency - console.warn('JSDOM not available during build, skipping sanitization'); - sanitized = html; - } - } - - return sanitized; -} - +import { marked } from 'marked'; +import DOMPurify from 'dompurify'; +import { browser } from '$app/environment'; + +// Shared sanitization config +const SANITIZE_CONFIG = { + ALLOWED_TAGS: [ + 'p', + 'br', + 'strong', + 'em', + 'u', + 's', + 'code', + 'pre', + 'a', + 'ul', + 'ol', + 'li', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'blockquote', + 'hr', + 'img', + 'table', + 'thead', + 'tbody', + 'tr', + 'th', + 'td', + ], + ALLOWED_ATTR: ['href', 'title', 'alt', 'src', 'class'], + ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i, +}; + +// Lazy-loaded jsdom for server-side use +let jsdomModule: typeof import('jsdom') | null = null; + +function getJSDOM(): typeof import('jsdom') { + if (!jsdomModule) { + // Use require for server-side to avoid bundling issues + // eslint-disable-next-line @typescript-eslint/no-require-imports + jsdomModule = require('jsdom') as typeof import('jsdom'); + } + return jsdomModule; +} + +/** + * Renders markdown to sanitized HTML + * @param markdown - The markdown string to render + * @returns Sanitized HTML string safe for use with {@html} directive + */ +export function renderMarkdown(markdown: string): string { + if (!markdown) return ''; + + // Configure marked options + marked.setOptions({ + breaks: true, // Convert line breaks to
+ gfm: true, // GitHub Flavored Markdown + }); + + // Parse markdown to HTML + const html = marked.parse(markdown) as string; + + // Sanitize HTML to prevent XSS attacks + // This is critical for user-generated content + let sanitized: string; + + if (browser) { + // Client-side: use DOMPurify directly + sanitized = DOMPurify.sanitize(html, SANITIZE_CONFIG); + } else { + // Server-side: use JSDOM for DOMPurify + // jsdom is externalized in vite.config.ts to avoid bundling + try { + const { JSDOM } = getJSDOM(); + const dom = new JSDOM(''); + const purify = DOMPurify(dom.window as unknown as Window & typeof globalThis); + sanitized = purify.sanitize(html, SANITIZE_CONFIG); + } catch (e) { + // Fallback during build: return unsanitized HTML + // This should only happen during build, not in production SSR + // In production, jsdom will be available as an external dependency + console.warn('JSDOM not available during build, skipping sanitization'); + sanitized = html; + } + } + + return sanitized; +} + diff --git a/src/routes/api/lessons/repos/+server.ts b/src/routes/api/lessons/repos/+server.ts new file mode 100644 index 0000000..397be4e --- /dev/null +++ b/src/routes/api/lessons/repos/+server.ts @@ -0,0 +1,46 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { logger } from '$lib/server/logger'; +import { getCachedRepos } from '$lib/server/redis'; + +export interface LessonRepo { + id: string; + name: string; + description: string | null; + html_url: string; + updated_at: string; + last_commit_date?: string; + language: string | null; + topics: string[]; +} + +export const GET: RequestHandler = async () => { + try { + // Try to get repos from Redis cache (populated by Python service) + const repos = await getCachedRepos(); + + if (repos && Array.isArray(repos) && repos.length > 0) { + logger.info('Returning lesson repos from Redis cache', { + repoCount: repos.length + }); + return json(repos); + } + + // Cache miss or empty - this should rarely happen if Python service is running + logger.warn('No repos found in Redis cache. Python service may not be running or cache is empty.'); + throw error(503, { + message: + 'Lesson repositories are temporarily unavailable. The cache service may be starting up. Please try again in a few moments.' + }); + } catch (err) { + // If it's already a SvelteKit error, re-throw it + if (err && typeof err === 'object' && 'status' in err) { + throw err; + } + + logger.error('Error fetching lesson repos from Redis', err); + const message = err instanceof Error ? err.message : 'Unknown error occurred'; + throw error(500, { message }); + } +}; + diff --git a/src/routes/api/lessons/repos/[name]/readme/+server.ts b/src/routes/api/lessons/repos/[name]/readme/+server.ts new file mode 100644 index 0000000..f3d9b39 --- /dev/null +++ b/src/routes/api/lessons/repos/[name]/readme/+server.ts @@ -0,0 +1,111 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { logger } from '$lib/server/logger'; +import { getCachedReadme } from '$lib/server/redis'; +import { env } from '$env/dynamic/private'; + +interface ReadmeResponse { + content: string; + encoding: string; +} + +async function fetchReadmeFromGitHub(repoName: string, org: string): Promise { + const githubToken = env.GITHUB_TOKEN; + if (!githubToken) { + logger.error('GITHUB_TOKEN environment variable is not set'); + return null; + } + + const headers: HeadersInit = { + Accept: 'application/vnd.github.v3+json', + Authorization: `token ${githubToken}`, + 'User-Agent': 'HacKSU-Website' + }; + + try { + logger.debug('Fetching README from GitHub API', { repoName, org }); + const response = await fetch(`https://api.github.com/repos/${org}/${repoName}/readme`, { + headers + }); + + if (!response.ok) { + if (response.status === 404) { + logger.debug('README not found for repository', { repoName }); + return null; + } + logger.error('GitHub API request failed for README', undefined, { + repoName, + status: response.status, + statusText: response.statusText + }); + return null; + } + + const data: ReadmeResponse = await response.json(); + + // Decode base64 content + if (data.encoding === 'base64') { + const content = Buffer.from(data.content, 'base64').toString('utf-8'); + logger.info('Successfully fetched and decoded README from GitHub', { + repoName, + contentLength: content.length + }); + return content; + } + + logger.warn('README encoding is not base64', { repoName, encoding: data.encoding }); + return data.content; + } catch (err) { + logger.error('Error fetching README from GitHub', err, { repoName }); + return null; + } +} + +export const GET: RequestHandler = async ({ params }) => { + const repoName = params.name; + const org = 'hacksu'; + + logger.info('Fetching README for lesson repo', { repoName, org }); + + if (!repoName) { + logger.warn('README request missing repo name'); + throw error(400, 'Repository name is required'); + } + + // Security: validate repo name to prevent path traversal + if (!/^[a-zA-Z0-9._-]+$/.test(repoName)) { + logger.warn('Invalid repository name format', { repoName }); + throw error(400, 'Invalid repository name'); + } + + try { + // Try Redis cache first + let content = await getCachedReadme(repoName); + + if (content) { + logger.info('Returning README from Redis cache', { + repoName, + contentLength: content.length + }); + return json({ content }); + } + + // Cache miss - fallback to GitHub API + logger.debug('README not in cache, fetching from GitHub', { repoName }); + content = await fetchReadmeFromGitHub(repoName, org); + + if (content) { + return json({ content }); + } + + // Not found in either cache or GitHub + throw error(404, 'README not found for this repository'); + } catch (err) { + if (err && typeof err === 'object' && 'status' in err) { + throw err; // Re-throw SvelteKit errors + } + logger.error('Error fetching README', err, { repoName }); + throw error(500, 'Failed to fetch README'); + } +}; + diff --git a/src/routes/lessons/+page.server.ts b/src/routes/lessons/+page.server.ts new file mode 100644 index 0000000..2dfef0a --- /dev/null +++ b/src/routes/lessons/+page.server.ts @@ -0,0 +1,11 @@ +import type { PageServerLoad } from './$types'; + +// Return empty data to allow immediate skeleton rendering +// Data will be loaded client-side for better perceived performance +export const load: PageServerLoad = async () => { + return { + repos: [], + error: null + }; +}; + diff --git a/src/routes/lessons/+page.svelte b/src/routes/lessons/+page.svelte new file mode 100644 index 0000000..ef98ce0 --- /dev/null +++ b/src/routes/lessons/+page.svelte @@ -0,0 +1,379 @@ + + +
+ {#if isLoading} + + {:else if error} +
+

Error Loading Lessons

+

{error}

+

+ Please check that the GITHUB_TOKEN environment variable is set correctly. +

+
+ {:else} +
+ {#if currentPath.length > 0} + + {/if} + +

HacKSU Lessons

+ + {#if !showSearchResults} +

+ {#if currentPath.length === 0} + Explore our collection of lessons organized by topic + {:else} + Lessons in {currentPath.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(' / ')} + {/if} +

+ {/if} + + + + {#if showSearchResults} +
+

+ {filteredLessons.length} result{filteredLessons.length !== 1 ? 's' : ''} found +

+ {#if filteredLessons.length === 0} +

No lessons found matching "{searchQuery}"

+ {:else} +
+ {#each filteredLessons as lesson} +
navigateToLesson(lesson)}> + +
+ {/each} +
+ {/if} +
+ {:else} + + + {#if currentPath.length === 1 && Object.keys(groupedBySections).length > 0} + + {#each Object.entries(groupedBySections) as [sectionName, sectionItems]} +
+

{sectionName.charAt(0).toUpperCase() + sectionName.slice(1)}

+ {#if sectionItems.categories.length > 0} +
+ {#each sectionItems.categories as category} + {@const subTreePath = [...currentPath, sectionName, category]} + {@const subTree = getSubTreeAtPath(categoryTree, subTreePath)} + {@const count = subTree ? countLessonsInCategory(subTree) : 0} +
navigateToCategoryInSection(category, sectionName)}> + +
+ {/each} +
+ {/if} + {#if sectionItems.lessons.length > 0} +
+ {#each sectionItems.lessons as lesson} +
navigateToLesson(lesson)}> + +
+ {/each} +
+ {/if} +
+ {/each} + {:else if groupedItems.categories.length === 0 && groupedItems.lessons.length === 0} +
+

No lessons found in this category.

+
+ {:else if groupedItems.categories.length > 0} +
+ {#each groupedItems.categories as category} + {@const subTreePath = [...currentPath, category]} + {@const subTree = getSubTreeAtPath(categoryTree, subTreePath)} + {@const count = subTree ? countLessonsInCategory(subTree) : 0} +
navigateToCategory(category)}> + +
+ {/each} +
+ {:else} +
+ {#each groupedItems.lessons as lesson} +
navigateToLesson(lesson)}> + +
+ {/each} +
+ {/if} + {/if} +
+ {/if} +
+ + + diff --git a/src/routes/lessons/[...path]/+page.server.ts b/src/routes/lessons/[...path]/+page.server.ts new file mode 100644 index 0000000..386f9ce --- /dev/null +++ b/src/routes/lessons/[...path]/+page.server.ts @@ -0,0 +1,9 @@ +import type { PageServerLoad } from './$types'; + +// Return empty data to allow immediate skeleton rendering +// Data will be loaded client-side for better perceived performance +export const load: PageServerLoad = async () => { + return {}; +}; + + diff --git a/src/routes/lessons/[...path]/+page.svelte b/src/routes/lessons/[...path]/+page.svelte new file mode 100644 index 0000000..dc98eb3 --- /dev/null +++ b/src/routes/lessons/[...path]/+page.svelte @@ -0,0 +1,413 @@ + + +
+ {#if isLoading} + + {:else if error} +
+

Error Loading Lessons

+

{error}

+

+ Please check that the GITHUB_TOKEN environment variable is set correctly. +

+
+ {:else} +
+ {#if currentPath.length > 0} + + {/if} + +

HacKSU Lessons

+ + {#if !showSearchResults} +

+ {#if currentPath.length === 0} + Explore our collection of lessons organized by topic + {:else} + Lessons in {currentPath.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(' / ')} + {/if} +

+ {/if} + + + + {#if showSearchResults} +
+

+ {filteredLessons.length} result{filteredLessons.length !== 1 ? 's' : ''} found +

+ {#if filteredLessons.length === 0} +

No lessons found matching "{searchQuery}"

+ {:else} +
+ {#each filteredLessons as lesson} +
navigateToLesson(lesson)}> + +
+ {/each} +
+ {/if} +
+ {:else} + + + {#if currentPath.length === 1 && Object.keys(groupedBySections).length > 0} + + {#each Object.entries(groupedBySections) as [sectionName, sectionItems]} +
+

{sectionName.charAt(0).toUpperCase() + sectionName.slice(1)}

+ {#if sectionItems.categories.length > 0} +
+ {#each sectionItems.categories as category} + {@const subTreePath = [...currentPath, sectionName, category]} + {@const subTree = getSubTreeAtPath(categoryTree, subTreePath)} + {@const count = subTree ? countLessonsInCategory(subTree) : 0} +
navigateToCategoryInSection(category, sectionName)}> + +
+ {/each} +
+ {/if} + {#if sectionItems.lessons.length > 0} +
+ {#each sectionItems.lessons as lesson} +
navigateToLesson(lesson)}> + +
+ {/each} +
+ {/if} +
+ {/each} + {:else if groupedItems.categories.length === 0 && groupedItems.lessons.length === 0 && lessonsBySubcategory.directLessons.length === 0 && lessonsBySubcategory.subcategoryGroups.length === 0} +
+

No lessons found in this category.

+
+ {:else if lessonsBySubcategory.subcategoryGroups.length > 0 || lessonsBySubcategory.directLessons.length > 0} + + {#if lessonsBySubcategory.directLessons.length > 0} +
+ {#each lessonsBySubcategory.directLessons as lesson} +
navigateToLesson(lesson)}> + +
+ {/each} +
+ {/if} + + {#each lessonsBySubcategory.subcategoryGroups as group} +
+

{group.name.charAt(0).toUpperCase() + group.name.slice(1)}

+
+
+ {#each group.lessons as lesson} +
navigateToLesson(lesson)}> + +
+ {/each} +
+
+ {/each} + {:else if groupedItems.categories.length > 0} + +
+ {#each groupedItems.categories as category} + {@const subTreePath = [...currentPath, category]} + {@const subTree = getSubTreeAtPath(categoryTree, subTreePath)} + {@const count = subTree ? countLessonsInCategory(subTree) : 0} +
navigateToCategory(category)}> + +
+ {/each} +
+ {:else} + +
+ {#each groupedItems.lessons as lesson} +
navigateToLesson(lesson)}> + +
+ {/each} +
+ {/if} + {/if} +
+ {/if} +
+ + + diff --git a/src/routes/lessons/detail/[name]/+page.server.ts b/src/routes/lessons/detail/[name]/+page.server.ts new file mode 100644 index 0000000..63c43a7 --- /dev/null +++ b/src/routes/lessons/detail/[name]/+page.server.ts @@ -0,0 +1,52 @@ +import type { PageServerLoad } from './$types'; +import { error } from '@sveltejs/kit'; +import { logger } from '$lib/server/logger'; + +export const load: PageServerLoad = async ({ params, fetch }) => { + const repoName = params.name; + + logger.info('Loading lesson detail page', { repoName }); + + if (!repoName) { + logger.warn('Lesson detail page missing repo name'); + throw error(400, 'Repository name is required'); + } + + // Security: validate repo name + if (!/^[a-zA-Z0-9._-]+$/.test(repoName)) { + logger.warn('Invalid repository name format in lesson detail', { repoName }); + throw error(400, 'Invalid repository name'); + } + + try { + const response = await fetch(`/api/lessons/repos/${repoName}/readme`); + if (!response.ok) { + logger.error('Failed to fetch README from API', undefined, { + repoName, + status: response.status, + statusText: response.statusText + }); + if (response.status === 404) { + throw error(404, 'README not found for this lesson'); + } + throw error(response.status, 'Failed to fetch README'); + } + const data = await response.json(); + logger.info('Successfully loaded lesson detail page', { + repoName, + readmeLength: data.content?.length || 0 + }); + return { readme: data.content, repoName, error: null }; + } catch (err) { + if (err && typeof err === 'object' && 'status' in err) { + throw err; // Re-throw SvelteKit errors + } + logger.error('Error loading lesson detail page', err, { repoName }); + return { + readme: null, + repoName, + error: err instanceof Error ? err.message : 'Failed to load README' + }; + } +}; + diff --git a/src/routes/lessons/detail/[name]/+page.svelte b/src/routes/lessons/detail/[name]/+page.svelte new file mode 100644 index 0000000..2c1d8d8 --- /dev/null +++ b/src/routes/lessons/detail/[name]/+page.svelte @@ -0,0 +1,198 @@ + + +
+
+ + + {#if error} +
+

Error Loading Lesson

+

{error}

+ +
+ {:else if !readme} +
+
+

Loading lesson content...

+
+ {:else} +
+
+

{repoName}

+ + View on GitHub → + +
+
{readme}
+
+ {/if} +
+
+ + + From 894003ba01e03ff01ec40f639f4e35d9d41a808a Mon Sep 17 00:00:00 2001 From: Monster0506 Date: Mon, 8 Dec 2025 10:57:11 -0500 Subject: [PATCH 3/6] Admin side of lesson icons --- drizzle/0014_spotty_misty_knight.sql | 6 + drizzle/meta/0014_snapshot.json | 477 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + .../components/lessons/CategoryCard.svelte | 9 +- src/lib/lessons/iconify.ts | 86 +++- src/lib/server/db/schema.ts | 8 + src/routes/admin/+page.svelte | 5 + src/routes/admin/api/dump/json/+server.ts | 42 +- src/routes/admin/lesson-icons/+page.server.ts | 18 + src/routes/admin/lesson-icons/+page.svelte | 231 +++++++++ .../[categoryName]/delete/+server.ts | 37 ++ src/routes/admin/lesson-icons/api/+server.ts | 43 ++ .../api/[categoryName]/+server.ts | 44 ++ src/routes/api/lesson-icons/+server.ts | 17 + 14 files changed, 1012 insertions(+), 18 deletions(-) create mode 100644 drizzle/0014_spotty_misty_knight.sql create mode 100644 drizzle/meta/0014_snapshot.json create mode 100644 src/routes/admin/lesson-icons/+page.server.ts create mode 100644 src/routes/admin/lesson-icons/+page.svelte create mode 100644 src/routes/admin/lesson-icons/[categoryName]/delete/+server.ts create mode 100644 src/routes/admin/lesson-icons/api/+server.ts create mode 100644 src/routes/admin/lesson-icons/api/[categoryName]/+server.ts create mode 100644 src/routes/api/lesson-icons/+server.ts diff --git a/drizzle/0014_spotty_misty_knight.sql b/drizzle/0014_spotty_misty_knight.sql new file mode 100644 index 0000000..65b9728 --- /dev/null +++ b/drizzle/0014_spotty_misty_knight.sql @@ -0,0 +1,6 @@ +CREATE TABLE "lesson_icons" ( + "category_name" text PRIMARY KEY NOT NULL, + "iconify_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); diff --git a/drizzle/meta/0014_snapshot.json b/drizzle/meta/0014_snapshot.json new file mode 100644 index 0000000..1bba6d5 --- /dev/null +++ b/drizzle/meta/0014_snapshot.json @@ -0,0 +1,477 @@ +{ + "id": "a51df9aa-1b3f-44da-bc7a-43defebd6c27", + "prevId": "b610820f-36a9-492b-8b29-02e9369b616b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_sessions": { + "name": "admin_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "discord_user_id": { + "name": "discord_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.information": { + "name": "information", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "photo": { + "name": "photo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_index": { + "name": "sort_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leadership": { + "name": "leadership", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grad_year": { + "name": "grad_year", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "grad_term": { + "name": "grad_term", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github": { + "name": "github", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "photo": { + "name": "photo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "titles": { + "name": "titles", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lesson_icons": { + "name": "lesson_icons", + "schema": "", + "columns": { + "category_name": { + "name": "category_name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "iconify_id": { + "name": "iconify_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.location": { + "name": "location", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "'current'" + }, + "time": { + "name": "time", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "building_room": { + "name": "building_room", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "building_selector": { + "name": "building_selector", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "building_url": { + "name": "building_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meetings": { + "name": "meetings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "presenter": { + "name": "presenter", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description_md": { + "name": "description_md", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "photo": { + "name": "photo", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notes": { + "name": "notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.redirects": { + "name": "redirects", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "target_url": { + "name": "target_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "clicks": { + "name": "clicks", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d1b9e73..2ff8df6 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1764870114999, "tag": "0013_cynical_nebula", "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1765208300249, + "tag": "0014_spotty_misty_knight", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/components/lessons/CategoryCard.svelte b/src/lib/components/lessons/CategoryCard.svelte index bcfd99d..d6d6946 100644 --- a/src/lib/components/lessons/CategoryCard.svelte +++ b/src/lib/components/lessons/CategoryCard.svelte @@ -1,12 +1,15 @@ diff --git a/src/routes/admin/api/dump/json/+server.ts b/src/routes/admin/api/dump/json/+server.ts index f8a8b5f..b05cc28 100644 --- a/src/routes/admin/api/dump/json/+server.ts +++ b/src/routes/admin/api/dump/json/+server.ts @@ -1,14 +1,14 @@ import type { RequestHandler } from './$types'; import { json } from '@sveltejs/kit'; import { db } from '$lib/server/db'; -import { adminSessions, location, leadership, meetings, redirects, notes, information } from '$lib/server/db/schema'; +import { adminSessions, location, leadership, meetings, redirects, notes, information, lessonIcons } from '$lib/server/db/schema'; import { requireAdmin } from '$lib/server/admin'; export const GET: RequestHandler = async (event) => { await requireAdmin(event); // Fetch everything we care about for backup/export - const [adminSessionsList, locationList, leadershipList, meetingsList, redirectsList, notesList, informationList] = + const [adminSessionsList, locationList, leadershipList, meetingsList, redirectsList, notesList, informationList, lessonIconsList] = await Promise.all([ // admin sessions (you may want to omit in some contexts, but exporting for now) db.select().from(adminSessions), @@ -23,7 +23,9 @@ export const GET: RequestHandler = async (event) => { // admin notes db.select().from(notes), // helpful information blocks - db.select().from(information) + db.select().from(information), + // lesson icon mappings + db.select().from(lessonIcons) ]); return json({ @@ -35,7 +37,8 @@ export const GET: RequestHandler = async (event) => { meetings: meetingsList, redirects: redirectsList, notes: notesList, - information: informationList + information: informationList, + lessonIcons: lessonIconsList }); }; @@ -52,7 +55,8 @@ export const POST: RequestHandler = async (event) => { meetings: meetingsList = [], redirects: redirectsList = [], notes: notesList = [], - information: informationList = [] + information: informationList = [], + lessonIcons: lessonIconsList = [] } = body ?? {}; // Naive restore strategy: @@ -69,6 +73,7 @@ export const POST: RequestHandler = async (event) => { await db.delete(redirects); await db.delete(notes); await db.delete(information); + await db.delete(lessonIcons); // Helper to parse ISO timestamp fields into Date instances const parseDate = (value: unknown): Date | null => { @@ -117,12 +122,16 @@ export const POST: RequestHandler = async (event) => { await db.insert(redirects).values(rows); } if (Array.isArray(notesList) && notesList.length > 0) { - const rows = notesList.map((row: any) => ({ - ...row, - date: parseDate(row.date), - createdAt: parseDate(row.createdAt), - updatedAt: parseDate(row.updatedAt) - })); + const rows = notesList.map((row: any) => { + // Use createdAt as fallback if date is null (for legacy data) + const date = parseDate(row.date) || parseDate(row.createdAt) || new Date(); + return { + ...row, + date, + createdAt: parseDate(row.createdAt), + updatedAt: parseDate(row.updatedAt) + }; + }); await db.insert(notes).values(rows); } if (Array.isArray(informationList) && informationList.length > 0) { @@ -133,6 +142,14 @@ export const POST: RequestHandler = async (event) => { })); await db.insert(information).values(rows); } + if (Array.isArray(lessonIconsList) && lessonIconsList.length > 0) { + const rows = lessonIconsList.map((row: any) => ({ + ...row, + createdAt: parseDate(row.createdAt), + updatedAt: parseDate(row.updatedAt) + })); + await db.insert(lessonIcons).values(rows); + } return json({ ok: true, @@ -144,7 +161,8 @@ export const POST: RequestHandler = async (event) => { meetings: meetingsList.length ?? 0, redirects: redirectsList.length ?? 0, notes: notesList.length ?? 0, - information: informationList.length ?? 0 + information: informationList.length ?? 0, + lessonIcons: lessonIconsList.length ?? 0 } }); }; diff --git a/src/routes/admin/lesson-icons/+page.server.ts b/src/routes/admin/lesson-icons/+page.server.ts new file mode 100644 index 0000000..a3082dd --- /dev/null +++ b/src/routes/admin/lesson-icons/+page.server.ts @@ -0,0 +1,18 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { lessonIcons } from '$lib/server/db/schema'; +import { requireAdmin } from '$lib/server/admin'; +import { asc } from 'drizzle-orm'; + +export const load: PageServerLoad = async (event) => { + await requireAdmin(event); + + const icons = await db.query.lessonIcons.findMany({ + orderBy: [asc(lessonIcons.categoryName)] + }); + + return { + icons + }; +}; + diff --git a/src/routes/admin/lesson-icons/+page.svelte b/src/routes/admin/lesson-icons/+page.svelte new file mode 100644 index 0000000..7fa1376 --- /dev/null +++ b/src/routes/admin/lesson-icons/+page.svelte @@ -0,0 +1,231 @@ + + +
+
+

Lesson Icons Editor

+

Manage icon mappings for lesson categories

+
+ +
+ +
+
+
?
+
+ +
+
+ + +
+ + + + +
+
+ + + {#each icons as icon} + {@const previewUrl = getIconUrl(icon.iconifyId)} +
+
+ {#if previewUrl} + {icon.categoryName} { + // Hide image on error, show placeholder instead + e.currentTarget.style.display = 'none'; + }} + /> + {:else} +
?
+ {/if} +
+ +
+
+ + +
+ + + +
+ +
{ + return ({ update }) => { + if (confirm(`Really delete icon mapping for "${icon.categoryName}"?`)) { + return update(); + } + return () => {}; + }; + }} + class="flex-1" + > + +
+
+
+
+ {/each} +
+
+ diff --git a/src/routes/admin/lesson-icons/[categoryName]/delete/+server.ts b/src/routes/admin/lesson-icons/[categoryName]/delete/+server.ts new file mode 100644 index 0000000..22f6819 --- /dev/null +++ b/src/routes/admin/lesson-icons/[categoryName]/delete/+server.ts @@ -0,0 +1,37 @@ +import { redirect, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { lessonIcons } from '$lib/server/db/schema'; +import { requireAdmin } from '$lib/server/admin'; +import { eq } from 'drizzle-orm'; + +export const POST: RequestHandler = async (event) => { + await requireAdmin(event); + + const categoryName = event.params.categoryName; + + if (!categoryName) { + throw error(400, { message: 'Category name is required' }); + } + + const existing = await db.query.lessonIcons.findFirst({ + where: (icon, { eq }) => eq(icon.categoryName, categoryName.toLowerCase()) + }); + + if (!existing) { + throw error(404, { message: 'Icon mapping not found' }); + } + + try { + await db.delete(lessonIcons).where(eq(lessonIcons.categoryName, categoryName.toLowerCase())); + + throw redirect(303, '/admin/lesson-icons'); + } catch (err) { + if (err && typeof err === 'object' && 'status' in err) { + throw err; + } + console.error('Error deleting lesson icon:', err); + throw error(500, { message: 'Failed to delete icon mapping' }); + } +}; + diff --git a/src/routes/admin/lesson-icons/api/+server.ts b/src/routes/admin/lesson-icons/api/+server.ts new file mode 100644 index 0000000..e330882 --- /dev/null +++ b/src/routes/admin/lesson-icons/api/+server.ts @@ -0,0 +1,43 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { lessonIcons } from '$lib/server/db/schema'; +import { requireAdmin } from '$lib/server/admin'; + +export const POST: RequestHandler = async (event) => { + await requireAdmin(event); + + const body = await event.request.json(); + const { categoryName, iconifyId } = body; + + if (!categoryName || !iconifyId) { + throw error(400, { message: 'Category name and iconify ID are required' }); + } + + // Validate iconify ID format (should be like "collection:icon") + if (!/^[^:]+:[^:]+$/.test(iconifyId)) { + throw error(400, { message: 'Invalid iconify ID format. Expected format: collection:icon' }); + } + + // Check if icon already exists + const existing = await db.query.lessonIcons.findFirst({ + where: (icon, { eq }) => eq(icon.categoryName, categoryName.toLowerCase()) + }); + + if (existing) { + throw error(400, { message: 'Icon mapping for this category already exists' }); + } + + try { + await db.insert(lessonIcons).values({ + categoryName: categoryName.toLowerCase(), + iconifyId + }); + + return json({ success: true }); + } catch (err) { + console.error('Error creating lesson icon:', err); + throw error(500, { message: 'Failed to create icon mapping' }); + } +}; + diff --git a/src/routes/admin/lesson-icons/api/[categoryName]/+server.ts b/src/routes/admin/lesson-icons/api/[categoryName]/+server.ts new file mode 100644 index 0000000..1dc83a9 --- /dev/null +++ b/src/routes/admin/lesson-icons/api/[categoryName]/+server.ts @@ -0,0 +1,44 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { lessonIcons } from '$lib/server/db/schema'; +import { requireAdmin } from '$lib/server/admin'; +import { eq } from 'drizzle-orm'; + +export const PUT: RequestHandler = async (event) => { + await requireAdmin(event); + + const categoryName = event.params.categoryName; + const body = await event.request.json(); + const { iconifyId } = body; + + if (!categoryName || !iconifyId) { + throw error(400, { message: 'Category name and iconify ID are required' }); + } + + // Validate iconify ID format + if (!/^[^:]+:[^:]+$/.test(iconifyId)) { + throw error(400, { message: 'Invalid iconify ID format. Expected format: collection:icon' }); + } + + const existing = await db.query.lessonIcons.findFirst({ + where: (icon, { eq }) => eq(icon.categoryName, categoryName.toLowerCase()) + }); + + if (!existing) { + throw error(404, { message: 'Icon mapping not found' }); + } + + try { + await db + .update(lessonIcons) + .set({ iconifyId, updatedAt: new Date() }) + .where(eq(lessonIcons.categoryName, categoryName.toLowerCase())); + + return json({ success: true }); + } catch (err) { + console.error('Error updating lesson icon:', err); + throw error(500, { message: 'Failed to update icon mapping' }); + } +}; + diff --git a/src/routes/api/lesson-icons/+server.ts b/src/routes/api/lesson-icons/+server.ts new file mode 100644 index 0000000..eed55df --- /dev/null +++ b/src/routes/api/lesson-icons/+server.ts @@ -0,0 +1,17 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { lessonIcons } from '$lib/server/db/schema'; + +export const GET: RequestHandler = async () => { + const icons = await db.query.lessonIcons.findMany(); + + // Convert to a map for easy lookup + const iconMap: Record = {}; + for (const icon of icons) { + iconMap[icon.categoryName.toLowerCase()] = icon.iconifyId; + } + + return json(iconMap); +}; + From 7756dd3fe3d1a7d32bfe111690f090f8f7111ebf Mon Sep 17 00:00:00 2001 From: Monster0506 Date: Mon, 8 Dec 2025 19:52:52 -0500 Subject: [PATCH 4/6] Improved lessons appearence --- .../components/lessons/CategoryCard.svelte | 2 +- .../lessons/LessonBreadcrumbs.svelte | 57 +--- src/lib/components/lessons/LessonCard.svelte | 2 +- .../components/lessons/LessonsSkeleton.svelte | 183 ++----------- src/lib/components/lessons/SearchBar.svelte | 49 +--- src/routes/admin/api/dump/json/+server.ts | 6 +- src/routes/lessons/+page.svelte | 246 +++++------------- src/routes/lessons/[...path]/+page.svelte | 214 +++------------ src/routes/lessons/detail/[name]/+page.svelte | 200 +++----------- 9 files changed, 168 insertions(+), 791 deletions(-) diff --git a/src/lib/components/lessons/CategoryCard.svelte b/src/lib/components/lessons/CategoryCard.svelte index d6d6946..11507a0 100644 --- a/src/lib/components/lessons/CategoryCard.svelte +++ b/src/lib/components/lessons/CategoryCard.svelte @@ -21,7 +21,7 @@
diff --git a/src/lib/components/lessons/LessonBreadcrumbs.svelte b/src/lib/components/lessons/LessonBreadcrumbs.svelte index f29cb97..6579ef5 100644 --- a/src/lib/components/lessons/LessonBreadcrumbs.svelte +++ b/src/lib/components/lessons/LessonBreadcrumbs.svelte @@ -13,13 +13,17 @@ } -