Merge pull request #4 from AustinKelsay/feature/dev-journey-basic

Dev Journey, Badges, And Github Account Linking
This commit is contained in:
Austin Kelsay 2025-01-03 15:26:41 -06:00 committed by GitHub
commit 42335d78c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 3867 additions and 1053 deletions

View File

@ -3,7 +3,7 @@ const removeImports = require("next-remove-imports")();
module.exports = removeImports({
reactStrictMode: true,
images: {
domains: ['localhost', 'secure.gravatar.com', 'plebdevs-three.vercel.app', 'plebdevs.com'],
domains: ['localhost', 'secure.gravatar.com', 'plebdevs-three.vercel.app', 'plebdevs.com', 'plebdevs-bucket.nyc3.cdn.digitaloceanspaces.com', 'avatars.githubusercontent.com'],
},
webpack(config, options) {
return config;

491
package-lock.json generated
View File

@ -46,6 +46,7 @@
"primereact": "^10.7.0",
"react": "^18",
"react-dom": "^18",
"reactflow": "^11.11.4",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"uuid": "^10.0.0",
@ -2503,6 +2504,108 @@
}
}
},
"node_modules/@reactflow/background": {
"version": "11.3.14",
"resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz",
"integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.3",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/controls": {
"version": "11.2.14",
"resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz",
"integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.3",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/core": {
"version": "11.11.4",
"resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz",
"integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==",
"license": "MIT",
"dependencies": {
"@types/d3": "^7.4.0",
"@types/d3-drag": "^3.0.1",
"@types/d3-selection": "^3.0.3",
"@types/d3-zoom": "^3.0.1",
"classcat": "^5.0.3",
"d3-drag": "^3.0.0",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/minimap": {
"version": "11.7.14",
"resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz",
"integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"@types/d3-selection": "^3.0.3",
"@types/d3-zoom": "^3.0.1",
"classcat": "^5.0.3",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/node-resizer": {
"version": "2.2.14",
"resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz",
"integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.4",
"d3-drag": "^3.0.0",
"d3-selection": "^3.0.0",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/node-toolbar": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz",
"integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.3",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@rushstack/eslint-patch": {
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.10.3.tgz",
@ -3450,6 +3553,259 @@
"react": "^18.0.0"
}
},
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
"integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
"license": "MIT",
"dependencies": {
"@types/d3-array": "*",
"@types/d3-axis": "*",
"@types/d3-brush": "*",
"@types/d3-chord": "*",
"@types/d3-color": "*",
"@types/d3-contour": "*",
"@types/d3-delaunay": "*",
"@types/d3-dispatch": "*",
"@types/d3-drag": "*",
"@types/d3-dsv": "*",
"@types/d3-ease": "*",
"@types/d3-fetch": "*",
"@types/d3-force": "*",
"@types/d3-format": "*",
"@types/d3-geo": "*",
"@types/d3-hierarchy": "*",
"@types/d3-interpolate": "*",
"@types/d3-path": "*",
"@types/d3-polygon": "*",
"@types/d3-quadtree": "*",
"@types/d3-random": "*",
"@types/d3-scale": "*",
"@types/d3-scale-chromatic": "*",
"@types/d3-selection": "*",
"@types/d3-shape": "*",
"@types/d3-time": "*",
"@types/d3-time-format": "*",
"@types/d3-timer": "*",
"@types/d3-transition": "*",
"@types/d3-zoom": "*"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
"integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==",
"license": "MIT"
},
"node_modules/@types/d3-axis": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
"integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-brush": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
"integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-chord": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
"integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-contour": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
"integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
"license": "MIT",
"dependencies": {
"@types/d3-array": "*",
"@types/geojson": "*"
}
},
"node_modules/@types/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
"license": "MIT"
},
"node_modules/@types/d3-dispatch": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.6.tgz",
"integrity": "sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-dsv": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
"integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-fetch": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
"integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
"license": "MIT",
"dependencies": {
"@types/d3-dsv": "*"
}
},
"node_modules/@types/d3-force": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
"license": "MIT"
},
"node_modules/@types/d3-format": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
"integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
"license": "MIT"
},
"node_modules/@types/d3-geo": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/d3-hierarchy": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
"integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==",
"license": "MIT"
},
"node_modules/@types/d3-polygon": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
"integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
"license": "MIT"
},
"node_modules/@types/d3-quadtree": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
"integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
"license": "MIT"
},
"node_modules/@types/d3-random": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
"integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz",
"integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
"license": "MIT"
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-shape": {
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz",
"integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-time-format": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
"integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@ -3474,6 +3830,12 @@
"@types/estree": "*"
}
},
"node_modules/@types/geojson": {
"version": "7946.0.15",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.15.tgz",
"integrity": "sha512-9oSxFzDCT2Rj6DfcHF8G++jxBKS7mBqXl5xrRW+Kbvjry6Uduya2iiwqHPhVXpasAVMBYKkEPGgKhd3+/HZ6xA==",
"license": "MIT"
},
"node_modules/@types/hast": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
@ -6092,6 +6454,12 @@
"node": ">=6"
}
},
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@ -6270,6 +6638,111 @@
"node": ">=0.12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@ -11852,6 +12325,24 @@
"react-dom": ">=16.6.0"
}
},
"node_modules/reactflow": {
"version": "11.11.4",
"resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz",
"integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==",
"license": "MIT",
"dependencies": {
"@reactflow/background": "11.3.14",
"@reactflow/controls": "11.2.14",
"@reactflow/core": "11.11.4",
"@reactflow/minimap": "11.7.14",
"@reactflow/node-resizer": "2.2.14",
"@reactflow/node-toolbar": "1.3.14"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",

View File

@ -47,6 +47,7 @@
"primereact": "^10.7.0",
"react": "^18",
"react-dom": "^18",
"reactflow": "^11.11.4",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"uuid": "^10.0.0",

View File

@ -0,0 +1,39 @@
-- CreateTable
CREATE TABLE "Badge" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"noteId" TEXT NOT NULL,
"courseId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Badge_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserBadge" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"badgeId" TEXT NOT NULL,
"awardedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UserBadge_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Badge_noteId_key" ON "Badge"("noteId");
-- CreateIndex
CREATE UNIQUE INDEX "Badge_courseId_key" ON "Badge"("courseId");
-- CreateIndex
CREATE UNIQUE INDEX "UserBadge_userId_badgeId_key" ON "UserBadge"("userId", "badgeId");
-- AddForeignKey
ALTER TABLE "Badge" ADD CONSTRAINT "Badge_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserBadge" ADD CONSTRAINT "UserBadge_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserBadge" ADD CONSTRAINT "UserBadge_badgeId_fkey" FOREIGN KEY ("badgeId") REFERENCES "Badge"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,8 @@
-- DropForeignKey
ALTER TABLE "Badge" DROP CONSTRAINT "Badge_courseId_fkey";
-- AlterTable
ALTER TABLE "Badge" ALTER COLUMN "courseId" DROP NOT NULL;
-- AddForeignKey
ALTER TABLE "Badge" ADD CONSTRAINT "Badge_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "UserCourse" ADD COLUMN "submittedRepoLink" TEXT;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Course" ADD COLUMN "submissionRequired" BOOLEAN NOT NULL DEFAULT false;

View File

@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@ -13,6 +13,7 @@ generator client {
provider = "prisma-client-js"
}
// todo name and username?
model User {
id String @id @default(uuid())
pubkey String? @unique
@ -37,6 +38,7 @@ model User {
userCourses UserCourse[]
nip05 Nip05?
lightningAddress LightningAddress?
userBadges UserBadge[]
}
model Session {
@ -128,9 +130,11 @@ model Course {
lessons Lesson[]
purchases Purchase[]
noteId String? @unique
submissionRequired Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userCourses UserCourse[]
badge Badge?
}
model CourseDraft {
@ -215,6 +219,7 @@ model UserCourse {
completed Boolean @default(false)
startedAt DateTime?
completedAt DateTime?
submittedRepoLink String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -246,3 +251,25 @@ model LightningAddress {
lndHost String
lndPort String @default("8080")
}
model Badge {
id String @id @default(uuid())
name String
noteId String @unique
courseId String? @unique // Optional relation to course
course Course? @relation(fields: [courseId], references: [id])
userBadges UserBadge[] // Many users can have this badge
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model UserBadge {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id])
badgeId String
badge Badge @relation(fields: [badgeId], references: [id])
awardedAt DateTime @default(now())
@@unique([userId, badgeId]) // Each user can only have one of each badge
}

View File

@ -1,6 +1,7 @@
import React, { useEffect, useState, useRef } from 'react';
import useWindowWidth from '@/hooks/useWindowWidth';
import Image from 'next/image';
import { signIn } from 'next-auth/react';
import { useImageProxy } from '@/hooks/useImageProxy';
import { useRouter } from 'next/router';
import { Avatar } from 'primereact/avatar';
@ -125,24 +126,27 @@ const HeroBanner = () => {
</div>
<div className="space-x-4">
<GenericButton
label="Learn"
label="Learn How to Code"
icon={<i className="pi pi-book pr-2 text-2xl" />}
rounded
severity="info"
className="border-2"
size={isMobile ? null : "large"}
outlined
onClick={() => router.push('/content?tag=all')}
onClick={() => signIn('anonymous', {
callbackUrl: '/course/naddr1qvzqqqr4xspzpueu32tp0jc47uzlcuxdgcw06m40ytu7ynpna2adnqty3e0vda6pqy88wumn8ghj7mn0wvhxcmmv9uq32amnwvaz7tmjv4kxz7fwv3sk6atn9e5k7tcpr9mhxue69uhhyetvv9ujuumwdae8gtnnda3kjctv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uq36amnwvaz7tmjv4kxz7fwd46hg6tw09mkzmrvv46zucm0d5hsz9mhwden5te0wfjkccte9ec8y6tdv9kzumn9wshszynhwden5te0dehhxarjxgcjucm0d5hszynhwden5te0dehhxarjw4jjucm0d5hsz9nhwden5te0wp6hyurvv4ex2mrp0yhxxmmd9uq3wamnwvaz7tmjv4kxz7fwv3jhvueww3hk7mrn9uqzge34xvuxvdtrx5knzcfhxgkngwpsxsknsetzxyknxe3sx43k2cfkxsurwdq68epwa?active=starter',
redirect: true,
})}
/>
<GenericButton
label="Connect"
icon={<i className="pi pi-users pr-2 text-2xl" />}
label="Level Up"
icon={<i className="pi pi-video pr-2 text-2xl" />}
rounded
size={isMobile ? null : "large"}
severity="success"
className="border-2"
outlined
onClick={() => router.push('/feed?channel=global')}
onClick={() => router.push('/content?tag=all')}
/>
</div>
</div>

View File

@ -0,0 +1,243 @@
import React, { useState, useCallback } from 'react';
import { Tooltip } from 'primereact/tooltip';
import useWindowWidth from '@/hooks/useWindowWidth';
const ActivityContributionChart = ({ session }) => {
const [contributionData, setContributionData] = useState({});
const [totalActivities, setTotalActivities] = useState(0);
const windowWidth = useWindowWidth();
// Prepare activity data
const prepareActivityData = useCallback(() => {
if (!session?.user?.userCourses) return {};
const activityData = {};
const allActivities = [];
// Process course activities
session.user.userCourses.forEach(courseProgress => {
if (courseProgress.started) {
const startDate = new Date(courseProgress.startedAt);
const date = new Date(startDate.getTime() - startDate.getTimezoneOffset() * 60000)
.toISOString().split('T')[0];
activityData[date] = (activityData[date] || 0) + 1;
allActivities.push({
type: 'course_started',
name: courseProgress.course?.name,
date: date
});
}
if (courseProgress.completed) {
const completeDate = new Date(courseProgress.completedAt);
const date = new Date(completeDate.getTime() - completeDate.getTimezoneOffset() * 60000)
.toISOString().split('T')[0];
activityData[date] = (activityData[date] || 0) + 1;
allActivities.push({
type: 'course_completed',
name: courseProgress.course?.name,
date: date
});
}
});
// Process lesson activities
session.user.userLessons?.forEach(lessonProgress => {
if (lessonProgress.opened) {
const openDate = new Date(lessonProgress.openedAt);
const date = new Date(openDate.getTime() - openDate.getTimezoneOffset() * 60000)
.toISOString().split('T')[0];
activityData[date] = (activityData[date] || 0) + 1;
allActivities.push({
type: 'lesson_started',
name: lessonProgress.lesson?.name,
date: date
});
}
if (lessonProgress.completed) {
const completeDate = new Date(lessonProgress.completedAt);
const date = new Date(completeDate.getTime() - completeDate.getTimezoneOffset() * 60000)
.toISOString().split('T')[0];
activityData[date] = (activityData[date] || 0) + 1;
allActivities.push({
type: 'lesson_completed',
name: lessonProgress.lesson?.name,
date: date
});
}
});
setContributionData(activityData);
setTotalActivities(Object.values(activityData).reduce((a, b) => a + b, 0));
return activityData;
}, [session]);
// Initialize data
React.useEffect(() => {
prepareActivityData();
}, [prepareActivityData]);
const getColor = useCallback((count) => {
if (count === 0) return 'bg-gray-100';
if (count < 3) return 'bg-green-300';
if (count < 6) return 'bg-green-400';
if (count < 12) return 'bg-green-600';
return 'bg-green-700';
}, []);
const generateCalendar = useCallback(() => {
const today = new Date();
today.setHours(23, 59, 59, 999);
// Calculate the start date (52 weeks + remaining days to today)
const oneYearAgo = new Date(today);
oneYearAgo.setDate(today.getDate() - 364);
// Start from the first Sunday before or on oneYearAgo
const startDate = new Date(oneYearAgo);
startDate.setDate(startDate.getDate() - startDate.getDay());
const calendar = [];
for (let i = 0; i < 7; i++) {
calendar[i] = [];
}
// Fill in the dates by week columns
let currentDate = new Date(startDate);
while (currentDate <= today) {
const weekDay = currentDate.getDay();
// Use local timezone date string instead of ISO string
const dateString = currentDate.toLocaleDateString('en-CA'); // YYYY-MM-DD format
const activityCount = contributionData[dateString] || 0;
calendar[weekDay].push({
date: new Date(currentDate),
count: activityCount
});
currentDate.setDate(currentDate.getDate() + 1);
}
return calendar;
}, [contributionData]);
const getMonthLabels = useCallback(() => {
const today = new Date();
today.setHours(23, 59, 59, 999);
// Calculate exactly 52 weeks back
const oneYearAgo = new Date(today);
oneYearAgo.setDate(today.getDate() - 364);
// Start from the first Sunday
const startDate = new Date(oneYearAgo);
startDate.setDate(startDate.getDate() - startDate.getDay());
const months = [];
let currentMonth = -1;
const calendar = generateCalendar();
let currentDate = new Date(startDate);
while (currentDate <= today) {
const month = currentDate.getMonth();
if (month !== currentMonth) {
months.push({
name: currentDate.toLocaleString('default', { month: 'short' }),
index: calendar[0].findIndex(
(_, weekIndex) => calendar[0][weekIndex]?.date.getMonth() === month
)
});
currentMonth = month;
}
currentDate.setDate(currentDate.getDate() + 1);
}
return months;
}, [generateCalendar]);
const calendar = generateCalendar();
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const getScaleClass = (width) => {
if (width <= 800) return 'overflow-x-auto';
if (width <= 1000) return 'scale-95 origin-top-left';
return '';
};
return (
<div className="w-full mx-2 bg-gray-800 rounded-lg border border-gray-700 shadow-md h-[330px] max-lap:mx-0 max-lap:mt-2">
<div className="flex flex-row justify-between items-center p-4">
<h1 className="text-2xl font-bold text-gray-200">Activity</h1>
<i className="pi pi-question-circle text-2xl cursor-pointer text-gray-200"
data-pr-tooltip="Total number of learning activities on the platform" />
<Tooltip target=".pi-question-circle" position="left" />
</div>
<div className={`${getScaleClass(windowWidth)}`}>
<div className="min-w-[910px] p-4">
<div className="flex justify-between items-center mb-3">
<h4 className="text-base font-semibold text-gray-200">
{totalActivities} learning activities in the last year
</h4>
</div>
<div className="flex">
{/* Days of week labels */}
<div className="flex flex-col gap-[3px] text-[11px] text-gray-400 pr-3">
{weekDays.map((day, index) => (
<div key={day} className="h-[13px] leading-[13px]">
{index % 2 === 0 && day}
</div>
))}
</div>
<div className="flex flex-col">
{/* Calendar grid */}
<div className="flex gap-[3px]">
{calendar[0].map((_, weekIndex) => (
<div key={weekIndex} className="flex flex-col gap-[3px]">
{calendar.map((row, dayIndex) => (
row[weekIndex] && (
<div
key={`${weekIndex}-${dayIndex}`}
className={`w-[14px] h-[14px] ${getColor(row[weekIndex].count)} rounded-[2px] cursor-pointer transition-colors duration-100`}
title={`${row[weekIndex].date.toDateString()}: ${
row[weekIndex].count > 0
? `${row[weekIndex].count} activit${row[weekIndex].count !== 1 ? 'ies' : 'y'}`
: 'No activities'
}`}
></div>
)
))}
</div>
))}
</div>
{/* Month labels */}
<div className="flex text-[11px] text-gray-400 h-[20px] mt-1 relative">
{getMonthLabels().map((month, index) => (
<div
key={index}
className="absolute"
style={{ left: `${month.index * 15}px` }}
>
{month.name}
</div>
))}
</div>
</div>
</div>
{/* Legend */}
<div className="text-[11px] text-gray-400 flex items-center justify-start pt-4">
<span className="mr-2">Less</span>
<div className="flex gap-[3px]">
<div className="w-[14px] h-[14px] bg-gray-100 rounded-[2px]"></div>
<div className="w-[14px] h-[14px] bg-green-300 rounded-[2px]"></div>
<div className="w-[14px] h-[14px] bg-green-400 rounded-[2px]"></div>
<div className="w-[14px] h-[14px] bg-green-600 rounded-[2px]"></div>
<div className="w-[14px] h-[14px] bg-green-700 rounded-[2px]"></div>
</div>
<span className="ml-2">More</span>
</div>
</div>
</div>
</div>
);
};
export default ActivityContributionChart;

View File

@ -0,0 +1,314 @@
import React, { useState, useCallback, useEffect } from 'react';
import { useFetchGithubCommits } from '@/hooks/githubQueries/useFetchGithubCommits';
import { Tooltip } from 'primereact/tooltip';
import { formatDateTime } from "@/utils/time";
import useWindowWidth from '@/hooks/useWindowWidth';
const CombinedContributionChart = ({ session }) => {
const [contributionData, setContributionData] = useState({});
const [totalContributions, setTotalContributions] = useState(0);
const windowWidth = useWindowWidth();
const [retryCount, setRetryCount] = useState(0);
const prepareProgressData = useCallback(() => {
if (!session?.user?.userCourses) return {};
const activityData = {};
const allActivities = []; // Array to store all activities for logging
// Process course activities
session.user.userCourses.forEach(courseProgress => {
if (courseProgress.started) {
const startDate = new Date(courseProgress.startedAt);
const date = new Date(startDate.getTime() - startDate.getTimezoneOffset() * 60000)
.toLocaleDateString('en-CA');
activityData[date] = (activityData[date] || 0) + 1;
allActivities.push({
type: 'course_started',
name: courseProgress.course?.name,
date: date
});
}
if (courseProgress.completed) {
const completeDate = new Date(courseProgress.completedAt);
const date = new Date(completeDate.getTime() - completeDate.getTimezoneOffset() * 60000)
.toLocaleDateString('en-CA');
activityData[date] = (activityData[date] || 0) + 1;
allActivities.push({
type: 'course_completed',
name: courseProgress.course?.name,
date: date
});
}
});
// Process lesson activities
session.user.userLessons?.forEach(lessonProgress => {
if (lessonProgress.opened) {
const openDate = new Date(lessonProgress.openedAt);
const date = new Date(openDate.getTime() - openDate.getTimezoneOffset() * 60000)
.toLocaleDateString('en-CA');
activityData[date] = (activityData[date] || 0) + 1;
allActivities.push({
type: 'lesson_started',
name: lessonProgress.lesson?.name,
date: date
});
}
if (lessonProgress.completed) {
const completeDate = new Date(lessonProgress.completedAt);
const date = new Date(completeDate.getTime() - completeDate.getTimezoneOffset() * 60000)
.toLocaleDateString('en-CA');
activityData[date] = (activityData[date] || 0) + 1;
allActivities.push({
type: 'lesson_completed',
name: lessonProgress.lesson?.name,
date: date
});
}
});
return activityData;
}, [session]);
const handleNewCommit = useCallback(({ contributionData, totalCommits }) => {
const activityData = prepareProgressData();
// Create a new object with GitHub commits
const combinedData = { ...contributionData };
// Add activities to the combined data
Object.entries(activityData).forEach(([date, count]) => {
combinedData[date] = (combinedData[date] || 0) + count;
});
setContributionData(combinedData);
setTotalContributions(totalCommits + Object.values(activityData).reduce((a, b) => a + b, 0));
}, [prepareProgressData]);
const {
data,
isLoading,
isFetching,
error,
refetch
} = useFetchGithubCommits(session, handleNewCommit);
// Add recovery logic
useEffect(() => {
if (error && retryCount < 3) {
const timer = setTimeout(() => {
setRetryCount(prev => prev + 1);
refetch();
}, 1000 * (retryCount + 1)); // Exponential backoff
return () => clearTimeout(timer);
}
}, [error, retryCount, refetch]);
// Reset retry count on successful data fetch
useEffect(() => {
if (data) {
setRetryCount(0);
}
}, [data]);
// Add loading state check
useEffect(() => {
if (isLoading || isFetching) {
const loadingTimeout = setTimeout(() => {
if (!data) {
refetch();
}
}, 5000); // Timeout after 5 seconds
return () => clearTimeout(loadingTimeout);
}
}, [isLoading, isFetching, data, refetch]);
// Initialize from cached data if available
useEffect(() => {
if (data && !isLoading) {
const activityData = prepareProgressData();
const combinedData = { ...data.contributionData };
// Add activities to the combined data
Object.entries(activityData).forEach(([date, count]) => {
combinedData[date] = (combinedData[date] || 0) + count;
});
setContributionData(combinedData);
setTotalContributions(data.totalCommits + Object.values(activityData).reduce((a, b) => a + b, 0));
}
}, [data, isLoading, prepareProgressData]);
const getColor = useCallback((count) => {
if (count === 0) return 'bg-gray-100';
if (count < 3) return 'bg-green-300';
if (count < 6) return 'bg-green-400';
if (count < 12) return 'bg-green-600';
return 'bg-green-700';
}, []);
const generateCalendar = useCallback(() => {
const today = new Date();
today.setHours(23, 59, 59, 999);
// Calculate the start date (52 weeks + remaining days to today)
const oneYearAgo = new Date(today);
oneYearAgo.setDate(today.getDate() - 364); // 52 weeks * 7 days = 364 days
// Start from the first Sunday before or on oneYearAgo
const startDate = new Date(oneYearAgo);
startDate.setDate(startDate.getDate() - startDate.getDay());
const calendar = [];
// Create 7 rows for days of the week (Sunday to Saturday)
for (let i = 0; i < 7; i++) {
calendar[i] = [];
}
// Fill in the dates by week columns
let currentDate = new Date(startDate);
while (currentDate <= today) {
const weekDay = currentDate.getDay();
const dateString = currentDate.toLocaleDateString('en-CA');
const githubCount = data?.contributionData[dateString] || 0;
const activityCount = (contributionData[dateString] || 0) - (data?.contributionData[dateString] || 0);
const totalCount = githubCount + activityCount;
calendar[weekDay].push({
date: new Date(currentDate),
count: totalCount,
githubCount,
activityCount
});
currentDate.setDate(currentDate.getDate() + 1);
}
return calendar;
}, [contributionData, data?.contributionData]);
const calendar = generateCalendar();
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const getMonthLabels = useCallback(() => {
const today = new Date();
const oneYearAgo = new Date(today);
oneYearAgo.setFullYear(today.getFullYear() - 1);
oneYearAgo.setDate(today.getDate() + 1);
const months = [];
let currentMonth = -1;
for (let d = new Date(oneYearAgo); d <= today; d.setDate(d.getDate() + 1)) {
const month = d.getMonth();
if (month !== currentMonth) {
months.push({
name: d.toLocaleString('default', { month: 'short' }),
index: calendar[0].findIndex(
(_, weekIndex) => calendar[0][weekIndex]?.date.getMonth() === month
)
});
currentMonth = month;
}
}
return months;
}, [calendar]);
const getScaleClass = (width) => {
if (width <= 800) return 'overflow-x-auto';
if (width <= 1000) return 'scale-95 origin-top-left';
return '';
};
return (
<div className="w-full mx-2 bg-gray-800 rounded-lg border border-gray-700 shadow-md h-[330px] max-lap:mx-0 max-lap:mt-2">
<div className="flex flex-row justify-between items-center p-4">
<h1 className="text-2xl font-bold text-gray-200">Activity</h1>
<i className="pi pi-question-circle text-2xl cursor-pointer text-gray-200"
data-pr-tooltip="Combined total of GitHub commits and learning activities (starting/completing courses and lessons)" />
<Tooltip target=".pi-question-circle" position="left" />
</div>
<div className={`${getScaleClass(windowWidth)}`}>
<div className="min-w-[910px] p-4">
{(isLoading || isFetching) && <h4 className="text-base font-semibold text-gray-200 mb-3">Loading contribution data... ({totalContributions} total contributions / activities fetched)</h4>}
{!isLoading && !isFetching &&
<div className="flex justify-between items-center mb-3">
<h4 className="text-base font-semibold text-gray-200">
{totalContributions} total contributions / activities in the last year
</h4>
</div>
}
<div className="flex">
{/* Days of week labels */}
<div className={`flex flex-col gap-[3px] text-[11px] text-gray-400 pr-3`}>
{weekDays.map((day, index) => (
<div key={day} className="h-[13px] leading-[13px]">
{index % 2 === 0 && day}
</div>
))}
</div>
<div className="flex flex-col">
{/* Calendar grid */}
<div className="flex gap-[3px]">
{calendar[0].map((_, weekIndex) => (
<div key={weekIndex} className="flex flex-col gap-[3px]">
{calendar.map((row, dayIndex) => (
row[weekIndex] && (
<div
key={`${weekIndex}-${dayIndex}`}
className={`w-[14px] h-[14px] ${getColor(row[weekIndex].count)} rounded-[2px] cursor-pointer transition-colors duration-100`}
title={`${row[weekIndex].date.toDateString()}: ${[
row[weekIndex].githubCount > 0 ? `${row[weekIndex].githubCount} contribution${row[weekIndex].githubCount !== 1 ? 's' : ''}` : '',
row[weekIndex].activityCount > 0 ? `${row[weekIndex].activityCount} activit${row[weekIndex].activityCount !== 1 ? 'ies' : 'y'}` : ''
].filter(Boolean).join(' & ') || 'No contributions or activities'
}`}
></div>
)
))}
</div>
))}
</div>
{/* Month labels moved to bottom */}
<div className="flex text-[11px] text-gray-400 h-[20px] mt-1 relative">
{getMonthLabels().map((month, index) => (
<div
key={index}
className="absolute"
style={{ left: `${month.index * 15}px` }}
>
{month.name}
</div>
))}
</div>
</div>
</div>
<div className="text-[11px] text-gray-400 flex items-center justify-start pt-4">
<span className="mr-2">Less</span>
<div className="flex gap-[3px]">
<div className="w-[14px] h-[14px] bg-gray-100 rounded-[2px]"></div>
<div className="w-[14px] h-[14px] bg-green-300 rounded-[2px]"></div>
<div className="w-[14px] h-[14px] bg-green-400 rounded-[2px]"></div>
<div className="w-[14px] h-[14px] bg-green-600 rounded-[2px]"></div>
<div className="w-[14px] h-[14px] bg-green-700 rounded-[2px]"></div>
</div>
<span className="ml-2">More</span>
</div>
{error && retryCount >= 3 && (
<div className="text-red-400 text-sm px-4">
Error loading data. <button onClick={() => {
setRetryCount(0);
refetch();
}} className="text-blue-400 hover:text-blue-300">
Try again
</button>
</div>
)}
</div>
</div>
</div>
);
};
export default CombinedContributionChart;

View File

@ -1,86 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useFetchGithubCommits } from '@/hooks/githubQueries/useFetchGithubCommits';
import { Tooltip } from 'primereact/tooltip';
const GithubContributionChart = ({ username }) => {
const [contributionData, setContributionData] = useState({});
const [totalCommits, setTotalCommits] = useState(0);
const { data: commits, isLoading, isFetching } = useFetchGithubCommits(username);
const getColor = useCallback((count) => {
if (count === 0) return 'bg-gray-100';
if (count < 5) return 'bg-green-300';
if (count < 10) return 'bg-green-400';
if (count < 20) return 'bg-green-600';
return 'bg-green-700';
}, []);
const generateCalendar = useCallback(() => {
const today = new Date();
const sixMonthsAgo = new Date(today.getFullYear(), today.getMonth() - 5, 1);
const calendar = [];
for (let d = new Date(sixMonthsAgo); d <= today; d.setDate(d.getDate() + 1)) {
const dateString = d.toISOString().split('T')[0];
const count = contributionData[dateString] || 0;
calendar.push({ date: new Date(d), count });
}
return calendar;
}, [contributionData]);
useEffect(() => {
if (commits) {
let commitCount = 0;
const newContributionData = {};
commits.forEach(commit => {
const date = commit.commit.author.date.split('T')[0];
newContributionData[date] = (newContributionData[date] || 0) + 1;
commitCount++;
});
setContributionData(newContributionData);
setTotalCommits(commitCount);
console.log(`Total commits fetched: ${commitCount}`);
}
}, [commits]);
const calendar = generateCalendar();
return (
<div className="mx-auto py-2 px-4 max-w-[900px] bg-gray-800 rounded-lg">
{(isLoading || isFetching) && <p>Loading contribution data... ({totalCommits} commits fetched)</p>}
{!isLoading && !isFetching &&
<div className="flex justify-between items-center pr-1">
<p className="mb-2">Total commits: {totalCommits}</p>
<i className="pi pi-question-circle cursor-pointer" data-pr-tooltip="Total number of commits made to GitHub repositories over the last 6 months. (may not be 100% accurate)" />
<Tooltip target=".pi-question-circle" position="top" />
</div>
}
<div className="flex flex-wrap gap-1">
{calendar.map((day, index) => (
<div
key={index}
className={`w-3 h-3 ${getColor(day.count)} rounded-sm cursor-pointer transition-all duration-200 ease-in-out hover:transform hover:scale-150`}
title={`${day.date.toDateString()}: ${day.count} contribution${day.count !== 1 ? 's' : ''}`}
></div>
))}
</div>
<div className="mt-2 text-sm text-gray-400 flex items-center">
<span className="mr-2">Less</span>
<div className="flex gap-1">
<div className="w-3 h-3 bg-gray-100 rounded-sm"></div>
<div className="w-3 h-3 bg-green-300 rounded-sm"></div>
<div className="w-3 h-3 bg-green-400 rounded-sm"></div>
<div className="w-3 h-3 bg-green-600 rounded-sm"></div>
<div className="w-3 h-3 bg-green-700 rounded-sm"></div>
</div>
<span className="ml-2">More</span>
</div>
</div>
);
};
export default GithubContributionChart;

View File

@ -1,55 +0,0 @@
import React, { useMemo } from 'react';
const GithubContributionChartDisabled = () => {
const getRandomColor = () => {
const random = Math.random();
if (random < 0.4) return 'bg-gray-100';
if (random < 0.6) return 'bg-green-300';
if (random < 0.75) return 'bg-green-400';
if (random < 0.9) return 'bg-green-600';
return 'bg-green-700';
};
const calendar = useMemo(() => {
const today = new Date();
const sixMonthsAgo = new Date(today.getFullYear(), today.getMonth() - 5, 1);
const calendar = [];
for (let d = new Date(sixMonthsAgo); d <= today; d.setDate(d.getDate() + 1)) {
calendar.push({ date: new Date(d), color: getRandomColor() });
}
return calendar;
}, []);
return (
<div className="relative mx-auto py-2 px-4 max-w-[900px] bg-gray-800 rounded-lg">
<div className="opacity-30">
<div className="flex flex-wrap gap-1">
{calendar.map((day, index) => (
<div
key={index}
className={`w-3 h-3 ${day.color} rounded-sm`}
></div>
))}
</div>
<div className="mt-2 text-sm text-gray-400 flex items-center">
<span className="mr-2">Less</span>
<div className="flex gap-1">
<div className="w-3 h-3 bg-gray-100 rounded-sm"></div>
<div className="w-3 h-3 bg-green-300 rounded-sm"></div>
<div className="w-3 h-3 bg-green-400 rounded-sm"></div>
<div className="w-3 h-3 bg-green-600 rounded-sm"></div>
<div className="w-3 h-3 bg-green-700 rounded-sm"></div>
</div>
<span className="ml-2">More</span>
</div>
</div>
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 rounded-lg">
<p className="text-white text-xl font-semibold">Connect to GitHub (Coming Soon)</p>
</div>
</div>
);
};
export default GithubContributionChartDisabled;

View File

@ -17,6 +17,7 @@ import { useNDKContext } from "@/context/NDKContext";
import { findKind0Fields } from '@/utils/nostr';
import appConfig from "@/config/appConfig";
import useTrackCourse from '@/hooks/tracking/useTrackCourse';
import WelcomeModal from '@/components/onboarding/WelcomeModal';
import { ProgressSpinner } from 'primereact/progressspinner';
export default function CourseDetails({ processedEvent, paidCourse, lessons, decryptionPerformed, handlePaymentSuccess, handlePaymentError }) {
@ -148,6 +149,7 @@ export default function CourseDetails({ processedEvent, paidCourse, lessons, dec
return (
<div className="w-full">
<WelcomeModal />
<div className="relative w-full h-[400px] mb-8">
<Image
alt="course image"

View File

@ -1,11 +1,11 @@
import React, { useEffect, useState } from "react";
import { useNDKContext } from "@/context/NDKContext";
import { parseCourseEvent } from "@/utils/nostr";
import { parseCourseEvent, parseEvent } from "@/utils/nostr";
import { ProgressSpinner } from "primereact/progressspinner";
import { nip19 } from "nostr-tools";
import appConfig from "@/config/appConfig";
const ProgressListItem = ({ dTag, category }) => {
const ProgressListItem = ({ dTag, category, type = 'course' }) => {
const { ndk } = useNDKContext();
const [event, setEvent] = useState(null);
@ -16,25 +16,26 @@ const ProgressListItem = ({ dTag, category }) => {
try {
await ndk.connect();
const filter = {
kinds: [30004],
"#d": [dTag]
kinds: type === 'course' ? [30004] : [30023, 30402],
authors: appConfig.authorPubkeys,
"#d": [dTag],
}
const event = await ndk.fetchEvent(filter);
if (event) {
setEvent(parseCourseEvent(event));
setEvent(type === 'course' ? parseCourseEvent(event) : parseEvent(event));
}
} catch (error) {
console.error("Error fetching event:", error);
}
}
fetchEvent();
}, [dTag, ndk]);
}, [dTag, ndk, type]);
const encodeNaddr = () => {
return nip19.naddrEncode({
pubkey: event.pubkey,
identifier: event.d,
kind: 30004,
kind: type === 'course' ? 30004 : event.kind,
relays: appConfig.defaultRelayUrls
})
}
@ -43,9 +44,13 @@ const ProgressListItem = ({ dTag, category }) => {
if (!event) return null;
if (category === "name") {
const href = type === 'course'
? `/course/${encodeNaddr()}`
: `/details/${encodeNaddr()}`;
return (
<a className="text-blue-500 underline hover:text-blue-600" href={`/course/${encodeNaddr()}`}>
{event.name}
<a className="text-blue-500 underline hover:text-blue-600" href={href}>
{event.name || event.title}
</a>
);
} else if (category === "lessons") {

View File

@ -77,7 +77,6 @@ const PublishedCourseForm = ({ course }) => {
if (!ndk.signer) {
await addSigner();
}
console.log('lessons', lessons);
const event = new NDKEvent(ndk);
event.kind = course.kind;

View File

@ -56,7 +56,7 @@ const UserAvatar = () => {
return null; // Or return a loader/spinner/placeholder
} else if (user && Object.keys(user).length > 0) {
// User exists, show username or pubkey
const displayName = user.username || user?.email || user?.pubkey.slice(0, 10) + '...';
const displayName = user.username || user?.name || user?.email || user?.pubkey.slice(0, 10) + '...' || "Anon";
const items = [
{

View File

@ -0,0 +1,74 @@
import React, { useEffect, useState } from 'react';
import { Dialog } from 'primereact/dialog';
import { useRouter } from 'next/router';
const WelcomeModal = () => {
const [visible, setVisible] = useState(false);
const router = useRouter();
useEffect(() => {
if (router.query.active === 'starter') {
setVisible(true);
}
}, [router.query]);
const onHide = () => {
setVisible(false);
// Update just the 'active' query parameter to '0' while preserving the path
router.replace({
pathname: router.pathname,
query: { ...router.query, active: '0' }
}, undefined, { shallow: true });
};
return (
<Dialog
header="Welcome to PlebDevs!"
visible={visible}
style={{ width: '90vw', maxWidth: '600px' }}
onHide={onHide}
>
<div className="text-center mb-4">
<h2 className="text-2xl font-bold text-primary">Start Your Dev Journey</h2>
<p className="text-gray-400">Welcome to the FREE Starter Course!</p>
</div>
<div className="flex flex-col gap-4 mb-4">
<div className="flex items-start">
<i className="pi pi-user text-2xl text-primary mr-2 text-blue-400"></i>
<div>
<h3 className="text-lg font-semibold">Your Account</h3>
<p>An anonymous account has been created for you and you can access it in the top right corner.</p>
<p className="mt-2">On your profile page you will find:</p>
<ul className="list-disc list-inside ml-2 mt-2">
<li>Full dev journey roadmap</li>
<li>Progress tracker</li>
<li>Achievement badges</li>
<li>And more!</li>
</ul>
</div>
</div>
<div className="flex items-start">
<i className="pi pi-book text-2xl text-primary mr-2 text-green-400"></i>
<div>
<h3 className="text-lg font-semibold">Starter Course</h3>
<p>This course will cover:</p>
<ul className="list-disc list-inside ml-2 mt-2">
<li>PlebDevs approach to learning how to code</li>
<li>Development tools setup</li>
<li>Foundation for the full Dev Journey</li>
<li>Learn basic HTML, CSS, and JavaScript</li>
</ul>
</div>
</div>
<div className="text-center mt-4">
<p className="font-bold text-lg">Let&apos;s start your coding journey! 🚀</p>
</div>
</div>
</Dialog>
);
};
export default WelcomeModal;

View File

@ -0,0 +1,212 @@
import React from "react";
import { DataTable } from "primereact/datatable";
import { Column } from "primereact/column";
import useWindowWidth from "@/hooks/useWindowWidth";
import ProgressListItem from "@/components/content/lists/ProgressListItem";
import { formatDateTime } from "@/utils/time";
import { ProgressSpinner } from "primereact/progressspinner";
import Link from 'next/link';
const UserProgressTable = ({ session, ndk }) => {
const prepareProgressData = () => {
if (!session?.user?.userCourses) return [];
const progressData = [];
// Add badge awards
session.user.userBadges?.forEach(userBadge => {
progressData.push({
id: `badge-${userBadge.id}`,
type: 'badge',
name: userBadge.badge?.name,
eventType: 'awarded',
date: userBadge.awardedAt,
courseId: userBadge.badge?.courseId,
badgeId: userBadge.badgeId,
noteId: userBadge.badge?.noteId
});
});
session.user.userCourses.forEach(courseProgress => {
// Add course start entry
if (courseProgress.started) {
progressData.push({
id: `${courseProgress.id}-start`,
type: 'course',
name: courseProgress.course?.name,
eventType: 'started',
date: courseProgress.startedAt,
courseId: courseProgress.courseId
});
}
// Add course completion entry
if (courseProgress.completed) {
progressData.push({
id: `${courseProgress.id}-complete`,
type: 'course',
name: courseProgress.course?.name,
eventType: 'completed',
date: courseProgress.completedAt,
courseId: courseProgress.courseId
});
}
// Add lesson entries
const courseLessons = session.user.userLessons?.filter(
lesson => lesson.lesson?.courseId === courseProgress.courseId
) || [];
courseLessons.forEach(lessonProgress => {
// Add lesson start entry
if (lessonProgress.opened) {
progressData.push({
id: `${lessonProgress.id}-start`,
type: 'lesson',
name: lessonProgress.lesson?.name,
eventType: 'started',
date: lessonProgress.openedAt,
courseId: courseProgress.courseId,
lessonId: lessonProgress.lessonId,
resourceId: lessonProgress.lesson?.resourceId
});
}
// Add lesson completion entry
if (lessonProgress.completed) {
progressData.push({
id: `${lessonProgress.id}-complete`,
type: 'lesson',
name: lessonProgress.lesson?.name,
eventType: 'completed',
date: lessonProgress.completedAt,
courseId: courseProgress.courseId,
lessonId: lessonProgress.lessonId,
resourceId: lessonProgress.lesson?.resourceId
});
}
});
});
// Sort by date, most recent first
return progressData.sort((a, b) => new Date(b.date) - new Date(a.date));
};
const header = (
<div className="flex flex-wrap align-items-center justify-content-between gap-2">
<span className="text-xl text-900 font-bold text-[#f8f8ff]">Progress</span>
</div>
);
const typeTemplate = (rowData) => (
<div className="flex items-center gap-2">
<i className={`pi ${
rowData.type === 'course' ? 'pi-book' :
rowData.type === 'lesson' ? 'pi-file' :
'pi-star' // Badge icon
} text-lg`}></i>
<span className="capitalize">{rowData.type}</span>
</div>
);
const eventTemplate = (rowData) => (
<div className="flex items-center gap-2">
<i className={`pi ${
rowData.eventType === 'started' ? 'pi-play' :
rowData.eventType === 'completed' ? 'pi-check-circle' :
'pi-trophy' // Badge award icon
} ${
rowData.eventType === 'started' ? 'text-blue-500' :
rowData.eventType === 'completed' ? 'text-green-500' :
'text-yellow-500' // Badge award color
} text-lg`}></i>
<span className="capitalize">{rowData.eventType}</span>
</div>
);
const nameTemplate = (rowData) => (
<div className="flex items-center">
{rowData.type === 'badge' ? (
<Link
href={`https://badges.page/a/${rowData.noteId}`}
target="_blank"
className="text-purple-400 hover:text-purple-300 transition-colors"
>
{rowData.name}
</Link>
) : rowData.type === 'course' ? (
<ProgressListItem dTag={rowData.courseId} category="name" type="course" />
) : (
<ProgressListItem dTag={rowData.resourceId} category="name" type="lesson" />
)}
</div>
);
const dateTemplate = (rowData) => {
// Adjust for timezone offset like in the contribution chart
const date = new Date(rowData.date);
const adjustedDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
return (
<div className="flex items-center gap-2">
<i className="pi pi-calendar text-gray-400"></i>
<span>{formatDateTime(adjustedDate)}</span>
</div>
);
};
if (!session || !session?.user || !ndk) {
return <div className='w-full h-full flex items-center justify-center'><ProgressSpinner /></div>;
}
return (
<DataTable
emptyMessage="No Courses or Milestones completed"
value={prepareProgressData()}
header={header}
className="m-2 max-lap:m-0"
style={{ width: "100%", borderRadius: "8px", border: "1px solid #333", boxShadow: "0 0 10px 0 rgba(0, 0, 0, 0.1)" }}
pt={{
wrapper: {
className: "rounded-b-lg shadow-md"
},
header: {
className: "rounded-t-lg border-b border-gray-700"
},
th: {
className: "text-gray-300 font-semibold"
},
bodyRow: {
className: "border-b border-gray-700"
},
bodyCell: {
className: "text-gray-200 p-4"
}
}}
stripedRows
>
<Column
field="type"
header="Type"
body={typeTemplate}
></Column>
<Column
field="eventType"
header="Event"
body={eventTemplate}
></Column>
<Column
field="name"
header="Name"
body={nameTemplate}
></Column>
<Column
field="date"
body={dateTemplate}
header="Date"
></Column>
</DataTable>
);
};
export default UserProgressTable;

View File

@ -0,0 +1,102 @@
import React from 'react';
import { DataTable } from "primereact/datatable";
import { Column } from "primereact/column";
import PurchasedListItem from "@/components/content/lists/PurchasedListItem";
import { formatDateTime } from "@/utils/time";
const UserPurchaseTable = ({ session, windowWidth }) => {
const purchasesHeader = (
<div className="flex flex-wrap align-items-center justify-content-between gap-2">
<span className="text-xl text-gray-200 font-bold">Purchases</span>
</div>
);
const costTemplate = (rowData) => (
<div className="flex items-center gap-2">
<i className="pi pi-wallet text-yellow-500 text-lg"></i>
<span>{rowData.amountPaid} sats</span>
</div>
);
const nameTemplate = (rowData) => (
<div className="flex items-center">
<PurchasedListItem
eventId={rowData?.resource?.noteId || rowData?.course?.noteId}
category={rowData?.course ? "courses" : "resources"}
/>
</div>
);
const categoryTemplate = (rowData) => (
<div className="flex items-center gap-2">
<i className={`pi ${rowData?.course ? 'pi-book' : 'pi-file'} text-lg`}></i>
<span className="capitalize">{rowData?.course ? 'course' : 'resource'}</span>
</div>
);
const dateTemplate = (rowData) => {
// Adjust for timezone offset like in the contribution chart
const date = new Date(rowData?.createdAt);
const adjustedDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
return (
<div className="flex items-center gap-2">
<i className="pi pi-calendar text-gray-400"></i>
<span>{formatDateTime(adjustedDate)}</span>
</div>
);
};
return (
session && session?.user && (
<DataTable
emptyMessage="No purchases"
value={session.user?.purchased}
header={purchasesHeader}
className="m-2 max-lap:m-0 max-lap:mt-2"
style={{ width: "100%", borderRadius: "8px", border: "1px solid #333", boxShadow: "0 0 10px 0 rgba(0, 0, 0, 0.1)" }}
pt={{
wrapper: {
className: "rounded-b-lg shadow-md"
},
header: {
className: "rounded-t-lg border-b border-gray-700"
},
th: {
className: "text-gray-300 font-semibold"
},
bodyRow: {
className: "border-b border-gray-700"
},
bodyCell: {
className: "text-gray-200 p-4"
}
}}
stripedRows
>
<Column
field="amountPaid"
header="Cost"
body={costTemplate}
></Column>
<Column
field="name"
header="Name"
body={nameTemplate}
></Column>
<Column
field="category"
header="Category"
body={categoryTemplate}
></Column>
<Column
field="createdAt"
header="Date"
body={dateTemplate}
></Column>
</DataTable>
)
);
};
export default UserPurchaseTable;

View File

@ -0,0 +1,139 @@
import React, { useState, useEffect, useCallback } from "react";
import { DataTable } from "primereact/datatable";
import { Column } from "primereact/column";
import { InputText } from "primereact/inputtext";
import GenericButton from "@/components/buttons/GenericButton";
import { useToast } from "@/hooks/useToast";
import appConfig from "@/config/appConfig";
const UserRelaysTable = ({ ndk, userRelays, setUserRelays, reInitializeNDK }) => {
const [collapsed, setCollapsed] = useState(true);
const [newRelayUrl, setNewRelayUrl] = useState("");
const { showToast } = useToast();
const [relayStatuses, setRelayStatuses] = useState({});
const [updateTrigger, setUpdateTrigger] = useState(0);
const updateRelayStatuses = useCallback(() => {
if (ndk) {
const statuses = {};
ndk.pool.relays.forEach((relay, url) => {
statuses[url] = relay.connectivity.status === 5;
});
setRelayStatuses(statuses);
}
}, [ndk]);
// Effect for periodic polling
useEffect(() => {
const intervalId = setInterval(() => {
setUpdateTrigger(prev => prev + 1);
}, 7000);
return () => clearInterval(intervalId);
}, []);
useEffect(() => {
updateRelayStatuses();
}, [updateRelayStatuses, updateTrigger]);
const addRelay = () => {
if (newRelayUrl && !userRelays.includes(newRelayUrl)) {
setUserRelays([...userRelays, newRelayUrl]);
setNewRelayUrl("");
reInitializeNDK();
setCollapsed(true);
showToast("success", "Relay added", "Relay successfully added to your list of relays.");
}
};
const removeRelay = (url) => {
if (!appConfig.defaultRelayUrls.includes(url)) {
setUserRelays(userRelays.filter(relay => relay !== url));
reInitializeNDK();
setCollapsed(true);
showToast("success", "Relay removed", "Relay successfully removed from your list of relays.");
}
};
const header = (
<div className="text-[#f8f8ff]">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold">Relays</h2>
<p className="text-gray-400">Manage your connected relays</p>
</div>
<GenericButton
icon="pi pi-plus"
label="Add Relay"
severity="success"
onClick={() => setCollapsed(!collapsed)}
/>
</div>
{!collapsed && (
<div className="flex gap-2 mt-4">
<InputText
placeholder="Relay URL"
value={newRelayUrl}
onChange={(e) => setNewRelayUrl(e.target.value)}
className="flex-1"
/>
<GenericButton
label="Add"
severity="success"
outlined
onClick={addRelay}
/>
</div>
)}
</div>
);
const relayStatusBody = (url) => {
const isConnected = relayStatuses[url];
return (
<i className={`pi ${isConnected ? 'pi-check-circle text-green-500' : 'pi-times-circle text-red-500'}`}></i>
);
};
const relayActionsBody = (rowData) => {
return (
<div>
{!appConfig.defaultRelayUrls.includes(rowData) ? (
<GenericButton
icon="pi pi-trash"
className="p-button-rounded p-button-danger p-button-text"
onClick={() => removeRelay(rowData)}
/>
) : (
<GenericButton
icon="pi pi-trash"
className="p-button-rounded p-button-danger p-button-text opacity-50"
onClick={() => removeRelay(rowData)}
tooltip="Cannot remove default relays at this time (soon ™)"
tooltipOptions={{ position: 'top' }}
style={{
pointerEvents: 'none',
cursor: 'not-allowed'
}}
/>
)}
</div>
);
};
return (
<div className="bg-gray-800 rounded-lg border border-gray-700">
<DataTable
value={userRelays}
className="border-none"
header={header}
>
<Column field={(url) => url} header="Relay URL"></Column>
<Column body={relayStatusBody} header="Status"></Column>
<Column body={relayActionsBody} header="Actions"></Column>
</DataTable>
</div>
);
};
export default UserRelaysTable;

View File

@ -0,0 +1,66 @@
import React, { useMemo } from 'react';
import { Dropdown } from 'primereact/dropdown';
import { useFetchGithubRepos } from '@/hooks/githubQueries/useFetchGithubRepos';
import { useSession } from 'next-auth/react';
import axios from 'axios';
import { useToast } from '@/hooks/useToast';
const RepoSelector = ({ courseId, onSubmit }) => {
const { data: session } = useSession();
const accessToken = session?.account?.access_token;
const { data: repos, isLoading } = useFetchGithubRepos(accessToken);
const { showToast } = useToast();
// Find the existing submission for this course
const existingSubmission = useMemo(() => {
return session?.user?.userCourses?.find(
course => course.courseId === courseId
)?.submittedRepoLink;
}, [session, courseId]);
const repoOptions = repos?.map(repo => ({
label: repo.name,
value: repo.html_url
})) || [];
const handleRepoSelect = async (repoLink) => {
try {
await axios.post(`/api/users/${session.user.id}/courses/${courseId}/submit-repo`, {
repoLink
});
onSubmit(repoLink);
showToast('success', 'Success', 'Repository submitted successfully');
} catch (error) {
console.error('Error submitting repo:', error);
showToast('error', 'Error', 'Failed to submit repository');
}
};
if (!accessToken) {
return (
<div className="pl-[28px] mt-2 text-gray-400">
GitHub connection required
</div>
);
}
return (
<div className="pl-[28px] mt-2">
<Dropdown
value={existingSubmission}
options={repoOptions}
onChange={(e) => handleRepoSelect(e.value)}
placeholder={isLoading ? "Loading repositories..." : "Select a repository"}
className="w-full max-w-[300px]"
loading={isLoading}
/>
{existingSubmission && (
<div className="text-sm text-gray-400 mt-1">
Repository submitted
</div>
)}
</div>
);
};
export default RepoSelector;

View File

@ -0,0 +1,152 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Dialog } from 'primereact/dialog';
import Image from 'next/image';
import { useNDKContext } from '@/context/NDKContext';
import { useSession } from 'next-auth/react';
import { ProgressSpinner } from 'primereact/progressspinner';
import { nip19 } from 'nostr-tools';
const UserBadges = ({ visible, onHide }) => {
const [badges, setBadges] = useState([]);
const [loading, setLoading] = useState(true);
const { ndk } = useNDKContext();
const { data: session } = useSession();
// Define fetchBadges as a useCallback to prevent unnecessary recreations
const fetchBadges = useCallback(async () => {
if (!ndk || !session?.user?.pubkey) return;
setLoading(true);
try {
// Fetch badge definitions (kind 30009)
const badgeDefinitions = await ndk.fetchEvents({
// todo: add the plebdevs hardcoded badge ids (probably in config?)
ids: ["4054a68f028edf38cd1d71cc4693d4ff5c9c54b0b44532361fe6abb29530cbf6", "5d38fea9a3c1fb4c55c9635c3132d34608c91de640f772438faa1942677087a8", "3ba20936d66523adb6d71793649bc77f3cea34f50c21ec7bb2c041f936022214", "41edee5af6d4e833d11f9411c2c27cc48c14d2a3c7966ae7648568e825eda1ed"]
});
// Fetch badge awards (kind 8) using fetchEvents instead of subscribe
const badgeAwards = await ndk.fetchEvents({
kinds: [8],
// todo: add the plebdevs author pubkey
authors: ["f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741"],
"#p": [session.user.pubkey]
});
// Create a map to store the latest badge for each definition
const latestBadgeMap = new Map();
// Process all awards
for (const award of badgeAwards) {
const definition = Array.from(badgeDefinitions).find(def => {
const defDTag = def.tags.find(t => t[0] === 'd')?.[1];
const awardATag = award.tags.find(t => t[0] === 'a')?.[1];
return awardATag?.includes(defDTag);
});
if (definition) {
const defId = definition.id;
const currentBadge = {
name: definition.tags.find(t => t[0] === 'name')?.[1] || 'Unknown Badge',
description: definition.tags.find(t => t[0] === 'description')?.[1] || '',
image: definition.tags.find(t => t[0] === 'image')?.[1] || '',
thumbnail: definition.tags.find(t => t[0] === 'thumb')?.[1] || '',
awardedOn: new Date(award.created_at * 1000).toISOString(),
nostrId: award.id,
naddr: nip19.naddrEncode({
pubkey: definition.pubkey,
kind: definition.kind,
identifier: definition.tags.find(t => t[0] === 'd')?.[1]
})
};
// Only update if this is the first instance or if it's newer than the existing one
if (!latestBadgeMap.has(defId) ||
new Date(currentBadge.awardedOn) > new Date(latestBadgeMap.get(defId).awardedOn)) {
latestBadgeMap.set(defId, currentBadge);
}
}
}
// Convert map values to array for state update
setBadges(Array.from(latestBadgeMap.values()));
} catch (error) {
console.error('Error fetching badges:', error);
} finally {
setLoading(false);
}
}, [ndk, session?.user?.pubkey]);
// Initial fetch effect
useEffect(() => {
if (visible) {
fetchBadges();
}
}, [visible, fetchBadges]);
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
return (
<Dialog
header="Your Badges"
visible={visible}
onHide={onHide}
className="w-full max-w-3xl"
>
<div className="p-4">
{loading ? (
<div className="flex justify-center items-center h-40">
<ProgressSpinner />
</div>
) : badges.length === 0 ? (
<div className="text-center text-gray-400">
No badges earned yet. Get started on the Dev Journey to earn badges!
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{badges.map((badge, index) => (
<div
key={index}
className="bg-gray-800 rounded-xl p-6 flex flex-col items-center transform transition-all duration-200 hover:scale-[1.02] hover:shadow-lg"
>
<div className="relative w-32 h-32 mb-4">
<Image
src={badge.thumbnail || badge.image}
alt={badge.name}
layout="fill"
objectFit="contain"
/>
</div>
<h3 className="text-white font-semibold text-xl mb-2">{badge.name}</h3>
<p className="text-gray-400 text-center text-sm">{badge.description}</p>
<div className="mt-4 flex flex-col items-center gap-2 w-full">
<div className="bg-blue-500/10 text-blue-400 px-3 py-1 rounded-full text-sm">
Earned on {formatDate(badge.awardedOn)}
</div>
<a
href={`https://badges.page/a/${badge.naddr}`}
target="_blank"
rel="noopener noreferrer"
className="text-purple-400 hover:text-purple-300 text-sm flex items-center gap-1 transition-colors"
>
<i className="pi pi-external-link" />
View on Nostr
</a>
</div>
</div>
))}
</div>
)}
</div>
</Dialog>
);
};
export default UserBadges;

View File

@ -1,78 +1,34 @@
import React, { useRef, useState, useEffect } from "react";
import { DataTable } from "primereact/datatable";
import { Menu } from "primereact/menu";
import { Column } from "primereact/column";
import { useImageProxy } from "@/hooks/useImageProxy";
import React, { useState, useEffect } from "react";
import { useSession } from 'next-auth/react';
import { ProgressSpinner } from "primereact/progressspinner";
import ProgressListItem from "@/components/content/lists/ProgressListItem";
import PurchasedListItem from "@/components/content/lists/PurchasedListItem";
import { useNDKContext } from "@/context/NDKContext";
import { formatDateTime } from "@/utils/time";
import { Tooltip } from "primereact/tooltip";
import { nip19 } from "nostr-tools";
import Image from "next/image";
import GithubContributionChart from "@/components/charts/GithubContributionChart";
import GithubContributionChartDisabled from "@/components/charts/GithubContributionChartDisabled";
import UserProfileCard from "@/components/profile/UserProfileCard";
import CombinedContributionChart from "@/components/charts/CombinedContributionChart";
import ActivityContributionChart from "@/components/charts/ActivityContributionChart";
import useCheckCourseProgress from "@/hooks/tracking/useCheckCourseProgress";
import useWindowWidth from "@/hooks/useWindowWidth";
import { useToast } from "@/hooks/useToast";
import UserProgress from "@/components/profile/progress/UserProgress";
import { classNames } from "primereact/utils";
import UserProgressTable from '@/components/profile/DataTables/UserProgressTable';
import UserPurchaseTable from '@/components/profile/DataTables/UserPurchaseTable';
const UserProfile = () => {
const windowWidth = useWindowWidth();
const [user, setUser] = useState(null);
const [account, setAccount] = useState(null);
const { data: session } = useSession();
const { returnImageProxy } = useImageProxy();
const { ndk, addSigner } = useNDKContext();
const { showToast } = useToast();
const menu = useRef(null);
useCheckCourseProgress();
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text);
showToast("success", "Copied", "Copied to clipboard");
};
useEffect(() => {
if (session?.user) {
console.log("Session", session)
setUser(session.user);
if (session?.account) {
setAccount(session.account);
}
}
}, [session]);
const header = (
<div className="flex flex-wrap align-items-center justify-content-between gap-2">
<span className="text-xl text-900 font-bold text-[#f8f8ff]">Progress</span>
</div>
);
const purchasesHeader = (
<div className="flex flex-wrap align-items-center justify-content-between gap-2">
<span className="text-xl text-900 font-bold text-[#f8f8ff]">Purchases</span>
</div>
);
const menuItems = [
...(user?.privkey ? [{
label: 'Copy nsec',
icon: 'pi pi-key',
command: () => {
const privkeyBuffer = Buffer.from(user.privkey, 'hex');
copyToClipboard(nip19.nsecEncode(privkeyBuffer));
}
}] : []),
{
label: 'Copy npub',
icon: 'pi pi-user',
command: () => {
if (user.pubkey) {
copyToClipboard(nip19.npubEncode(user?.pubkey));
}
}
}
];
return (
user && (
<div className="p-4">
@ -81,115 +37,29 @@ const UserProfile = () => {
<h1 className="text-3xl font-bold mb-6">Profile</h1>
)
}
<div className="w-full flex flex-col justify-center mx-auto">
<div className="relative flex w-full items-center justify-center">
<Image
alt="user's avatar"
src={returnImageProxy(user.avatar, user?.pubkey || "")}
width={100}
height={100}
className="rounded-full my-4"
/>
<div className="absolute top-8 right-80 max-tab:right-20 max-mob:left-0">
<i
className="pi pi-ellipsis-h text-2xl cursor-pointer"
onClick={(e) => menu.current.toggle(e)}
/>
<Menu
model={menuItems}
popup
ref={menu}
id="profile-options-menu"
/>
</div>
<div className="w-full flex flex-row max-lap:flex-col">
<div className="w-[22%] h-full max-lap:w-full">
{user && <UserProfileCard user={user} />}
</div>
<h1 className="text-center text-2xl my-2">
{user.username || user?.email || "Anon"}
</h1>
{user.pubkey && (
<h2 className="text-center text-xl my-2 truncate max-tab:px-4 max-mob:px-4">
<Tooltip target=".pubkey-tooltip" content={"this is your nostr npub"} />
{nip19.npubEncode(user.pubkey)} <i className="pi pi-question-circle text-xl pubkey-tooltip" />
</h2>
)}
{user?.lightningAddress && (
<h3 className="w-fit mx-auto text-center text-xl my-2 bg-gray-800 rounded-lg p-4">
<span className="font-bold">Lightning Address:</span> {user.lightningAddress.name}@plebdevs.com <i className="pi pi-copy cursor-pointer hover:text-gray-400" onClick={() => copyToClipboard(user.lightningAddress.name + "@plebdevs.com")} />
</h3>
)}
{user?.nip05 && (
<h3 className="w-fit mx-auto text-center text-xl my-2 bg-gray-800 rounded-lg p-4">
<span className="font-bold">NIP-05:</span> {user.nip05.name}@plebdevs.com <i className="pi pi-copy cursor-pointer hover:text-gray-400" onClick={() => copyToClipboard(user.nip05.name + "@plebdevs.com")} />
</h3>
)}
{/* <GithubContributionChart username={"austinkelsay"} /> */}
<GithubContributionChartDisabled username={"austinkelsay"} />
<UserProgress />
<div className="w-[78%] flex flex-col justify-center mx-auto max-lap:w-full">
{account && account?.provider === "github" ? (
<CombinedContributionChart session={session} />
) : (
<ActivityContributionChart session={session} />
)}
<UserProgress />
<UserProgressTable
session={session}
ndk={ndk}
windowWidth={windowWidth}
/>
<UserPurchaseTable
session={session}
windowWidth={windowWidth}
/>
</div>
</div>
{!session || !session?.user || !ndk ? (
<div className='w-full h-full flex items-center justify-center'><ProgressSpinner /></div>
) : (
<DataTable
emptyMessage="No Courses or Milestones completed"
value={session.user?.userCourses}
header={header}
style={{ maxWidth: windowWidth < 768 ? "100%" : "90%", margin: "0 auto", borderRadius: "10px" }}
pt={{
wrapper: {
className: "rounded-lg rounded-t-none"
},
header: {
className: "rounded-t-lg"
}
}}
>
<Column
field="completed"
header="Completed"
body={(rowData) => (
<i className={classNames('pi', { 'pi-check-circle text-green-500': rowData.completed, 'pi-times-circle text-red-500': !rowData.completed })}></i>
)}
></Column>
<Column
body={(rowData) => {
return <ProgressListItem dTag={rowData.courseId} category="name" />
}}
header="Name"
></Column>
<Column body={(rowData) => {
return <ProgressListItem dTag={rowData.courseId} category="lessons" />
}} header="Lessons"></Column>
<Column body={rowData => formatDateTime(rowData?.createdAt)} header="Date"></Column>
</DataTable>
)}
{session && session?.user && (
<DataTable
emptyMessage="No purchases"
value={session.user?.purchased}
header={purchasesHeader}
style={{ maxWidth: windowWidth < 768 ? "100%" : "90%", margin: "0 auto", borderRadius: "10px" }}
pt={{
wrapper: {
className: "rounded-lg rounded-t-none"
},
header: {
className: "rounded-t-lg mt-4"
}
}}
>
<Column field="amountPaid" header="Cost"></Column>
<Column
body={(rowData) => {
return <PurchasedListItem eventId={rowData?.resource?.noteId || rowData?.course?.noteId} category={rowData?.course ? "courses" : "resources"} />
}}
header="Name"
></Column>
<Column body={session.user?.purchased?.some((item) => item.courseId) ? "course" : "resource"} header="Category"></Column>
<Column body={rowData => formatDateTime(rowData?.createdAt)} header="Date"></Column>
</DataTable>
)}
</div>
)
);

View File

@ -0,0 +1,232 @@
import React, { useRef, useState } from 'react';
import Image from 'next/image';
import { Menu } from 'primereact/menu';
import { Tooltip } from 'primereact/tooltip';
import { nip19 } from 'nostr-tools';
import { useImageProxy } from '@/hooks/useImageProxy';
import { useToast } from '@/hooks/useToast';
import UserBadges from '@/components/profile/UserBadges';
import useWindowWidth from '@/hooks/useWindowWidth';
const UserProfileCard = ({ user }) => {
const [showBadges, setShowBadges] = useState(false);
const menu = useRef(null);
const { showToast } = useToast();
const { returnImageProxy } = useImageProxy();
const windowWidth = useWindowWidth();
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text);
showToast("success", "Copied", "Copied to clipboard");
};
const menuItems = [
...(user?.privkey ? [{
label: 'Copy nsec',
icon: 'pi pi-key',
command: () => {
const privkeyBuffer = Buffer.from(user.privkey, 'hex');
copyToClipboard(nip19.nsecEncode(privkeyBuffer));
}
}] : []),
{
label: 'Copy npub',
icon: 'pi pi-user',
command: () => {
if (user.pubkey) {
copyToClipboard(nip19.npubEncode(user?.pubkey));
}
}
},
{
label: 'Open Nostr Profile',
icon: 'pi pi-external-link',
command: () => window.open(`https://nostr.com/${nip19.npubEncode(user?.pubkey)}`, '_blank')
}
];
const MobileProfileCard = () => (
<div className="w-full bg-gray-800 rounded-lg p-2 py-1 border border-gray-700 shadow-md h-[420px] flex flex-col justify-center items-start">
<div className="flex flex-col gap-2 pt-4 w-full relative">
<div className="absolute top-8 right-[10px]">
<i
className="pi pi-ellipsis-h text-2xl cursor-pointer"
onClick={(e) => menu.current.toggle(e)}
/>
<Menu
model={menuItems}
popup
ref={menu}
id="profile-options-menu"
/>
</div>
</div>
<Image
alt="user's avatar"
src={returnImageProxy(user.avatar, user?.pubkey || "")}
width={100}
height={100}
className="rounded-full m-2 mt-0"
/>
<h3 className="text-center">
{user.username || user?.name || user?.email || "Anon"}
</h3>
<div className="flex flex-col gap-2 justify-center w-full overflow-hidden">
{
user?.pubkey && (
<div className="flex flex-row gap-2 items-center w-full overflow-hidden">
<div className="overflow-hidden">
<p className="text-ellipsis overflow-hidden whitespace-nowrap">
{nip19.npubEncode(user.pubkey)}
</p>
</div>
<Tooltip target=".pubkey-tooltip" content={"this is your account pubkey"} />
<i className="pi pi-question-circle pubkey-tooltip text-xs cursor-pointer shrink-0" />
</div>
)
}
{user?.createdAt && (
<p className="truncate">
Joined: {new Date(user.createdAt).toLocaleDateString()}
</p>
)}
</div>
<div className='w-full flex flex-row justify-between'>
<div className="flex flex-col justify-between gap-4 my-2">
{user?.lightningAddress ? (
<h4 className="bg-gray-900 rounded-lg p-3 max-lap:w-fit min-w-[240px]">
<span className="font-bold">Lightning Address:</span> {user.lightningAddress.name}@plebdevs.com <i className="pi pi-copy cursor-pointer hover:text-gray-400" onClick={() => copyToClipboard(user.lightningAddress.name + "@plebdevs.com")} />
</h4>
) : (
<div className="flex flex-row justify-between bg-gray-900 rounded-lg p-3 max-lap:w-fit min-w-[240px]">
<h4 >
<span className="font-bold">Lightning Address:</span> None
</h4>
{/* todo: add tooltip */}
<Tooltip target=".lightning-address-tooltip" content={"this is your account lightning address"} />
<i className="pi pi-question-circle lightning-address-tooltip text-xs cursor-pointer" />
</div>
)}
{user?.nip05 ? (
<h4 className="bg-gray-900 rounded-lg p-3 max-lap:w-fit min-w-[240px]">
<span className="font-bold">NIP-05:</span> {user.nip05.name}@plebdevs.com <i className="pi pi-copy cursor-pointer hover:text-gray-400" onClick={() => copyToClipboard(user.nip05.name + "@plebdevs.com")} />
</h4>
) : (
<div className="flex flex-row justify-between bg-gray-900 rounded-lg p-3 max-lap:w-fit min-w-[240px]">
<h4>
<span className="font-bold">NIP-05:</span> None
</h4>
{/* todo: add tooltip */}
<Tooltip target=".nip05-tooltip" content={"this is your account nip05"} />
<i className="pi pi-question-circle nip05-tooltip text-xs cursor-pointer" />
</div>
)}
<div className="flex flex-col justify-center min-w-[140px] px-2">
<button
className="w-full py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-full font-semibold"
onClick={() => setShowBadges(true)}
>
View Badges
</button>
</div>
</div>
</div>
</div>
);
const DesktopProfileCard = () => (
<div className="w-full bg-gray-800 rounded-lg p-2 py-1 border border-gray-700 shadow-md h-[330px]">
<div className="flex flex-row w-full justify-evenly">
<Image
alt="user's avatar"
src={returnImageProxy(user.avatar, user?.pubkey || "")}
width={100}
height={100}
className="rounded-full my-4"
/>
<div className="flex flex-col gap-2 pt-4 w-fit relative">
<div className="absolute top-[-4px] right-[-30px]">
<i
className="pi pi-ellipsis-h text-2xl cursor-pointer"
onClick={(e) => menu.current.toggle(e)}
/>
<Menu
model={menuItems}
popup
ref={menu}
id="profile-options-menu"
/>
</div>
<h3 className="self-start">
{user.username || user?.name || user?.email || "Anon"}
</h3>
{
user?.pubkey && (
<div className="flex flex-row gap-2">
<p className="truncate">
{nip19.npubEncode(user.pubkey).slice(0, 12)}...
</p>
<Tooltip target=".pubkey-tooltip" content={"this is your account pubkey"} />
<i className="pi pi-question-circle pubkey-tooltip text-xs cursor-pointer" />
</div>
)
}
{user?.createdAt && (
<p className="truncate">
Joined: {new Date(user.createdAt).toLocaleDateString()}
</p>
)}
</div>
</div>
<div className="flex flex-col justify-between gap-4 my-2">
{user?.lightningAddress ? (
<h4 className="bg-gray-900 rounded-lg p-3 max-lap:w-fit min-w-[240px]">
<span className="font-bold">Lightning Address:</span> {user.lightningAddress.name}@plebdevs.com <i className="pi pi-copy cursor-pointer hover:text-gray-400" onClick={() => copyToClipboard(user.lightningAddress.name + "@plebdevs.com")} />
</h4>
) : (
<div className="flex flex-row justify-between bg-gray-900 rounded-lg p-3 max-lap:w-fit min-w-[240px]">
<h4 >
<span className="font-bold">Lightning Address:</span> None
</h4>
{/* todo: add tooltip */}
<Tooltip target=".lightning-address-tooltip" content={"this is your account lightning address"} />
<i className="pi pi-question-circle lightning-address-tooltip text-xs cursor-pointer" />
</div>
)}
{user?.nip05 ? (
<h4 className="bg-gray-900 rounded-lg p-3 max-lap:w-fit min-w-[240px]">
<span className="font-bold">NIP-05:</span> {user.nip05.name}@plebdevs.com <i className="pi pi-copy cursor-pointer hover:text-gray-400" onClick={() => copyToClipboard(user.nip05.name + "@plebdevs.com")} />
</h4>
) : (
<div className="flex flex-row justify-between bg-gray-900 rounded-lg p-3 max-lap:w-fit min-w-[240px]">
<h4>
<span className="font-bold">NIP-05:</span> None
</h4>
{/* todo: add tooltip */}
<Tooltip target=".nip05-tooltip" content={"this is your account nip05"} />
<i className="pi pi-question-circle nip05-tooltip text-xs cursor-pointer" />
</div>
)}
<button
className="w-full py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-full font-semibold max-lap:w-fit min-w-[140px]"
onClick={() => setShowBadges(true)}
>
View Badges
</button>
</div>
</div>
);
// 1440px is the max-lap breakpoint from tailwind config
return (
<>
{windowWidth <= 1440 ? <MobileProfileCard /> : <DesktopProfileCard />}
<UserBadges
visible={showBadges}
onHide={() => setShowBadges(false)}
/>
</>
);
};
export default UserProfileCard;

View File

@ -1,37 +1,23 @@
import React, { useRef, useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback } from "react";
import GenericButton from "@/components/buttons/GenericButton";
import { DataTable } from "primereact/datatable";
import { Column } from "primereact/column";
import { Menu } from "primereact/menu";
import { useImageProxy } from "@/hooks/useImageProxy";
import UserProfileCard from "@/components/profile/UserProfileCard";
import { useSession } from 'next-auth/react';
import { ProgressSpinner } from "primereact/progressspinner";
import { useNDKContext } from "@/context/NDKContext";
import useWindowWidth from "@/hooks/useWindowWidth";
import Image from "next/image";
import PurchasedListItem from "@/components/content/lists/PurchasedListItem";
import { formatDateTime } from "@/utils/time";
import BitcoinConnectButton from "@/components/bitcoinConnect/BitcoinConnect";
import { Panel } from "primereact/panel";
import { nip19 } from "nostr-tools";
import { InputText } from "primereact/inputtext";
import { Tooltip } from "primereact/tooltip";
import { useToast } from "@/hooks/useToast";
import SubscribeModal from "@/components/profile/subscription/SubscribeModal";
import appConfig from "@/config/appConfig";
import UserRelaysTable from "@/components/profile/DataTables/UserRelaysTable";
const UserSettings = () => {
const [user, setUser] = useState(null);
const [collapsed, setCollapsed] = useState(true);
const { ndk, userRelays, setUserRelays, reInitializeNDK } = useNDKContext();
const { data: session } = useSession();
const { returnImageProxy } = useImageProxy();
const menu = useRef(null);
const windowWidth = useWindowWidth();
const [newRelayUrl, setNewRelayUrl] = useState("");
const { showToast } = useToast();
const [relayStatuses, setRelayStatuses] = useState({});
const [updateTrigger, setUpdateTrigger] = useState(0);
useEffect(() => {
if (session?.user) {
@ -39,251 +25,40 @@ const UserSettings = () => {
}
}, [session]);
useEffect(() => {
if (ndk) {
updateRelayStatuses();
}
}, [ndk]);
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text);
showToast("success", "Copied", "Copied to clipboard");
};
const updateRelayStatuses = useCallback(() => {
// export enum NDKRelayStatus {
// DISCONNECTING, // 0
// DISCONNECTED, // 1
// RECONNECTING, // 2
// FLAPPING, // 3
// CONNECTING, // 4
// // connected states
// CONNECTED, // 5
// AUTH_REQUESTED, // 6
// AUTHENTICATING, // 7
// AUTHENTICATED, // 8
// }
if (ndk) {
const statuses = {};
ndk.pool.relays.forEach((relay, url) => {
statuses[url] = relay.connectivity.status === 5;
});
setRelayStatuses(statuses);
}
}, [ndk]);
// Effect for periodic polling
useEffect(() => {
const intervalId = setInterval(() => {
setUpdateTrigger(prev => prev + 1);
}, 7000); // Poll every 7 seconds
return () => clearInterval(intervalId); // Cleanup on unmount
}, []);
// Effect to update on every render and when updateTrigger changes
useEffect(() => {
updateRelayStatuses();
}, [updateRelayStatuses, updateTrigger]);
const relayStatusBody = (url) => {
const isConnected = relayStatuses[url];
return (
<i className={`pi ${isConnected ? 'pi-check-circle text-green-500' : 'pi-times-circle text-red-500'}`}></i>
);
};
const addRelay = () => {
if (newRelayUrl && !userRelays.includes(newRelayUrl)) {
setUserRelays([...userRelays, newRelayUrl]);
setNewRelayUrl("");
reInitializeNDK();
setCollapsed(true);
showToast("success", "Relay added", "Relay successfully added to your list of relays.");
}
};
const removeRelay = (url) => {
if (!appConfig.defaultRelayUrls.includes(url)) {
setUserRelays(userRelays.filter(relay => relay !== url));
reInitializeNDK();
setCollapsed(true);
showToast("success", "Relay removed", "Relay successfully removed from your list of relays.");
}
};
const relayActionsBody = (rowData) => {
return (
<div>
{!appConfig.defaultRelayUrls.includes(rowData) ? (
<GenericButton
icon="pi pi-trash"
className="p-button-rounded p-button-danger p-button-text"
onClick={() => removeRelay(rowData)}
/>
) : (
<>
<GenericButton
icon="pi pi-trash"
className="p-button-rounded p-button-danger p-button-text opacity-50"
onClick={() => removeRelay(rowData)}
tooltip="Cannot remove default relays at this time (soon ™)"
tooltipOptions={{ position: 'top' }}
style={{
pointerEvents: 'none',
cursor: 'not-allowed'
}}
/>
</>
)}
</div>
);
};
const PanelHeader = (options) => {
return (
<div className="flex flex-row justify-between px-4 py-[6px] bg-gray-800 rounded-t-lg border-b border-gray-700">
<p className="text-[#f8f8ff] text-900 text-xl mt-2 h-fit font-bold">Relays</p>
<GenericButton
onClick={options.onTogglerClick}
icon={options.collapsed ? "pi pi-plus" : "pi pi-minus"}
className="p-button-rounded p-button-success p-button-text"
/>
</div>
);
};
const header = (
<div className="flex flex-wrap align-items-center justify-content-between gap-2">
<span className="text-xl text-900 font-bold text-[#f8f8ff]">Purchases</span>
</div>
);
const menuItems = [
...(user?.privkey ? [{
label: 'Copy nsec',
icon: 'pi pi-key',
command: () => {
const privkeyBuffer = Buffer.from(user.privkey, 'hex');
copyToClipboard(nip19.nsecEncode(privkeyBuffer));
}
}] : []),
{
label: 'Copy npub',
icon: 'pi pi-user',
command: () => {
copyToClipboard(nip19.npubEncode(user?.pubkey));
}
}
];
return (
user && (
<div className="p-4">
{
windowWidth < 768 && (
<h1 className="text-3xl font-bold mb-6">Settings</h1>
)
}
<div className="w-full flex flex-col justify-center mx-auto">
<div className="relative flex w-full items-center justify-center">
<Image
alt="user's avatar"
src={returnImageProxy(user.avatar, user?.pubkey || "")}
width={100}
height={100}
className="rounded-full my-4"
/>
<div className="absolute top-8 right-80 max-tab:right-20 max-mob:left-0">
<i
className="pi pi-ellipsis-h text-2xl cursor-pointer user-menu-trigger"
onClick={(e) => menu.current.toggle(e)}
/>
<Menu
model={menuItems}
popup
ref={menu}
id="profile-options-menu"
/>
</div>
</div>
{windowWidth < 768 && (
<h1 className="text-3xl font-bold mb-6">Settings</h1>
)}
<div className="w-full flex flex-row max-lap:flex-col">
<div className="w-[22%] h-full max-lap:w-full">
<UserProfileCard user={user} />
<h1 className="text-center text-2xl my-2">
{user.username || user?.email || "Anon"}
</h1>
{user.pubkey && (
<h2 className="text-center text-xl my-2 truncate max-tab:px-4 max-mob:px-4">
<Tooltip target=".pubkey-tooltip" content={"this is your nostr npub"} />
{nip19.npubEncode(user.pubkey)} <i className="pi pi-question-circle text-xl pubkey-tooltip" />
</h2>
)}
{user?.lightningAddress && (
<h3 className="w-fit mx-auto text-center text-xl my-2 bg-gray-800 rounded-lg p-4">
<span className="font-bold">Lightning Address:</span> {user.lightningAddress.name}@plebdevs.com <i className="pi pi-copy cursor-pointer hover:text-gray-400" onClick={() => copyToClipboard(user.lightningAddress.name + "@plebdevs.com")} />
</h3>
)}
{user?.nip05 && (
<h3 className="w-fit mx-auto text-center text-xl my-2 bg-gray-800 rounded-lg p-4">
<span className="font-bold">NIP-05:</span> {user.nip05.name}@plebdevs.com <i className="pi pi-copy cursor-pointer hover:text-gray-400" onClick={() => copyToClipboard(user.nip05.name + "@plebdevs.com")} />
</h3>
)}
<div className="bg-gray-800 rounded-lg p-6 shadow-lg w-1/4 mx-auto my-4 max-mob:w-full max-tab:w-full">
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-2">
{/* Lightning Info Card */}
<div className="bg-gray-800 rounded-lg p-4 my-4 border border-gray-700">
<div className="flex items-center gap-2 mb-4">
<i className="pi pi-bolt text-yellow-500 text-2xl"></i>
<h3 className="text-xl font-semibold max-mob:text-base max-tab:text-base">
Lightning Wallet Connection
</h3>
<h3 className="text-xl font-semibold">Lightning Wallet Connection</h3>
</div>
<p>
<p className="text-gray-400 mb-4">
Connect your Lightning wallet for easier payments across the platform
</p>
<BitcoinConnectButton />
</div>
{/* Subscription Modal */}
{user && <SubscribeModal user={user} />}
</div>
<div className="w-[78%] flex flex-col justify-center mx-auto max-lap:w-full ml-2 max-lap:ml-0">
<UserRelaysTable
ndk={ndk}
userRelays={userRelays}
setUserRelays={setUserRelays}
reInitializeNDK={reInitializeNDK}
/>
</div>
{user && (
<SubscribeModal user={user} />
)}
</div>
<div>
<Panel
headerTemplate={PanelHeader}
toggleable
collapsed={collapsed}
onToggle={(e) => setCollapsed(e.value)}
>
<div className="flex flex-row justify-between">
<InputText
placeholder="Relay URL"
value={newRelayUrl}
onChange={(e) => setNewRelayUrl(e.target.value)}
/>
<GenericButton
label="Add"
severity="success"
className='w-fit px-4'
outlined
onClick={addRelay}
/>
</div>
</Panel>
<DataTable value={userRelays}
pt={{
wrapper: {
className: "rounded-lg rounded-t-none"
},
header: {
className: "rounded-t-lg"
}
}}
onValueChange={() => setUpdateTrigger(prev => prev + 1)} // Trigger update when table value changes
>
<Column field={(url) => url} header="Relay URL"></Column>
<Column body={relayStatusBody} header="Status"></Column>
<Column body={relayActionsBody} header="Actions"></Column>
</DataTable>
</div>
</div>
)

View File

@ -1,54 +1,73 @@
import React, { useState, useEffect } from 'react';
import { ProgressBar } from 'primereact/progressbar';
import { Accordion, AccordionTab } from 'primereact/accordion';
import { useSession } from 'next-auth/react';
import { useSession, signIn, getSession } from 'next-auth/react';
import { useRouter } from 'next/router';
import { useBadge } from '@/hooks/badges/useBadge';
import GenericButton from '@/components/buttons/GenericButton';
import UserProgressFlow from './UserProgressFlow';
import { Tooltip } from 'primereact/tooltip';
import RepoSelector from '@/components/profile/RepoSelector';
const allTasks = [
{ status: 'Create Account', completed: true, tier: 'Pleb', courseId: null },
{
status: 'Connect GitHub',
completed: false,
tier: 'Pleb',
courseId: null,
subTasks: [
{ status: 'Connect your GitHub account', completed: false },
]
},
{
status: 'PlebDevs Starter',
completed: false,
tier: 'New Dev',
tier: 'Plebdev',
courseId: "f538f5c5-1a72-4804-8eb1-3f05cea64874",
subTasks: [
{ status: 'Connect GitHub', completed: false },
{ status: 'Create First GitHub Repo', completed: false },
{ status: 'Push Commit', completed: false }
]
},
{
status: 'Frontend Course',
completed: false,
tier: 'Junior Dev',
courseId: 'f73c37f4-df2e-4f7d-a838-dce568c76136',
courseNAddress: "naddr1qvzqqqr4xspzpueu32tp0jc47uzlcuxdgcw06m40ytu7ynpna2adnqty3e0vda6pqy88wumn8ghj7mn0wvhxcmmv9uq32amnwvaz7tmjv4kxz7fwv3sk6atn9e5k7tcpr9mhxue69uhhyetvv9ujuumwdae8gtnnda3kjctv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uq36amnwvaz7tmjv4kxz7fwd46hg6tw09mkzmrvv46zucm0d5hsz9mhwden5te0wfjkccte9ec8y6tdv9kzumn9wshszynhwden5te0dehhxarjxgcjucm0d5hszynhwden5te0dehhxarjw4jjucm0d5hsz9nhwden5te0wp6hyurvv4ex2mrp0yhxxmmd9uq3wamnwvaz7tmjv4kxz7fwv3jhvueww3hk7mrn9uqzge34xvuxvdtrx5knzcfhxgkngwpsxsknsetzxyknxe3sx43k2cfkxsurwdq68epwa",
subTasks: [
{ status: 'Complete the course', completed: false },
{ status: 'Submit Link to completed project', completed: false },
]
},
{
status: 'Backend Course',
completed: false,
tier: 'Plebdev',
courseId: 'f6825391-831c-44da-904a-9ac3d149b7be',
{
status: 'Frontend Course',
completed: false,
tier: 'Frontend Dev',
courseId: 'f73c37f4-df2e-4f7d-a838-dce568c76136',
courseNAddress: "naddr1qvzqqqr4xspzpueu32tp0jc47uzlcuxdgcw06m40ytu7ynpna2adnqty3e0vda6pqy88wumn8ghj7mn0wvhxcmmv9uq32amnwvaz7tmjv4kxz7fwv3sk6atn9e5k7tcpr9mhxue69uhhyetvv9ujuumwdae8gtnnda3kjctv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uq36amnwvaz7tmjv4kxz7fwd46hg6tw09mkzmrvv46zucm0d5hsz9mhwden5te0wfjkccte9ec8y6tdv9kzumn9wshszynhwden5te0dehhxarjxgcjucm0d5hszynhwden5te0dehhxarjw4jjucm0d5hsz9nhwden5te0wp6hyurvv4ex2mrp0yhxxmmd9uq3wamnwvaz7tmjv4kxz7fwv3jhvueww3hk7mrn9uqzge3hxd3nxdmxxskkge3jv5knge3hvskkzwpn8qkkgcm9x5mrscehxccnxdsc53n8w",
subTasks: [
{status: 'Complete the course', completed: false},
{ status: 'Submit Link to completed project', completed: false },
{ status: 'Complete the course', completed: false },
{ status: 'Submit your project repository', completed: false },
]
},
{
status: 'Backend Course',
completed: false,
tier: 'Backend Dev',
courseId: 'f6825391-831c-44da-904a-9ac3d149b7be',
courseNAddress: "naddr1qvzqqqr4xspzpueu32tp0jc47uzlcuxdgcw06m40ytu7ynpna2adnqty3e0vda6pqy88wumn8ghj7mn0wvhxcmmv9uq32amnwvaz7tmjv4kxz7fwv3sk6atn9e5k7tcpr9mhxue69uhhyetvv9ujuumwdae8gtnnda3kjctv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uq36amnwvaz7tmjv4kxz7fwd46hg6tw09mkzmrvv46zucm0d5hsz9mhwden5te0wfjkccte9ec8y6tdv9kzumn9wshszynhwden5te0dehhxarjxgcjucm0d5hszynhwden5te0dehhxarjw4jjucm0d5hsz9nhwden5te0wp6hyurvv4ex2mrp0yhxxmmd9uq3wamnwvaz7tmjv4kxz7fwv3jhvueww3hk7mrn9uqzge3k8qer2veexyknsve3vvkngdryvyknjvp5vyknjctrxdjrzdpevgmkyegqyn0ns",
subTasks: [
{ status: 'Complete the course', completed: false },
{ status: 'Submit your project repository', completed: false },
]
},
];
const UserProgress = () => {
const [progress, setProgress] = useState(0);
const [currentTier, setCurrentTier] = useState('Pleb');
const [currentTier, setCurrentTier] = useState(null);
const [expandedItems, setExpandedItems] = useState({});
const [completedCourses, setCompletedCourses] = useState([]);
const [tasks, setTasks] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const { data: session } = useSession();
const router = useRouter();
const { data: session, update } = useSession();
useBadge();
useEffect(() => {
if (session?.user) {
setIsLoading(true);
const user = session.user;
const ids = user?.userCourses?.map(course => course?.completed ? course.courseId : null).filter(id => id !== null);
if (ids && ids.length > 0) {
@ -61,25 +80,51 @@ const UserProgress = () => {
calculateProgress([]);
calculateCurrentTier([]);
}
setIsLoading(false);
}
}, [session]);
const generateTasks = (completedCourseIds) => {
const updatedTasks = allTasks.map(task => ({
...task,
completed: task.courseId === null || completedCourseIds.includes(task.courseId),
subTasks: task.subTasks ? task.subTasks.map(subTask => ({
...subTask,
completed: completedCourseIds.includes(task.courseId)
})) : undefined
}));
const updatedTasks = allTasks.map(task => {
if (task.status === 'Connect GitHub') {
return {
...task,
completed: session?.account?.provider === 'github' ? true : false,
subTasks: task.subTasks.map(subTask => ({
...subTask,
completed: session?.account?.provider === 'github' ? true : false
}))
};
}
const userCourse = session?.user?.userCourses?.find(uc => uc.courseId === task.courseId);
const courseCompleted = completedCourseIds.includes(task.courseId);
const repoSubmitted = userCourse?.submittedRepoLink ? true : false;
return {
...task,
completed: courseCompleted && (task.courseId === null || repoSubmitted),
subTasks: task.subTasks.map(subTask => ({
...subTask,
completed: subTask.status.includes('Complete')
? courseCompleted
: subTask.status.includes('repository')
? repoSubmitted
: false
}))
};
});
setTasks(updatedTasks);
};
const calculateProgress = (completedCourseIds) => {
let progressValue = 25;
let progressValue = 0;
if (session?.account?.provider === 'github') {
progressValue += 25;
}
const remainingTasks = allTasks.slice(1);
remainingTasks.forEach(task => {
if (completedCourseIds.includes(task.courseId)) {
@ -91,18 +136,18 @@ const UserProgress = () => {
};
const calculateCurrentTier = (completedCourseIds) => {
let tier = 'Pleb';
if (completedCourseIds.includes("f538f5c5-1a72-4804-8eb1-3f05cea64874")) {
tier = 'New Dev';
}
if (completedCourseIds.includes("f73c37f4-df2e-4f7d-a838-dce568c76136")) {
tier = 'Junior Dev';
}
let tier = null;
if (completedCourseIds.includes("f6825391-831c-44da-904a-9ac3d149b7be")) {
tier = 'Backend Dev';
} else if (completedCourseIds.includes("f73c37f4-df2e-4f7d-a838-dce568c76136")) {
tier = 'Frontend Dev';
} else if (completedCourseIds.includes("f6daa88a-53d6-4901-8dbd-d2203a05b7ab")) {
tier = 'Plebdev';
} else if (session?.account?.provider === 'github') {
tier = 'Pleb';
}
setCurrentTier(tier);
};
@ -113,10 +158,43 @@ const UserProgress = () => {
}));
};
const handleGitHubLink = async () => {
try {
// If user is already signed in, we'll link the accounts
if (session?.user) {
const result = await signIn("github", {
redirect: false,
// Pass existing user data for linking
userId: session?.user?.id,
pubkey: session?.user?.pubkey,
privkey: session?.user?.privkey || null
});
if (result?.ok) {
// Wait for session update
await new Promise(resolve => setTimeout(resolve, 1000));
const updatedSession = await getSession();
if (updatedSession?.account?.provider === 'github') {
router.push('/profile'); // Accounts linked successfully
}
}
} else {
// Normal GitHub sign in
await signIn("github");
}
} catch (error) {
console.error("GitHub sign in error:", error);
}
};
return (
<div className="bg-gray-800 rounded-3xl p-6 w-[500px] max-mob:w-full max-tab:w-full mx-auto my-8">
<h1 className="text-3xl font-bold text-white mb-2">Dev Journey (coming soon)</h1>
<p className="text-gray-400 mb-4">Track your progress from Pleb to Plebdev</p>
<div className="bg-gray-800 rounded-lg p-4 pb-0 m-2 w-full border border-gray-700 shadow-md max-lap:mx-0">
<div className="flex flex-row justify-between items-center">
<h1 className="text-3xl font-bold text-white mb-2">Dev Journey</h1>
<i className="pi pi-question-circle journey-tooltip text-2xl cursor-pointer text-gray-200" />
<Tooltip target=".journey-tooltip" position="left" className="w-[300px]" content="This is an optional Dev Journey that will walk you through the primary course materials and help you learn how to code, gain the required experience to Build Bitcoin/Lightning/Nostr Apps, and set you up to go through the rest of the free workshops and other content on the platform." />
</div>
<p className="text-gray-400 mb-4">Track your progress through the courses, showcase your GitHub contributions, submit projects, and earn badges!</p>
<div className="flex justify-between items-center mb-2">
<span className="text-gray-300">Progress</span>
@ -130,36 +208,136 @@ const UserProgress = () => {
<div className="mb-6">
<span className="text-white text-lg font-semibold">Current Tier: </span>
<span className="bg-green-500 text-white px-3 py-1 rounded-full">{currentTier}</span>
{currentTier ? (
<span className="bg-green-500 text-white px-3 py-1 rounded-full">
{currentTier}
</span>
) : (
<span className="bg-gray-700 text-gray-400 px-3 py-1 rounded-full text-sm">
Not Started
</span>
)}
</div>
<ul className="space-y-4 mb-6">
{tasks.map((task, index) => (
<li key={index}>
<div className="flex items-center justify-between">
<div className="flex items-center">
{task.completed ? (
<div className="w-6 h-6 bg-green-500 rounded-full flex items-center justify-center mr-3">
<i className="pi pi-check text-white text-lg"></i>
</div>
) : (
<div className="w-6 h-6 bg-gray-700 rounded-full flex items-center justify-center mr-3">
<i className="pi pi-info-circle text-white text-lg"></i>
</div>
)}
<span className={`text-lg ${task.completed ? 'text-white' : 'text-gray-400'}`}>{task.status}</span>
</div>
<span className="bg-blue-500 text-white text-xs px-2 py-1 rounded-full w-20 text-center">
{task.tier}
</span>
</div>
</li>
))}
</ul>
<div className="flex max-sidebar:flex-col gap-6 mb-6">
<div className="w-1/2 max-sidebar:w-full">
<ul className="space-y-6 pt-2">
{tasks.map((task, index) => (
<li key={index}>
<Accordion
activeIndex={expandedItems[index] ? 0 : null}
onTabChange={(e) => handleAccordionChange(index, e.index === 0)}
>
<AccordionTab
header={
<div className="flex items-center justify-between w-full">
<div className="flex items-center">
{task.completed ? (
<div className="w-6 h-6 bg-green-500 rounded-full flex items-center justify-center mr-3">
<i className="pi pi-check text-white text-lg"></i>
</div>
) : (
<div className="w-6 h-6 bg-gray-700 rounded-full flex items-center justify-center mr-3">
<i className="pi pi-info-circle text-white text-lg"></i>
</div>
)}
<span className={`text-lg ${task.completed ? 'text-white' : 'text-gray-400'}`}>{task.status}</span>
</div>
<span className="bg-blue-500 text-white text-sm px-2 py-1 rounded-full w-24 text-center">
{task.tier}
</span>
</div>
}
>
{task.status === 'Connect GitHub' && !task.completed && (
<div className="mb-4">
<GenericButton
label="Connect GitHub"
icon="pi pi-github"
onClick={handleGitHubLink}
className="w-fit bg-[#24292e] hover:bg-[#2f363d] border border-[#f8f8ff] text-[#f8f8ff] font-semibold"
rounded
/>
</div>
)}
{task.subTasks && (
<ul className="space-y-2">
{task.subTasks.map((subTask, subIndex) => (
<li key={subIndex}>
<div className="flex items-center pl-[28px]">
{subTask.completed ? (
<div className="w-4 h-4 bg-green-500 rounded-full flex items-center justify-center mr-3">
<i className="pi pi-check text-white text-sm"></i>
</div>
) : (
<div className="w-4 h-4 bg-gray-700 rounded-full flex items-center justify-center mr-3">
<i className="pi pi-info-circle text-white text-sm"></i>
</div>
)}
<span className={`${subTask.completed ? 'text-white' : 'text-gray-400'}`}>
{subTask.status}
</span>
{subTask.status === 'Connect your GitHub account' && (
<>
<i className="pi pi-question-circle github-tooltip ml-2 text-sm cursor-pointer text-gray-200"
data-pr-tooltip="Connect your GitHub account to track your progress and submit projects" />
<Tooltip target=".github-tooltip" position="right" />
</>
)}
</div>
{subTask.status.includes('repository') && !subTask.completed && (
<RepoSelector
courseId={task.courseId}
onSubmit={() => {
const updatedTasks = tasks.map(t =>
t.courseId === task.courseId
? {
...t,
subTasks: t.subTasks.map(st =>
st.status === subTask.status
? { ...st, completed: true }
: st
)
}
: t
);
setTasks(updatedTasks);
router.push('/profile');
}}
/>
)}
</li>
))}
</ul>
)}
{task.courseNAddress && (
<div className="mt-2 flex justify-end">
<GenericButton
icon="pi pi-external-link"
label="View Course"
onClick={() => router.push(`/course/${task.courseNAddress}`)}
outlined
size="small"
/>
</div>
)}
</AccordionTab>
</Accordion>
</li>
))}
</ul>
</div>
<button className="w-full py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-full font-semibold">
View Badges (Coming Soon)
</button>
<div className="w-1/2 max-sidebar:w-full">
{isLoading ? (
<div className="h-[400px] bg-gray-800 rounded-3xl flex items-center justify-center">
<i className="pi pi-spin pi-spinner text-4xl text-gray-600"></i>
</div>
) : (
<UserProgressFlow tasks={tasks} />
)}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,152 @@
import React from 'react';
import ReactFlow, {
Background,
Handle,
Position,
Controls
} from 'reactflow';
import 'reactflow/dist/style.css';
const CustomNode = ({ data }) => (
<div className={`px-4 py-2 rounded-lg shadow-md w-48 text-center transition-all duration-300 ${
data.completed
? 'bg-green-500 text-white border-2 border-green-400 bg-opacity-50'
: 'bg-gray-700 text-gray-300 border-2 border-gray-600 bg-opacity-50'
}`}>
<Handle type="target" position={Position.Top} />
<div className="flex items-center justify-center gap-2">
{data.completed ? (
<div className="w-5 h-5 bg-green-500 rounded-full flex items-center justify-center">
<i className="pi pi-check text-white text-sm"></i>
</div>
) : (
<div className="w-5 h-5 bg-gray-600 rounded-full flex items-center justify-center">
<i className="pi pi-lock text-gray-400 text-sm"></i>
</div>
)}
<div className="font-semibold">{data.label}</div>
</div>
<div className="text-sm mt-1 px-2 py-0.5 bg-blue-500 rounded-full inline-block">
{data.tier}
</div>
<Handle type="source" position={Position.Bottom} />
</div>
);
const nodeTypes = {
custom: CustomNode,
};
const UserProgressFlow = ({ tasks }) => {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
const nodes = [
{
id: '1',
type: 'custom',
position: { x: 250, y: 0 },
data: {
label: tasks[0]?.status || 'Connect GitHub',
tier: tasks[0]?.tier || 'Pleb',
completed: tasks[0]?.completed || false,
},
},
{
id: '2',
type: 'custom',
position: { x: 250, y: 120 },
data: {
label: tasks[1]?.status || 'PlebDevs Starter',
tier: tasks[1]?.tier || 'Plebdev',
completed: tasks[1]?.completed || false,
},
},
{
id: '3',
type: 'custom',
position: { x: 100, y: 240 },
data: {
label: tasks[2]?.status || 'Frontend Course',
tier: tasks[2]?.tier || 'Frontend Dev',
completed: tasks[2]?.completed || false,
},
},
{
id: '4',
type: 'custom',
position: { x: 400, y: 240 },
data: {
label: tasks[3]?.status || 'Backend Course',
tier: tasks[3]?.tier || 'Backend Dev',
completed: tasks[3]?.completed || false,
},
},
];
const edges = [
{
id: 'e1-2',
source: '1',
target: '2',
style: {
stroke: tasks[0]?.completed ? '#22c55e' : '#4b5563',
strokeWidth: 2,
},
animated: tasks[0]?.completed && !tasks[1]?.completed,
},
{
id: 'e2-3',
source: '2',
target: '3',
style: {
stroke: tasks[1]?.completed ? '#22c55e' : '#4b5563',
strokeWidth: 2,
},
animated: tasks[1]?.completed && !tasks[2]?.completed,
},
{
id: 'e2-4',
source: '2',
target: '4',
style: {
stroke: tasks[1]?.completed ? '#22c55e' : '#4b5563',
strokeWidth: 2,
},
animated: tasks[1]?.completed && !tasks[3]?.completed,
},
];
if (!mounted) return <div style={{ height: 400 }} className="bg-gray-800 rounded-3xl" />;
return (
<div style={{ height: 400 }} className="bg-gray-800 rounded-3xl">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
fitView
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
panOnDrag={false}
zoomOnScroll={false}
panOnScroll={false}
selectNodesOnDrag={false}
preventScrolling
minZoom={1}
maxZoom={1}
defaultViewport={{ x: 0, y: 0, zoom: 1 }}
>
<Background color="#4a5568" gap={16} />
{/* <Controls position="top-right" /> */}
</ReactFlow>
</div>
);
};
export default UserProgressFlow;

View File

@ -148,7 +148,7 @@ const SubscribeModal = ({ user }) => {
return (
<>
<Card title={subscriptionCardTitle} className="w-1/4 m-4 mx-auto max-mob:w-full max-tab:w-full">
<Card title={subscriptionCardTitle} className="w-full m-4 mx-auto border border-gray-700">
{subscribed && !user?.role?.nwc && (
<div className="flex flex-col">
<Message className="w-fit" severity="success" text="Subscribed!" />

View File

@ -5,13 +5,11 @@ import { useToast } from '@/hooks/useToast';
import axios from 'axios';
import { Card } from 'primereact/card';
import useWindowWidth from '@/hooks/useWindowWidth';
import { Menu } from "primereact/menu";
import { Message } from "primereact/message";
import { ProgressSpinner } from 'primereact/progressspinner';
import SubscriptionPaymentButtons from '@/components/bitcoinConnect/SubscriptionPaymentButton';
import Image from 'next/image';
import NostrIcon from '../../../../public/images/nostr.png';
import { Badge } from 'primereact/badge';
import GenericButton from '@/components/buttons/GenericButton';
import CancelSubscription from '@/components/profile/subscription/CancelSubscription';
import CalendlyEmbed from '@/components/profile/subscription/CalendlyEmbed';
@ -100,130 +98,141 @@ const UserSubscription = () => {
{windowWidth < 768 && (
<h1 className="text-3xl font-bold mb-6">Subscription Management</h1>
)}
<div className="mb-4 p-4 bg-gray-800 rounded-lg w-fit">
{subscribed && !user?.role?.nwc && (
<div className="flex flex-col">
<Message className="w-fit" severity="success" text="Subscribed!" />
<p className="mt-4">Thank you for your support 🎉</p>
<p className="text-sm text-gray-400">Pay-as-you-go subscription requires manual renewal on {subscribedUntil.toLocaleDateString()}</p>
</div>
)}
{subscribed && user?.role?.nwc && (
<div className="flex flex-col">
<Message className="w-fit" severity="success" text="Subscribed!" />
<p className="mt-4">Thank you for your support 🎉</p>
<p className="text-sm text-gray-400">Recurring subscription will AUTO renew on {subscribedUntil.toLocaleDateString()}</p>
</div>
)}
{(!subscribed && !subscriptionExpiredAt) && (
<div className="flex flex-col">
<Message className="w-fit" severity="info" text="You currently have no active subscription" />
</div>
)}
{subscriptionExpiredAt && (
<div className="flex flex-col">
<Message className="w-fit" severity="warn" text={`Your subscription expired on ${subscriptionExpiredAt.toLocaleDateString()}`} />
</div>
)}
</div>
{!subscribed && (
<Card title="Subscribe to PlebDevs" className="mb-4">
{isProcessing ? (
<div className="w-full flex flex-col mx-auto justify-center items-center mt-4">
<div className='w-full h-full flex items-center justify-center'><ProgressSpinner /></div>
<span className="ml-2">Processing subscription...</span>
</div>
) : (
<div className="flex flex-col">
<div className="mb-4">
<h2 className="text-2xl font-bold text-primary">Unlock Premium Benefits</h2>
<p className="text-gray-400">Subscribe now and elevate your development journey!</p>
</div>
<div className="flex flex-col gap-4 mb-4">
<div className="flex items-center">
<i className="pi pi-book text-2xl text-primary mr-2 text-blue-400"></i>
<span>Access ALL current and future PlebDevs content</span>
</div>
<div className="flex items-center">
<i className="pi pi-calendar text-2xl text-primary mr-2 text-red-400"></i>
<span>Personal mentorship & guidance and access to exclusive 1:1 booking calendar</span>
</div>
<div className="flex items-center">
<i className="pi pi-bolt text-2xl text-primary mr-2 text-yellow-500"></i>
<span>Claim your own personal plebdevs.com Lightning Address</span>
</div>
<div className="flex items-center">
<Image src={NostrIcon} alt="Nostr" width={25} height={25} className='mr-2' />
<span>Claim your own personal plebdevs.com Nostr NIP-05 identity</span>
</div>
<div className="flex items-center">
<i className="pi pi-star text-2xl text-primary mr-2 text-yellow-500"></i>
<span>I WILL MAKE SURE YOU WIN HARD AND LEVEL UP AS A DEV!</span>
</div>
</div>
<SubscriptionPaymentButtons
onSuccess={handleSubscriptionSuccess}
onRecurringSubscriptionSuccess={handleRecurringSubscriptionSuccess}
onError={handleSubscriptionError}
setIsProcessing={setIsProcessing}
layout={windowWidth < 768 ? "col" : "row"}
/>
</div>
)}
</Card>
)}
{subscribed && (
<>
<Card title="Subscription Benefits" className="mb-4">
{isProcessing ? (
<div className="w-full flex flex-col mx-auto justify-center items-center mt-4">
<div className='w-full h-full flex items-center justify-center'><ProgressSpinner /></div>
<span className="ml-2">Processing subscription...</span>
</div>
) : (
<div className="w-full flex flex-row max-lap:flex-col">
{/* Left Column - 22% */}
<div className="w-[21%] h-full max-lap:w-full">
<div className="p-4 bg-gray-800 rounded-lg max-lap:mb-4">
{/* Subscription Status Messages */}
{subscribed && !user?.role?.nwc && (
<div className="flex flex-col">
<div className="flex flex-col gap-4">
<GenericButton severity="info" outlined className="w-fit text-start" label="Schedule 1:1" icon="pi pi-calendar" onClick={() => setCalendlyVisible(true)} />
<GenericButton severity="help" outlined className="w-fit text-start" label={user?.nip05 ? "Update Nostr NIP-05" : "Claim PlebDevs Nostr NIP-05"} icon="pi pi-at" onClick={() => setNip05Visible(true)} />
<GenericButton severity="warning" outlined className="w-fit text-start" label={user?.lightningAddress ? "Update Lightning Address" : "Claim PlebDevs Lightning Address"} icon={<i style={{ color: "orange" }} className="pi pi-bolt mr-2"></i>} onClick={() => setLightningAddressVisible(true)} />
</div>
<Message className="w-fit" severity="success" text="Subscribed!" />
<p className="mt-4">Thank you for your support 🎉</p>
<p className="text-sm text-gray-400">Pay-as-you-go subscription requires manual renewal on {subscribedUntil.toLocaleDateString()}</p>
</div>
)}
</Card>
<Card title="Manage Subscription" className="mb-4">
<div className='flex flex-col gap-4'>
<GenericButton outlined className="w-fit" label="Renew Subscription" icon="pi pi-sync" onClick={() => setRenewSubscriptionVisible(true)} />
<GenericButton severity="danger" outlined className="w-fit" label="Cancel Subscription" icon="pi pi-trash" onClick={() => setCancelSubscriptionVisible(true)} />
{subscribed && user?.role?.nwc && (
<div className="flex flex-col">
<Message className="w-fit" severity="success" text="Subscribed!" />
<p className="mt-4">Thank you for your support 🎉</p>
<p className="text-sm text-gray-400">Recurring subscription will AUTO renew on {subscribedUntil.toLocaleDateString()}</p>
</div>
)}
{(!subscribed && !subscriptionExpiredAt) && (
<div className="flex flex-col">
<Message className="w-fit" severity="info" text="You currently have no active subscription" />
</div>
)}
{subscriptionExpiredAt && (
<div className="flex flex-col">
<Message className="w-fit" severity="warn" text={`Your subscription expired on ${subscriptionExpiredAt.toLocaleDateString()}`} />
</div>
)}
</div>
</div>
{/* Right Column - 78% */}
<div className="w-[78%] flex flex-col justify-center mx-auto max-lap:w-full">
{!subscribed && (
<Card title="Subscribe to PlebDevs" className="mb-4">
{isProcessing ? (
<div className="w-full flex flex-col mx-auto justify-center items-center mt-4">
<div className='w-full h-full flex items-center justify-center'><ProgressSpinner /></div>
<span className="ml-2">Processing subscription...</span>
</div>
) : (
<div className="flex flex-col">
<div className="mb-4">
<h2 className="text-2xl font-bold text-primary">Unlock Premium Benefits</h2>
<p className="text-gray-400">Subscribe now and elevate your development journey!</p>
</div>
<div className="flex flex-col gap-4 mb-4">
<div className="flex items-center">
<i className="pi pi-book text-2xl text-primary mr-2 text-blue-400"></i>
<span>Access ALL current and future PlebDevs content</span>
</div>
<div className="flex items-center">
<i className="pi pi-calendar text-2xl text-primary mr-2 text-red-400"></i>
<span>Personal mentorship & guidance and access to exclusive 1:1 booking calendar</span>
</div>
<div className="flex items-center">
<i className="pi pi-bolt text-2xl text-primary mr-2 text-yellow-500"></i>
<span>Claim your own personal plebdevs.com Lightning Address</span>
</div>
<div className="flex items-center">
<Image src={NostrIcon} alt="Nostr" width={25} height={25} className='mr-2' />
<span>Claim your own personal plebdevs.com Nostr NIP-05 identity</span>
</div>
<div className="flex items-center">
<i className="pi pi-star text-2xl text-primary mr-2 text-yellow-500"></i>
<span>I WILL MAKE SURE YOU WIN HARD AND LEVEL UP AS A DEV!</span>
</div>
</div>
<SubscriptionPaymentButtons
onSuccess={handleSubscriptionSuccess}
onRecurringSubscriptionSuccess={handleRecurringSubscriptionSuccess}
onError={handleSubscriptionError}
setIsProcessing={setIsProcessing}
layout={windowWidth < 768 ? "col" : "row"}
/>
</div>
)}
</Card>
)}
{subscribed && (
<>
<Card title="Subscription Benefits" className="mb-4">
{isProcessing ? (
<div className="w-full flex flex-col mx-auto justify-center items-center mt-4">
<div className='w-full h-full flex items-center justify-center'><ProgressSpinner /></div>
<span className="ml-2">Processing subscription...</span>
</div>
) : (
<div className="flex flex-col">
<div className="flex flex-col gap-4">
<GenericButton severity="info" outlined className="w-fit text-start" label="Schedule 1:1" icon="pi pi-calendar" onClick={() => setCalendlyVisible(true)} />
<GenericButton severity="help" outlined className="w-fit text-start" label={user?.nip05 ? "Update Nostr NIP-05" : "Claim PlebDevs Nostr NIP-05"} icon="pi pi-at" onClick={() => setNip05Visible(true)} />
<GenericButton severity="warning" outlined className="w-fit text-start" label={user?.lightningAddress ? "Update Lightning Address" : "Claim PlebDevs Lightning Address"} icon={<i style={{ color: "orange" }} className="pi pi-bolt mr-2"></i>} onClick={() => setLightningAddressVisible(true)} />
</div>
</div>
)}
</Card>
<Card title="Manage Subscription" className="mb-4">
<div className='flex flex-col gap-4'>
<GenericButton outlined className="w-fit" label="Renew Subscription" icon="pi pi-sync" onClick={() => setRenewSubscriptionVisible(true)} />
<GenericButton severity="danger" outlined className="w-fit" label="Cancel Subscription" icon="pi pi-trash" onClick={() => setCancelSubscriptionVisible(true)} />
</div>
</Card>
</>
)}
<Card title="Frequently Asked Questions" className="mb-6">
<div className="flex flex-col gap-4">
<div>
<h3 className="text-lg font-semibold">How does the subscription work?</h3>
<p>Think of the subscriptions as a paetreon type model. You pay a monthly fee and in return you get access to premium features and all of the paid content. You can cancel at any time.</p>
</div>
<div>
<h3 className="text-lg font-semibold">How do I Subscribe? (Pay as you go)</h3>
<p>The pay as you go subscription is a one-time payment that gives you access to all of the premium features for one month. You will need to manually renew your subscription every month.</p>
</div>
<div>
<h3 className="text-lg font-semibold">How do I Subscribe? (Recurring)</h3>
<p>The recurring subscription option allows you to submit a Nostr Wallet Connect URI that will be used to automatically send the subscription fee every month. You can cancel at any time.</p>
</div>
<div>
<h3 className="text-lg font-semibold">Can I cancel my subscription?</h3>
<p>Yes, you can cancel your subscription at any time. Your access will remain active until the end of the current billing period.</p>
</div>
<div>
<h3 className="text-lg font-semibold">What happens if I don&apos;t renew my subscription?</h3>
<p>If you don&apos;t renew your subscription, your access to 1:1 calendar and paid content will be removed. However, you will still have access to your plebdevs Lightning Address, NIP-05, and any content that you paid for.</p>
</div>
{/* Add more FAQ items as needed */}
</div>
</Card>
</>
)}
<Card title="Frequently Asked Questions" className="mb-6">
<div className="flex flex-col gap-4">
<div>
<h3 className="text-lg font-semibold">How does the subscription work?</h3>
<p>Think of the subscriptions as a paetreon type model. You pay a monthly fee and in return you get access to premium features and all of the paid content. You can cancel at any time.</p>
</div>
<div>
<h3 className="text-lg font-semibold">How do I Subscribe? (Pay as you go)</h3>
<p>The pay as you go subscription is a one-time payment that gives you access to all of the premium features for one month. You will need to manually renew your subscription every month.</p>
</div>
<div>
<h3 className="text-lg font-semibold">How do I Subscribe? (Recurring)</h3>
<p>The recurring subscription option allows you to submit a Nostr Wallet Connect URI that will be used to automatically send the subscription fee every month. You can cancel at any time.</p>
</div>
<div>
<h3 className="text-lg font-semibold">Can I cancel my subscription?</h3>
<p>Yes, you can cancel your subscription at any time. Your access will remain active until the end of the current billing period.</p>
</div>
<div>
<h3 className="text-lg font-semibold">What happens if I don&apos;t renew my subscription?</h3>
<p>If you don&apos;t renew your subscription, your access to 1:1 calendar and paid content will be removed. However, you will still have access to your plebdevs Lightning Address, NIP-05, and any content that you paid for.</p>
</div>
{/* Add more FAQ items as needed */}
</div>
</Card>
</div>
<CalendlyEmbed
visible={calendlyVisible}

View File

@ -0,0 +1,82 @@
import prisma from "@/db/prisma";
export const getAllBadges = async () => {
return await prisma.badge.findMany({
include: {
course: true,
userBadges: {
include: {
user: true
}
}
}
});
};
export const getBadgeById = async (id) => {
return await prisma.badge.findUnique({
where: { id },
include: {
course: true,
userBadges: {
include: {
user: true
}
}
}
});
};
export const getBadgeByCourseId = async (courseId) => {
return await prisma.badge.findUnique({
where: { courseId },
include: {
course: true,
userBadges: {
include: {
user: true
}
}
}
});
};
export const createBadge = async (data) => {
return await prisma.badge.create({
data: {
name: data.name,
noteId: data.noteId,
course: data.courseId ? {
connect: { id: data.courseId }
} : undefined
},
include: {
course: true,
userBadges: {
include: {
user: true
}
}
}
});
};
export const updateBadge = async (id, data) => {
return await prisma.badge.update({
where: { id },
data: {
name: data.name,
noteId: data.noteId
},
include: {
course: true,
userBadges: true
}
});
};
export const deleteBadge = async (id) => {
return await prisma.badge.delete({
where: { id }
});
};

View File

@ -13,6 +13,7 @@ export const getAllCourses = async () => {
}
},
purchases: true,
badge: true
},
});
};
@ -31,34 +32,47 @@ export const getCourseById = async (id) => {
}
},
purchases: true,
badge: true
},
});
};
export const createCourse = async (data) => {
const { badge, ...courseData } = data;
return await prisma.course.create({
data: {
id: data.id,
noteId: data.noteId,
price: data.price,
user: { connect: { id: data.user.connect.id } },
id: courseData.id,
noteId: courseData.noteId,
price: courseData.price,
submissionRequired: courseData.submissionRequired || false,
user: { connect: { id: courseData.user.connect.id } },
lessons: {
connect: data.lessons.connect
}
connect: courseData.lessons.connect
},
...(badge && {
badge: {
create: {
name: badge.name,
noteId: badge.noteId
}
}
})
},
include: {
lessons: true,
user: true
user: true,
badge: true
}
});
};
export const updateCourse = async (id, data) => {
const { lessons, ...otherData } = data;
const { lessons, badge, ...otherData } = data;
return await prisma.course.update({
where: { id },
data: {
...otherData,
submissionRequired: otherData.submissionRequired || false,
lessons: {
deleteMany: {},
create: lessons.map((lesson, index) => ({
@ -66,7 +80,21 @@ export const updateCourse = async (id, data) => {
draftId: lesson.draftId || null,
index: index
}))
}
},
...(badge && {
badge: {
upsert: {
create: {
name: badge.name,
noteId: badge.noteId
},
update: {
name: badge.name,
noteId: badge.noteId
}
}
}
})
},
include: {
lessons: {
@ -77,12 +105,17 @@ export const updateCourse = async (id, data) => {
orderBy: {
index: 'asc'
}
}
},
badge: true
}
});
};
export const deleteCourse = async (id) => {
await prisma.badge.deleteMany({
where: { courseId: id }
});
return await prisma.course.delete({
where: { id },
});

View File

@ -0,0 +1,64 @@
import prisma from "@/db/prisma";
export const getUserBadges = async (userId) => {
return await prisma.userBadge.findMany({
where: { userId },
include: {
badge: true,
user: true
}
});
};
export const getUserBadge = async (userId, badgeId) => {
return await prisma.userBadge.findUnique({
where: {
userId_badgeId: {
userId,
badgeId
}
},
include: {
badge: true,
user: true
}
});
};
export const awardBadgeToUser = async (userId, badgeId) => {
return await prisma.userBadge.create({
data: {
user: {
connect: { id: userId }
},
badge: {
connect: { id: badgeId }
}
},
include: {
badge: true,
user: true
}
});
};
export const removeUserBadge = async (userId, badgeId) => {
return await prisma.userBadge.delete({
where: {
userId_badgeId: {
userId,
badgeId
}
}
});
};
export const getUsersWithBadge = async (badgeId) => {
return await prisma.userBadge.findMany({
where: { badgeId },
include: {
user: true,
badge: true
}
});
};

View File

@ -20,6 +20,24 @@ export const getUserCourse = async (userId, courseId) => {
};
export const createOrUpdateUserCourse = async (userId, courseId, data) => {
const existing = await prisma.userCourse.findUnique({
where: {
userId_courseId: {
userId,
courseId,
},
},
});
const updateData = existing?.completed ? {
...data,
updatedAt: new Date(),
completedAt: existing.completedAt,
} : {
...data,
updatedAt: new Date(),
};
return await prisma.userCourse.upsert({
where: {
userId_courseId: {
@ -27,10 +45,7 @@ export const createOrUpdateUserCourse = async (userId, courseId, data) => {
courseId,
},
},
update: {
...data,
updatedAt: new Date(),
},
update: updateData,
create: {
userId,
courseId,
@ -50,6 +65,20 @@ export const deleteUserCourse = async (userId, courseId) => {
});
};
export const submitCourseRepo = async (userId, courseSlug, repoLink) => {
return await prisma.userCourse.update({
where: {
userId_courseId: {
userId,
courseId: courseSlug
}
},
data: {
submittedRepoLink: repoLink
}
});
};
export const checkCourseCompletion = async (userId, courseId) => {
const course = await prisma.course.findUnique({
where: { id: courseId },
@ -72,10 +101,19 @@ export const checkCourseCompletion = async (userId, courseId) => {
lesson.userLessons.length > 0 && lesson.userLessons[0].completed
);
const existingUserCourse = await prisma.userCourse.findUnique({
where: {
userId_courseId: {
userId,
courseId,
}
}
});
if (allLessonsCompleted) {
await createOrUpdateUserCourse(userId, courseId, {
completed: true,
completedAt: new Date()
...(existingUserCourse?.completed ? {} : { completedAt: new Date() })
});
return true;
}

View File

@ -20,6 +20,11 @@ export const getAllUsers = async () => {
lesson: true,
},
},
userBadges: {
include: {
badge: true
}
}
},
});
};
@ -47,6 +52,11 @@ export const getUserById = async (id) => {
},
nip05: true,
lightningAddress: true,
userBadges: {
include: {
badge: true
}
}
},
});
};
@ -74,6 +84,11 @@ export const getUserByPubkey = async (pubkey) => {
},
nip05: true,
lightningAddress: true,
userBadges: {
include: {
badge: true
}
}
},
});
}
@ -235,28 +250,45 @@ export const expireUserSubscriptions = async (userIds) => {
};
export const getUserByEmail = async (email) => {
return await prisma.user.findUnique({
where: { email },
include: {
role: true,
purchased: {
include: {
course: true,
resource: true,
},
},
userCourses: {
include: {
course: true,
},
},
userLessons: {
include: {
lesson: true,
},
},
nip05: true,
lightningAddress: true,
},
});
if (!email || typeof email !== 'string') {
console.error('Invalid email parameter:', email);
return null;
}
try {
return await prisma.user.findUnique({
where: {
email: email.toLowerCase().trim()
},
include: {
role: true,
purchased: {
include: {
course: true,
resource: true,
},
},
userCourses: {
include: {
course: true,
},
},
userLessons: {
include: {
lesson: true,
},
},
nip05: true,
lightningAddress: true,
userBadges: {
include: {
badge: true
}
}
},
});
} catch (error) {
console.error('Error in getUserByEmail:', error);
return null;
}
};

View File

@ -0,0 +1,37 @@
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { useSession } from 'next-auth/react';
import { useState } from 'react';
export function useCompletedCoursesQuery() {
const { data: session } = useSession();
const [retryCount, setRetryCount] = useState(0);
const fetchCompletedCourses = async () => {
if (!session?.user?.id) return [];
try {
const response = await axios.get('/api/courses/completed', {
params: {
userId: session.user.id
}
});
return response.data;
} catch (error) {
console.error('Error fetching completed courses:', error);
throw error; // Let React Query handle the retry
}
};
return useQuery({
queryKey: ['completedCourses', session?.user?.id],
queryFn: fetchCompletedCourses,
enabled: !!session?.user?.id,
staleTime: 1000 * 60 * 5, // 5 minutes
cacheTime: 1000 * 60 * 30, // 30 minutes
refetchOnWindowFocus: false,
refetchOnMount: false,
retry: 3,
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
});
}

View File

@ -0,0 +1,113 @@
import { useEffect, useState } from 'react';
import { useSession } from 'next-auth/react';
import axios from 'axios';
import { useCompletedCoursesQuery } from '../apiQueries/useCompletedCoursesQuery';
import { useQueryClient } from '@tanstack/react-query';
export const useBadge = () => {
const { data: session, update } = useSession();
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState(null);
const { data: completedCourses } = useCompletedCoursesQuery();
const queryClient = useQueryClient();
useEffect(() => {
if (!session?.user || isProcessing) return;
const checkForBadgeEligibility = async () => {
setIsProcessing(true);
setError(null);
try {
const { userBadges } = session.user;
let badgesAwarded = false;
// Check for GitHub connection badge
if (session?.account?.provider === 'github') {
const hasPlebBadge = userBadges?.some(
userBadge => userBadge.badge?.id === '4664e73f-c618-41dd-a7cc-f3393b031fdf'
);
if (!hasPlebBadge) {
try {
const response = await axios.post('/api/badges/issue', {
badgeId: '4664e73f-c618-41dd-a7cc-f3393b031fdf',
userId: session.user.id,
});
if (response.data.success) {
badgesAwarded = true;
}
} catch (error) {
console.error('Error issuing Pleb badge:', error);
}
}
}
// Check for course-related badges
const eligibleCourses = completedCourses?.filter(userCourse => {
const isCompleted = userCourse.completed;
const hasNoBadge = !userBadges?.some(
userBadge => userBadge.badge?.courseId === userCourse.courseId
);
const hasBadgeDefined = !!userCourse.course?.badge;
// Check if course requires repo submission
const requiresRepo = userCourse.course?.submissionRequired ?? false;
const hasRepoIfRequired = requiresRepo ? !!userCourse.submittedRepoLink : true;
return isCompleted && hasNoBadge && hasBadgeDefined && hasRepoIfRequired;
});
for (const course of eligibleCourses || []) {
try {
const response = await axios.post('/api/badges/issue', {
courseId: course?.courseId,
userId: session.user.id,
});
if (response.data.success) {
badgesAwarded = true;
}
} catch (error) {
console.error('Error issuing badge:', error);
}
}
if (badgesAwarded) {
// First invalidate the queries
await queryClient.invalidateQueries(['completedCourses']);
await queryClient.invalidateQueries(['githubCommits']);
// Wait a brief moment before updating session
await new Promise(resolve => setTimeout(resolve, 100));
// Update session last
await update({ revalidate: false });
// Force a refetch of the invalidated queries
await Promise.all([
queryClient.refetchQueries(['completedCourses']),
queryClient.refetchQueries(['githubCommits'])
]);
}
} catch (error) {
console.error('Error checking badge eligibility:', error);
setError(error.message);
} finally {
setIsProcessing(false);
}
};
const timeoutId = setTimeout(checkForBadgeEligibility, 0);
// Reduce the frequency of checks to avoid potential race conditions
const interval = setInterval(checkForBadgeEligibility, 600000); // 10 minutes
return () => {
clearTimeout(timeoutId);
clearInterval(interval);
};
}, [session?.user?.id, completedCourses]);
return { isProcessing, error };
};

View File

@ -1,22 +1,43 @@
import { useQuery } from '@tanstack/react-query';
import { getAllCommits } from '@/lib/github';
export function useFetchGithubCommits(username) {
const fetchCommits = async () => {
const sixMonthsAgo = new Date();
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
const commits = [];
for await (const commit of getAllCommits(username, sixMonthsAgo)) {
commits.push(commit);
}
return commits;
};
export function useFetchGithubCommits(session, onCommitReceived) {
const accessToken = session?.account?.access_token;
return useQuery({
queryKey: ['githubCommits', username],
queryFn: fetchCommits,
queryKey: ['githubCommits', accessToken],
queryFn: async () => {
if (!accessToken) return { commits: [], contributionData: {}, totalCommits: 0 };
const today = new Date();
const oneYearAgo = new Date(today);
oneYearAgo.setDate(today.getDate() - 364);
const commits = [];
const contributionData = {};
let totalCommits = 0;
for await (const commit of getAllCommits(accessToken, oneYearAgo)) {
commits.push(commit);
const date = commit.commit.author.date.split('T')[0];
contributionData[date] = (contributionData[date] || 0) + 1;
totalCommits++;
// Call the callback with the running totals
onCommitReceived?.({
contributionData,
totalCommits
});
}
return {
commits,
contributionData,
totalCommits
};
},
staleTime: 1000 * 60 * 30, // 30 minutes
refetchInterval: 1000 * 60 * 30, // 30 minutes
cacheTime: 1000 * 60 * 60, // 1 hour
});
}

View File

@ -0,0 +1,53 @@
import { useQuery } from '@tanstack/react-query';
import { Octokit } from "@octokit/rest";
import { throttling } from "@octokit/plugin-throttling";
const ThrottledOctokit = Octokit.plugin(throttling);
export function useFetchGithubRepos(accessToken) {
return useQuery({
queryKey: ['githubRepos', accessToken],
queryFn: async () => {
if (!accessToken) {
console.log('No access token provided');
return [];
}
try {
const octokit = new ThrottledOctokit({
auth: accessToken,
throttle: {
onRateLimit: (retryAfter, options, octokit, retryCount) => {
console.log(`Rate limit exceeded, retrying after ${retryAfter} seconds`);
if (retryCount < 2) return true;
return false;
},
onSecondaryRateLimit: (retryAfter, options, octokit) => {
console.log(`Secondary rate limit hit, retrying after ${retryAfter} seconds`);
return true;
},
},
});
console.log('Fetching repositories...');
const { data } = await octokit.repos.listForAuthenticatedUser({
sort: 'updated',
per_page: 100
});
console.log(`Found ${data.length} repositories`);
return data.map(repo => ({
id: repo.id,
name: repo.name,
html_url: repo.html_url
}));
} catch (error) {
console.error('Error fetching GitHub repos:', error);
throw error;
}
},
staleTime: 1000 * 60 * 5, // 5 minutes
enabled: !!accessToken,
retry: 3,
});
}

View File

@ -24,7 +24,7 @@ const useCheckCourseProgress = () => {
completed: true,
completedAt: new Date().toISOString(),
});
update()
update();
}
} catch (error) {
console.error(`Failed to update course ${courseId} completion status:`, error);

View File

@ -81,11 +81,9 @@ const useTrackVideoLesson = ({lessonId, videoDuration, courseId, videoPlayed, pa
const alreadyCompleted = await checkOrCreateUserLesson();
if (!alreadyCompleted && videoDuration && !completedRef.current && videoPlayed && (paidCourse === false || (paidCourse && decryptionPerformed))) {
setIsTracking(true);
console.log('🎥 Starting video tracking - Duration:', videoDuration);
timerRef.current = setInterval(() => {
setTimeSpent(prevTime => {
const newTime = prevTime + 1;
// console.log(`⏱️ Time spent: ${newTime}s / ${videoDuration}s (${((newTime/videoDuration)*100).toFixed(1)}%)`);
return newTime;
});
}, 1000);
@ -104,8 +102,8 @@ const useTrackVideoLesson = ({lessonId, videoDuration, courseId, videoPlayed, pa
useEffect(() => {
if (isAdmin) return;
if (videoDuration && timeSpent >= Math.round(videoDuration * 0.9) && !completedRef.current) {
console.log('🎯 Video reached 90% threshold - Marking as completed');
if (videoDuration && timeSpent >= Math.round(videoDuration * 0.8) && !completedRef.current) {
console.log('🎯 Video reached 80% threshold - Marking as completed');
markLessonAsCompleted();
}
}, [timeSpent, videoDuration, markLessonAsCompleted, isAdmin]);

View File

@ -4,7 +4,7 @@ import { throttling } from "@octokit/plugin-throttling";
const ThrottledOctokit = Octokit.plugin(throttling);
const octokit = new ThrottledOctokit({
auth: process.env.NEXT_PUBLIC_GITHUB_ACCESS_KEY,
auth: process.env.NEXT_PUBLIC_GITHUB_API,
throttle: {
onRateLimit: (retryAfter, options, octokit, retryCount) => {
octokit.log.warn(`Request quota exhausted for request ${options.method} ${options.url}`);
@ -20,45 +20,65 @@ const octokit = new ThrottledOctokit({
},
});
export async function* getAllCommits(username, since) {
let page = 1;
while (true) {
try {
const { data: repos } = await octokit.repos.listForUser({
username,
per_page: 100,
page,
});
if (repos.length === 0) break;
const repoPromises = repos.map(repo =>
octokit.repos.listCommits({
owner: username,
repo: repo.name,
since: since.toISOString(),
per_page: 100,
})
);
const repoResults = await Promise.allSettled(repoPromises);
for (const result of repoResults) {
if (result.status === 'fulfilled') {
for (const commit of result.value.data) {
yield commit;
}
} else {
console.warn(`Error fetching commits: ${result.reason}`);
export async function* getAllCommits(accessToken, since) {
const auth = accessToken || process.env.NEXT_PUBLIC_GITHUB_API;
const octokit = new ThrottledOctokit({
auth,
throttle: {
onRateLimit: (retryAfter, options, octokit, retryCount) => {
octokit.log.warn(`Request quota exhausted for request ${options.method} ${options.url}`);
if (retryCount < 2) {
octokit.log.info(`Retrying after ${retryAfter} seconds!`);
return true;
}
}
},
onSecondaryRateLimit: (retryAfter, options, octokit) => {
octokit.log.warn(`Secondary rate limit hit for request ${options.method} ${options.url}`);
return true;
},
},
});
page++;
} catch (error) {
console.error("Error fetching repositories:", error.message);
break;
// First, get the authenticated user's information
const { data: user } = await octokit.users.getAuthenticated();
const endDate = new Date();
let currentDate = new Date(since);
while (currentDate < endDate) {
let nextDate = new Date(currentDate);
nextDate.setMonth(nextDate.getMonth() + 1);
if (nextDate > endDate) {
nextDate = endDate;
}
let page = 1;
while (true) {
try {
const { data } = await octokit.search.commits({
q: `author:${user.login} committer-date:${currentDate.toISOString().split('T')[0]}..${nextDate.toISOString().split('T')[0]}`,
per_page: 100,
page,
});
if (data.items.length === 0) break;
for (const commit of data.items) {
yield commit;
}
if (data.items.length < 100) break;
page++;
} catch (error) {
console.error("Error fetching commits:", error.message);
break;
}
}
currentDate = nextDate;
}
}

View File

@ -1,108 +1,140 @@
import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import EmailProvider from "next-auth/providers/email";
import NDK from "@nostr-dev-kit/ndk";
import GithubProvider from "next-auth/providers/github";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import prisma from "@/db/prisma";
import nodemailer from 'nodemailer';
import { findKind0Fields } from "@/utils/nostr";
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'
import { bytesToHex } from '@noble/hashes/utils'
import { updateUser, getUserByPubkey, createUser, getUserByEmail } from "@/db/models/userModels";
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
import { bytesToHex } from '@noble/hashes/utils';
import { updateUser, getUserByPubkey, createUser, getUserById, getUserByEmail } from "@/db/models/userModels";
import { createRole } from "@/db/models/roleModels";
import appConfig from "@/config/appConfig";
import NDK from "@nostr-dev-kit/ndk";
// todo: currently email accounts ephemeral privkey gets saved to db but not anon user, is this required at all given the newer auth setup?
// Initialize NDK for Nostr interactions
const ndk = new NDK({
explicitRelayUrls: [...appConfig.defaultRelayUrls]
explicitRelayUrls: appConfig.defaultRelayUrls
});
const authorize = async (pubkey) => {
/**
* Handles Nostr profile synchronization and user creation/update
* @param {string} pubkey - User's public key
* @returns {Promise<Object|null>} User object or null if failed
*/
const syncNostrProfile = async (pubkey) => {
await ndk.connect();
const user = ndk.getUser({ pubkey });
try {
const profile = await user.fetchProfile();
// Check if user exists, create if not
const fields = await findKind0Fields(profile);
let dbUser = await getUserByPubkey(pubkey);
if (dbUser) {
const fields = await findKind0Fields(profile);
// Only update 'avatar' or 'username' if they are different from kind0 fields on the dbUser
if (fields.avatar !== dbUser.avatar) {
const updatedUser = await updateUser(dbUser.id, { avatar: fields.avatar });
if (updatedUser) {
dbUser = await getUserByPubkey(pubkey);
}
} else if (fields.username !== dbUser.username) {
const updatedUser = await updateUser(dbUser.id, { username: fields.username });
if (updatedUser) {
dbUser = await getUserByPubkey(pubkey);
}
// Update existing user if kind0 fields differ
if (fields.avatar !== dbUser.avatar || fields.username !== dbUser.username) {
const updates = {
...(fields.avatar !== dbUser.avatar && { avatar: fields.avatar }),
...(fields.username !== dbUser.username && {
username: fields.username,
name: fields.username
})
};
await updateUser(dbUser.id, updates);
dbUser = await getUserByPubkey(pubkey);
}
// add the kind0 fields to the user
const combinedUser = { ...dbUser, kind0: fields };
return combinedUser;
} else {
// Create user
if (profile) {
const fields = await findKind0Fields(profile);
const payload = { pubkey, username: fields.username, avatar: fields.avatar };
// Create new user
const username = fields.username || pubkey.slice(0, 8);
const payload = {
pubkey,
username,
avatar: fields.avatar,
name: username
};
if (appConfig.authorPubkeys.includes(pubkey)) {
// create a new author role for this user
const createdUser = await createUser(payload);
const role = await createRole({
userId: createdUser.id,
admin: true,
subscribed: false,
});
dbUser = await createUser(payload);
if (!role) {
console.error("Failed to create role");
return null;
}
const updatedUser = await updateUser(createdUser.id, { role: role.id });
if (!updatedUser) {
console.error("Failed to update user");
return null;
}
const fullUser = await getUserByPubkey(pubkey);
return { ...fullUser, kind0: fields };
} else {
dbUser = await createUser(payload);
return { ...dbUser, kind0: fields };
// Create author role if applicable
if (appConfig.authorPubkeys.includes(pubkey)) {
const role = await createRole({
userId: dbUser.id,
admin: true,
subscribed: false,
});
if (role) {
await updateUser(dbUser.id, { role: role.id });
dbUser = await getUserByPubkey(pubkey);
}
}
}
return { ...dbUser, kind0: fields };
} catch (error) {
console.error("Nostr login error:", error);
console.error("Nostr profile sync error:", error);
return null;
}
return null;
}
};
/**
* Generates an ephemeral keypair for non-Nostr login methods
* @returns {Object} Object containing public and private keys
*/
const generateEphemeralKeypair = () => {
const privkey = generateSecretKey();
const pubkey = getPublicKey(privkey);
// pubkey is hex, privkey is bytes need to convert to hex
return {
pubkey,
privkey: bytesToHex(privkey)
};
};
export const authOptions = {
adapter: PrismaAdapter(prisma),
providers: [
// Nostr login provider
CredentialsProvider({
id: "nostr",
name: "Nostr",
credentials: {
pubkey: { label: "Public Key", type: "text" },
pubkey: { label: "Public Key", type: "text" }
},
authorize: async (credentials) => {
if (credentials?.pubkey) {
return await authorize(credentials.pubkey);
}
return null;
},
if (!credentials?.pubkey) return null;
return await syncNostrProfile(credentials.pubkey);
}
}),
// Anonymous login provider
CredentialsProvider({
id: "anonymous",
name: "Anonymous",
credentials: {
pubkey: { label: "Public Key", type: "text" },
privkey: { label: "Private Key", type: "text" }
},
authorize: async (credentials) => {
const keys = (credentials?.pubkey && credentials?.pubkey !== 'null' && credentials?.privkey && credentials?.privkey !== 'null') ?
{ pubkey: credentials.pubkey, privkey: credentials.privkey } :
generateEphemeralKeypair();
let user = await getUserByPubkey(keys.pubkey);
if (!user) {
user = await createUser({
...keys,
username: `anon-${keys.pubkey.slice(0, 8)}`,
name: `anon-${keys.pubkey.slice(0, 8)}`
});
}
return { ...user, privkey: keys.privkey };
}
}),
// Email provider with simpler configuration
EmailProvider({
server: {
host: process.env.EMAIL_SERVER_HOST,
@ -112,129 +144,198 @@ export const authOptions = {
pass: process.env.EMAIL_SERVER_PASSWORD
}
},
from: process.env.EMAIL_FROM,
sendVerificationRequest: async ({ identifier, url, provider }) => {
// Use nodemailer to send the email
const transport = nodemailer.createTransport(provider.server);
await transport.sendMail({
to: identifier,
from: provider.from,
subject: `Sign in to ${new URL(url).host}`,
text: `Sign in to ${new URL(url).host}\n${url}\n\n`,
html: `<p>Sign in to <strong>${new URL(url).host}</strong></p><p><a href="${url}">Sign in</a></p>`,
});
from: process.env.EMAIL_FROM
}),
// Github provider with ephemeral keypair generation
GithubProvider({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
profile: async (profile) => {
const keys = generateEphemeralKeypair();
return {
id: profile.id.toString(),
pubkey: keys.pubkey,
privkey: keys.privkey,
name: profile.login,
email: profile.email,
avatar: profile.avatar_url
};
}
}),
CredentialsProvider({
id: "anonymous",
name: "Anonymous",
credentials: {
pubkey: { label: "Public Key", type: "text" },
privkey: { label: "Private Key", type: "text" },
},
authorize: async (credentials) => {
let pubkey, privkey;
if (credentials?.pubkey && credentials?.pubkey !== "null" && credentials?.privkey && credentials?.privkey !== "null") {
// Use provided keys
pubkey = credentials.pubkey;
privkey = credentials.privkey;
} else {
// Generate new keys
const sk = generateSecretKey();
pubkey = getPublicKey(sk);
privkey = bytesToHex(sk);
}
// Check if user exists in the database
let dbUser = await getUserByPubkey(pubkey);
if (!dbUser) {
// Create new user if not exists
dbUser = await createUser({
pubkey: pubkey,
username: pubkey.slice(0, 8), // Use first 8 characters of pubkey as username
});
}
// Return user object with pubkey and privkey
return { ...dbUser, pubkey, privkey };
},
}),
})
],
callbacks: {
async jwt({ token, user, account, trigger }) {
if (trigger === "update" && account?.provider !== "anonymous") {
// if we trigger an update call the authorize function again
const newUser = await authorize(token.user.pubkey);
token.user = newUser;
}
// Move email handling to the signIn callback
async signIn({ user, account }) {
// Only handle email provider sign ins
if (account?.provider === "email") {
try {
// Check if this is an existing user
const existingUser = await getUserByEmail(user.email);
if (!existingUser && user) {
// First time login: generate keypair
const keys = generateEphemeralKeypair();
// if we sign up with email and we don't have a pubkey or privkey, we need to generate them
if (trigger === "signUp" && account?.provider === "email" && !user.pubkey && !user.privkey) {
const sk = generateSecretKey();
const pubkey = getPublicKey(sk);
const privkey = bytesToHex(sk);
const newUser = {
pubkey: keys.pubkey,
privkey: keys.privkey,
username: user.email.split('@')[0],
email: user.email,
avatar: user.image,
name: user.email.split('@')[0],
}
// Update the user with the new keypair
const createdUser = await createUser(newUser);
return createdUser;
} else {
console.log("User already exists", existingUser);
}
return true;
} catch (error) {
console.error("Email sign in error:", error);
return false;
}
}
return true; // Allow other provider sign ins
},
async session({ session, user, token }) {
const userData = token.user || user;
if (userData) {
const fullUser = await getUserById(userData.id);
// Update the user in the database
await prisma.user.update({
where: { id: user.id },
data: { pubkey, privkey }
// Get the user's GitHub account if it exists
const githubAccount = await prisma.account.findFirst({
where: {
userId: fullUser.id,
provider: 'github'
}
});
// Update the user object
user.pubkey = pubkey;
user.privkey = privkey;
session.user = {
...session.user,
id: fullUser.id,
pubkey: fullUser.pubkey,
privkey: fullUser.privkey,
role: fullUser.role,
username: fullUser.username,
avatar: fullUser.avatar,
name: fullUser.name,
userCourses: fullUser.userCourses,
userLessons: fullUser.userLessons,
purchased: fullUser.purchased,
nip05: fullUser.nip05,
lightningAddress: fullUser.lightningAddress,
githubUsername: token.githubUsername,
createdAt: fullUser.createdAt,
userBadges: fullUser.userBadges
};
// Add GitHub account info to session if it exists
if (githubAccount) {
session.account = {
provider: githubAccount.provider,
type: githubAccount.type,
providerAccountId: githubAccount.providerAccountId,
access_token: githubAccount.access_token,
token_type: githubAccount.token_type,
scope: githubAccount.scope,
};
}
}
return session;
},
async jwt({ token, user, account, profile, session }) {
// If we are linking a github account to an existing email or anon account (we have privkey)
if (account?.provider === "github" && user?.id && user?.pubkey && user?.privkey) {
try {
// First update the user's profile with GitHub info
const updatedUser = await updateUser(user.id, {
name: profile?.login || profile?.name,
username: profile?.login || profile?.name,
avatar: profile?.avatar_url,
image: profile?.avatar_url,
});
// Get the updated user
const existingUser = await getUserById(updatedUser?.id);
if (existingUser) {
token.user = existingUser;
}
// add github username to token
token.githubUsername = profile?.login || profile?.name;
} catch (error) {
console.error("Error linking GitHub account:", error);
}
}
// nostr login (we have no privkey)
if (account?.provider === "github" && user?.id && user?.pubkey) {
try {
// First check if there's already a GitHub account linked
const existingGithubAccount = await prisma.account.findFirst({
where: {
userId: user.id,
provider: 'github'
}
});
// add github username to token
token.githubUsername = profile?.login || profile?.name;
if (!existingGithubAccount) {
// Update user profile with GitHub info
const updatedUser = await updateUser(user.id, {
name: profile?.login || profile?.name,
username: profile?.login || profile?.name,
avatar: profile?.avatar_url,
image: profile?.avatar_url,
email: profile?.email // Add email if user wants it
});
// Create the GitHub account link
await prisma.account.create({
data: {
userId: user.id,
type: account.type,
provider: account.provider,
providerAccountId: account.providerAccountId,
access_token: account.access_token,
token_type: account.token_type,
scope: account.scope
}
});
// Get the updated user
const existingUser = await getUserById(updatedUser?.id);
if (existingUser) {
token.user = existingUser;
}
}
} catch (error) {
console.error("Error linking GitHub account:", error);
}
}
if (user) {
token.user = user;
if (user.pubkey && user.privkey) {
token.pubkey = user.pubkey;
token.privkey = user.privkey;
}
}
if (account?.provider === 'anonymous') {
token.isAnonymous = true;
if (account) {
token.account = account;
}
return token;
},
async session({ session, token }) {
session.user = token.user;
if (token.pubkey && token.privkey) {
session.pubkey = token.pubkey;
session.privkey = token.privkey;
}
session.isAnonymous = token.isAnonymous;
return session;
},
async redirect({ url, baseUrl }) {
return baseUrl;
},
async signOut({ token, session }) {
token = {}
session = {}
return true
},
async signIn({ user, account }) {
if (account.provider === 'anonymous') {
return {
...user,
pubkey: user.pubkey,
privkey: user.privkey,
};
}
return true;
},
}
},
secret: process.env.NEXTAUTH_SECRET,
session: { strategy: "jwt" },
jwt: {
signingKey: process.env.JWT_SECRET,
session: {
strategy: 'jwt',
maxAge: 30 * 24 * 60 * 60, // 30 days
},
pages: {
signIn: "/auth/signin",
}
debug: process.env.NODE_ENV === 'development',
};
export default NextAuth(authOptions);

View File

@ -0,0 +1,179 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "../auth/[...nextauth]";
import prisma from "@/db/prisma";
import { finalizeEvent, verifyEvent } from 'nostr-tools/pure';
import { SimplePool } from 'nostr-tools/pool';
import { nip19 } from 'nostr-tools';
import { Buffer } from 'buffer';
import appConfig from "@/config/appConfig";
const hexToBytes = (hex) => {
return Buffer.from(hex, 'hex');
};
const BADGE_AWARD_KIND = 8;
const BADGE_DEFINITION_KIND = 30009;
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
// Verify authentication
const session = await getServerSession(req, res, authOptions);
if (!session) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { courseId, badgeId, userId } = req.body;
let badge;
if (courseId && courseId !== null && courseId !== undefined) {
// Existing course badge logic
const userCourse = await prisma.userCourse.findFirst({
where: {
userId,
courseId,
completed: true,
},
include: {
course: {
include: {
badge: true,
},
},
user: true,
},
});
if (!userCourse) {
return res.status(400).json({ error: 'Course not completed' });
}
// Check if course requires repo submission
if (userCourse.course.submissionRequired && !userCourse.submittedRepoLink) {
return res.status(400).json({
error: 'Repository submission required',
message: 'You must submit a project repository to earn this badge'
});
}
badge = userCourse.course.badge;
} else if (badgeId) {
// Direct badge lookup for non-course badges
badge = await prisma.badge.findUnique({
where: { id: badgeId },
include: { userBadges: true },
});
if (!badge) {
return res.status(400).json({ error: 'Badge not found' });
}
} else {
return res.status(400).json({ error: 'Either courseId or badgeId is required' });
}
// Check if badge already exists
const existingBadge = await prisma.userBadge.findFirst({
where: {
userId,
badgeId: badge.id,
},
});
if (existingBadge) {
return res.status(400).json({ error: 'Badge already awarded' });
}
// Get user for pubkey
const user = await prisma.user.findUnique({
where: { id: userId },
});
let noteId = badge.noteId;
if (noteId && noteId.startsWith("naddr")) {
const naddr = nip19.decode(noteId);
noteId = `${naddr.data.kind}:${naddr.data.pubkey}:${naddr.data.identifier}`;
}
// Get the signing key from environment and convert to bytes
const signingKey = process.env.BADGE_SIGNING_KEY;
if (!signingKey) {
throw new Error('Signing key not configured');
}
const signingKeyBytes = hexToBytes(signingKey);
// Create event template
const eventTemplate = {
kind: BADGE_AWARD_KIND,
created_at: Math.floor(Date.now() / 1000),
tags: [
['p', user.pubkey],
['a', noteId],
['d', `plebdevs-badge-award-${session.user.id}`],
],
content: ""
};
// Add validation for required fields
if (!user.pubkey || !noteId) {
return res.status(400).json({
error: 'Missing required fields',
message: 'Pubkey and noteId are required'
});
}
// Finalize (sign) the event
const signedEvent = finalizeEvent(eventTemplate, signingKeyBytes);
// Verify the event
const isValid = verifyEvent(signedEvent);
if (!isValid) {
throw new Error('Event validation failed');
}
// Initialize pool and publish to relays
const pool = new SimplePool();
let published = false;
try {
await Promise.any(pool.publish(appConfig.defaultRelayUrls, signedEvent));
published = true;
} catch (error) {
throw new Error('Failed to publish to any relay');
} finally {
// Add a small delay before closing the pool
if (published) {
await new Promise(resolve => setTimeout(resolve, 100));
}
await pool.close(appConfig.defaultRelayUrls); // Pass the relays array to close()
}
// Store badge in database
const userBadge = await prisma.userBadge.create({
data: {
userId,
badgeId: badge.id,
awardedAt: new Date(),
},
include: {
badge: true,
},
});
return res.status(200).json({
success: true,
userBadge,
event: signedEvent
});
} catch (error) {
console.error('Error issuing badge:', error);
return res.status(500).json({
error: 'Failed to issue badge',
message: error.message
});
}
}

View File

@ -0,0 +1,37 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "../auth/[...nextauth]";
import prisma from "@/db/prisma";
export default async function handler(req, res) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const session = await getServerSession(req, res, authOptions);
if (!session) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { userId } = req.query;
const completedCourses = await prisma.userCourse.findMany({
where: {
userId: userId,
completed: true,
},
include: {
course: {
include: {
badge: true,
},
},
},
});
return res.status(200).json(completedCourses);
} catch (error) {
console.error('Error fetching completed courses:', error);
return res.status(500).json({ error: 'Failed to fetch completed courses' });
}
}

View File

@ -0,0 +1,24 @@
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/pages/api/auth/[...nextauth].js";
import { submitCourseRepo } from "@/db/models/userCourseModels";
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const session = await getServerSession(req, res, authOptions);
if (!session) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { courseSlug } = req.query;
const { repoLink } = req.body;
try {
await submitCourseRepo(session.user.id, courseSlug, repoLink);
return res.status(200).json({ success: true });
} catch (error) {
return res.status(500).json({ error: 'Failed to submit repo' });
}
}

View File

@ -38,28 +38,42 @@ export default function SignIn() {
const storedPubkey = localStorage.getItem('anonymousPubkey')
const storedPrivkey = localStorage.getItem('anonymousPrivkey')
const result = await signIn("anonymous", {
pubkey: storedPubkey,
privkey: storedPrivkey,
redirect: false
})
try {
const result = await signIn("anonymous", {
pubkey: storedPubkey,
privkey: storedPrivkey,
redirect: false,
callbackUrl: '/'
});
if (result?.ok) {
// Fetch the session to get the pubkey and privkey
const session = await getSession()
if (session?.pubkey && session?.privkey) {
localStorage.setItem('anonymousPubkey', session.pubkey)
localStorage.setItem('anonymousPrivkey', session.privkey)
router.push('/')
} else {
console.error("Pubkey or privkey not found in session")
}
// Redirect or update UI as needed
} else {
// Handle error
console.error("Anonymous login failed:", result?.error)
if (result?.ok) {
// Wait a moment for the session to be updated
await new Promise(resolve => setTimeout(resolve, 1000));
// Fetch the session
const session = await getSession();
if (session?.user?.pubkey && session?.user?.privkey) {
localStorage.setItem('anonymousPubkey', session.user.pubkey);
localStorage.setItem('anonymousPrivkey', session.user.privkey);
router.push('/');
} else {
console.error("Session data incomplete:", session);
}
} else {
console.error("Anonymous login failed:", result?.error);
}
} catch (error) {
console.error("Sign in error:", error);
}
}
};
useEffect(() => {
// Redirect if already signed in
if (session?.user) {
router.push('/');
}
}, [session, router]);
return (
<div className="w-[100vw] min-bottom-bar:w-[86vw] mx-auto mt-24 flex flex-col justify-center">
@ -78,6 +92,13 @@ export default function SignIn() {
rounded
onClick={() => setShowEmailInput(!showEmailInput)}
/>
<GenericButton
label={"login with github"}
icon="pi pi-github"
className="text-[#f8f8ff] w-[250px] my-4 mx-auto"
rounded
onClick={() => signIn("github")}
/>
{showEmailInput && (
<form onSubmit={handleEmailSignIn} className="flex flex-col items-center bg-gray-700 w-fit mx-auto p-4 rounded-lg">
<InputText

View File

@ -145,3 +145,7 @@ code {
}
}
/* hide attribution */
div.react-flow__attribution {
display: none !important;
}