mirror of
https://github.com/AustinKelsay/plebdevs.git
synced 2025-07-22 05:05:34 +00:00
Merge pull request #4 from AustinKelsay/feature/dev-journey-basic
Dev Journey, Badges, And Github Account Linking
This commit is contained in:
commit
42335d78c5
@ -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
491
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
39
prisma/migrations/20241210224336_add_badges/migration.sql
Normal file
39
prisma/migrations/20241210224336_add_badges/migration.sql
Normal 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;
|
@ -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;
|
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "UserCourse" ADD COLUMN "submittedRepoLink" TEXT;
|
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Course" ADD COLUMN "submissionRequired" BOOLEAN NOT NULL DEFAULT false;
|
@ -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"
|
@ -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
|
||||
}
|
||||
|
@ -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>
|
||||
|
243
src/components/charts/ActivityContributionChart.js
Normal file
243
src/components/charts/ActivityContributionChart.js
Normal 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;
|
314
src/components/charts/CombinedContributionChart.js
Normal file
314
src/components/charts/CombinedContributionChart.js
Normal 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;
|
@ -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;
|
@ -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;
|
@ -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"
|
||||
|
@ -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") {
|
||||
|
@ -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;
|
||||
|
@ -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 = [
|
||||
{
|
||||
|
74
src/components/onboarding/WelcomeModal.js
Normal file
74
src/components/onboarding/WelcomeModal.js
Normal 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's start your coding journey! 🚀</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default WelcomeModal;
|
212
src/components/profile/DataTables/UserProgressTable.js
Normal file
212
src/components/profile/DataTables/UserProgressTable.js
Normal 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;
|
102
src/components/profile/DataTables/UserPurchaseTable.js
Normal file
102
src/components/profile/DataTables/UserPurchaseTable.js
Normal 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;
|
139
src/components/profile/DataTables/UserRelaysTable.js
Normal file
139
src/components/profile/DataTables/UserRelaysTable.js
Normal 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;
|
66
src/components/profile/RepoSelector.js
Normal file
66
src/components/profile/RepoSelector.js
Normal 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;
|
152
src/components/profile/UserBadges.js
Normal file
152
src/components/profile/UserBadges.js
Normal 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;
|
@ -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>
|
||||
)
|
||||
);
|
||||
|
232
src/components/profile/UserProfileCard.js
Normal file
232
src/components/profile/UserProfileCard.js
Normal 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;
|
@ -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>
|
||||
)
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
152
src/components/profile/progress/UserProgressFlow.js
Normal file
152
src/components/profile/progress/UserProgressFlow.js
Normal 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;
|
@ -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!" />
|
||||
|
@ -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't renew my subscription?</h3>
|
||||
<p>If you don'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't renew my subscription?</h3>
|
||||
<p>If you don'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}
|
||||
|
82
src/db/models/badgeModels.js
Normal file
82
src/db/models/badgeModels.js
Normal 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 }
|
||||
});
|
||||
};
|
@ -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 },
|
||||
});
|
||||
|
64
src/db/models/userBadgeModels.js
Normal file
64
src/db/models/userBadgeModels.js
Normal 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
|
||||
}
|
||||
});
|
||||
};
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
37
src/hooks/apiQueries/useCompletedCoursesQuery.js
Normal file
37
src/hooks/apiQueries/useCompletedCoursesQuery.js
Normal 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),
|
||||
});
|
||||
}
|
113
src/hooks/badges/useBadge.js
Normal file
113
src/hooks/badges/useBadge.js
Normal 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 };
|
||||
};
|
@ -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
|
||||
});
|
||||
}
|
53
src/hooks/githubQueries/useFetchGithubRepos.js
Normal file
53
src/hooks/githubQueries/useFetchGithubRepos.js
Normal 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,
|
||||
});
|
||||
}
|
@ -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);
|
||||
|
@ -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]);
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
179
src/pages/api/badges/issue.js
Normal file
179
src/pages/api/badges/issue.js
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
37
src/pages/api/courses/completed.js
Normal file
37
src/pages/api/courses/completed.js
Normal 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' });
|
||||
}
|
||||
}
|
@ -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' });
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -145,3 +145,7 @@ code {
|
||||
}
|
||||
}
|
||||
|
||||
/* hide attribution */
|
||||
div.react-flow__attribution {
|
||||
display: none !important;
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user