diff --git a/README.md b/README.md
index 7f73892..0a4193f 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,15 @@
# Course-Compass
## by team [Inference](https://inferencekth.github.io/Course-Compass/)
-Course-Compass is a webpage for interacting with the kth courses via the kth api. It allows for searching and filtering through all active courses.
+Course-Compass is an interactive web application for exploring KTH courses. It allows users to search, filter, and review courses while providing prerequisite visualization and personalized recommendations. The application uses Firebase for data storage and real-time updates.
+
+
+## Features
+- Course search with advanced filtering
+- Course reviews and ratings
+- Interactive prerequisite visualization
+- Transcript upload for eligibility checking
+- Personal course favorites
+- Dark/Light mode support
## How to run
@@ -19,7 +28,6 @@ docker-compose up
```
builds and starts the container.
-
### Building with NPM
After downloading the repository navigate to the folder my-app and install the dependencies with
@@ -36,9 +44,127 @@ for production use
npm run build
```
+## Environment Setup
+
+### Firebase Configuration
+This project uses Firebase for backend services. To set up your development environment:
+
+Update the api keys in firebase.js to your keys.
+
+```js
+const firebaseConfig = {
+ apiKey: "",
+ authDomain: "",
+ databaseURL:"",
+ projectId: "",
+ storageBucket: "",
+ messagingSenderId: "",
+ appId: "",
+};
+```
+
+### Database Population
+To populate the Firebase database with course data:
+
+1. Use the JSON file in `/src/assets/example.json` or prepare a file according to the following outline:
+```json
+{
+ "courseCode": {
+ "code": "string",
+ "name": "string",
+ "location": "string",
+ "department": "string",
+ "language": "string",
+ "description": "string",
+ "academic_level": "string",
+ "periods": "array",
+ "credits": "number",
+ "prerequisites": "object",
+ "prerequisites_text": "string",
+ "learning_outcomes": "string"
+ }
+}
+```
+
+2. Use the `model.populateDatabase(data)` function to upload courses:
+```javascript
+import data from "./assets/example.json";
+model.populateDatabase(data);
+```
+
+### Firebase Security Rules
+
+Click to view Firebase Rules
+
+```json
+{
+ "rules": {
+ "courses": {
+ ".read": true,
+ ".write": "auth != null && (auth.uid === 'adminuid' || auth.uid === 'adminuid')"
+ },
+ "metadata": {
+ ".read": true,
+ ".write": "auth != null && (auth.uid === 'adminuid' || auth.uid === 'adminuid')"
+ },
+ "departments": {
+ ".read": true,
+ ".write": "auth != null && (auth.uid === 'adminuid' || auth.uid === 'adminuid')"
+ },
+ "locations": {
+ ".read": true,
+ ".write": "auth != null && (auth.uid === 'adminuid' || auth.uid === 'adminuid')"
+ },
+ "reviews": {
+ ".read": true,
+ "$courseCode": {
+ "$userID": {
+ ".write": "auth != null && (auth.uid === $userID || data.child('uid').val() === auth.uid || !data.exists())",
+ ".validate": "newData.hasChildren(['text', 'timestamp']) && newData.child('text').isString() && newData.child('timestamp').isNumber()"
+ }
+ }
+ },
+ "users": {
+ "$userID": {
+ ".read": "auth != null && auth.uid === $userID",
+ ".write": "auth != null && auth.uid === $userID"
+ }
+ }
+ }
+}
+```
+
+
+To deploy these rules:
+```bash
+firebase deploy --only database
+```
+
+### ⚠️ Security Notes
+- Never commit `.env.local` to version control
+- Keep your Firebase credentials secure
+- Contact the team lead if you need access to the Firebase configuration
+
## Project structure
-The project uses the **[Model–view–presenter (MVP)](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93presenter)** paradime. The view displays the data. The presenter contains the logic. The model contains the data.
+The project uses the **[Model–view–presenter (MVP)](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93presenter)** paradigm. The view displays the data. The presenter contains the logic. The model contains the data.
+
+### Key Components
+- **/src/model.js**: Core data model and business logic
+- **/src/views/**: UI components and layouts
+- **/src/presenters/**: Interface between Model and View
+- **/src/scripts/**: Utility scripts including transcript parsing
+- **/src/assets/**: Static resources and images
+
+### Development Components
+- **/src/dev/**: Development utilities and component previews
+- **/src/presenters/Tests/**: Test implementations
+- **/scripts/transcript-scraper/**: Transcript parsing tools
+
+
+### Project Tree
+
+Click to view Project Tree
```
.
@@ -153,10 +279,11 @@ The project uses the **[Model–view–presenter (MVP)](https://en.wikipedia.org
21 directories, 87 files
```
+
## Other branches
-The **[docs](https://github.com/InferenceKTH/Course-Compass/tree/kth-api)** branch contains the team website.
+The **[docs](https://github.com/InferenceKTH/Course-Compass/tree/docs)** branch contains the team website.
The **[kth-api](https://github.com/InferenceKTH/Course-Compass/tree/kth-api)** contains most of the tools used for gathering and processing the course info.
diff --git a/my-app/.gitignore b/my-app/.gitignore
index 9212cac..9f6211d 100644
--- a/my-app/.gitignore
+++ b/my-app/.gitignore
@@ -6,22 +6,45 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
+
+# Dependencies
/node_modules
-.firebase
+/.pnp
+.pnp.js
+/.firebase
+
+# Testing
+/coverage
-node_modules
-dist
+# Production
+/build
+/dist
dist-ssr
*.local
+# Environment Variables
+.env
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
-.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+# OS generated files
+.DS_Store
+.DS_Store?
+._*
+.Spotlight-V100
+.Trashes
+ehthumbs.db
+Thumbs.db
+
diff --git a/my-app/firebase.js b/my-app/firebase.js
index d479c53..295cdd4 100644
--- a/my-app/firebase.js
+++ b/my-app/firebase.js
@@ -1,7 +1,7 @@
import { initializeApp } from "firebase/app";
import { getAuth, GoogleAuthProvider, onAuthStateChanged } from "firebase/auth";
import { getFunctions, httpsCallable } from 'firebase/functions';
-import { get, getDatabase, ref, set, onValue, onChildRemoved, onChildAdded } from "firebase/database";
+import { get, getDatabase, ref, set, onValue, onChildRemoved, onChildAdded, runTransaction } from "firebase/database";
import { reaction, toJS } from "mobx";
// Your web app's Firebase configuration
@@ -24,7 +24,6 @@ export const db = getDatabase(app);
export const googleProvider = new GoogleAuthProvider();
googleProvider.addScope("profile");
googleProvider.addScope("email");
-let noUpload = false;
export function connectToFirebase(model) {
loadCoursesFromCacheOrFirebase(model);
@@ -61,42 +60,46 @@ export function connectToFirebase(model) {
// fetches all relevant information to create the model
async function firebaseToModel(model) {
- const userRef = ref(db, `users/${model.user.uid}`);
- onValue(userRef, (snapshot) => {
- if (!snapshot.exists()) return;
- const data = snapshot.val();
- noUpload = true;
- if (data?.favourites) model.setFavourite(data.favourites);
- if (data?.currentSearchText)
- model.setCurrentSearchText(data.currentSearchText);
- // if (data.scrollPosition)
- // model.setScrollPosition(data.scrollPosition);
- // if (data.filterOptions) model.setFilterOptions(data.filterOptions);
- noUpload = false;
- });
+ const userRef = ref(db, `users/${model.user.uid}`);
+ onValue(userRef, async (snapshot) => {
+ if (!snapshot.exists()) return;
+ const data = snapshot.val();
+
+ // Use a transaction to ensure atomicity
+ await runTransaction(userRef, (currentData) => {
+ if (currentData) {
+ if (data?.favourites) model.setFavourite(data.favourites);
+ if (data?.currentSearchText) model.setCurrentSearchText(data.currentSearchText);
+ // Add other fields as needed
+ }
+ return currentData; // Return the current data to avoid overwriting
+ });
+ });
}
export function syncModelToFirebase(model) {
- reaction(
- () => ({
- userId: model?.user.uid,
- favourites: toJS(model.favourites),
- currentSearchText: toJS(model.currentSearchText),
- // filterOptions: toJS(model.filterOptions),
- // Add more per-user attributes here
- }),
- // eslint-disable-next-line no-unused-vars
- ({ userId, favourites, currentSearchText }) => {
- if (noUpload || !userId) return;
- const userRef = ref(db, "users/${userId}");
- const dataToSync = {
- favourites,
- currentSearchText,
- // filterOptions,
- };
- set(userRef, dataToSync).catch(console.error);
- }
- );
+ reaction(
+ () => ({
+ userId: model?.user.uid,
+ favourites: toJS(model.favourites),
+ currentSearchText: toJS(model.currentSearchText),
+ }),
+ async ({ userId, favourites, currentSearchText }) => {
+ if (!userId) return;
+
+ const userRef = ref(db, `users/${userId}`);
+ await runTransaction(userRef, (currentData) => {
+ // Merge the new data with the existing data
+ return {
+ ...currentData,
+ favourites,
+ currentSearchText,
+ };
+ }).catch((error) => {
+ console.error('Error syncing model to Firebase:', error);
+ });
+ }
+ );
}
export function syncScrollPositionToFirebase(model, containerRef) {
@@ -172,9 +175,12 @@ async function fetchLastUpdatedTimestamp() {
}
export async function addCourse(course) {
- if (!course?.code) return;
- const myRef = ref(db, `courses/${course.code}`);
- await set(myRef, course);
+ if (!auth.currentUser)
+ throw new Error('User must be authenticated');
+ if (!course?.code)
+ throw new Error('Invalid course data');
+ const myRef = ref(db, `courses/${course.code}`);
+ await set(myRef, course);
updateLastUpdatedTimestamp();
}
@@ -322,9 +328,8 @@ function startAverageRatingListener(model) {
initialRatings[courseCode] = avgRating;
}
});
-
model.setAverageRatings(initialRatings);
- });
+ })
}
// Step 2: listener for each courses avgRating
diff --git a/my-app/firebase_rules.json b/my-app/firebase_rules.json
new file mode 100644
index 0000000..4af3777
--- /dev/null
+++ b/my-app/firebase_rules.json
@@ -0,0 +1,42 @@
+{
+ "rules": {
+ // Courses and Metadata
+ "courses": {
+ ".read": true,
+ ".write": "auth != null && (auth.uid === 'adminuid' || auth.uid === 'adminuid')"
+ },
+ "metadata": {
+ ".read": true,
+ ".write": "auth != null && (auth.uid === 'adminuid' || auth.uid === 'adminuid')"
+ },
+ "departments": {
+ ".read": true,
+ ".write": "auth != null && (auth.uid === 'adminuid' || auth.uid === 'adminuid')"
+ },
+ "locations": {
+ ".read": true,
+ ".write": "auth != null && (auth.uid === 'adminuid' || auth.uid === 'adminuid')"
+ },
+
+ // Reviews
+ "reviews": {
+ ".read":true,
+ "$courseCode": {
+ "$userID": {
+ ".write": "auth != null && (auth.uid === $userID || data.child('uid').val() === auth.uid || !data.exists())",
+ ".validate": "newData.hasChildren(['text', 'timestamp']) &&
+ newData.child('text').isString() &&
+ newData.child('timestamp').isNumber()"
+ }
+ }
+ },
+
+ // Users
+ "users": {
+ "$userID": {
+ ".read": "auth != null && auth.uid === $userID",
+ ".write": "auth != null && auth.uid === $userID"
+ }
+ }
+ }
+ }
\ No newline at end of file