Contributing to BrainMapRevision - Architecture, setup, and best practices
Welcome to the BrainMapRevision developer guide! This document will help you understand the project structure, set up your development environment, and contribute effectively.
# 1. Fork the repository on GitHub
# 2. Clone your fork
git clone https://github.com/YOUR-USERNAME/BrainMapRevision.git
# 3. Navigate to project directory
cd BrainMapRevision
# 4. Create a new branch for your feature
git checkout -b feature/your-feature-name
# 5. Open in your editor
code .
assets/js/supabase-config.js:const SUPABASE_URL = 'https://your-project.supabase.co';
const SUPABASE_ANON_KEY = 'your-anon-key-here';
const supabase = window.supabase.createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
Since this is a static site, you can use any local server:
# Using Python 3
python -m http.server 8000
# Using Node.js (install live-server globally)
npm install -g live-server
live-server
# Using VS Code Live Server extension
# Right-click index.html → "Open with Live Server"
Navigate to http://localhost:8000 in your browser.
BrainMapRevision/
├── index.html # Homepage
├── style.css # Global styles
├── script.js # Global scripts
├── pages/ # All feature pages
│ ├── subjects.html
│ ├── past-papers.html
│ ├── revision-boards.html
│ ├── quizzes.html
│ └── ...
├── subjects/ # Subject-specific pages
├── key-topics/ # Topic deep-dives
├── exam-boards/ # Exam board pages
├── auth/ # Authentication pages
├── assets/
│ ├── css/ # Component-specific CSS
│ ├── js/ # JavaScript modules
│ └── images/ # Images and icons
├── supabase/
│ ├── schema.sql # Database schema
│ ├── seed.sql # Initial data
│ └── functions/ # Database functions
├── docs/ # Documentation
├── legal/ # Legal pages
└── markdown/ # Project documentation
Styles are organized as follows:
style.css - Global styles, variables, componentsassets/css/[feature].css - Feature-specific stylesassets/css/themes/ - Purchasable themesUse CSS custom properties defined in :root for consistent theming:
--bg-primary, --bg-secondary--text-primary, --text-secondary--accent-primary, --accent-secondary--border-color, --card-bgJavaScript is modular and organized by feature:
script.js - Global functionality (theme toggle, navigation)assets/js/database.js - Database helper functionsassets/js/auth.js - Authentication logicassets/js/quiz-engine.js - Quiz functionalityassets/js/xp-system.js - XP calculationsReusable components follow this pattern:
// Component: Quiz Card
class QuizCard {
constructor(data) {
this.question = data.question;
this.options = data.options;
this.correctAnswer = data.correctAnswer;
}
render() {
return `
<div class="quiz-card">
<h3>${this.question}</h3>
<div class="options">
${this.renderOptions()}
</div>
</div>
`;
}
renderOptions() {
return this.options
.map(opt => `<button class="option">${opt}</button>`)
.join('');
}
}
// Usage
const quiz = new QuizCard({
question: "What is the capital of France?",
options: ["London", "Paris", "Berlin", "Madrid"],
correctAnswer: 1
});
document.getElementById('quiz-container').innerHTML = quiz.render();
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email VARCHAR(255) UNIQUE NOT NULL,
username VARCHAR(50) UNIQUE NOT NULL,
role VARCHAR(20) DEFAULT 'student', -- 'student' or 'teacher'
year_group VARCHAR(20),
xp INTEGER DEFAULT 0,
level INTEGER DEFAULT 1,
brain_coins INTEGER DEFAULT 0,
hint_tokens INTEGER DEFAULT 5,
study_streak INTEGER DEFAULT 0,
last_login TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE subjects (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
icon VARCHAR(50),
description TEXT,
year_groups TEXT[] -- ['Year 7-9', 'Year 10-11']
);
CREATE TABLE past_paper_questions (
id SERIAL PRIMARY KEY,
subject_id INTEGER REFERENCES subjects(id),
exam_board VARCHAR(50) NOT NULL, -- 'AQA', 'Edexcel', etc.
year INTEGER NOT NULL,
paper_number VARCHAR(20),
question_number VARCHAR(20),
marks INTEGER,
question_text TEXT NOT NULL,
mark_scheme TEXT,
examiner_comments TEXT,
revision_guide_id INTEGER REFERENCES revision_guides(id),
difficulty VARCHAR(20), -- 'Foundation' or 'Higher'
topics TEXT[], -- ['Algebra', 'Equations']
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE revision_guides (
id SERIAL PRIMARY KEY,
subject_id INTEGER REFERENCES subjects(id),
topic VARCHAR(200) NOT NULL,
title VARCHAR(200) NOT NULL,
content TEXT NOT NULL, -- Markdown format
key_concepts TEXT[],
worked_examples TEXT,
common_mistakes TEXT,
exam_tips TEXT,
author_id UUID REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE user_progress (
id SERIAL PRIMARY KEY,
user_id UUID REFERENCES users(id),
subject_id INTEGER REFERENCES subjects(id),
topic VARCHAR(200),
questions_attempted INTEGER DEFAULT 0,
questions_correct INTEGER DEFAULT 0,
mastery_percentage DECIMAL(5,2) DEFAULT 0.00,
last_studied TIMESTAMP,
total_study_time INTEGER DEFAULT 0, -- in minutes
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, subject_id, topic)
);
CREATE TABLE quizzes (
id SERIAL PRIMARY KEY,
user_id UUID REFERENCES users(id),
subject_id INTEGER REFERENCES subjects(id),
score INTEGER,
total_questions INTEGER,
xp_earned INTEGER,
coins_earned INTEGER,
hints_used INTEGER DEFAULT 0,
completion_time INTEGER, -- in seconds
completed_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE revision_boards (
id SERIAL PRIMARY KEY,
user_id UUID REFERENCES users(id),
title VARCHAR(200) NOT NULL,
description TEXT,
subject_id INTEGER REFERENCES subjects(id),
content JSONB, -- Flexible content structure
is_public BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE shop_items (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
description TEXT,
category VARCHAR(50), -- 'theme', 'hint_pack', 'power_up', 'cosmetic'
price INTEGER NOT NULL, -- in Brain Coins
min_level INTEGER DEFAULT 1,
image_url VARCHAR(255),
properties JSONB -- Additional item data
);
CREATE TABLE user_inventory (
id SERIAL PRIMARY KEY,
user_id UUID REFERENCES users(id),
item_id INTEGER REFERENCES shop_items(id),
purchased_at TIMESTAMP DEFAULT NOW(),
is_equipped BOOLEAN DEFAULT FALSE,
UNIQUE(user_id, item_id)
);
CREATE TABLE classrooms (
id SERIAL PRIMARY KEY,
teacher_id UUID REFERENCES users(id),
name VARCHAR(200) NOT NULL,
subject_id INTEGER REFERENCES subjects(id),
year_group VARCHAR(20),
invite_code VARCHAR(10) UNIQUE,
created_at TIMESTAMP DEFAULT NOW()
);
Common database operations are wrapped in functions for consistency:
-- Increment XP and handle level-ups
CREATE OR REPLACE FUNCTION increment_xp(
user_uuid UUID,
xp_amount INTEGER
) RETURNS TABLE(new_xp INTEGER, new_level INTEGER, leveled_up BOOLEAN) AS $$
DECLARE
current_xp INTEGER;
current_level INTEGER;
new_total_xp INTEGER;
calculated_level INTEGER;
BEGIN
SELECT xp, level INTO current_xp, current_level
FROM users WHERE id = user_uuid;
new_total_xp := current_xp + xp_amount;
calculated_level := calculate_level(new_total_xp);
UPDATE users
SET xp = new_total_xp, level = calculated_level
WHERE id = user_uuid;
RETURN QUERY
SELECT new_total_xp, calculated_level, (calculated_level > current_level);
END;
$$ LANGUAGE plpgsql;
BrainMapRevision uses Supabase's built-in authentication:
// assets/js/auth.js
// Sign up
async function signUp(email, password, username, role) {
try {
const { data, error } = await supabase.auth.signUp({
email: email,
password: password,
options: {
data: {
username: username,
role: role
}
}
});
if (error) throw error;
// Create user profile
await createUserProfile(data.user.id, username, role);
return { success: true, user: data.user };
} catch (error) {
return { success: false, error: error.message };
}
}
// Sign in
async function signIn(email, password) {
try {
const { data, error } = await supabase.auth.signInWithPassword({
email: email,
password: password
});
if (error) throw error;
// Update last login
await updateLastLogin(data.user.id);
return { success: true, user: data.user };
} catch (error) {
return { success: false, error: error.message };
}
}
// Sign out
async function signOut() {
const { error } = await supabase.auth.signOut();
if (!error) {
window.location.href = '/index.html';
}
}
// Get current user
async function getCurrentUser() {
const { data: { user } } = await supabase.auth.getUser();
return user;
}
// Check if user is authenticated
async function isAuthenticated() {
const user = await getCurrentUser();
return user !== null;
}
Pages that require authentication should check on load:
// At the top of protected pages
document.addEventListener('DOMContentLoaded', async () => {
const authenticated = await isAuthenticated();
if (!authenticated) {
window.location.href = '/auth/login.html';
return;
}
// Load page content
loadPageContent();
});
Enable RLS on all tables and create policies:
-- Enable RLS
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE revision_boards ENABLE ROW LEVEL SECURITY;
-- Users can only see/edit their own data
CREATE POLICY "Users can view own profile"
ON users FOR SELECT
USING (auth.uid() = id);
CREATE POLICY "Users can update own profile"
ON users FOR UPDATE
USING (auth.uid() = id);
-- Revision boards: users can CRUD their own, view public ones
CREATE POLICY "Users can create own boards"
ON revision_boards FOR INSERT
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can view own and public boards"
ON revision_boards FOR SELECT
USING (auth.uid() = user_id OR is_public = true);
git checkout -b feature/feature-nameassets/css/assets/js/<div class="quiz-container">
<div class="quiz-header">
<h1>Mathematics Quiz</h1>
<div class="quiz-progress">
<span>Question <span id="current-question">1</span> of <span id="total-questions">10</span></span>
<div class="progress-bar">
<div class="progress-fill" id="progress-fill"></div>
</div>
</div>
</div>
<div id="quiz-content">
<!-- Questions loaded dynamically -->
</div>
<div class="quiz-actions">
<button id="hint-btn" class="btn btn-secondary">Use Hint (-1 token)</button>
<button id="submit-btn" class="btn btn-primary">Submit Answer</button>
</div>
</div>
.quiz-container {
max-width: 800px;
margin: 40px auto;
padding: 40px;
background: var(--card-bg);
border-radius: 20px;
border: 1px solid var(--border-color);
}
.quiz-progress {
margin-top: 20px;
}
.progress-bar {
width: 100%;
height: 8px;
background: var(--border-color);
border-radius: 10px;
overflow: hidden;
margin-top: 10px;
}
.progress-fill {
height: 100%;
background: var(--accent-gradient);
width: 10%; /* Updated via JS */
transition: width 0.3s ease;
}
.quiz-question {
font-size: 1.3rem;
margin: 30px 0;
}
.quiz-options {
display: grid;
gap: 15px;
}
.option-btn {
padding: 15px 20px;
background: var(--card-bg);
border: 2px solid var(--border-color);
border-radius: 10px;
cursor: pointer;
transition: var(--transition);
}
.option-btn:hover {
border-color: var(--accent-primary);
background: rgba(167, 139, 250, 0.1);
}
.option-btn.selected {
border-color: var(--accent-primary);
background: rgba(167, 139, 250, 0.2);
}
.option-btn.correct {
border-color: #48BB78;
background: rgba(72, 187, 120, 0.2);
}
.option-btn.incorrect {
border-color: #F56565;
background: rgba(245, 101, 101, 0.2);
}
// Quiz Engine
class QuizEngine {
constructor(questions) {
this.questions = questions;
this.currentIndex = 0;
this.score = 0;
this.hintsUsed = 0;
this.userAnswers = [];
}
getCurrentQuestion() {
return this.questions[this.currentIndex];
}
submitAnswer(answerIndex) {
const question = this.getCurrentQuestion();
const isCorrect = answerIndex === question.correctAnswer;
this.userAnswers.push({
questionId: question.id,
answer: answerIndex,
correct: isCorrect
});
if (isCorrect) {
this.score++;
}
return isCorrect;
}
nextQuestion() {
this.currentIndex++;
return this.currentIndex < this.questions.length;
}
getProgress() {
return ((this.currentIndex + 1) / this.questions.length) * 100;
}
useHint(hintType) {
this.hintsUsed++;
const question = this.getCurrentQuestion();
switch(hintType) {
case 'remove_two':
return this.removeIncorrectOptions(2);
case 'show_topic':
return question.topic;
case 'first_letter':
return question.answer[0];
default:
return null;
}
}
removeIncorrectOptions(count) {
const question = this.getCurrentQuestion();
const incorrectIndexes = question.options
.map((opt, idx) => idx)
.filter(idx => idx !== question.correctAnswer);
// Randomly select 'count' incorrect options to remove
const toRemove = incorrectIndexes
.sort(() => Math.random() - 0.5)
.slice(0, count);
return toRemove;
}
calculateXP() {
const baseXP = 20;
const perfectBonus = this.score === this.questions.length ? 50 : 0;
const hintPenalty = this.hintsUsed * 2;
return Math.max(baseXP + perfectBonus - hintPenalty, 10);
}
calculateCoins() {
const baseCoins = 10;
const perfectBonus = this.score === this.questions.length ? 25 : 0;
return baseCoins + perfectBonus;
}
getResults() {
return {
score: this.score,
total: this.questions.length,
percentage: (this.score / this.questions.length) * 100,
hintsUsed: this.hintsUsed,
xpEarned: this.calculateXP(),
coinsEarned: this.calculateCoins(),
answers: this.userAnswers
};
}
}
// Initialize quiz
async function initQuiz() {
const questions = await fetchQuizQuestions(subjectId, topicId);
const quiz = new QuizEngine(questions);
displayQuestion(quiz);
}
function displayQuestion(quiz) {
const question = quiz.getCurrentQuestion();
const container = document.getElementById('quiz-content');
container.innerHTML = `
${question.question}
`;
// Update progress
document.getElementById('current-question').textContent = quiz.currentIndex + 1;
document.getElementById('progress-fill').style.width = quiz.getProgress() + '%';
// Add event listeners
setupOptionListeners(quiz);
}
function setupOptionListeners(quiz) {
const options = document.querySelectorAll('.option-btn');
options.forEach(btn => {
btn.addEventListener('click', () => {
options.forEach(o => o.classList.remove('selected'));
btn.classList.add('selected');
});
});
document.getElementById('submit-btn').addEventListener('click', () => {
submitQuizAnswer(quiz);
});
document.getElementById('hint-btn').addEventListener('click', () => {
useQuizHint(quiz);
});
}
async function submitQuizAnswer(quiz) {
const selected = document.querySelector('.option-btn.selected');
if (!selected) {
alert('Please select an answer');
return;
}
const answerIndex = parseInt(selected.dataset.index);
const isCorrect = quiz.submitAnswer(answerIndex);
// Show feedback
showAnswerFeedback(selected, isCorrect);
// Wait then move to next question
setTimeout(() => {
if (quiz.nextQuestion()) {
displayQuestion(quiz);
} else {
showResults(quiz);
}
}, 2000);
}
function showAnswerFeedback(element, isCorrect) {
element.classList.add(isCorrect ? 'correct' : 'incorrect');
const feedback = document.createElement('div');
feedback.className = 'feedback-popup';
feedback.textContent = isCorrect ? '✓ Correct!' : '✗ Incorrect';
feedback.style.color = isCorrect ? '#48BB78' : '#F56565';
element.appendChild(feedback);
}
async function showResults(quiz) {
const results = quiz.getResults();
// Save to database
await saveQuizResults(results);
// Award XP and coins
await awardXP(results.xpEarned);
await awardCoins(results.coinsEarned);
// Display results screen
displayResultsScreen(results);
}
async function useQuizHint(quiz) {
const user = await getCurrentUser();
if (user.hint_tokens < 1) {
alert('No hint tokens available. Purchase more in the shop!');
return;
}
const hintType = 'remove_two'; // or show modal to choose
const toRemove = quiz.useHint(hintType);
// Update UI to disable removed options
toRemove.forEach(idx => {
const btn = document.querySelector(`[data-index="${idx}"]`);
btn.disabled = true;
btn.style.opacity = '0.3';
});
// Deduct hint token
await deductHintToken(user.id);
}
// Fetch quiz questions from database
async function fetchQuizQuestions(subjectId, topicId, count = 10) {
const { data, error } = await supabase
.from('quiz_questions')
.select('*')
.eq('subject_id', subjectId)
.eq('topic', topicId)
.limit(count);
if (error) {
console.error('Error fetching questions:', error);
return [];
}
return data;
}
// Save quiz results
async function saveQuizResults(results) {
const user = await getCurrentUser();
const { error } = await supabase
.from('quizzes')
.insert({
user_id: user.id,
subject_id: currentSubjectId,
score: results.score,
total_questions: results.total,
xp_earned: results.xpEarned,
coins_earned: results.coinsEarned,
hints_used: results.hintsUsed
});
if (error) {
console.error('Error saving results:', error);
}
}
// Award XP to user
async function awardXP(amount) {
const user = await getCurrentUser();
const { data, error } = await supabase
.rpc('increment_xp', {
user_uuid: user.id,
xp_amount: amount
});
if (!error && data.leveled_up) {
showLevelUpAnimation(data.new_level);
}
}
// Award Brain Coins
async function awardCoins(amount) {
const user = await getCurrentUser();
await supabase
.from('users')
.update({
brain_coins: user.brain_coins + amount
})
.eq('id', user.id);
}
/* Block */
.quiz-card {
background: var(--card-bg);
border-radius: 15px;
}
/* Element */
.quiz-card__header {
padding: 20px;
border-bottom: 1px solid var(--border-color);
}
.quiz-card__title {
font-size: 1.5rem;
font-weight: 600;
}
/* Modifier */
.quiz-card--completed {
opacity: 0.6;
}
.quiz-card--highlighted {
border: 2px solid var(--accent-primary);
}
/* Mobile first */
.feature-grid {
display: grid;
grid-template-columns: 1fr;
gap: 20px;
}
/* Tablet */
@media (min-width: 768px) {
.feature-grid {
grid-template-columns: repeat(2, 1fr);
gap: 30px;
}
}
/* Desktop */
@media (min-width: 1024px) {
.feature-grid {
grid-template-columns: repeat(4, 1fr);
gap: 40px;
}
}
Always test features in both light and dark modes. Use CSS variables:
/* GOOD: Uses theme variables */
.card {
background: var(--card-bg);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
/* BAD: Hardcoded colors */
.card {
background: white;
color: #333;
border: 1px solid #ddd;
}
<button
class="btn btn-primary"
aria-label="Start quiz"
aria-describedby="quiz-description">
Start Quiz
</button>
<p id="quiz-description" class="sr-only">
Begin a 10 question mathematics quiz
</p>
Before submitting a PR, ensure you've tested:
Test on:
// Test in browser console
async function testXPFunction() {
const user = await getCurrentUser();
console.log('Current XP:', user.xp);
const result = await supabase
.rpc('increment_xp', {
user_uuid: user.id,
xp_amount: 100
});
console.log('Result:', result.data);
}
testXPFunction();
Always handle errors gracefully:
async function loadQuizData() {
try {
const questions = await fetchQuizQuestions(subjectId);
if (!questions || questions.length === 0) {
showError('No questions available for this topic');
return;
}
initQuiz(questions);
} catch (error) {
console.error('Error loading quiz:', error);
showError('Failed to load quiz. Please try again later.');
}
}
function showError(message) {
const errorDiv = document.createElement('div');
errorDiv.className = 'error-message';
errorDiv.textContent = message;
document.body.appendChild(errorDiv);
setTimeout(() => errorDiv.remove(), 5000);
}
The site is hosted on GitHub Pages and automatically deploys from the main branch.
https://jayden4400338.github.io/BrainMapRevisionFor production, use environment variables for sensitive data:
Never commit API keys or secrets to the repository. Use environment variables or GitHub Secrets for sensitive configuration.
When updating the database schema:
supabase/migrations/20250121_add_achievements_table.sqlPlease read and follow our Code of Conduct. We're committed to providing a welcoming and inclusive environment.
git checkout -b feature/your-featureType: Brief description (max 50 chars)
Detailed explanation of changes if needed.
- What was changed
- Why it was changed
- Any breaking changes
Closes #issue-number
Types:
feat: New featurefix: Bug fixdocs: Documentation changesstyle: Code style changes (formatting, etc.)refactor: Code refactoringtest: Adding or updating testschore: Maintenance tasksYour PR should:
Content:
Code:
Documentation:
If you need help:
Every contribution, no matter how small, helps make BrainMapRevision better for students everywhere. We appreciate your time and effort!