diff --git a/data/skills_counters.json b/data/skills_counters.json new file mode 100644 index 00000000..57415e5d --- /dev/null +++ b/data/skills_counters.json @@ -0,0 +1,211 @@ +{ + "python": 0, + "javascript": 0, + "java": 0, + "c++": 0, + "c#": 0, + "ruby": 0, + "php": 0, + "go": 0, + "swift": 0, + "typescript": 0, + "html": 0, + "css": 0, + "sql": 0, + "kotlin": 0, + "rust": 0, + "scala": 0, + "r": 0, + "matlab": 0, + "perl": 0, + "lua": 0, + "haskell": 0, + "elixir": 0, + "erlang": 0, + "clojure": 0, + "f#": 0, + "dart": 0, + "julia": 0, + "groovy": 0, + "assembly": 0, + "bash": 0, + "powershell": 0, + "solidity": 0, + "objective-c": 0, + "cobol": 0, + "fortran": 0, + "zig": 0, + "nim": 0, + "ocaml": 0, + "crystal": 0, + "react": 0, + "angular": 0, + "vue.js": 0, + "fastapi": 0, + "express.js": 0, + "next.js": 0, + "nuxt.js": 0, + "svelte": 0, + "sveltekit": 0, + "remix": 0, + "astro": 0, + "laravel": 0, + "symfony": 0, + "rails": 0, + "asp.net": 0, + "blazor": 0, + "phoenix": 0, + "gin": 0, + "fiber": 0, + "fastify": 0, + "hapi.js": 0, + "koa.js": 0, + "nest.js": 0, + "strapi": 0, + "gatsby": 0, + "htmx": 0, + "alpinejs": 0, + "solid.js": 0, + "qwik": 0, + "jquery": 0, + "bootstrap": 0, + "tailwindcss": 0, + "chakra-ui": 0, + "material-ui": 0, + "shadcn": 0, + "three.js": 0, + "d3.js": 0, + "rxjs": 0, + "node.js": 0, + "django": 0, + "flask": 0, + "spring": 0, + "flutter": 0, + "react-native": 0, + "ionic": 0, + "xamarin": 0, + "maui": 0, + "jetpack-compose": 0, + "swiftui": 0, + "capacitor": 0, + "mongodb": 0, + "postgresql": 0, + "mysql": 0, + "sqlite": 0, + "redis": 0, + "cassandra": 0, + "firebase": 0, + "supabase": 0, + "dynamodb": 0, + "neo4j": 0, + "elasticsearch": 0, + "couchdb": 0, + "influxdb": 0, + "mariadb": 0, + "cockroachdb": 0, + "planetscale": 0, + "prisma": 0, + "sqlalchemy": 0, + "typeorm": 0, + "aws": 0, + "azure": 0, + "gcp": 0, + "docker": 0, + "kubernetes": 0, + "terraform": 0, + "ansible": 0, + "pulumi": 0, + "helm": 0, + "nginx": 0, + "apache": 0, + "linux": 0, + "vercel": 0, + "netlify": 0, + "cloudflare": 0, + "serverless": 0, + "openshift": 0, + "vagrant": 0, + "prometheus": 0, + "grafana": 0, + "datadog": 0, + "git": 0, + "tensorflow": 0, + "pytorch": 0, + "scikit-learn": 0, + "keras": 0, + "huggingface": 0, + "langchain": 0, + "openai-api": 0, + "llm": 0, + "rag": 0, + "computer-vision": 0, + "nlp": 0, + "pandas": 0, + "numpy": 0, + "matplotlib": 0, + "seaborn": 0, + "plotly": 0, + "opencv": 0, + "spark": 0, + "hadoop": 0, + "airflow": 0, + "dbt": 0, + "mlflow": 0, + "wandb": 0, + "jupyter": 0, + "data science": 0, + "machine learning": 0, + "artificial intelligence": 0, + "deep-learning": 0, + "reinforcement-learning": 0, + "generative-ai": 0, + "jest": 0, + "pytest": 0, + "cypress": 0, + "playwright": 0, + "selenium": 0, + "vitest": 0, + "mocha": 0, + "junit": 0, + "testng": 0, + "webpack": 0, + "vite": 0, + "babel": 0, + "eslint": 0, + "prettier": 0, + "github-actions": 0, + "jenkins": 0, + "sonarqube": 0, + "jira": 0, + "figma": 0, + "storybook": 0, + "microservices": 0, + "websockets": 0, + "grpc": 0, + "kafka": 0, + "rabbitmq": 0, + "oauth": 0, + "jwt": 0, + "webassembly": 0, + "pwa": 0, + "electron": 0, + "tauri": 0, + "opengl": 0, + "webgl": 0, + "unity": 0, + "unreal-engine": 0, + "embedded-systems": 0, + "iot": 0, + "arduino": 0, + "raspberry-pi": 0, + "quantum-computing": 0, + "ar/vr": 0, + "cybersecurity": 0, + "blockchain": 0, + "devops": 0, + "ci/cd": 0, + "restapi": 0, + "graphql": 0, + "ui/ux design": 0, + "game development": 0 +} \ No newline at end of file diff --git a/routes/main_routes.py b/routes/main_routes.py index 4cce0460..ecc0bb81 100644 --- a/routes/main_routes.py +++ b/routes/main_routes.py @@ -6,7 +6,7 @@ from flask import Blueprint, render_template, request, jsonify, send_from_directory, abort from utils.recommender import get_recommendations, validate_recommendation_inputs -from utils.data_loader import find_project_by_id, get_project_stats +from utils.data_loader import find_project_by_id, get_project_stats, increment_skill_counter, get_top_skills from utils.file_server import read_starter_code, resolve_starter_file, get_starter_code_dir # Create the Blueprint that app.py will register @@ -61,6 +61,41 @@ def recommend(): return jsonify({"projects": results}), 200 +@main.route("/api/top-skills", methods=["GET"]) +def top_skills(): + """ + Return the top 5 most-selected skills across the community. + Returns a JSON array of skill objects with 'label' and 'counter' properties. + """ + top_5 = get_top_skills(5) + return jsonify({"skills": top_5}), 200 + + +@main.route("/api/track-skill", methods=["POST"]) +def track_skill(): + """ + Increment the counter for a given skill. + Expects JSON body with 'skill' field (the skill label). + Returns the updated counter value or an error message. + """ + payload = request.get_json() + + if not payload: + return jsonify({"error": "Request body must be valid JSON."}), 400 + + skill = payload.get("skill", "").strip() + + if not skill: + return jsonify({"error": "Skill name is required."}), 400 + + counter_value = increment_skill_counter(skill) + + if counter_value == -1: + return jsonify({"error": f"Skill '{skill}' not found."}), 404 + + return jsonify({"skill": skill, "counter": counter_value}), 200 + + @main.route("/project/") def project_detail(project_id): """Render the full detail page for a single project.""" diff --git a/static/script.js b/static/script.js index aebc9225..08077f1e 100644 --- a/static/script.js +++ b/static/script.js @@ -207,6 +207,87 @@ if (isIndexPage) { }); } + // Function to track skill selection on the server + function trackSkillOnServer(skillLabel) { + fetch("/api/track-skill", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ skill: skillLabel }) + }) + .catch(function (error) { + console.warn("Failed to track skill on server:", error); + // Silently fail - don't break user experience if tracking fails + }); + } + + // Function to get the top 5 skills from the server + function loadTopSkillsFromServer(callback) { + fetch("/api/top-skills") + .then(function (response) { + if (!response.ok) throw new Error("Failed to fetch top skills"); + return response.json(); + }) + .then(function (data) { + callback(data.skills || []); + }) + .catch(function (error) { + console.warn("Failed to load top skills from server:", error); + // Fall back to default skills if server request fails + callback([]); + }); + } + + // Function to render the top 5 skills dynamically using server data + function renderDynamicTopSkills() { + var chipsContainer = document.getElementById("skill-chips-available"); + if (!chipsContainer) return; + + loadTopSkillsFromServer(function (topSkills) { + chipsContainer.innerHTML = ""; + + // If there are no top skills from server, show a default set of popular skills + if (topSkills.length === 0) { + topSkills = [ + { label: "Python" }, + { label: "JavaScript" }, + { label: "HTML" }, + { label: "CSS" }, + { label: "React" } + ]; + } + + topSkills.forEach(function (skill) { + var btn = document.createElement("button"); + btn.type = "button"; + btn.className = "skill-chip"; + btn.setAttribute("data-skill", skill.label); + btn.textContent = skill.label; + + btn.addEventListener("click", function () { + var skillLabel = btn.getAttribute("data-skill"); + var isAlreadySelected = selectedSkills.some(function (s) { + return s.toLowerCase() === skillLabel.toLowerCase(); + }); + + if (isAlreadySelected) { + removeSkill(skillLabel); + } else { + addSkill(skillLabel); + } + hideSuggestions(); + skillsTextInput.value = ""; + }); + + chipsContainer.appendChild(btn); + }); + + updateQuickPickState(); + }); + } + + // Add skill on Enter key in the text input skillsTextInput.addEventListener("keydown", function (evt) { if (evt.key === "ArrowDown" || evt.key === "ArrowUp") { @@ -245,23 +326,9 @@ if (isIndexPage) { } }); - // Add/toggle skill on quick-pick chip click - quickPickChips.forEach(function (chip) { - chip.addEventListener("click", function () { - var skill = chip.getAttribute("data-skill"); - var isAlreadySelected = selectedSkills.some(function (s) { - return s.toLowerCase() === skill.toLowerCase(); - }); + // Dynamic skill chips are now rendered by renderDynamicTopSkills() + // Event listeners for chips are added in renderDynamicTopSkills() - if (isAlreadySelected) { - removeSkill(skill); - } else { - addSkill(skill); - } - hideSuggestions(); - skillsTextInput.value = ""; - }); - }); // Show suggestions on input skillsTextInput.addEventListener("input", function (evt) { @@ -307,8 +374,13 @@ if (isIndexPage) { if (isSkillSelected(skill)) return; selectedSkills.push(skill); + + // Track the skill selection on the server + trackSkillOnServer(skill); + renderSelectedChips(); syncSkillsHiddenInput(); + renderDynamicTopSkills(); updateQuickPickState(); // Once a skill is added, remove the "please add a skill" error if it was showing clearFieldError("skills-error"); @@ -321,6 +393,7 @@ if (isIndexPage) { }); renderSelectedChips(); syncSkillsHiddenInput(); + renderDynamicTopSkills(); updateQuickPickState(); } @@ -355,7 +428,8 @@ if (isIndexPage) { skillsHidden.value = selectedSkills.join(", "); } - updateQuickPickState(); + // Initialize the dynamic top skills rendering + renderDynamicTopSkills(); // ---------------------------------------------------------- diff --git a/utils/data_loader.py b/utils/data_loader.py index 569199d1..7a4ea044 100644 --- a/utils/data_loader.py +++ b/utils/data_loader.py @@ -1,12 +1,20 @@ # utils/data_loader.py # Handles all reading and lookup of project data from the JSON file. +# Manages skill counter persistence for tracking community usage. import json import os +from threading import Lock # Build the path to projects.json relative to this file's location DATA_FILE = os.path.join(os.path.dirname(__file__), "..", "data", "projects.json") +# Path to skills counters data +SKILLS_COUNTERS_FILE = os.path.join(os.path.dirname(__file__), "..", "data", "skills_counters.json") + +# Thread lock to prevent concurrent writes to skills_counters.json +skills_counter_lock = Lock() + def load_all_projects(): """Read and return the full list of projects from the JSON file.""" @@ -48,6 +56,60 @@ def get_project_stats(): "beginner_friendly": beginner_friendly } + +def load_skill_counters(): + """Read and return the skill counters from the JSON file.""" + with skills_counter_lock: + with open(SKILLS_COUNTERS_FILE, "r", encoding="utf-8") as f: + return json.load(f) + + +def save_skill_counters(counters): + """Write skill counters to the JSON file.""" + with skills_counter_lock: + with open(SKILLS_COUNTERS_FILE, "w", encoding="utf-8") as f: + json.dump(counters, f, indent=2, ensure_ascii=False) + + +def increment_skill_counter(skill_label): + """ + Increment the counter for a given skill label. + Returns the updated counter value, or -1 if the skill is not found. + """ + counters = load_skill_counters() + + # Normalize the skill label for case-insensitive lookup + skill_key = None + for key in counters.keys(): + if key.lower() == skill_label.lower(): + skill_key = key + break + + if skill_key is None: + return -1 + + counters[skill_key] = counters[skill_key] + 1 + save_skill_counters(counters) + + return counters[skill_key] + + +def get_top_skills(count=5): + """ + Get the top N skills by counter value. + Returns a list of dicts with 'label' and 'counter' keys. + """ + counters = load_skill_counters() + + # Convert to list of (label, count) tuples, sort by count descending + sorted_skills = sorted(counters.items(), key=lambda x: x[1], reverse=True) + + # Return only the top N + top_skills = sorted_skills[:count] + + return [{"label": label, "counter": count} for label, count in top_skills] + + # Cache for loaded projects _projects_cache = None