diff --git a/README.md b/README.md
index eb4d52f..aace9b0 100644
--- a/README.md
+++ b/README.md
@@ -9,9 +9,25 @@ You can run it without production configuration locally, but it is generally not
You will need a `.env` file. This file should contain all of the variables in the `.env.example` file at the root of the project correctly filled out.
+## Environment Variables
+
+Required environment variables:
+- `GITHUB_TOKEN` - GitHub personal access token for accessing the GitHub API
+- `DATABASE_URL` - PostgreSQL connection string (automatically set in Docker Compose)
+- `REDIS_URL` - Redis connection string (defaults to `redis://localhost:6379`, automatically set in Docker Compose)
+- `GITHUB_ORG` - GitHub organization name (defaults to `hacksu`, optional)
+
+The `REDIS_URL` is automatically configured in Docker Compose to connect to the Redis service. For local development outside Docker, you may need to set it manually.
+
To make changes on the admin side, go to the `/admin` route, and login with discord. If you have acceptable roles, this will authenticate you.
-Run the containers with `docker compose -d --build`
-This should setup persistent postgres volume and expose `localhost:3000` with the site
+Run the containers with `docker compose up -d --build`
+This should setup persistent postgres and redis volumes and expose `localhost:3002` with the site.
+
+The setup includes:
+- **PostgreSQL** - Main database (port 5434)
+- **Redis** - Cache for lesson repositories (port 6379)
+- **Lessons Service** - Python service that fetches and caches GitHub lesson repos
+- **SvelteKit App** - Main web application (port 3002)
diff --git a/bun.lock b/bun.lock
index 3f1434e..367d1e1 100644
--- a/bun.lock
+++ b/bun.lock
@@ -13,6 +13,7 @@
"lucia": "^3.2.2",
"marked": "^17.0.1",
"postgres": "^3.4.7",
+ "redis": "^5.10.0",
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
@@ -215,6 +216,16 @@
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
+ "@redis/bloom": ["@redis/bloom@5.10.0", "", { "peerDependencies": { "@redis/client": "^5.10.0" } }, "sha512-doIF37ob+l47n0rkpRNgU8n4iacBlKM9xLiP1LtTZTvz8TloJB8qx/MgvhMhKdYG+CvCY2aPBnN2706izFn/4A=="],
+
+ "@redis/client": ["@redis/client@5.10.0", "", { "dependencies": { "cluster-key-slot": "1.1.2" } }, "sha512-JXmM4XCoso6C75Mr3lhKA3eNxSzkYi3nCzxDIKY+YOszYsJjuKbFgVtguVPbLMOttN4iu2fXoc2BGhdnYhIOxA=="],
+
+ "@redis/json": ["@redis/json@5.10.0", "", { "peerDependencies": { "@redis/client": "^5.10.0" } }, "sha512-B2G8XlOmTPUuZtD44EMGbtoepQG34RCDXLZbjrtON1Djet0t5Ri7/YPXvL9aomXqP8lLTreaprtyLKF4tmXEEA=="],
+
+ "@redis/search": ["@redis/search@5.10.0", "", { "peerDependencies": { "@redis/client": "^5.10.0" } }, "sha512-3SVcPswoSfp2HnmWbAGUzlbUPn7fOohVu2weUQ0S+EMiQi8jwjL+aN2p6V3TI65eNfVsJ8vyPvqWklm6H6esmg=="],
+
+ "@redis/time-series": ["@redis/time-series@5.10.0", "", { "peerDependencies": { "@redis/client": "^5.10.0" } }, "sha512-cPkpddXH5kc/SdRhF0YG0qtjL+noqFT0AcHbQ6axhsPsO7iqPi1cjxgdkE9TNeKiBUUdCaU1DbqkR/LzbzPBhg=="],
+
"@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@28.0.9", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA=="],
"@rollup/plugin-json": ["@rollup/plugin-json@6.1.0", "", { "dependencies": { "@rollup/pluginutils": "^5.1.0" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA=="],
@@ -389,6 +400,8 @@
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
+ "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
+
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
@@ -645,6 +658,8 @@
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
+ "redis": ["redis@5.10.0", "", { "dependencies": { "@redis/bloom": "5.10.0", "@redis/client": "5.10.0", "@redis/json": "5.10.0", "@redis/search": "5.10.0", "@redis/time-series": "5.10.0" } }, "sha512-0/Y+7IEiTgVGPrLFKy8oAEArSyEJkU0zvgV5xyi9NzNQ+SLZmyFbUsWIbgPcd4UdUh00opXGKlXJwMmsis5Byw=="],
+
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
diff --git a/compose.yaml b/compose.yaml
index 53422b1..9023548 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -16,6 +16,39 @@ services:
networks:
- app-network
+ redis:
+ image: redis:7-alpine
+ restart: on-failure:5
+ ports:
+ - 6379:6379
+ volumes:
+ - redisdata:/data
+ command: redis-server --appendonly yes
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - app-network
+
+ lessons-service:
+ build:
+ context: ./lessons-service
+ dockerfile: Dockerfile
+ restart: on-failure:5
+ env_file:
+ - .env
+ environment:
+ REDIS_URL: redis://redis:6379
+ GITHUB_TOKEN: ${GITHUB_TOKEN}
+ GITHUB_ORG: ${GITHUB_ORG:-hacksu}
+ depends_on:
+ redis:
+ condition: service_healthy
+ networks:
+ - app-network
+
app:
build: .
restart: on-failure:5
@@ -23,18 +56,22 @@ services:
- .env
environment:
DATABASE_URL: postgresql://postgres:postgres@db:5432/hacksu
+ REDIS_URL: redis://redis:6379
NODE_ENV: production
volumes:
- uploads:/app/static/uploads
depends_on:
db:
condition: service_healthy
+ redis:
+ condition: service_healthy
networks:
- app-network
volumes:
pgdata:
uploads:
+ redisdata:
networks:
app-network:
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/lessons-service/.dockerignore b/lessons-service/.dockerignore
new file mode 100644
index 0000000..5033033
--- /dev/null
+++ b/lessons-service/.dockerignore
@@ -0,0 +1,16 @@
+__pycache__
+*.pyc
+*.pyo
+*.pyd
+.Python
+*.so
+*.egg
+*.egg-info
+dist
+build
+.env
+.venv
+venv/
+ENV/
+env/
+
diff --git a/lessons-service/.python-version b/lessons-service/.python-version
new file mode 100644
index 0000000..6324d40
--- /dev/null
+++ b/lessons-service/.python-version
@@ -0,0 +1 @@
+3.14
diff --git a/lessons-service/Dockerfile b/lessons-service/Dockerfile
new file mode 100644
index 0000000..992d0a7
--- /dev/null
+++ b/lessons-service/Dockerfile
@@ -0,0 +1,13 @@
+FROM ghcr.io/astral-sh/uv:alpine
+WORKDIR /app
+
+# Copy requirements first for better caching
+COPY pyproject.toml uv.lock ./
+RUN uv sync --no-dev
+
+# Copy application code
+COPY . ./
+
+# Run the service
+CMD ["uv", "run", "main.py"]
+
diff --git a/lessons-service/README.md b/lessons-service/README.md
new file mode 100644
index 0000000..a8a0b0f
--- /dev/null
+++ b/lessons-service/README.md
@@ -0,0 +1,62 @@
+# Lessons Service
+
+A Python microservice that fetches lesson repositories from GitHub and caches them in Redis.
+
+## Overview
+
+This service periodically fetches all repositories with the "lesson" topic from the GitHub organization and caches them in Redis. This offloads the heavy GitHub API work from the main SvelteKit application and provides fast, cached responses.
+
+## Features
+
+- **GraphQL API**: Uses GitHub's GraphQL API for efficient batch fetching of repositories and topics
+- **Automatic Refresh**: Refreshes the cache every 10 minutes
+- **Immediate Startup**: Runs initial cache refresh on startup (doesn't wait 10 minutes)
+- **Stale-on-failure**: If GitHub is unreachable, keeps the existing cache warm
+- **Health Endpoint**: Provides `/health` endpoint for monitoring
+- **Persistent Cache**: Uses Redis with 30-minute TTL
+
+## Environment Variables
+
+- `GITHUB_TOKEN` (required): GitHub personal access token
+- `GITHUB_ORG` (optional): GitHub organization name (defaults to "hacksu")
+- `REDIS_URL` (optional): Redis connection URL (defaults to "redis://localhost:6379")
+- `PORT` (optional): Port for health check endpoint (defaults to 8080)
+
+## Architecture
+
+```
+GitHub API (GraphQL)
+ |
+ V
+Python Service (fetches every 10 min)
+ |
+ V
+Redis Cache (30 min TTL, stale-on-failure)
+ |
+ V
+SvelteKit App (reads from cache)
+```
+
+## Health Check
+
+The service exposes a `/health` endpoint that returns:
+- `status`: "healthy", "unhealthy", or "error"
+- `running`: Whether the scheduler is running
+- `last_refresh_time`: Timestamp of last cache refresh
+- `refresh_interval_minutes`: Refresh interval (10)
+- `cache_healthy`: Whether Redis connection is healthy
+
+## Development
+
+```bash
+# Install dependencies
+uv sync
+
+# Run locally
+uv run main.py
+```
+
+## Docker
+
+The service is built and run as part of the Docker Compose stack. See the main `compose.yaml` for configuration.
+
diff --git a/lessons-service/cache_manager.py b/lessons-service/cache_manager.py
new file mode 100644
index 0000000..af87f4e
--- /dev/null
+++ b/lessons-service/cache_manager.py
@@ -0,0 +1,77 @@
+"""Redis cache manager for lesson repositories and READMEs."""
+
+import os
+import json
+import logging
+import redis
+from typing import List, Dict, Any, Optional
+
+logger = logging.getLogger(__name__)
+
+
+class CacheManager:
+ """Manages Redis caching for lesson repositories and READMEs."""
+
+ def __init__(self, redis_url: str = None):
+ redis_url = redis_url or os.getenv("REDIS_URL", "redis://localhost:6379")
+ self.redis_client = redis.from_url(redis_url, decode_responses=True)
+ self.repos_key = "lessons:repos"
+ self.readme_prefix = "lessons:readme:"
+ # Use a 30-minute TTL to ride out short upstream outages
+ self.ttl = 30 * 60
+
+ def cache_repos(self, repos: List[Dict[str, Any]]) -> bool:
+ """Cache the list of lesson repositories."""
+ try:
+ data = json.dumps(repos)
+ self.redis_client.setex(self.repos_key, self.ttl, data)
+ logger.info(f"Cached {len(repos)} lesson repos with TTL {self.ttl}s")
+ return True
+ except Exception as e:
+ logger.error(f"Error caching repos: {e}")
+ return False
+
+ def get_repos(self) -> Optional[List[Dict[str, Any]]]:
+ """Retrieve cached lesson repositories."""
+ try:
+ data = self.redis_client.get(self.repos_key)
+ if data:
+ repos = json.loads(data)
+ logger.debug(f"Retrieved {len(repos)} repos from cache")
+ return repos
+ return None
+ except Exception as e:
+ logger.error(f"Error retrieving repos from cache: {e}")
+ return None
+
+ def cache_readme(self, repo_name: str, content: str) -> bool:
+ """Cache README content for a repository."""
+ try:
+ key = f"{self.readme_prefix}{repo_name}"
+ self.redis_client.setex(key, self.ttl, content)
+ logger.debug(f"Cached README for {repo_name}")
+ return True
+ except Exception as e:
+ logger.error(f"Error caching README for {repo_name}: {e}")
+ return False
+
+ def get_readme(self, repo_name: str) -> Optional[str]:
+ """Retrieve cached README content for a repository."""
+ try:
+ key = f"{self.readme_prefix}{repo_name}"
+ content = self.redis_client.get(key)
+ if content:
+ logger.debug(f"Retrieved README for {repo_name} from cache")
+ return content
+ return None
+ except Exception as e:
+ logger.error(f"Error retrieving README for {repo_name} from cache: {e}")
+ return None
+
+ def health_check(self) -> bool:
+ """Check if Redis connection is healthy."""
+ try:
+ return self.redis_client.ping()
+ except Exception as e:
+ logger.error(f"Redis health check failed: {e}")
+ return False
diff --git a/lessons-service/github_client.py b/lessons-service/github_client.py
new file mode 100644
index 0000000..7931dc7
--- /dev/null
+++ b/lessons-service/github_client.py
@@ -0,0 +1,201 @@
+"""GitHub API client using GraphQL for efficient batch fetching."""
+
+import logging
+from typing import List, Dict, Any, Optional
+import requests
+import time
+
+logger = logging.getLogger(__name__)
+
+
+class GitHubClient:
+ """Client for fetching lesson repositories from GitHub using GraphQL API."""
+
+ def __init__(self, token: str, org: str = "hacksu"):
+ self.token = token
+ self.org = org
+ self.base_url = "https://api.github.com/graphql"
+ self.headers = {
+ "Authorization": f"Bearer {token}",
+ "Content-Type": "application/json",
+ }
+
+ def _make_request(
+ self, query: str, variables: Optional[Dict[str, Any]] = None
+ ) -> Dict[str, Any]:
+ """Make a GraphQL request to GitHub API."""
+ payload = {"query": query}
+ if variables:
+ payload["variables"] = variables
+
+ try:
+ response = requests.post(
+ self.base_url,
+ json=payload,
+ headers=self.headers,
+ timeout=30,
+ )
+ response.raise_for_status()
+ data = response.json()
+
+ if "errors" in data:
+ error_messages = [
+ err.get("message", "Unknown error") for err in data["errors"]
+ ]
+ logger.error(f"GraphQL errors: {error_messages}")
+ raise Exception(f"GraphQL errors: {', '.join(error_messages)}")
+
+ return data.get("data", {})
+ except requests.exceptions.RequestException as e:
+ logger.error(f"GitHub API request failed: {e}")
+ raise
+
+ def fetch_lesson_repos(self) -> List[Dict[str, Any]]:
+ """
+ Fetch all repositories with the 'lesson' topic from the organization.
+ Uses GraphQL to efficiently batch fetch repos and topics in a single query.
+ """
+ logger.info(f"Fetching lesson repos from GitHub organization: {self.org}")
+
+ all_repos = []
+ cursor = None
+ has_next_page = True
+
+ while has_next_page:
+ query = """
+ query($org: String!, $cursor: String) {
+ organization(login: $org) {
+ repositories(
+ first: 100
+ after: $cursor
+ orderBy: {field: UPDATED_AT, direction: DESC}
+ ) {
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ nodes {
+ id
+ name
+ description
+ url
+ updatedAt
+ defaultBranchRef {
+ target {
+ ... on Commit {
+ committedDate
+ }
+ }
+ }
+ primaryLanguage {
+ name
+ }
+ repositoryTopics(first: 20) {
+ nodes {
+ topic {
+ name
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ variables = {"org": self.org}
+ if cursor:
+ variables["cursor"] = cursor
+
+ try:
+ data = self._make_request(query, variables)
+ org_data = data.get("organization", {})
+ repos_data = org_data.get("repositories", {})
+ page_info = repos_data.get("pageInfo", {})
+ nodes = repos_data.get("nodes", [])
+
+ # Filter repos that have the "lesson" topic
+ for repo in nodes:
+ topics_data = repo.get("repositoryTopics", {}).get("nodes", [])
+ topics = [
+ node["topic"]["name"]
+ for node in topics_data
+ if node.get("topic")
+ ]
+
+ if "lesson" in topics:
+ category_topics = [
+ t for t in topics if t != "lesson" and t != "hacksu"
+ ]
+ # Get last commit date from default branch
+ default_branch = repo.get("defaultBranchRef", {})
+ commit_target = default_branch.get("target", {}) if default_branch else {}
+ last_commit_date = commit_target.get("committedDate") if commit_target else repo["updatedAt"]
+
+ all_repos.append(
+ {
+ "id": repo["id"],
+ "name": repo["name"],
+ "description": repo.get("description"),
+ "html_url": repo["url"],
+ "updated_at": repo["updatedAt"],
+ "last_commit_date": last_commit_date,
+ "language": (
+ repo.get("primaryLanguage", {}).get("name")
+ if repo.get("primaryLanguage")
+ else None
+ ),
+ "topics": category_topics,
+ }
+ )
+ logger.debug(
+ f"Added lesson repo: {repo['name']} with topics: {category_topics}"
+ )
+
+ has_next_page = page_info.get("hasNextPage", False)
+ cursor = page_info.get("endCursor")
+
+ # Rate limiting: GitHub allows 5000 points per hour for authenticated requests
+ # Each query costs 1 point, so we can be generous, but let's be safe
+ if has_next_page:
+ time.sleep(0.1) # Small delay between pages
+
+ except Exception as e:
+ logger.error(f"Error fetching repos page: {e}")
+ raise
+
+ logger.info(f"Fetched {len(all_repos)} lesson repos from {self.org}")
+ return all_repos
+
+ def fetch_readme(self, repo_name: str) -> Optional[str]:
+ """
+ Fetch README content for a specific repository.
+ Falls back to REST API since GraphQL doesn't support file content easily.
+ """
+ url = f"https://api.github.com/repos/{self.org}/{repo_name}/readme"
+ headers = {
+ "Accept": "application/vnd.github.v3+json",
+ "Authorization": f"Bearer {self.token}",
+ "User-Agent": "HacKSU-Lessons-Service",
+ }
+
+ try:
+ response = requests.get(url, headers=headers, timeout=30)
+ if response.status_code == 404:
+ logger.debug(f"README not found for repo: {repo_name}")
+ return None
+ response.raise_for_status()
+ data = response.json()
+
+ if data.get("encoding") == "base64":
+ import base64
+
+ content = base64.b64decode(data["content"]).decode("utf-8")
+ logger.debug(f"Fetched README for {repo_name}, length: {len(content)}")
+ return content
+ else:
+ logger.warn(f"Unexpected encoding for README: {data.get('encoding')}")
+ return data.get("content")
+ except requests.exceptions.RequestException as e:
+ logger.error(f"Error fetching README for {repo_name}: {e}")
+ return None
diff --git a/lessons-service/main.py b/lessons-service/main.py
new file mode 100644
index 0000000..1cd8be0
--- /dev/null
+++ b/lessons-service/main.py
@@ -0,0 +1,66 @@
+"""Main entry point for the lessons service."""
+
+import os
+import logging
+from flask import Flask, jsonify
+from scheduler import LessonCacheScheduler
+
+# Configure logging
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
+)
+logger = logging.getLogger(__name__)
+
+# Initialize Flask app
+app = Flask(__name__)
+
+# Initialize scheduler
+scheduler = None
+
+
+@app.route("/health", methods=["GET"])
+def health():
+ """Health check endpoint."""
+ try:
+ status = scheduler.get_status() if scheduler else {"running": False}
+ cache_healthy = status.get("cache_healthy", False)
+ if cache_healthy:
+ return jsonify({"status": "healthy", **status}), 200
+ else:
+ return jsonify({"status": "unhealthy", **status}), 503
+ except Exception as e:
+ logger.error(f"Health check failed: {e}")
+ return jsonify({"status": "error", "error": str(e)}), 500
+
+
+def main():
+ """Main function to start the service."""
+ global scheduler
+
+ # Validate required environment variables
+ github_token = os.getenv("GITHUB_TOKEN")
+ if not github_token:
+ logger.error("GITHUB_TOKEN environment variable is required")
+ raise ValueError("GITHUB_TOKEN environment variable is required")
+
+ redis_url = os.getenv("REDIS_URL", "redis://localhost:6379")
+ logger.info(f"Connecting to Redis at {redis_url}")
+
+ # Initialize and start scheduler
+ try:
+ scheduler = LessonCacheScheduler()
+ scheduler.start()
+ logger.info("Lessons service started successfully")
+ except Exception as e:
+ logger.error(f"Failed to start scheduler: {e}", exc_info=True)
+ raise
+
+ # Start Flask server for health checks
+ port = int(os.getenv("PORT", "8080"))
+ logger.info(f"Starting Flask server on port {port}")
+ app.run(host="0.0.0.0", port=port, debug=False)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/lessons-service/pyproject.toml b/lessons-service/pyproject.toml
new file mode 100644
index 0000000..4671be7
--- /dev/null
+++ b/lessons-service/pyproject.toml
@@ -0,0 +1,12 @@
+[project]
+name = "lessons-service"
+version = "0.1.0"
+description = "Add your description here"
+readme = "README.md"
+requires-python = ">=3.14"
+dependencies = [
+ "flask>=3.1.2",
+ "redis>=7.1.0",
+ "requests>=2.32.5",
+ "schedule>=1.2.2",
+]
diff --git a/lessons-service/scheduler.py b/lessons-service/scheduler.py
new file mode 100644
index 0000000..7bbe6a9
--- /dev/null
+++ b/lessons-service/scheduler.py
@@ -0,0 +1,98 @@
+"""Scheduler for refreshing lesson repository cache."""
+
+import os
+import logging
+import schedule
+import time
+import threading
+from github_client import GitHubClient
+from cache_manager import CacheManager
+
+logger = logging.getLogger(__name__)
+
+
+class LessonCacheScheduler:
+ """Schedules periodic refresh of lesson repository cache."""
+
+ def __init__(self):
+ github_token = os.getenv("GITHUB_TOKEN")
+ github_org = os.getenv("GITHUB_ORG", "hacksu")
+
+ if not github_token:
+ raise ValueError("GITHUB_TOKEN environment variable is required")
+
+ self.github_client = GitHubClient(github_token, github_org)
+ self.cache_manager = CacheManager()
+ self.last_refresh_time = None
+ self.refresh_interval = 10
+ self._running = False
+ self._thread = None
+
+ def refresh_cache(self):
+ """Fetch repos from GitHub and update cache."""
+ try:
+ logger.info("Starting cache refresh...")
+ repos = self.github_client.fetch_lesson_repos()
+ success = self.cache_manager.cache_repos(repos)
+ if success:
+ self.last_refresh_time = time.time()
+ logger.info(
+ f"Cache refresh completed successfully. Cached {len(repos)} repos."
+ )
+ else:
+ logger.error("Cache refresh failed: could not write to cache")
+ except Exception as e:
+ logger.error(f"Cache refresh failed: {e}", exc_info=True)
+ # Serve stale: if existing cache is present, extend its TTL so we don't go empty
+ stale = self.cache_manager.get_repos()
+ if stale:
+ self.cache_manager.cache_repos(stale)
+ logger.warning(
+ "GitHub fetch failed; serving stale lesson cache and extended its TTL",
+ exc_info=False,
+ )
+ else:
+ logger.warning("GitHub fetch failed and no stale cache is available")
+
+ def start(self):
+ """Start the scheduler in a background thread."""
+ if self._running:
+ logger.warn("Scheduler is already running")
+ return
+
+ # Run immediately on startup
+ logger.info("Running initial cache refresh...")
+ self.refresh_cache()
+
+ # Schedule periodic refreshes
+ schedule.every(self.refresh_interval).minutes.do(
+ self.refresh_cache
+ ) # I love this syntax
+ logger.info(f"Scheduled cache refresh every {self.refresh_interval} minutes")
+
+ self._running = True
+
+ def run_scheduler():
+ while self._running:
+ schedule.run_pending()
+ time.sleep(1)
+
+ self._thread = threading.Thread(target=run_scheduler, daemon=True)
+ self._thread.start()
+ logger.info("Scheduler started")
+
+ def stop(self):
+ """Stop the scheduler."""
+ self._running = False
+ if self._thread:
+ self._thread.join(timeout=5)
+ logger.info("Scheduler stopped")
+
+ def get_status(self) -> dict:
+ """Get scheduler status."""
+ return {
+ "running": self._running,
+ "last_refresh_time": self.last_refresh_time,
+ "refresh_interval_minutes": self.refresh_interval,
+ "cache_healthy": self.cache_manager.health_check(),
+ }
diff --git a/lessons-service/uv.lock b/lessons-service/uv.lock
new file mode 100644
index 0000000..f3967a7
--- /dev/null
+++ b/lessons-service/uv.lock
@@ -0,0 +1,217 @@
+version = 1
+revision = 3
+requires-python = ">=3.14"
+
+[[package]]
+name = "blinker"
+version = "1.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2025.11.12"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
+ { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
+ { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
+ { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
+ { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
+ { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
+ { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
+ { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
+ { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
+ { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
+]
+
+[[package]]
+name = "click"
+version = "8.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "flask"
+version = "3.1.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "blinker" },
+ { name = "click" },
+ { name = "itsdangerous" },
+ { name = "jinja2" },
+ { name = "markupsafe" },
+ { name = "werkzeug" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.11"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
+]
+
+[[package]]
+name = "itsdangerous"
+version = "2.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
+]
+
+[[package]]
+name = "lessons-service"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+ { name = "flask" },
+ { name = "redis" },
+ { name = "requests" },
+ { name = "schedule" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "flask", specifier = ">=3.1.2" },
+ { name = "redis", specifier = ">=7.1.0" },
+ { name = "requests", specifier = ">=2.32.5" },
+ { name = "schedule", specifier = ">=1.2.2" },
+]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
+ { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
+ { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
+ { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
+ { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
+ { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
+ { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
+ { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
+ { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
+]
+
+[[package]]
+name = "redis"
+version = "7.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" },
+]
+
+[[package]]
+name = "requests"
+version = "2.32.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
+]
+
+[[package]]
+name = "schedule"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0c/91/b525790063015759f34447d4cf9d2ccb52cdee0f1dd6ff8764e863bcb74c/schedule-1.2.2.tar.gz", hash = "sha256:15fe9c75fe5fd9b9627f3f19cc0ef1420508f9f9a46f45cd0769ef75ede5f0b7", size = 26452, upload-time = "2024-06-18T20:03:14.633Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/20/a7/84c96b61fd13205f2cafbe263cdb2745965974bdf3e0078f121dfeca5f02/schedule-1.2.2-py3-none-any.whl", hash = "sha256:5bef4a2a0183abf44046ae0d164cadcac21b1db011bdd8102e4a0c1e91e06a7d", size = 12220, upload-time = "2024-05-25T18:41:59.121Z" },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1c/43/554c2569b62f49350597348fc3ac70f786e3c32e7f19d266e19817812dd3/urllib3-2.6.0.tar.gz", hash = "sha256:cb9bcef5a4b345d5da5d145dc3e30834f58e8018828cbc724d30b4cb7d4d49f1", size = 432585, upload-time = "2025-12-05T15:08:47.885Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/56/1a/9ffe814d317c5224166b23e7c47f606d6e473712a2fad0f704ea9b99f246/urllib3-2.6.0-py3-none-any.whl", hash = "sha256:c90f7a39f716c572c4e3e58509581ebd83f9b59cced005b7db7ad2d22b0db99f", size = 131083, upload-time = "2025-12-05T15:08:45.983Z" },
+]
+
+[[package]]
+name = "werkzeug"
+version = "3.1.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/45/ea/b0f8eeb287f8df9066e56e831c7824ac6bab645dd6c7a8f4b2d767944f9b/werkzeug-3.1.4.tar.gz", hash = "sha256:cd3cd98b1b92dc3b7b3995038826c68097dcb16f9baa63abe35f20eafeb9fe5e", size = 864687, upload-time = "2025-11-29T02:15:22.841Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2f/f9/9e082990c2585c744734f85bec79b5dae5df9c974ffee58fe421652c8e91/werkzeug-3.1.4-py3-none-any.whl", hash = "sha256:2ad50fb9ed09cc3af22c54698351027ace879a0b60a3b5edf5730b2f7d876905", size = 224960, upload-time = "2025-11-29T02:15:21.13Z" },
+]
diff --git a/package.json b/package.json
index 95631e5..d5ff8d5 100644
--- a/package.json
+++ b/package.json
@@ -59,6 +59,7 @@
"jsdom": "^27.2.0",
"lucia": "^3.2.2",
"marked": "^17.0.1",
- "postgres": "^3.4.7"
+ "postgres": "^3.4.7",
+ "redis": "^5.10.0"
}
}
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'}
-
- {:else if link.name === 'GitHub'}
-
- {:else if link.name === 'Discord'}
-
- {/if}
+
{/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 @@
+
+
+ {lesson.description} +
+ {/if} + +Loading lessons...
+ + +Manage icon mappings for lesson categories
+{error}
++ Please check that the GITHUB_TOKEN environment variable is set correctly. +
++ {#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} + +No lessons found matching "{searchQuery}"
+ {:else} +{error}
++ Please check that the GITHUB_TOKEN environment variable is set correctly. +
++ {#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} + +No lessons found matching "{searchQuery}"
+ {:else} +{error}
+ +Loading lesson content...
+