feat: implement content management dashboard and update proxy configurations to support AI-assisted learning material generation
This commit is contained in:
63
AI_AGENT.md
63
AI_AGENT.md
@@ -3,30 +3,43 @@
|
|||||||
Welcome, fellow AI agent! If you are reading this, you are tasked with maintaining or extending the Respellion Learning Platform. This document provides the critical context, architectural patterns, and design standards you need to successfully work on this codebase.
|
Welcome, fellow AI agent! If you are reading this, you are tasked with maintaining or extending the Respellion Learning Platform. This document provides the critical context, architectural patterns, and design standards you need to successfully work on this codebase.
|
||||||
|
|
||||||
## 1. Architectural Overview
|
## 1. Architectural Overview
|
||||||
This is a single-page React application built with **Vite**. It is completely self-contained and currently runs without a dedicated backend database.
|
This is a single-page React application built with **Vite**, backed by **PocketBase** as the database and auth layer.
|
||||||
* **Frontend:** React, React Router, Tailwind CSS.
|
* **Frontend:** React, React Router, Tailwind CSS.
|
||||||
|
* **Backend:** PocketBase (self-hosted). All data is stored in PocketBase collections, not localStorage.
|
||||||
* **Animations:** Framer Motion (used extensively for page transitions, podium effects, and gamification feedback).
|
* **Animations:** Framer Motion (used extensively for page transitions, podium effects, and gamification feedback).
|
||||||
* **Icons:** Lucide React.
|
* **Icons:** Lucide React.
|
||||||
* **Visualizations:** D3.js (used strictly for the Admin Knowledge Graph).
|
* **Visualizations:** D3.js (used strictly for the Admin Knowledge Graph).
|
||||||
|
|
||||||
## 2. State Management & Storage (Critical)
|
## 2. State Management & Storage (Critical)
|
||||||
There is **no backend database**. All data is persisted locally in the browser using a custom wrapper around `window.localStorage` located at `src/lib/storage.js`.
|
All persistent data lives in **PocketBase**. The data access layer is in `src/lib/db.js`, which wraps the PocketBase SDK client from `src/lib/pb.js`.
|
||||||
|
|
||||||
**Namespacing Convention:**
|
**PocketBase Collections:**
|
||||||
You must strictly adhere to these prefixes when reading/writing to `storage.js`:
|
* `topics` — Knowledge graph nodes (`id`, `label`, `type`, `description`).
|
||||||
* `kb:*` - Knowledge base data (e.g., `kb:topics`, `kb:relations`, `kb:content:{topicId}`).
|
* `relations` — Knowledge graph edges (`source`, `target`, `type`).
|
||||||
* `admin:*` - Admin settings (e.g., `admin:anthropic_key`, `admin:sources`, `admin:use_simulation`).
|
* `content` — AI-generated learning modules per topic (`topic_id`, `data`).
|
||||||
* `user:{id}:*` - Specific user progress (e.g., `user:{id}:week:{n}:learn_done`).
|
* `quiz_banks` — Banks of AI-generated questions per topic (`topic_id`, `questions[]`).
|
||||||
* `quiz:bank:{topicId}` - Cached banks of AI-generated multiple-choice questions.
|
* `quiz_results` — User test scores (`user_id`, `week_number`, `score`, `total`, `percentage`, etc.).
|
||||||
* `quiz:result:{userId}:week:{n}` - The exact score and payload of a user's weekly test.
|
* `quiz_cache` — Cached weekly quiz for a user (`user_id`, `week_number`, `questions[]`, `meta`).
|
||||||
* `leaderboard:current` - The active points ledger. Array of objects: `{ userId, name, points, testsCompleted, perfectScores }`.
|
* `learn_progress` — Whether a user completed the weekly learning session (`user_id`, `week_number`, `done`).
|
||||||
* `users:registry` - Array of registered users, including admins.
|
* `leaderboard` — Points ledger (`user_id`, `name`, `points`, `tests_completed`).
|
||||||
|
* `team_members` — Registered users with PIN auth (`name`, `role`, `pin`).
|
||||||
|
* `sources` — Uploaded source documents and their extraction status (`name`, `status`, `error`).
|
||||||
|
* `settings` — Key/value store for app-wide settings (`key`, `value`).
|
||||||
|
|
||||||
|
**localStorage** is only used for **admin browser settings** (not user data):
|
||||||
|
* `respellion:admin:anthropic_key` — Anthropic API key.
|
||||||
|
* `respellion:admin:model` — Model override.
|
||||||
|
* `respellion:admin:use_simulation` — Simulation mode toggle.
|
||||||
|
|
||||||
|
**Session:** User login is PIN-based. The logged-in user's ID is stored in `sessionStorage` under `respellion_session` and resolved against `team_members` on app load (see `src/store/AppContext.jsx`).
|
||||||
|
|
||||||
|
**Important:** All `db.js` functions are `async`. Always `await` them — omitting `await` will silently pass a Promise where data is expected.
|
||||||
|
|
||||||
## 3. The AI Integration (Anthropic)
|
## 3. The AI Integration (Anthropic)
|
||||||
The application acts as a proxy to the Anthropic API (`claude-sonnet-4`).
|
The application calls the Anthropic API via a proxy to avoid CORS issues.
|
||||||
* **Location:** `src/lib/api.js`.
|
* **Location:** `src/lib/api.js`.
|
||||||
* **Proxy:** In Docker, `/api/anthropic` is proxied via Nginx to bypass CORS restrictions. If the user reports CORS errors during local dev, ensure the Vite proxy in `vite.config.js` matches the Nginx spec.
|
* **Proxy:** In Docker, `/api/anthropic` is proxied via Nginx to the Anthropic API endpoint. In local dev, configure `vite.config.js` to proxy the same path.
|
||||||
* **JSON Enforcement:** Prompts *must* strictly enforce that Claude returns *only* raw JSON. Do not let the AI wrap the response in markdown blocks (`\`\`\``) unless you build a regex to strip them (which is currently implemented via `.match(/\{[\s\S]*\}/)`).
|
* **JSON Enforcement:** Prompts *must* strictly enforce that Claude returns *only* raw JSON. Do not let the AI wrap the response in markdown blocks — a regex strip via `.match(/\{[\s\S]*\}/)` is already applied in all service files.
|
||||||
|
|
||||||
## 4. Design System & Aesthetics
|
## 4. Design System & Aesthetics
|
||||||
Respellion relies on a premium, modern aesthetic.
|
Respellion relies on a premium, modern aesthetic.
|
||||||
@@ -38,21 +51,23 @@ Respellion relies on a premium, modern aesthetic.
|
|||||||
|
|
||||||
## 5. Gamification Rules
|
## 5. Gamification Rules
|
||||||
If you are extending the Gamification system (`src/pages/Leaderboard.jsx`):
|
If you are extending the Gamification system (`src/pages/Leaderboard.jsx`):
|
||||||
* Tests grant **+2 points** per correct answer.
|
* Tests grant **+2 points** per correct answer (handled in `testService.js → saveTestResult`).
|
||||||
* A 100% score grants the **Perfectionist** badge.
|
* A 100% score grants the **Perfectionist** badge (computed at render time in `Leaderboard.jsx`).
|
||||||
* Completing 1 test grants **First Steps**, completing 5 grants **Veteran**.
|
* Completing 1 test grants **First Steps**, completing 5 grants **Veteran**.
|
||||||
* Do not reset points dynamically. Read from `leaderboard:current`, mutate, and save back immediately.
|
* Points are accumulated in the `leaderboard` collection via `db.upsertLeaderboardEntry`.
|
||||||
|
|
||||||
## 6. Docker & Deployment
|
## 6. Docker & Deployment
|
||||||
The app is fully containerized.
|
The app is fully containerized. PocketBase runs as a sidecar service.
|
||||||
* **Build:** `docker build -t respellion-app:latest .`
|
* **Build:** `docker build -t respellion-app:latest .`
|
||||||
* **Run:** `docker run -d -p 8080:80 --name respellion respellion-app:latest`
|
* **Run:** `docker compose up -d` (see `docker-compose.yml`).
|
||||||
* **Nginx:** Ensure any new frontend routes are caught by the Nginx `try_files $uri $uri/ /index.html;` directive defined in `nginx.conf` to prevent 404s on page refresh.
|
* **Nginx:** Ensures any frontend route not matching a static file falls back to `index.html` (SPA support). The `/api/anthropic` path is proxied to the Anthropic API. The `/pb` path is proxied to the PocketBase service.
|
||||||
|
* **PocketBase URL:** Resolved from `VITE_PB_URL` env var, or falls back to `window.location.origin + '/pb'` (see `src/lib/pb.js`).
|
||||||
|
|
||||||
## 7. How to Add New Features
|
## 7. How to Add New Features
|
||||||
1. **Define State:** Decide where the data lives in `localStorage`.
|
1. **Define Schema:** Add a new PocketBase collection via the Admin UI or the init script (`scripts/setup-pb-collections.mjs`).
|
||||||
2. **Build UI:** Create components using `Card`, `Button`, and `framer-motion` for smooth entry (`animate-in fade-in slide-in-from-bottom-4`).
|
2. **Add DB Helpers:** Add async CRUD functions in `src/lib/db.js`.
|
||||||
3. **Wire Logic:** Connect to `src/store/AppContext.jsx` if it requires global user context, otherwise manage locally and persist to `storage.js`.
|
3. **Build UI:** Create components using `Card`, `Button`, and `framer-motion` for smooth entry (`animate-in fade-in slide-in-from-bottom-4`).
|
||||||
4. **Test:** Run the Docker container to ensure no build errors exist.
|
4. **Wire Logic:** Connect to `src/store/AppContext.jsx` if it requires global user context, otherwise manage state locally.
|
||||||
|
5. **Test:** Run the Docker stack (`docker compose up`) to ensure no build errors exist.
|
||||||
|
|
||||||
Good luck! You are building a platform that empowers continuous learning. Keep it fast, keep it beautiful, and keep the user engaged.
|
Good luck! You are building a platform that empowers continuous learning. Keep it fast, keep it beautiful, and keep the user engaged.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
X-Content-Type-Options "nosniff"
|
X-Content-Type-Options "nosniff"
|
||||||
X-XSS-Protection "1; mode=block"
|
X-XSS-Protection "1; mode=block"
|
||||||
Referrer-Policy "strict-origin-when-cross-origin"
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'"
|
Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com"
|
||||||
Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,11 +31,18 @@ services:
|
|||||||
CLEAN_DOMAIN=$(printf '%s' "$DOMAIN_NAME" | tr -d ';')
|
CLEAN_DOMAIN=$(printf '%s' "$DOMAIN_NAME" | tr -d ';')
|
||||||
cat > /etc/caddy/Caddyfile <<EOF
|
cat > /etc/caddy/Caddyfile <<EOF
|
||||||
$${CLEAN_DOMAIN} {
|
$${CLEAN_DOMAIN} {
|
||||||
handle /pb/* {
|
handle /api/anthropic/* {
|
||||||
uri strip_prefix /pb
|
reverse_proxy frontend:80
|
||||||
|
}
|
||||||
|
handle /api/* {
|
||||||
reverse_proxy pocketbase:8090
|
reverse_proxy pocketbase:8090
|
||||||
}
|
}
|
||||||
reverse_proxy /* frontend:80
|
handle /_/* {
|
||||||
|
reverse_proxy pocketbase:8090
|
||||||
|
}
|
||||||
|
handle {
|
||||||
|
reverse_proxy frontend:80
|
||||||
|
}
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
|
caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ const ContentManager = () => {
|
|||||||
const [actionState, setActionState] = useState({}); // { [topicId]: { loading, error, success } }
|
const [actionState, setActionState] = useState({}); // { [topicId]: { loading, error, success } }
|
||||||
const [refineText, setRefineText] = useState('');
|
const [refineText, setRefineText] = useState('');
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = async () => {
|
||||||
const fresh = getAllGeneratedContent();
|
const fresh = await getAllGeneratedContent();
|
||||||
setItems(fresh);
|
setItems(fresh);
|
||||||
// Keep detail view in sync if its topic was updated
|
// Keep detail view in sync if its topic was updated
|
||||||
if (selected) {
|
if (selected) {
|
||||||
@@ -56,8 +56,8 @@ const ContentManager = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (topicId) => {
|
const handleDelete = async (topicId) => {
|
||||||
deleteCachedContent(topicId);
|
await deleteCachedContent(topicId);
|
||||||
if (selected?.topic.id === topicId) setSelected(null);
|
if (selected?.topic.id === topicId) setSelected(null);
|
||||||
setActionState(prev => { const n = { ...prev }; delete n[topicId]; return n; });
|
setActionState(prev => { const n = { ...prev }; delete n[topicId]; return n; });
|
||||||
refresh();
|
refresh();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import PocketBase from 'pocketbase';
|
import PocketBase from 'pocketbase';
|
||||||
|
|
||||||
const pbUrl = import.meta.env.VITE_PB_URL ||
|
const pbUrl = import.meta.env.VITE_PB_URL ||
|
||||||
(typeof window !== 'undefined' ? window.location.origin + '/pb' : 'http://localhost:8090');
|
(typeof window !== 'undefined' ? window.location.origin : 'http://localhost:8090');
|
||||||
|
|
||||||
export const pb = new PocketBase(pbUrl);
|
export const pb = new PocketBase(pbUrl);
|
||||||
|
|||||||
@@ -53,14 +53,14 @@ const Leren = () => {
|
|||||||
}
|
}
|
||||||
}, [state.currentUser, state.weekNumber]);
|
}, [state.currentUser, state.weekNumber]);
|
||||||
|
|
||||||
const handleOpenTopic = (topic) => {
|
const handleOpenTopic = async (topic) => {
|
||||||
setActiveTopic(topic);
|
setActiveTopic(topic);
|
||||||
setView('detail');
|
setView('detail');
|
||||||
setSessionDone(false);
|
setSessionDone(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
setFeedbackText('');
|
setFeedbackText('');
|
||||||
setFeedbackPrompted(false);
|
setFeedbackPrompted(false);
|
||||||
const cached = getCachedContent(topic.id);
|
const cached = await getCachedContent(topic.id);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
setContent(cached);
|
setContent(cached);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -37,11 +37,14 @@ const Testen = () => {
|
|||||||
// ── Check for existing result ──
|
// ── Check for existing result ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentUser) {
|
if (currentUser) {
|
||||||
const existing = getTestResult(currentUser.id, weekNumber);
|
const check = async () => {
|
||||||
|
const existing = await getTestResult(currentUser.id, weekNumber);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
setResult(existing);
|
setResult(existing);
|
||||||
setPhase('results');
|
setPhase('results');
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
check();
|
||||||
}
|
}
|
||||||
}, [currentUser, weekNumber]);
|
}, [currentUser, weekNumber]);
|
||||||
|
|
||||||
@@ -98,7 +101,7 @@ const Testen = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Finish quiz & score ──
|
// ── Finish quiz & score ──
|
||||||
const finishQuiz = useCallback(() => {
|
const finishQuiz = useCallback(async () => {
|
||||||
clearInterval(timerRef.current);
|
clearInterval(timerRef.current);
|
||||||
if (!quiz) return;
|
if (!quiz) return;
|
||||||
|
|
||||||
@@ -129,7 +132,7 @@ const Testen = () => {
|
|||||||
breakdown,
|
breakdown,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { pointsEarned } = saveTestResult(currentUser.id, weekNumber, testResult);
|
const { pointsEarned } = await saveTestResult(currentUser.id, weekNumber, testResult);
|
||||||
testResult.pointsEarned = pointsEarned;
|
testResult.pointsEarned = pointsEarned;
|
||||||
setResult(testResult);
|
setResult(testResult);
|
||||||
setPhase('results');
|
setPhase('results');
|
||||||
|
|||||||
@@ -38,8 +38,13 @@ export function AppProvider({ children }) {
|
|||||||
let members = await db.getTeamMembers();
|
let members = await db.getTeamMembers();
|
||||||
|
|
||||||
if (!members || members.length === 0) {
|
if (!members || members.length === 0) {
|
||||||
|
try {
|
||||||
const created = await db.addTeamMember({ name: 'Admin', role: 'admin', pin: '0000' });
|
const created = await db.addTeamMember({ name: 'Admin', role: 'admin', pin: '0000' });
|
||||||
members = [created];
|
members = [created];
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[AppContext] Could not auto-create admin user:', e.message,
|
||||||
|
'— Run scripts/setup-pb-collections.mjs to configure the database.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const storedWeek = Number(await db.getSetting('admin:current_week', 1));
|
const storedWeek = Number(await db.getSetting('admin:current_week', 1));
|
||||||
|
|||||||
Reference in New Issue
Block a user