Skip to content
Open
125 changes: 74 additions & 51 deletions DevPath/static/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -925,63 +925,86 @@ updateProfileWidgets();
}

function buildProjectCard(project) {

var card = document.createElement("div");
card.className = "project-card";

// Console logging for debugging
console.log("Building card for project:", project);
console.log("Project ID:", project.id);

// Title
var title = document.createElement("h3");
title.className = "project-card-title";
title.textContent = project.title;

// Description (truncated for visual consistency)
var desc = document.createElement("p");
desc.className = "project-card-desc";
// Cut description to 120 chars so all cards stay the same height
desc.textContent = truncate(project.description, 120);

// Tags row
var tagsRow = document.createElement("div");
tagsRow.className = "project-card-tags";

// Show all project skills as tags so users can see the full match
(project.skills || []).forEach(function (skill) {
tagsRow.appendChild(createTag(skill, "skill"));
});
var card = document.createElement("div");
card.className = "project-card";

// Level tag (colour-coded via CSS class)
// Lowercase so it matches the CSS class names like "level beginner", "level advanced"
tagsRow.appendChild(createTag(project.level, "level " + (project.level || "").toLowerCase()));
// Title
var title = document.createElement("h3");
title.className = "project-card-title";
title.textContent = project.title;

// Time tag
tagsRow.appendChild(createTag("Time: " + project.time, "time"));
// Description (truncated for visual consistency)
var desc = document.createElement("p");
desc.className = "project-card-desc";
desc.textContent = truncate(project.description, 120);

// Footer with view-details link
var footer = document.createElement("div");
footer.className = "project-card-footer";
// Tags row
var tagsRow = document.createElement("div");
tagsRow.className = "project-card-tags";

var link = document.createElement("a");
link.className = "btn-details";
link.textContent = "View Full Project";
link.href = "/project/" + project.id; //each project has a unique id

console.log("Created link with href:", link.href);

link.href = "/project/" + project.id;
footer.appendChild(saveButton);
footer.appendChild(link);
(project.skills || []).forEach(function (skill) {
tagsRow.appendChild(createTag(skill, "skill"));
});

// Assemble the card in order
card.appendChild(title);
card.appendChild(desc);
card.appendChild(tagsRow);
card.appendChild(footer);
return card;
tagsRow.appendChild(createTag(project.level, "level " + (project.level || "").toLowerCase()));
tagsRow.appendChild(createTag("Time: " + project.time, "time"));

// ============================================================================
// FOOTER with SAVE BUTTON + VIEW DETAILS LINK
// ============================================================================
var footer = document.createElement("div");
footer.className = "project-card-footer";

// SAVE PROJECT BUTTON (wired to DevPathBookmarks)
var saveBtn = document.createElement("button");
saveBtn.type = "button";
saveBtn.className = "btn-save-project";
saveBtn.setAttribute("data-save-project-id", project.id);
saveBtn.setAttribute("aria-label", "Save project");
saveBtn.setAttribute("aria-pressed", "false");

// Check if already saved and set initial state
var isSaved = DevPathBookmarks.isSaved(project.id);
if (isSaved) {
saveBtn.classList.add("saved");
saveBtn.setAttribute("aria-pressed", "true");
saveBtn.setAttribute("aria-label", "Remove saved project");
}

// Set button content (SVG + label)
DevPathBookmarks.setButtonContent(saveBtn, isSaved);

// Wire up click handler
saveBtn.addEventListener("click", function(e) {
e.preventDefault();
var projectObj = {
id: project.id,
title: project.title,
level: project.level,
time: project.time,
skills: project.skills || []
};
DevPathBookmarks.toggle(projectObj, saveBtn);
});

// VIEW DETAILS LINK (existing)
var link = document.createElement("a");
link.className = "btn-details";
link.textContent = "View Full Project";
link.href = "/project/" + project.id;

// Assemble footer
footer.appendChild(saveBtn);
footer.appendChild(link);

// Assemble the card
card.appendChild(title);
card.appendChild(desc);
card.appendChild(tagsRow);
card.appendChild(footer);

return card;
}

function runProjectSearch(query) {
if (!query) return;
Expand Down
6 changes: 6 additions & 0 deletions config/recommendation_weights.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"skill": 3,
"level": 2,
"interest": 2,
"time": 1
}
Empty file added src/__init__.py
Empty file.
159 changes: 159 additions & 0 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
# between environments (development, production, forks).

import os
import json
from pathlib import Path

class Config:
"""Base configuration class with sensible defaults."""
Expand Down Expand Up @@ -50,3 +52,160 @@ def get_og_image_url(cls):
def get_base_url(cls):
"""Return the base URL without trailing slash."""
return cls.BASE_URL.rstrip("/")
@classmethod
def load_recommendation_weights(cls):
"""
Load recommendation scoring weights from config file.

The weights file is located at config/recommendation_weights.json
and can be overridden via RECOMMENDATION_WEIGHTS_PATH env var.

Returns:
dict: Weights dictionary with keys like "skill", "level", etc.

Raises:
FileNotFoundError: If the weights config file doesn't exist
ValueError: If the JSON is invalid or weights are malformed
"""
weights_path = os.getenv(
"RECOMMENDATION_WEIGHTS_PATH",
Path(__file__).parent.parent / "config" / "recommendation_weights.json"
)

try:
with open(weights_path, 'r', encoding='utf-8') as f:
weights = json.load(f)

# Validate that all expected keys exist
expected_keys = {"skill", "level", "interest", "time"}
if not expected_keys.issubset(weights.keys()):
missing = expected_keys - set(weights.keys())
raise ValueError(f"Missing required weight keys: {missing}")

# Validate that all values are positive numbers
for key, value in weights.items():
if not isinstance(value, (int, float)) or value < 0:
raise ValueError(f"Weight '{key}' must be a positive number, got {value}")

return weights

except FileNotFoundError:
raise FileNotFoundError(
f"Recommendation weights config not found at {weights_path}. "
"Please create config/recommendation_weights.json"
)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON in recommendation weights config: {e}")


## Step 3: Add these lines at the end of the file (outside the Config class)

# Lazy-load weights to avoid file I/O on import
_RECOMMENDATION_WEIGHTS = None

def get_recommendation_weights():
"""
Get recommendation scoring weights (cached).

Weights are loaded once on first call and then cached.
"""
global _RECOMMENDATION_WEIGHTS
if _RECOMMENDATION_WEIGHTS is None:
_RECOMMENDATION_WEIGHTS = Config.load_recommendation_weights()
return _RECOMMENDATION_WEIGHTS

## Complete Updated config.py

# config.py
# Application configuration settings for DevPath.
#
# This module centralizes all configuration values that might change
# between environments (development, production, forks).

import os
import json
from pathlib import Path

class Config:
"""Base configuration class with sensible defaults."""

# Base URL for the application - used for OG tags and canonical URLs
# Can be overridden via environment variable for different deployments
BASE_URL = os.getenv("BASE_URL", "https://mydevpath-github.vercel.app")

# Application metadata for OG tags
SITE_NAME = "DevPath"
SITE_DESCRIPTION = "Get personalized coding project recommendations with step-by-step roadmaps and starter code."

# OG image path (relative to static folder)
OG_IMAGE_PATH = "/static/og-banner.png"

@classmethod
def get_og_image_url(cls):
"""Return the full URL for the OG banner image."""
return f"{cls.BASE_URL.rstrip('/')}{cls.OG_IMAGE_PATH}"

@classmethod
def get_base_url(cls):
"""Return the base URL without trailing slash."""
return cls.BASE_URL.rstrip("/")

@classmethod
def load_recommendation_weights(cls):
"""
Load recommendation scoring weights from config file.

The weights file is located at config/recommendation_weights.json
and can be overridden via RECOMMENDATION_WEIGHTS_PATH env var.

Returns:
dict: Weights dictionary with keys like "skill", "level", etc.

Raises:
FileNotFoundError: If the weights config file doesn't exist
ValueError: If the JSON is invalid or weights are malformed
"""
weights_path = os.getenv(
"RECOMMENDATION_WEIGHTS_PATH",
Path(__file__).parent.parent / "config" / "recommendation_weights.json"
)

try:
with open(weights_path, 'r', encoding='utf-8') as f:
weights = json.load(f)

# Validate that all expected keys exist
expected_keys = {"skill", "level", "interest", "time"}
if not expected_keys.issubset(weights.keys()):
missing = expected_keys - set(weights.keys())
raise ValueError(f"Missing required weight keys: {missing}")

# Validate that all values are positive numbers
for key, value in weights.items():
if not isinstance(value, (int, float)) or value < 0:
raise ValueError(f"Weight '{key}' must be a positive number, got {value}")

return weights

except FileNotFoundError:
raise FileNotFoundError(
f"Recommendation weights config not found at {weights_path}. "
"Please create config/recommendation_weights.json"
)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON in recommendation weights config: {e}")


# Lazy-load weights to avoid file I/O on import
_RECOMMENDATION_WEIGHTS = None

def get_recommendation_weights():
"""
Get recommendation scoring weights (cached).

Weights are loaded once on first call and then cached.
"""
global _RECOMMENDATION_WEIGHTS
if _RECOMMENDATION_WEIGHTS is None:
_RECOMMENDATION_WEIGHTS = Config.load_recommendation_weights()
return _RECOMMENDATION_WEIGHTS
Loading