diff --git a/web-app/.gitignore b/web-app/.gitignore index a547bf36..7ceb59f8 100644 --- a/web-app/.gitignore +++ b/web-app/.gitignore @@ -22,3 +22,4 @@ dist-ssr *.njsproj *.sln *.sw? +.env diff --git a/web-app/package-lock.json b/web-app/package-lock.json index b20f3a63..6fa70a2a 100644 --- a/web-app/package-lock.json +++ b/web-app/package-lock.json @@ -8,6 +8,7 @@ "name": "web-app", "version": "0.0.0", "dependencies": { + "@supabase/supabase-js": "^2.97.0", "clsx": "^2.1.1", "framer-motion": "^12.34.2", "github-markdown-css": "^5.9.0", @@ -78,7 +79,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1388,6 +1388,86 @@ "win32" ] }, + "node_modules/@supabase/auth-js": { + "version": "2.97.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.97.0.tgz", + "integrity": "sha512-2Og/1lqp+AIavr8qS2X04aSl8RBY06y4LrtIAGxat06XoXYiDxKNQMQzWDAKm1EyZFZVRNH48DO5YvIZ7la5fQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.97.0", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.97.0.tgz", + "integrity": "sha512-fSaA0ZeBUS9hMgpGZt5shIZvfs3Mvx2ZdajQT4kv/whubqDBAp3GU5W8iIXy21MRvKmO2NpAj8/Q6y+ZkZyF/w==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.97.0", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.97.0.tgz", + "integrity": "sha512-g4Ps0eaxZZurvfv/KGoo2XPZNpyNtjth9aW8eho9LZWM0bUuBtxPZw3ZQ6ERSpEGogshR+XNgwlSPIwcuHCNww==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.97.0", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.97.0.tgz", + "integrity": "sha512-37Jw0NLaFP0CZd7qCan97D1zWutPrTSpgWxAw6Yok59JZoxp4IIKMrPeftJ3LZHmf+ILQOPy3i0pRDHM9FY36Q==", + "license": "MIT", + "dependencies": { + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.97.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.97.0.tgz", + "integrity": "sha512-9f6NniSBfuMxOWKwEFb+RjJzkfMdJUwv9oHuFJKfe/5VJR8cd90qw68m6Hn0ImGtwG37TUO+QHtoOechxRJ1Yg==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.97.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.97.0.tgz", + "integrity": "sha512-kTD91rZNO4LvRUHv4x3/4hNmsEd2ofkYhuba2VMUPRVef1RCmnHtm7rIws38Fg0yQnOSZOplQzafn0GSiy6GVg==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.97.0", + "@supabase/functions-js": "2.97.0", + "@supabase/postgrest-js": "2.97.0", + "@supabase/realtime-js": "2.97.0", + "@supabase/storage-js": "2.97.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@tailwindcss/node": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.0.tgz", @@ -1759,12 +1839,26 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", + "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/phoenix": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", + "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1785,6 +1879,15 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -1818,7 +1921,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1971,7 +2073,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2332,7 +2433,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2805,6 +2905,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4141,7 +4250,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4169,7 +4277,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4221,7 +4328,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4231,7 +4337,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4612,6 +4717,12 @@ "node": ">= 0.8.0" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -4774,7 +4885,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4870,6 +4980,27 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -4896,7 +5027,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web-app/package.json b/web-app/package.json index efa74166..2f615958 100644 --- a/web-app/package.json +++ b/web-app/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@supabase/supabase-js": "^2.97.0", "clsx": "^2.1.1", "framer-motion": "^12.34.2", "github-markdown-css": "^5.9.0", diff --git a/web-app/src/lib/supabase.js b/web-app/src/lib/supabase.js new file mode 100644 index 00000000..b8bc44c8 --- /dev/null +++ b/web-app/src/lib/supabase.js @@ -0,0 +1,9 @@ +import { createClient } from '@supabase/supabase-js' + +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || 'https://gczhgcbtjbvfrgfmpbmv.supabase.co'; +const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY || 'sb_publishable_CyVwHGbtT80AuDFmXNkc9Q_YNcamTGg'; + +// Create a single supabase client for interacting with your database +export const supabase = supabaseUrl && supabaseAnonKey + ? createClient(supabaseUrl, supabaseAnonKey) + : null diff --git a/web-app/src/pages/Home.jsx b/web-app/src/pages/Home.jsx index 7cdd2ee7..906f43f3 100644 --- a/web-app/src/pages/Home.jsx +++ b/web-app/src/pages/Home.jsx @@ -1,8 +1,8 @@ - import { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; -import { Search, Filter, Book, AlertCircle, ArrowRight } from 'lucide-react'; +import { Search, Filter, Book, AlertCircle, ArrowRight, Star } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; +import { supabase } from '../lib/supabase'; export function Home() { const [skills, setSkills] = useState([]); @@ -10,21 +10,84 @@ export function Home() { const [search, setSearch] = useState(''); const [categoryFilter, setCategoryFilter] = useState('all'); const [loading, setLoading] = useState(true); + const [stars, setStars] = useState({}); useEffect(() => { - fetch('/skills.json') - .then(res => res.json()) - .then(data => { + const fetchSkillsAndStars = async () => { + try { + // Fetch basic skill data + const res = await fetch('/skills.json'); + const data = await res.json(); + setSkills(data); setFilteredSkills(data); - setLoading(false); - }) - .catch(err => { + + // Fetch star counts if supabase is configured + if (supabase) { + const { data: starData, error } = await supabase + .from('skill_stars') + .select('skill_id, star_count'); + + if (!error && starData) { + const starMap = {}; + starData.forEach(item => { + starMap[item.skill_id] = item.star_count; + }); + setStars(starMap); + } + } + } catch (err) { console.error("Failed to load skills", err); + } finally { setLoading(false); - }); + } + }; + + fetchSkillsAndStars(); }, []); + const handleStarClick = async (e, skillId) => { + e.preventDefault(); // Prevent link navigation + + // Basic check to prevent spamming from same browser + const storedStars = JSON.parse(localStorage.getItem('user_stars') || '{}'); + if (storedStars[skillId]) return; + + // Optimistically update UI + setStars(prev => ({ + ...prev, + [skillId]: (prev[skillId] || 0) + 1 + })); + + // Remember locally + localStorage.setItem('user_stars', JSON.stringify({ + ...storedStars, + [skillId]: true + })); + + if (supabase) { + // First try to select existing + const { data } = await supabase + .from('skill_stars') + .select('star_count') + .eq('skill_id', skillId) + .single(); + + if (data) { + // Update existing + await supabase + .from('skill_stars') + .update({ star_count: data.star_count + 1 }) + .eq('skill_id', skillId); + } else { + // Insert new + await supabase + .from('skill_stars') + .insert({ skill_id: skillId, star_count: 1 }); + } + } + }; + useEffect(() => { let result = skills; @@ -115,6 +178,14 @@ export function Home() { {skill.category || 'Uncategorized'} +

diff --git a/web-app/src/pages/SkillDetail.jsx b/web-app/src/pages/SkillDetail.jsx index ddc448db..a627169d 100644 --- a/web-app/src/pages/SkillDetail.jsx +++ b/web-app/src/pages/SkillDetail.jsx @@ -1,8 +1,8 @@ - import { useState, useEffect } from 'react'; import { useParams, Link } from 'react-router-dom'; import Markdown from 'react-markdown'; -import { ArrowLeft, Copy, Check, FileCode, AlertTriangle } from 'lucide-react'; +import { ArrowLeft, Copy, Check, FileCode, AlertTriangle, Star } from 'lucide-react'; +import { supabase } from '../lib/supabase'; export function SkillDetail() { const { id } = useParams(); @@ -13,44 +13,88 @@ export function SkillDetail() { const [copiedFull, setCopiedFull] = useState(false); const [error, setError] = useState(null); const [customContext, setCustomContext] = useState(''); + const [starCount, setStarCount] = useState(0); useEffect(() => { - // 1. Fetch index to get skill metadata and path - fetch('/skills.json') - .then(res => res.json()) - .then(skills => { + // Fetch index and stars in parallel when possible + const loadData = async () => { + try { + // 1. Fetch index to get skill metadata and path + const res = await fetch('/skills.json'); + const skills = await res.json(); const foundSkill = skills.find(s => s.id === id); + if (foundSkill) { setSkill(foundSkill); + + // Fetch star count + if (supabase) { + const { data } = await supabase + .from('skill_stars') + .select('star_count') + .eq('skill_id', id) + .single(); + + if (data) { + setStarCount(data.star_count); + } + } + // 2. Fetch the actual markdown content - // The path in JSON is like "skills/category/name" - // We mapped it to "/skills/..." in public folder - // Remove "skills/" prefix if it exists in path to avoid double const cleanPath = foundSkill.path.startsWith('skills/') ? foundSkill.path.replace('skills/', '') : foundSkill.path; - fetch(`/skills/${cleanPath}/SKILL.md`) - .then(res => { - if (!res.ok) throw new Error('Skill file not found'); - return res.text(); - }) - .then(text => { - setContent(text); - setLoading(false); - }) - .catch(err => { - console.error("Failed to load skill content", err); - setError("Could not load skill content. File might be missing."); - setLoading(false); - }); + const mdRes = await fetch(`/skills/${cleanPath}/SKILL.md`); + if (!mdRes.ok) throw new Error('Skill file not found'); + + const text = await mdRes.text(); + setContent(text); } else { setError("Skill not found in registry."); - setLoading(false); } - }); + } catch (err) { + console.error("Failed to load skill data", err); + setError(err.message || "Could not load skill content."); + } finally { + setLoading(false); + } + }; + + loadData(); }, [id]); + const handleStarClick = async () => { + const storedStars = JSON.parse(localStorage.getItem('user_stars') || '{}'); + if (storedStars[id]) return; + + // Optimistic UI updates + setStarCount(prev => prev + 1); + localStorage.setItem('user_stars', JSON.stringify({ + ...storedStars, + [id]: true + })); + + if (supabase) { + const { data } = await supabase + .from('skill_stars') + .select('star_count') + .eq('skill_id', id) + .single(); + + if (data) { + await supabase + .from('skill_stars') + .update({ star_count: data.star_count + 1 }) + .eq('skill_id', id); + } else { + await supabase + .from('skill_stars') + .insert({ skill_id: id, star_count: 1 }); + } + } + }; + const copyToClipboard = () => { const basePrompt = `Use @${skill.name}`; const finalPrompt = customContext.trim() @@ -112,6 +156,13 @@ export function SkillDetail() { {skill.source} )} +

@{skill.name}