diff --git a/DevPath/static/script.js b/DevPath/static/script.js index b57bb9ce..adb4095a 100644 --- a/DevPath/static/script.js +++ b/DevPath/static/script.js @@ -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; diff --git a/config/recommendation_weights.json b/config/recommendation_weights.json new file mode 100644 index 00000000..3180458f --- /dev/null +++ b/config/recommendation_weights.json @@ -0,0 +1,6 @@ +{ + "skill": 3, + "level": 2, + "interest": 2, + "time": 1 +} \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/config.py b/src/config.py index 22b11aa5..a4da449a 100644 --- a/src/config.py +++ b/src/config.py @@ -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.""" @@ -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 diff --git a/src/static/bookmarks.css b/src/static/bookmarks.css index 1147a4d4..acad1fd8 100644 --- a/src/static/bookmarks.css +++ b/src/static/bookmarks.css @@ -31,6 +31,113 @@ ------------------------------------------------------------------ */ .saved-projects-panel { display: none; + margin-top: 3rem; + padding: 2rem; + background: var(--card-bg, #ffffff); + border: 1px solid var(--border, #e5e7eb); + border-radius: 1rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} + +.saved-projects-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border, #e5e7eb); +} + +.saved-projects-header h3 { + font-size: 1.125rem; + font-weight: 600; + margin: 0; + color: var(--text-heading, #1f2937); +} + +.saved-projects-header p { + font-size: 0.875rem; + color: var(--text-muted, #6b7280); + margin: 0.25rem 0 0 0; +} + +.saved-projects-count { + font-size: 0.8rem; + font-weight: 600; + color: var(--text-muted, #6b7280); + background: var(--gray-100, #f3f4f6); + padding: 0.375rem 0.875rem; + border-radius: 2rem; + white-space: nowrap; +} + +.saved-projects-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; +} + +.saved-project-item { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1rem; + background: var(--gray-50, #f9fafb); + border: 1px solid var(--border, #e5e7eb); + border-radius: 0.75rem; + transition: all 0.2s ease; +} + +.saved-project-item:hover { + background: var(--gray-100, #f3f4f6); + border-color: var(--accent, #6366f1); + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.1); + transform: translateY(-2px); +} + +.saved-project-item a { + font-weight: 600; + color: var(--accent, #6366f1); + text-decoration: none; + transition: color 0.2s; + word-break: break-word; + overflow-wrap: break-word; +} + +.saved-project-item a:hover { + color: var(--accent-dark, #4f46e5); + text-decoration: underline; +} + +.saved-project-item span { + font-size: 0.8rem; + color: var(--text-muted, #6b7280); +} + +.saved-project-remove { + align-self: flex-start; + background: none; + border: none; + color: var(--text-muted, #6b7280); + font-size: 0.75rem; + cursor: pointer; + padding: 0.25rem 0.5rem; + border-radius: 0.375rem; + transition: all 0.2s; + font-weight: 500; +} + +.saved-project-remove:hover { + background: #fee2e2; + color: #dc2626; +} + +.saved-projects-empty { + grid-column: 1 / -1; + text-align: center; + padding: 2rem; + color: var(--text-muted, #6b7280); + font-style: italic; } /* ------------------------------------------------------------------ @@ -51,3 +158,76 @@ font-size: 0.75rem; color: var(--gray-600); } +/* Project card footer layout */ +.project-card-footer { + display: flex; + gap: 0.75rem; + align-items: center; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border, #e5e7eb); +} + +/* Save button */ +.btn-save-project { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0.5rem 0.875rem; + background: var(--gray-100, #f3f4f6); + color: var(--text-body, #374151); + border: 1px solid var(--border, #e5e7eb); + border-radius: 0.5rem; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + transition: all 0.2s ease; +} + +.btn-save-project:hover { + background: var(--gray-200, #e5e7eb); + border-color: var(--accent, #6366f1); + color: var(--accent, #6366f1); +} + +.btn-save-project.saved { + background: var(--accent, #6366f1); + color: white; + border-color: var(--accent, #6366f1); +} + +.btn-save-project.saved:hover { + background: var(--accent-dark, #4f46e5); + border-color: var(--accent-dark, #4f46e5); +} + +.btn-save-project svg { + width: 14px; + height: 14px; + flex-shrink: 0; + transition: fill 0.2s ease, stroke 0.2s ease; +} + +.btn-save-project.saved svg { + fill: currentColor; +} + +/* View details link */ +.btn-details { + flex: 1; + padding: 0.5rem 0.875rem; + background: none; + color: var(--accent, #6366f1); + border: 1px solid var(--accent, #6366f1); + border-radius: 0.5rem; + text-decoration: none; + text-align: center; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.btn-details:hover { + background: var(--accent, #6366f1); + color: white; +} \ No newline at end of file diff --git a/src/static/recently-viewed.js b/src/static/recently-viewed.js new file mode 100644 index 00000000..68fda858 --- /dev/null +++ b/src/static/recently-viewed.js @@ -0,0 +1,213 @@ +/** + * static/recently-viewed.js + * Recently Viewed Projects tracking utility for DevPath. + * + * Responsibilities + * ---------------- + * 1. Track project views - record when users click on or visit projects + * 2. Persist data - store in localStorage across browser sessions + * 3. Deduplication - if same project viewed again, move to top, don't duplicate + * 4. Render panel - display up to 5 most recent projects + * 5. Clear history - remove all tracked projects + * + * Public API + * ---------- + * RecentlyViewed.trackView(project) – track a project view + * RecentlyViewed.getRecentlyViewed() – get array of all tracked projects + * RecentlyViewed.isTracked(id) – boolean check if project is tracked + * RecentlyViewed.clearHistory() – remove all tracked projects + * RecentlyViewed.renderPanel() – redraw the recently viewed panel + */ + +var RecentlyViewed = (function () { + "use strict"; + + /* ------------------------------------------------------------------ */ + /* Constants */ + /* ------------------------------------------------------------------ */ + var STORAGE_KEY = "devpathRecentlyViewed"; + var MAX_ITEMS = 10; // Store max 10 items in localStorage + var DISPLAY_ITEMS = 5; // Display max 5 in UI + var PANEL_ID = "recently-viewed-panel"; + var LIST_ID = "recently-viewed-list"; + + /* ------------------------------------------------------------------ */ + /* Safe localStorage helpers */ + /* ------------------------------------------------------------------ */ + function lsGet(key) { + try { + return JSON.parse(localStorage.getItem(key) || "null"); + } catch (err) { + console.warn("[RecentlyViewed] localStorage read error:", err); + return null; + } + } + + function lsSet(key, value) { + try { + localStorage.setItem(key, JSON.stringify(value)); + return true; + } catch (err) { + console.warn("[RecentlyViewed] localStorage write error:", err); + return false; + } + } + + /* ------------------------------------------------------------------ */ + /* Time formatting utilities */ + /* ------------------------------------------------------------------ */ + function formatTimeAgo(timestamp) { + if (!timestamp) return "Recently viewed"; + + var now = Date.now(); + var diff = now - timestamp; + var seconds = Math.floor(diff / 1000); + var minutes = Math.floor(seconds / 60); + var hours = Math.floor(minutes / 60); + var days = Math.floor(hours / 24); + + if (seconds < 60) return "Just now"; + if (minutes < 60) return minutes + " min ago"; + if (hours < 24) return hours + " hour" + (hours > 1 ? "s" : "") + " ago"; + if (days === 1) return "Yesterday"; + if (days < 7) return days + " days ago"; + if (days < 30) return Math.floor(days / 7) + " weeks ago"; + + // Format as date for older items + var date = new Date(timestamp); + return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + } + + /* ------------------------------------------------------------------ */ + /* Recently Viewed Data Management */ + /* ------------------------------------------------------------------ */ + function getRecentlyViewed() { + var data = lsGet(STORAGE_KEY); + return Array.isArray(data) ? data : []; + } + + function isTracked(projectId) { + return getRecentlyViewed().some(function (p) { + return String(p.id) === String(projectId); + }); + } + + function trackView(project) { + if (!project || !project.id) return false; + + var projectId = project.id; + var viewed = getRecentlyViewed(); + + // Check if already tracked - if so, remove old entry and re-add at top + viewed = viewed.filter(function (p) { + return String(p.id) !== String(projectId); + }); + + // Add to top with current timestamp + var newEntry = { + id: projectId, + title: project.title || "Project " + projectId, + interest: project.interest || "General", + timestamp: Date.now() + }; + + viewed.unshift(newEntry); + + // Keep only the last MAX_ITEMS + if (viewed.length > MAX_ITEMS) { + viewed = viewed.slice(0, MAX_ITEMS); + } + + lsSet(STORAGE_KEY, viewed); + renderPanel(); + return true; + } + + function clearHistory() { + lsSet(STORAGE_KEY, []); + renderPanel(); + return true; + } + + /* ------------------------------------------------------------------ */ + /* Rendering */ + /* ------------------------------------------------------------------ */ + function renderPanel() { + var panel = document.getElementById(PANEL_ID); + if (!panel) return; + + var viewed = getRecentlyViewed(); + var list = document.getElementById(LIST_ID); + + if (!list) return; + + // Clear the list + list.innerHTML = ""; + + // If no recently viewed projects, hide the panel or show empty state + if (viewed.length === 0) { + panel.style.display = "none"; + return; + } + + panel.style.display = "block"; + + // Show only the first DISPLAY_ITEMS + var displayItems = viewed.slice(0, DISPLAY_ITEMS); + + displayItems.forEach(function (project) { + var card = buildProjectCard(project); + list.appendChild(card); + }); + } + + function buildProjectCard(project) { + var card = document.createElement("div"); + card.className = "recently-viewed-card"; + card.setAttribute("data-project-id", project.id); + + var link = document.createElement("a"); + link.href = "/project/" + project.id; + link.className = "recently-viewed-link"; + + var title = document.createElement("div"); + title.className = "recently-viewed-title"; + title.textContent = project.title; + + var meta = document.createElement("div"); + meta.className = "recently-viewed-meta"; + + var interest = document.createElement("span"); + interest.className = "recently-viewed-interest"; + interest.textContent = project.interest; + + var time = document.createElement("span"); + time.className = "recently-viewed-time"; + time.textContent = formatTimeAgo(project.timestamp); + + meta.appendChild(interest); + meta.appendChild(time); + + link.appendChild(title); + link.appendChild(meta); + + card.appendChild(link); + return card; + } + + /* ------------------------------------------------------------------ */ + /* Public API */ + /* ------------------------------------------------------------------ */ + return { + trackView: trackView, + getRecentlyViewed: getRecentlyViewed, + isTracked: isTracked, + clearHistory: clearHistory, + renderPanel: renderPanel + }; +})(); + +// Initialize on DOM ready +document.addEventListener("DOMContentLoaded", function () { + RecentlyViewed.renderPanel(); +}); \ No newline at end of file diff --git a/src/static/recently_viewed.css b/src/static/recently_viewed.css new file mode 100644 index 00000000..22f585b2 --- /dev/null +++ b/src/static/recently_viewed.css @@ -0,0 +1,210 @@ +/** + * static/recently-viewed.css + * Styling for the Recently Viewed Projects panel. + * Matches the design of the saved projects panel for consistency. + */ + +/* Recently Viewed Panel Container */ +.recently-viewed-panel { + margin-bottom: 2rem; + padding: 1.5rem; + background: var(--card-bg, #ffffff); + border: 1px solid var(--border, #e5e7eb); + border-radius: 1rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} + +.recently-viewed-panel[style*="display: none"] { + display: none !important; +} + +/* Header with title and count */ +.recently-viewed-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border, #e5e7eb); +} + +.recently-viewed-header > div { + flex: 1; +} + +.recently-viewed-header h3 { + font-size: 1.125rem; + font-weight: 600; + margin: 0 0 0.25rem 0; + color: var(--text-heading, #1f2937); +} + +.recently-viewed-header p { + font-size: 0.875rem; + color: var(--text-muted, #6b7280); + margin: 0; +} + +.recently-viewed-count { + font-size: 0.875rem; + font-weight: 500; + color: var(--text-muted, #6b7280); + white-space: nowrap; +} + +/* List of recently viewed cards */ +.recently-viewed-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 1rem; + margin: 0; + padding: 0; + list-style: none; +} + +/* Individual recently viewed card */ +.recently-viewed-card { + position: relative; + display: flex; + flex-direction: column; +} + +.recently-viewed-link { + display: flex; + flex-direction: column; + padding: 1rem; + background: var(--gray-50, #f9fafb); + border: 1px solid var(--border, #e5e7eb); + border-radius: 0.75rem; + text-decoration: none; + color: inherit; + transition: all 0.2s ease; + cursor: pointer; +} + +.recently-viewed-link:hover { + background: var(--gray-100, #f3f4f6); + border-color: var(--accent, #6366f1); + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.1); + transform: translateY(-2px); +} + +/* Title of recently viewed project */ +.recently-viewed-title { + font-size: 0.95rem; + font-weight: 600; + color: var(--text-heading, #1f2937); + margin: 0 0 0.75rem 0; + word-break: break-word; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* Meta information (interest + time) */ +.recently-viewed-meta { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.recently-viewed-interest { + font-size: 0.75rem; + font-weight: 600; + color: #6366f1; + background: rgba(99, 102, 241, 0.1); + padding: 0.25rem 0.5rem; + border-radius: 0.375rem; + text-transform: capitalize; + white-space: nowrap; +} + +.recently-viewed-time { + font-size: 0.75rem; + color: var(--text-muted, #6b7280); + white-space: nowrap; +} + +/* Dark mode support */ +[data-theme="dark"] .recently-viewed-panel { + background: var(--card-bg-dark, #1f2937); + border-color: var(--border-dark, #374151); +} + +[data-theme="dark"] .recently-viewed-link { + background: var(--gray-700, #374151); + border-color: var(--border-dark, #4b5563); +} + +[data-theme="dark"] .recently-viewed-link:hover { + background: var(--gray-600, #4b5563); + border-color: var(--accent, #818cf8); + box-shadow: 0 4px 12px rgba(129, 140, 248, 0.2); +} + +[data-theme="dark"] .recently-viewed-title { + color: var(--text-light, #f3f4f6); +} + +[data-theme="dark"] .recently-viewed-meta { + color: var(--text-muted-dark, #9ca3af); +} + +[data-theme="dark"] .recently-viewed-interest { + background: rgba(129, 140, 248, 0.15); + color: #a5b4fc; +} + +[data-theme="dark"] .recently-viewed-time { + color: var(--text-muted-dark, #9ca3af); +} + +/* Mobile responsiveness */ +@media (max-width: 768px) { + .recently-viewed-list { + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 0.75rem; + } + + .recently-viewed-header { + flex-direction: column; + gap: 0.5rem; + } + + .recently-viewed-count { + align-self: flex-start; + } + + .recently-viewed-link { + padding: 0.75rem; + } + + .recently-viewed-title { + font-size: 0.875rem; + margin-bottom: 0.5rem; + } + + .recently-viewed-meta { + font-size: 0.7rem; + } +} + +@media (max-width: 480px) { + .recently-viewed-list { + grid-template-columns: 1fr; + } + + .recently-viewed-link { + padding: 1rem; + } + + .recently-viewed-meta { + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + } +} \ No newline at end of file diff --git a/src/static/script.js b/src/static/script.js index 51fddecb..e2b83598 100644 --- a/src/static/script.js +++ b/src/static/script.js @@ -791,6 +791,12 @@ async function updatePortfolioAnalysis() { link.className = "btn-details"; link.textContent = "View Full Project"; link.href = "/project/" + project.id; + + link.addEventListener("click", function() { + if (typeof RecentlyViewed !== "undefined") { + RecentlyViewed.trackView(project); + } + }); footer.appendChild(link); card.appendChild(title); @@ -920,12 +926,11 @@ async function updatePortfolioAnalysis() { clearAllErrors(); hideSuggestions(); resultsSection.style.display = "none"; - showMoreBtn.style.display = "none"; - allProjects = []; - visibleProjectCount = PROJECTS_PER_LOAD; + if (typeof RecentlyViewed !== "undefined") { + RecentlyViewed.clearHistory(); + } if (skillsInput) skillsInput.focus(); - } - + } var clearBtn = document.getElementById("clear-filters-btn"); if (clearBtn) { clearBtn.addEventListener("click", resetFormAndState); @@ -1099,6 +1104,17 @@ async function updatePortfolioAnalysis() { (function initDetailPage() { if (typeof PROJECT_ID === "undefined") return; recordProjectView(); + +if (typeof RecentlyViewed !== "undefined") { + var projectTitle = typeof PROJECT_TITLE !== "undefined" ? PROJECT_TITLE : "Project " + PROJECT_ID; + var projectInterest = document.querySelector("[data-project-interest]"); + var interest = projectInterest ? projectInterest.getAttribute("data-project-interest") : "General"; + RecentlyViewed.trackView({ + id: PROJECT_ID, + title: projectTitle, + interest: interest + }); + } var codePanel = document.getElementById("code-panel"); var codePanelOverlay = document.getElementById("code-panel-overlay"); diff --git a/src/static/style.css b/src/static/style.css index 572da1c8..cde7ad1c 100644 --- a/src/static/style.css +++ b/src/static/style.css @@ -5779,302 +5779,136 @@ body.dark-theme .theme-toggle:hover { background: rgba(124, 58, 237, 0.15); color: #c4b5fd; } -.portfolio-analysis-section{ - padding: 80px 0; -} - -.portfolio-card{ - background: var(--surface); - border: 1px solid var(--border); - border-radius: 20px; - padding: 36px; - box-shadow: var(--shadow-md); -} - -.portfolio-card--empty{ - text-align: center; - padding: 56px 32px; -} - -.portfolio-empty-icon{ - font-size: 40px; - margin-bottom: 12px; -} - -.portfolio-card--empty h3{ - color: var(--text-heading); - margin: 0 0 8px; - font-size: 20px; -} - -.portfolio-card--empty p{ - color: var(--text-body); - max-width: 420px; - margin: 0 auto; - font-size: 15px; -} - -/* ---- Summary row: gauge + headline ---- */ -.portfolio-summary{ - display: flex; - align-items: center; - gap: 32px; - flex-wrap: wrap; - margin-bottom: 32px; -} - -.portfolio-gauge{ - --ring-color: var(--indigo-500); - position: relative; - width: 120px; - height: 120px; - flex-shrink: 0; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - background: conic-gradient(var(--ring-color) calc(var(--score) * 3.6deg), var(--gray-200) 0deg); -} - -.portfolio-gauge--good{ --ring-color: var(--orange-500); } -.portfolio-gauge--low{ --ring-color: var(--red-500); } - -.portfolio-gauge-inner{ - width: 92px; - height: 92px; - border-radius: 50%; - background: var(--surface); - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - line-height: 1; -} - -.portfolio-gauge-value{ - font-size: 28px; - font-weight: 800; - color: var(--text-heading); -} - -.portfolio-gauge-max{ - font-size: 12px; - color: var(--text-light); - margin-top: 2px; -} - -.portfolio-summary-text{ - flex: 1; - min-width: 220px; -} - -.portfolio-summary-eyebrow{ - display: inline-block; - font-size: 12px; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--indigo-500); - margin-bottom: 6px; -} - -.portfolio-summary-text h3{ - font-size: 22px; - color: var(--text-heading); - margin: 0 0 8px; -} +/* Extra small screens (360px - budget Android devices) */ +/* ============================================================ + RESPONSIVE PROJECT CARDS — Multi-breakpoint approach + Ensures no regressions on tablets and desktops + ============================================================ */ + +/* Base: 320px - 380px (Mobile) */ +@media (max-width: 380px) { + .project-card { + padding: 20px; + gap: 12px; + } -.portfolio-summary-text p{ - color: var(--text-body); - font-size: 15px; - margin: 0; -} + .results-grid { + grid-template-columns: 1fr; + gap: 16px; + } -/* ---- Category grid ---- */ -.portfolio-cat-grid{ - display: grid; - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); - gap: 12px; - margin-bottom: 32px; -} + .project-card-tags { + gap: 6px; + margin-bottom: 8px; + } -.portfolio-cat-card{ - display: flex; - align-items: center; - gap: 10px; - padding: 14px 16px; - border-radius: 14px; - border: 1px solid var(--border); - background: var(--bg-secondary); -} + .project-tag { + font-size: 0.7rem; + padding: 3px 9px; + white-space: nowrap; + } -.portfolio-cat-card.is-covered{ - border-color: rgba(16, 185, 129, 0.35); - background: var(--green-100); -} + .btn-save-project, + .btn-details { + padding: 10px 14px; + font-size: 0.82rem; + width: 100%; + } -.portfolio-cat-card.is-missing{ - opacity: 0.75; -} + .project-card-footer { + gap: 8px; + padding-top: 6px; + } -.portfolio-cat-icon{ - font-size: 18px; - flex-shrink: 0; -} + .project-card-title { + font-size: 0.95rem; + } -.portfolio-cat-name{ - flex: 1; - font-size: 14px; - font-weight: 600; - color: var(--text-heading); -} + .project-card-desc { + font-size: 0.82rem; + line-height: 1.6; + } -.portfolio-cat-status{ - display: inline-flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - border-radius: 50%; - flex-shrink: 0; + .container { + padding: 0 16px; + } } -.portfolio-cat-card.is-covered .portfolio-cat-status{ - color: #fff; - background: var(--green-500); -} +/* 381px - 640px (Small Tablet) — RESTORE & STABILIZE */ +@media (min-width: 381px) and (max-width: 640px) { + .project-card { + padding: 24px; /* Intermediate between 20px and 28px */ + gap: 14px; + } -.portfolio-cat-card.is-missing .portfolio-cat-status{ - color: var(--text-light); - background: var(--gray-200); -} + .results-grid { + grid-template-columns: 1fr; /* Still single column */ + gap: 20px; + } -/* ---- Recommendations ---- */ -.portfolio-recommendations h4{ - font-size: 16px; - color: var(--text-heading); - margin: 0 0 14px; -} + .project-tag { + font-size: 0.72rem; + padding: 3px 10px; + } -.portfolio-rec-grid{ - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 12px; -} + .btn-save-project, + .btn-details { + padding: 10px 16px; + font-size: 0.84rem; + } -.portfolio-rec-card{ - display: flex; - align-items: flex-start; - gap: 12px; - padding: 14px 16px; - border-radius: 14px; - border: 1px solid var(--border); - background: var(--indigo-50); -} + .project-card-title { + font-size: 1rem; + } -.portfolio-rec-icon{ - font-size: 20px; - line-height: 1; -} + .project-card-desc { + font-size: 0.85rem; + } -.portfolio-rec-text{ - display: flex; - flex-direction: column; - gap: 2px; + .container { + padding: 0 20px; + } } -.portfolio-rec-category{ - font-size: 11px; - font-weight: 700; - letter-spacing: 0.05em; - text-transform: uppercase; - color: var(--indigo-500); -} +/* 641px+ (Tablet & Desktop) — RESTORE ORIGINAL STYLES */ +@media (min-width: 641px) { + .project-card { + padding: 30px 28px; /* Original value */ + gap: 16px; /* Original value */ + } -.portfolio-rec-title{ - font-size: 14px; - font-weight: 600; - color: var(--text-heading); -} + .results-grid { + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 26px; /* Original value */ + } -.portfolio-rec-empty{ - color: var(--text-body); - font-size: 14px; -} + .project-tag { + font-size: 0.74rem; /* Original */ + padding: 3px 11px; /* Original */ + white-space: normal; /* Allow wrap on larger screens */ + } -@media (max-width: 640px){ - .portfolio-card{ - padding: 24px; + .btn-save-project, + .btn-details { + padding: 11px 18px; /* Original */ + font-size: 0.88rem; /* Original */ } - .portfolio-summary{ - gap: 20px; + + .project-card-footer { + gap: 10px; /* Original */ + padding-top: 8px; /* Original */ } -} -.project-match-explanation { - display: flex; - align-items: flex-start; - gap: 6px; - font-size: 0.85rem; - color: var(--text-color); - opacity: 0.85; - background: var(--bg-hover); - padding: 10px 12px; - border-radius: 6px; - border-left: 3px solid var(--primary-color); - margin-top: 15px; - margin-bottom: 15px; - line-height: 1.4; -} -.explanation-icon { - flex-shrink: 0; - margin-top: 2px; - color: var(--primary-color); -} + .project-card-title { + font-size: 1.08rem; /* Original */ + } -.project-match-score { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 10px; - font-size: 0.8rem; -} -.score-label { - font-weight: 600; - color: var(--text-body); - white-space: nowrap; -} -.score-value { - font-weight: 700; - color: var(--accent); - white-space: nowrap; -} -.score-bar { - flex: 1; - height: 6px; - background: var(--border); - border-radius: 3px; - overflow: hidden; -} -.score-bar-fill { - height: 100%; - background: var(--accent); - border-radius: 3px; - transition: width 0.4s ease; -} + .project-card-desc { + font-size: 0.88rem; /* Original */ + line-height: 1.7; /* Original */ + } -/* Surprise Me Button Styling */ -.btn-surprise { - background-color: #6b21a8; - color: #ffffff; - padding: 0.5rem 1rem; - border: none; - border-radius: 0.375rem; - font-weight: 600; - cursor: pointer; - transition: background-color 0.2s ease-in-out; - margin-left: 0.5rem; -} -.btn-surprise:hover { - background-color: #581c87; + .container { + padding: 0 32px; /* Original */ + } } diff --git a/src/templates/index.html b/src/templates/index.html index a3bdbe30..79c82f75 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -58,7 +58,7 @@ {% include 'partials/theme_head.html' %} - + @@ -1167,14 +1167,15 @@
Projects you've checked out recently.
+