mirror of
https://github.com/AustinKelsay/plebdevs.git
synced 2025-09-06 03:29:26 +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({
|
module.exports = removeImports({
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
images: {
|
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) {
|
webpack(config, options) {
|
||||||
return config;
|
return config;
|
||||||
|
491
package-lock.json
generated
491
package-lock.json
generated
@ -46,6 +46,7 @@
|
|||||||
"primereact": "^10.7.0",
|
"primereact": "^10.7.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
|
"reactflow": "^11.11.4",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"uuid": "^10.0.0",
|
"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": {
|
"node_modules/@rushstack/eslint-patch": {
|
||||||
"version": "1.10.3",
|
"version": "1.10.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.10.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.10.3.tgz",
|
||||||
@ -3450,6 +3553,259 @@
|
|||||||
"react": "^18.0.0"
|
"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": {
|
"node_modules/@types/debug": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||||
@ -3474,6 +3830,12 @@
|
|||||||
"@types/estree": "*"
|
"@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": {
|
"node_modules/@types/hast": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
|
||||||
@ -6092,6 +6454,12 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/client-only": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||||
@ -6270,6 +6638,111 @@
|
|||||||
"node": ">=0.12"
|
"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": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||||
@ -11852,6 +12325,24 @@
|
|||||||
"react-dom": ">=16.6.0"
|
"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": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
|
@ -47,6 +47,7 @@
|
|||||||
"primereact": "^10.7.0",
|
"primereact": "^10.7.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
|
"reactflow": "^11.11.4",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"uuid": "^10.0.0",
|
"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
|
# 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"
|
provider = "postgresql"
|
@ -13,6 +13,7 @@ generator client {
|
|||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// todo name and username?
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
pubkey String? @unique
|
pubkey String? @unique
|
||||||
@ -37,6 +38,7 @@ model User {
|
|||||||
userCourses UserCourse[]
|
userCourses UserCourse[]
|
||||||
nip05 Nip05?
|
nip05 Nip05?
|
||||||
lightningAddress LightningAddress?
|
lightningAddress LightningAddress?
|
||||||
|
userBadges UserBadge[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Session {
|
model Session {
|
||||||
@ -128,9 +130,11 @@ model Course {
|
|||||||
lessons Lesson[]
|
lessons Lesson[]
|
||||||
purchases Purchase[]
|
purchases Purchase[]
|
||||||
noteId String? @unique
|
noteId String? @unique
|
||||||
|
submissionRequired Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
userCourses UserCourse[]
|
userCourses UserCourse[]
|
||||||
|
badge Badge?
|
||||||
}
|
}
|
||||||
|
|
||||||
model CourseDraft {
|
model CourseDraft {
|
||||||
@ -215,6 +219,7 @@ model UserCourse {
|
|||||||
completed Boolean @default(false)
|
completed Boolean @default(false)
|
||||||
startedAt DateTime?
|
startedAt DateTime?
|
||||||
completedAt DateTime?
|
completedAt DateTime?
|
||||||
|
submittedRepoLink String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@ -246,3 +251,25 @@ model LightningAddress {
|
|||||||
lndHost String
|
lndHost String
|
||||||
lndPort String @default("8080")
|
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 React, { useEffect, useState, useRef } from 'react';
|
||||||
import useWindowWidth from '@/hooks/useWindowWidth';
|
import useWindowWidth from '@/hooks/useWindowWidth';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
import { signIn } from 'next-auth/react';
|
||||||
import { useImageProxy } from '@/hooks/useImageProxy';
|
import { useImageProxy } from '@/hooks/useImageProxy';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { Avatar } from 'primereact/avatar';
|
import { Avatar } from 'primereact/avatar';
|
||||||
@ -125,24 +126,27 @@ const HeroBanner = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-x-4">
|
<div className="space-x-4">
|
||||||
<GenericButton
|
<GenericButton
|
||||||
label="Learn"
|
label="Learn How to Code"
|
||||||
icon={<i className="pi pi-book pr-2 text-2xl" />}
|
icon={<i className="pi pi-book pr-2 text-2xl" />}
|
||||||
rounded
|
rounded
|
||||||
severity="info"
|
severity="info"
|
||||||
className="border-2"
|
className="border-2"
|
||||||
size={isMobile ? null : "large"}
|
size={isMobile ? null : "large"}
|
||||||
outlined
|
outlined
|
||||||
onClick={() => router.push('/content?tag=all')}
|
onClick={() => signIn('anonymous', {
|
||||||
|
callbackUrl: '/course/naddr1qvzqqqr4xspzpueu32tp0jc47uzlcuxdgcw06m40ytu7ynpna2adnqty3e0vda6pqy88wumn8ghj7mn0wvhxcmmv9uq32amnwvaz7tmjv4kxz7fwv3sk6atn9e5k7tcpr9mhxue69uhhyetvv9ujuumwdae8gtnnda3kjctv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uq36amnwvaz7tmjv4kxz7fwd46hg6tw09mkzmrvv46zucm0d5hsz9mhwden5te0wfjkccte9ec8y6tdv9kzumn9wshszynhwden5te0dehhxarjxgcjucm0d5hszynhwden5te0dehhxarjw4jjucm0d5hsz9nhwden5te0wp6hyurvv4ex2mrp0yhxxmmd9uq3wamnwvaz7tmjv4kxz7fwv3jhvueww3hk7mrn9uqzge34xvuxvdtrx5knzcfhxgkngwpsxsknsetzxyknxe3sx43k2cfkxsurwdq68epwa?active=starter',
|
||||||
|
redirect: true,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
<GenericButton
|
<GenericButton
|
||||||
label="Connect"
|
label="Level Up"
|
||||||
icon={<i className="pi pi-users pr-2 text-2xl" />}
|
icon={<i className="pi pi-video pr-2 text-2xl" />}
|
||||||
rounded
|
rounded
|
||||||
size={isMobile ? null : "large"}
|
size={isMobile ? null : "large"}
|
||||||
severity="success"
|
severity="success"
|
||||||
className="border-2"
|
className="border-2"
|
||||||
outlined
|
outlined
|
||||||
onClick={() => router.push('/feed?channel=global')}
|
onClick={() => router.push('/content?tag=all')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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 { findKind0Fields } from '@/utils/nostr';
|
||||||
import appConfig from "@/config/appConfig";
|
import appConfig from "@/config/appConfig";
|
||||||
import useTrackCourse from '@/hooks/tracking/useTrackCourse';
|
import useTrackCourse from '@/hooks/tracking/useTrackCourse';
|
||||||
|
import WelcomeModal from '@/components/onboarding/WelcomeModal';
|
||||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||||
|
|
||||||
export default function CourseDetails({ processedEvent, paidCourse, lessons, decryptionPerformed, handlePaymentSuccess, handlePaymentError }) {
|
export default function CourseDetails({ processedEvent, paidCourse, lessons, decryptionPerformed, handlePaymentSuccess, handlePaymentError }) {
|
||||||
@ -148,6 +149,7 @@ export default function CourseDetails({ processedEvent, paidCourse, lessons, dec
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
|
<WelcomeModal />
|
||||||
<div className="relative w-full h-[400px] mb-8">
|
<div className="relative w-full h-[400px] mb-8">
|
||||||
<Image
|
<Image
|
||||||
alt="course image"
|
alt="course image"
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useNDKContext } from "@/context/NDKContext";
|
import { useNDKContext } from "@/context/NDKContext";
|
||||||
import { parseCourseEvent } from "@/utils/nostr";
|
import { parseCourseEvent, parseEvent } from "@/utils/nostr";
|
||||||
import { ProgressSpinner } from "primereact/progressspinner";
|
import { ProgressSpinner } from "primereact/progressspinner";
|
||||||
import { nip19 } from "nostr-tools";
|
import { nip19 } from "nostr-tools";
|
||||||
import appConfig from "@/config/appConfig";
|
import appConfig from "@/config/appConfig";
|
||||||
|
|
||||||
const ProgressListItem = ({ dTag, category }) => {
|
const ProgressListItem = ({ dTag, category, type = 'course' }) => {
|
||||||
const { ndk } = useNDKContext();
|
const { ndk } = useNDKContext();
|
||||||
const [event, setEvent] = useState(null);
|
const [event, setEvent] = useState(null);
|
||||||
|
|
||||||
@ -16,25 +16,26 @@ const ProgressListItem = ({ dTag, category }) => {
|
|||||||
try {
|
try {
|
||||||
await ndk.connect();
|
await ndk.connect();
|
||||||
const filter = {
|
const filter = {
|
||||||
kinds: [30004],
|
kinds: type === 'course' ? [30004] : [30023, 30402],
|
||||||
"#d": [dTag]
|
authors: appConfig.authorPubkeys,
|
||||||
|
"#d": [dTag],
|
||||||
}
|
}
|
||||||
const event = await ndk.fetchEvent(filter);
|
const event = await ndk.fetchEvent(filter);
|
||||||
if (event) {
|
if (event) {
|
||||||
setEvent(parseCourseEvent(event));
|
setEvent(type === 'course' ? parseCourseEvent(event) : parseEvent(event));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching event:", error);
|
console.error("Error fetching event:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchEvent();
|
fetchEvent();
|
||||||
}, [dTag, ndk]);
|
}, [dTag, ndk, type]);
|
||||||
|
|
||||||
const encodeNaddr = () => {
|
const encodeNaddr = () => {
|
||||||
return nip19.naddrEncode({
|
return nip19.naddrEncode({
|
||||||
pubkey: event.pubkey,
|
pubkey: event.pubkey,
|
||||||
identifier: event.d,
|
identifier: event.d,
|
||||||
kind: 30004,
|
kind: type === 'course' ? 30004 : event.kind,
|
||||||
relays: appConfig.defaultRelayUrls
|
relays: appConfig.defaultRelayUrls
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -43,9 +44,13 @@ const ProgressListItem = ({ dTag, category }) => {
|
|||||||
if (!event) return null;
|
if (!event) return null;
|
||||||
|
|
||||||
if (category === "name") {
|
if (category === "name") {
|
||||||
|
const href = type === 'course'
|
||||||
|
? `/course/${encodeNaddr()}`
|
||||||
|
: `/details/${encodeNaddr()}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a className="text-blue-500 underline hover:text-blue-600" href={`/course/${encodeNaddr()}`}>
|
<a className="text-blue-500 underline hover:text-blue-600" href={href}>
|
||||||
{event.name}
|
{event.name || event.title}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
} else if (category === "lessons") {
|
} else if (category === "lessons") {
|
||||||
|
@ -77,7 +77,6 @@ const PublishedCourseForm = ({ course }) => {
|
|||||||
if (!ndk.signer) {
|
if (!ndk.signer) {
|
||||||
await addSigner();
|
await addSigner();
|
||||||
}
|
}
|
||||||
console.log('lessons', lessons);
|
|
||||||
|
|
||||||
const event = new NDKEvent(ndk);
|
const event = new NDKEvent(ndk);
|
||||||
event.kind = course.kind;
|
event.kind = course.kind;
|
||||||
|
@ -56,7 +56,7 @@ const UserAvatar = () => {
|
|||||||
return null; // Or return a loader/spinner/placeholder
|
return null; // Or return a loader/spinner/placeholder
|
||||||
} else if (user && Object.keys(user).length > 0) {
|
} else if (user && Object.keys(user).length > 0) {
|
||||||
// User exists, show username or pubkey
|
// 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 = [
|
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 React, { 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 { useSession } from 'next-auth/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 { useNDKContext } from "@/context/NDKContext";
|
||||||
import { formatDateTime } from "@/utils/time";
|
import UserProfileCard from "@/components/profile/UserProfileCard";
|
||||||
import { Tooltip } from "primereact/tooltip";
|
import CombinedContributionChart from "@/components/charts/CombinedContributionChart";
|
||||||
import { nip19 } from "nostr-tools";
|
import ActivityContributionChart from "@/components/charts/ActivityContributionChart";
|
||||||
import Image from "next/image";
|
|
||||||
import GithubContributionChart from "@/components/charts/GithubContributionChart";
|
|
||||||
import GithubContributionChartDisabled from "@/components/charts/GithubContributionChartDisabled";
|
|
||||||
import useCheckCourseProgress from "@/hooks/tracking/useCheckCourseProgress";
|
import useCheckCourseProgress from "@/hooks/tracking/useCheckCourseProgress";
|
||||||
import useWindowWidth from "@/hooks/useWindowWidth";
|
import useWindowWidth from "@/hooks/useWindowWidth";
|
||||||
import { useToast } from "@/hooks/useToast";
|
|
||||||
import UserProgress from "@/components/profile/progress/UserProgress";
|
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 UserProfile = () => {
|
||||||
const windowWidth = useWindowWidth();
|
const windowWidth = useWindowWidth();
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
|
const [account, setAccount] = useState(null);
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const { returnImageProxy } = useImageProxy();
|
|
||||||
const { ndk, addSigner } = useNDKContext();
|
const { ndk, addSigner } = useNDKContext();
|
||||||
const { showToast } = useToast();
|
|
||||||
const menu = useRef(null);
|
|
||||||
useCheckCourseProgress();
|
useCheckCourseProgress();
|
||||||
|
|
||||||
const copyToClipboard = (text) => {
|
|
||||||
navigator.clipboard.writeText(text);
|
|
||||||
showToast("success", "Copied", "Copied to clipboard");
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session?.user) {
|
if (session?.user) {
|
||||||
|
console.log("Session", session)
|
||||||
setUser(session.user);
|
setUser(session.user);
|
||||||
|
|
||||||
|
if (session?.account) {
|
||||||
|
setAccount(session.account);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [session]);
|
}, [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 (
|
return (
|
||||||
user && (
|
user && (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
@ -81,115 +37,29 @@ const UserProfile = () => {
|
|||||||
<h1 className="text-3xl font-bold mb-6">Profile</h1>
|
<h1 className="text-3xl font-bold mb-6">Profile</h1>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
<div className="w-full flex flex-col justify-center mx-auto">
|
<div className="w-full flex flex-row max-lap:flex-col">
|
||||||
<div className="relative flex w-full items-center justify-center">
|
<div className="w-[22%] h-full max-lap:w-full">
|
||||||
<Image
|
{user && <UserProfileCard user={user} />}
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
|
<div className="w-[78%] flex flex-col justify-center mx-auto max-lap:w-full">
|
||||||
<h1 className="text-center text-2xl my-2">
|
{account && account?.provider === "github" ? (
|
||||||
{user.username || user?.email || "Anon"}
|
<CombinedContributionChart session={session} />
|
||||||
</h1>
|
) : (
|
||||||
{user.pubkey && (
|
<ActivityContributionChart session={session} />
|
||||||
<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"} />
|
<UserProgress />
|
||||||
{nip19.npubEncode(user.pubkey)} <i className="pi pi-question-circle text-xl pubkey-tooltip" />
|
<UserProgressTable
|
||||||
</h2>
|
session={session}
|
||||||
)}
|
ndk={ndk}
|
||||||
{user?.lightningAddress && (
|
windowWidth={windowWidth}
|
||||||
<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")} />
|
<UserPurchaseTable
|
||||||
</h3>
|
session={session}
|
||||||
)}
|
windowWidth={windowWidth}
|
||||||
{user?.nip05 && (
|
/>
|
||||||
<h3 className="w-fit mx-auto text-center text-xl my-2 bg-gray-800 rounded-lg p-4">
|
</div>
|
||||||
<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>
|
</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>
|
</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 GenericButton from "@/components/buttons/GenericButton";
|
||||||
import { DataTable } from "primereact/datatable";
|
import { DataTable } from "primereact/datatable";
|
||||||
import { Column } from "primereact/column";
|
import { Column } from "primereact/column";
|
||||||
import { Menu } from "primereact/menu";
|
import UserProfileCard from "@/components/profile/UserProfileCard";
|
||||||
import { useImageProxy } from "@/hooks/useImageProxy";
|
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { ProgressSpinner } from "primereact/progressspinner";
|
|
||||||
import { useNDKContext } from "@/context/NDKContext";
|
import { useNDKContext } from "@/context/NDKContext";
|
||||||
import useWindowWidth from "@/hooks/useWindowWidth";
|
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 BitcoinConnectButton from "@/components/bitcoinConnect/BitcoinConnect";
|
||||||
import { Panel } from "primereact/panel";
|
|
||||||
import { nip19 } from "nostr-tools";
|
|
||||||
import { InputText } from "primereact/inputtext";
|
import { InputText } from "primereact/inputtext";
|
||||||
import { Tooltip } from "primereact/tooltip";
|
|
||||||
import { useToast } from "@/hooks/useToast";
|
import { useToast } from "@/hooks/useToast";
|
||||||
import SubscribeModal from "@/components/profile/subscription/SubscribeModal";
|
import SubscribeModal from "@/components/profile/subscription/SubscribeModal";
|
||||||
import appConfig from "@/config/appConfig";
|
import appConfig from "@/config/appConfig";
|
||||||
|
import UserRelaysTable from "@/components/profile/DataTables/UserRelaysTable";
|
||||||
|
|
||||||
const UserSettings = () => {
|
const UserSettings = () => {
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
const [collapsed, setCollapsed] = useState(true);
|
|
||||||
const { ndk, userRelays, setUserRelays, reInitializeNDK } = useNDKContext();
|
const { ndk, userRelays, setUserRelays, reInitializeNDK } = useNDKContext();
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const { returnImageProxy } = useImageProxy();
|
|
||||||
const menu = useRef(null);
|
|
||||||
const windowWidth = useWindowWidth();
|
const windowWidth = useWindowWidth();
|
||||||
const [newRelayUrl, setNewRelayUrl] = useState("");
|
|
||||||
const { showToast } = useToast();
|
|
||||||
const [relayStatuses, setRelayStatuses] = useState({});
|
|
||||||
const [updateTrigger, setUpdateTrigger] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session?.user) {
|
if (session?.user) {
|
||||||
@ -39,251 +25,40 @@ const UserSettings = () => {
|
|||||||
}
|
}
|
||||||
}, [session]);
|
}, [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 (
|
return (
|
||||||
user && (
|
user && (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
{
|
{windowWidth < 768 && (
|
||||||
windowWidth < 768 && (
|
<h1 className="text-3xl font-bold mb-6">Settings</h1>
|
||||||
<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">
|
||||||
<div className="w-full flex flex-col justify-center mx-auto">
|
<UserProfileCard user={user} />
|
||||||
<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>
|
|
||||||
|
|
||||||
<h1 className="text-center text-2xl my-2">
|
{/* Lightning Info Card */}
|
||||||
{user.username || user?.email || "Anon"}
|
<div className="bg-gray-800 rounded-lg p-4 my-4 border border-gray-700">
|
||||||
</h1>
|
<div className="flex items-center gap-2 mb-4">
|
||||||
{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">
|
|
||||||
<i className="pi pi-bolt text-yellow-500 text-2xl"></i>
|
<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">
|
<h3 className="text-xl font-semibold">Lightning Wallet Connection</h3>
|
||||||
Lightning Wallet Connection
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p className="text-gray-400 mb-4">
|
||||||
Connect your Lightning wallet for easier payments across the platform
|
Connect your Lightning wallet for easier payments across the platform
|
||||||
</p>
|
</p>
|
||||||
<BitcoinConnectButton />
|
<BitcoinConnectButton />
|
||||||
</div>
|
</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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -1,54 +1,73 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { ProgressBar } from 'primereact/progressbar';
|
import { ProgressBar } from 'primereact/progressbar';
|
||||||
import { Accordion, AccordionTab } from 'primereact/accordion';
|
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 = [
|
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',
|
status: 'PlebDevs Starter',
|
||||||
completed: false,
|
completed: false,
|
||||||
tier: 'New Dev',
|
tier: 'Plebdev',
|
||||||
courseId: "f538f5c5-1a72-4804-8eb1-3f05cea64874",
|
courseId: "f538f5c5-1a72-4804-8eb1-3f05cea64874",
|
||||||
|
courseNAddress: "naddr1qvzqqqr4xspzpueu32tp0jc47uzlcuxdgcw06m40ytu7ynpna2adnqty3e0vda6pqy88wumn8ghj7mn0wvhxcmmv9uq32amnwvaz7tmjv4kxz7fwv3sk6atn9e5k7tcpr9mhxue69uhhyetvv9ujuumwdae8gtnnda3kjctv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uq36amnwvaz7tmjv4kxz7fwd46hg6tw09mkzmrvv46zucm0d5hsz9mhwden5te0wfjkccte9ec8y6tdv9kzumn9wshszynhwden5te0dehhxarjxgcjucm0d5hszynhwden5te0dehhxarjw4jjucm0d5hsz9nhwden5te0wp6hyurvv4ex2mrp0yhxxmmd9uq3wamnwvaz7tmjv4kxz7fwv3jhvueww3hk7mrn9uqzge34xvuxvdtrx5knzcfhxgkngwpsxsknsetzxyknxe3sx43k2cfkxsurwdq68epwa",
|
||||||
subTasks: [
|
subTasks: [
|
||||||
{ status: 'Connect GitHub', completed: false },
|
{ status: 'Complete the course', completed: false },
|
||||||
{ status: 'Create First GitHub Repo', completed: false },
|
|
||||||
{ status: 'Push Commit', completed: false }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
status: 'Frontend Course',
|
status: 'Frontend Course',
|
||||||
completed: false,
|
completed: false,
|
||||||
tier: 'Junior Dev',
|
tier: 'Frontend Dev',
|
||||||
courseId: 'f73c37f4-df2e-4f7d-a838-dce568c76136',
|
courseId: 'f73c37f4-df2e-4f7d-a838-dce568c76136',
|
||||||
|
courseNAddress: "naddr1qvzqqqr4xspzpueu32tp0jc47uzlcuxdgcw06m40ytu7ynpna2adnqty3e0vda6pqy88wumn8ghj7mn0wvhxcmmv9uq32amnwvaz7tmjv4kxz7fwv3sk6atn9e5k7tcpr9mhxue69uhhyetvv9ujuumwdae8gtnnda3kjctv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uq36amnwvaz7tmjv4kxz7fwd46hg6tw09mkzmrvv46zucm0d5hsz9mhwden5te0wfjkccte9ec8y6tdv9kzumn9wshszynhwden5te0dehhxarjxgcjucm0d5hszynhwden5te0dehhxarjw4jjucm0d5hsz9nhwden5te0wp6hyurvv4ex2mrp0yhxxmmd9uq3wamnwvaz7tmjv4kxz7fwv3jhvueww3hk7mrn9uqzge3hxd3nxdmxxskkge3jv5knge3hvskkzwpn8qkkgcm9x5mrscehxccnxdsc53n8w",
|
||||||
subTasks: [
|
subTasks: [
|
||||||
{ status: 'Complete the course', completed: false },
|
{ status: 'Complete the course', completed: false },
|
||||||
{ status: 'Submit Link to completed project', completed: false },
|
{ status: 'Submit your project repository', completed: false },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
status: 'Backend Course',
|
status: 'Backend Course',
|
||||||
completed: false,
|
completed: false,
|
||||||
tier: 'Plebdev',
|
tier: 'Backend Dev',
|
||||||
courseId: 'f6825391-831c-44da-904a-9ac3d149b7be',
|
courseId: 'f6825391-831c-44da-904a-9ac3d149b7be',
|
||||||
|
courseNAddress: "naddr1qvzqqqr4xspzpueu32tp0jc47uzlcuxdgcw06m40ytu7ynpna2adnqty3e0vda6pqy88wumn8ghj7mn0wvhxcmmv9uq32amnwvaz7tmjv4kxz7fwv3sk6atn9e5k7tcpr9mhxue69uhhyetvv9ujuumwdae8gtnnda3kjctv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uq36amnwvaz7tmjv4kxz7fwd46hg6tw09mkzmrvv46zucm0d5hsz9mhwden5te0wfjkccte9ec8y6tdv9kzumn9wshszynhwden5te0dehhxarjxgcjucm0d5hszynhwden5te0dehhxarjw4jjucm0d5hsz9nhwden5te0wp6hyurvv4ex2mrp0yhxxmmd9uq3wamnwvaz7tmjv4kxz7fwv3jhvueww3hk7mrn9uqzge3k8qer2veexyknsve3vvkngdryvyknjvp5vyknjctrxdjrzdpevgmkyegqyn0ns",
|
||||||
subTasks: [
|
subTasks: [
|
||||||
{status: 'Complete the course', completed: false},
|
{ status: 'Complete the course', completed: false },
|
||||||
{ status: 'Submit Link to completed project', completed: false },
|
{ status: 'Submit your project repository', completed: false },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const UserProgress = () => {
|
const UserProgress = () => {
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [currentTier, setCurrentTier] = useState('Pleb');
|
const [currentTier, setCurrentTier] = useState(null);
|
||||||
const [expandedItems, setExpandedItems] = useState({});
|
const [expandedItems, setExpandedItems] = useState({});
|
||||||
const [completedCourses, setCompletedCourses] = useState([]);
|
const [completedCourses, setCompletedCourses] = useState([]);
|
||||||
const [tasks, setTasks] = useState([]);
|
const [tasks, setTasks] = useState([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
const { data: session } = useSession();
|
const router = useRouter();
|
||||||
|
const { data: session, update } = useSession();
|
||||||
|
useBadge();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session?.user) {
|
if (session?.user) {
|
||||||
|
setIsLoading(true);
|
||||||
const user = session.user;
|
const user = session.user;
|
||||||
const ids = user?.userCourses?.map(course => course?.completed ? course.courseId : null).filter(id => id !== null);
|
const ids = user?.userCourses?.map(course => course?.completed ? course.courseId : null).filter(id => id !== null);
|
||||||
if (ids && ids.length > 0) {
|
if (ids && ids.length > 0) {
|
||||||
@ -61,24 +80,50 @@ const UserProgress = () => {
|
|||||||
calculateProgress([]);
|
calculateProgress([]);
|
||||||
calculateCurrentTier([]);
|
calculateCurrentTier([]);
|
||||||
}
|
}
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [session]);
|
}, [session]);
|
||||||
|
|
||||||
const generateTasks = (completedCourseIds) => {
|
const generateTasks = (completedCourseIds) => {
|
||||||
const updatedTasks = allTasks.map(task => ({
|
const updatedTasks = allTasks.map(task => {
|
||||||
...task,
|
if (task.status === 'Connect GitHub') {
|
||||||
completed: task.courseId === null || completedCourseIds.includes(task.courseId),
|
return {
|
||||||
subTasks: task.subTasks ? task.subTasks.map(subTask => ({
|
...task,
|
||||||
...subTask,
|
completed: session?.account?.provider === 'github' ? true : false,
|
||||||
completed: completedCourseIds.includes(task.courseId)
|
subTasks: task.subTasks.map(subTask => ({
|
||||||
})) : undefined
|
...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);
|
setTasks(updatedTasks);
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateProgress = (completedCourseIds) => {
|
const calculateProgress = (completedCourseIds) => {
|
||||||
let progressValue = 25;
|
let progressValue = 0;
|
||||||
|
|
||||||
|
if (session?.account?.provider === 'github') {
|
||||||
|
progressValue += 25;
|
||||||
|
}
|
||||||
|
|
||||||
const remainingTasks = allTasks.slice(1);
|
const remainingTasks = allTasks.slice(1);
|
||||||
remainingTasks.forEach(task => {
|
remainingTasks.forEach(task => {
|
||||||
@ -91,16 +136,16 @@ const UserProgress = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const calculateCurrentTier = (completedCourseIds) => {
|
const calculateCurrentTier = (completedCourseIds) => {
|
||||||
let tier = 'Pleb';
|
let tier = null;
|
||||||
|
|
||||||
if (completedCourseIds.includes("f538f5c5-1a72-4804-8eb1-3f05cea64874")) {
|
|
||||||
tier = 'New Dev';
|
|
||||||
}
|
|
||||||
if (completedCourseIds.includes("f73c37f4-df2e-4f7d-a838-dce568c76136")) {
|
|
||||||
tier = 'Junior Dev';
|
|
||||||
}
|
|
||||||
if (completedCourseIds.includes("f6825391-831c-44da-904a-9ac3d149b7be")) {
|
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';
|
tier = 'Plebdev';
|
||||||
|
} else if (session?.account?.provider === 'github') {
|
||||||
|
tier = 'Pleb';
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentTier(tier);
|
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 (
|
return (
|
||||||
<div className="bg-gray-800 rounded-3xl p-6 w-[500px] max-mob:w-full max-tab:w-full mx-auto my-8">
|
<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">
|
||||||
<h1 className="text-3xl font-bold text-white mb-2">Dev Journey (coming soon)</h1>
|
<div className="flex flex-row justify-between items-center">
|
||||||
<p className="text-gray-400 mb-4">Track your progress from Pleb to Plebdev</p>
|
<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">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<span className="text-gray-300">Progress</span>
|
<span className="text-gray-300">Progress</span>
|
||||||
@ -130,36 +208,136 @@ const UserProgress = () => {
|
|||||||
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<span className="text-white text-lg font-semibold">Current Tier: </span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<ul className="space-y-4 mb-6">
|
<div className="flex max-sidebar:flex-col gap-6 mb-6">
|
||||||
{tasks.map((task, index) => (
|
<div className="w-1/2 max-sidebar:w-full">
|
||||||
<li key={index}>
|
<ul className="space-y-6 pt-2">
|
||||||
<div className="flex items-center justify-between">
|
{tasks.map((task, index) => (
|
||||||
<div className="flex items-center">
|
<li key={index}>
|
||||||
{task.completed ? (
|
<Accordion
|
||||||
<div className="w-6 h-6 bg-green-500 rounded-full flex items-center justify-center mr-3">
|
activeIndex={expandedItems[index] ? 0 : null}
|
||||||
<i className="pi pi-check text-white text-lg"></i>
|
onTabChange={(e) => handleAccordionChange(index, e.index === 0)}
|
||||||
</div>
|
>
|
||||||
) : (
|
<AccordionTab
|
||||||
<div className="w-6 h-6 bg-gray-700 rounded-full flex items-center justify-center mr-3">
|
header={
|
||||||
<i className="pi pi-info-circle text-white text-lg"></i>
|
<div className="flex items-center justify-between w-full">
|
||||||
</div>
|
<div className="flex items-center">
|
||||||
)}
|
{task.completed ? (
|
||||||
<span className={`text-lg ${task.completed ? 'text-white' : 'text-gray-400'}`}>{task.status}</span>
|
<div className="w-6 h-6 bg-green-500 rounded-full flex items-center justify-center mr-3">
|
||||||
</div>
|
<i className="pi pi-check text-white text-lg"></i>
|
||||||
<span className="bg-blue-500 text-white text-xs px-2 py-1 rounded-full w-20 text-center">
|
</div>
|
||||||
{task.tier}
|
) : (
|
||||||
</span>
|
<div className="w-6 h-6 bg-gray-700 rounded-full flex items-center justify-center mr-3">
|
||||||
</div>
|
<i className="pi pi-info-circle text-white text-lg"></i>
|
||||||
</li>
|
</div>
|
||||||
))}
|
)}
|
||||||
</ul>
|
<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">
|
<div className="w-1/2 max-sidebar:w-full">
|
||||||
View Badges (Coming Soon)
|
{isLoading ? (
|
||||||
</button>
|
<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>
|
</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 (
|
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 && (
|
{subscribed && !user?.role?.nwc && (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<Message className="w-fit" severity="success" text="Subscribed!" />
|
<Message className="w-fit" severity="success" text="Subscribed!" />
|
||||||
|
@ -5,13 +5,11 @@ import { useToast } from '@/hooks/useToast';
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Card } from 'primereact/card';
|
import { Card } from 'primereact/card';
|
||||||
import useWindowWidth from '@/hooks/useWindowWidth';
|
import useWindowWidth from '@/hooks/useWindowWidth';
|
||||||
import { Menu } from "primereact/menu";
|
|
||||||
import { Message } from "primereact/message";
|
import { Message } from "primereact/message";
|
||||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||||
import SubscriptionPaymentButtons from '@/components/bitcoinConnect/SubscriptionPaymentButton';
|
import SubscriptionPaymentButtons from '@/components/bitcoinConnect/SubscriptionPaymentButton';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import NostrIcon from '../../../../public/images/nostr.png';
|
import NostrIcon from '../../../../public/images/nostr.png';
|
||||||
import { Badge } from 'primereact/badge';
|
|
||||||
import GenericButton from '@/components/buttons/GenericButton';
|
import GenericButton from '@/components/buttons/GenericButton';
|
||||||
import CancelSubscription from '@/components/profile/subscription/CancelSubscription';
|
import CancelSubscription from '@/components/profile/subscription/CancelSubscription';
|
||||||
import CalendlyEmbed from '@/components/profile/subscription/CalendlyEmbed';
|
import CalendlyEmbed from '@/components/profile/subscription/CalendlyEmbed';
|
||||||
@ -100,130 +98,141 @@ const UserSubscription = () => {
|
|||||||
{windowWidth < 768 && (
|
{windowWidth < 768 && (
|
||||||
<h1 className="text-3xl font-bold mb-6">Subscription Management</h1>
|
<h1 className="text-3xl font-bold mb-6">Subscription Management</h1>
|
||||||
)}
|
)}
|
||||||
<div className="mb-4 p-4 bg-gray-800 rounded-lg w-fit">
|
<div className="w-full flex flex-row max-lap:flex-col">
|
||||||
{subscribed && !user?.role?.nwc && (
|
{/* Left Column - 22% */}
|
||||||
<div className="flex flex-col">
|
<div className="w-[21%] h-full max-lap:w-full">
|
||||||
<Message className="w-fit" severity="success" text="Subscribed!" />
|
<div className="p-4 bg-gray-800 rounded-lg max-lap:mb-4">
|
||||||
<p className="mt-4">Thank you for your support 🎉</p>
|
{/* Subscription Status Messages */}
|
||||||
<p className="text-sm text-gray-400">Pay-as-you-go subscription requires manual renewal on {subscribedUntil.toLocaleDateString()}</p>
|
{subscribed && !user?.role?.nwc && (
|
||||||
</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="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex flex-col gap-4">
|
<Message className="w-fit" severity="success" text="Subscribed!" />
|
||||||
<GenericButton severity="info" outlined className="w-fit text-start" label="Schedule 1:1" icon="pi pi-calendar" onClick={() => setCalendlyVisible(true)} />
|
<p className="mt-4">Thank you for your support 🎉</p>
|
||||||
<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)} />
|
<p className="text-sm text-gray-400">Pay-as-you-go subscription requires manual renewal on {subscribedUntil.toLocaleDateString()}</p>
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
{subscribed && user?.role?.nwc && (
|
||||||
<Card title="Manage Subscription" className="mb-4">
|
<div className="flex flex-col">
|
||||||
<div className='flex flex-col gap-4'>
|
<Message className="w-fit" severity="success" text="Subscribed!" />
|
||||||
<GenericButton outlined className="w-fit" label="Renew Subscription" icon="pi pi-sync" onClick={() => setRenewSubscriptionVisible(true)} />
|
<p className="mt-4">Thank you for your support 🎉</p>
|
||||||
<GenericButton severity="danger" outlined className="w-fit" label="Cancel Subscription" icon="pi pi-trash" onClick={() => setCancelSubscriptionVisible(true)} />
|
<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>
|
</div>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
<CalendlyEmbed
|
<CalendlyEmbed
|
||||||
visible={calendlyVisible}
|
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,
|
purchases: true,
|
||||||
|
badge: true
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -31,34 +32,47 @@ export const getCourseById = async (id) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
purchases: true,
|
purchases: true,
|
||||||
|
badge: true
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createCourse = async (data) => {
|
export const createCourse = async (data) => {
|
||||||
|
const { badge, ...courseData } = data;
|
||||||
return await prisma.course.create({
|
return await prisma.course.create({
|
||||||
data: {
|
data: {
|
||||||
id: data.id,
|
id: courseData.id,
|
||||||
noteId: data.noteId,
|
noteId: courseData.noteId,
|
||||||
price: data.price,
|
price: courseData.price,
|
||||||
user: { connect: { id: data.user.connect.id } },
|
submissionRequired: courseData.submissionRequired || false,
|
||||||
|
user: { connect: { id: courseData.user.connect.id } },
|
||||||
lessons: {
|
lessons: {
|
||||||
connect: data.lessons.connect
|
connect: courseData.lessons.connect
|
||||||
}
|
},
|
||||||
|
...(badge && {
|
||||||
|
badge: {
|
||||||
|
create: {
|
||||||
|
name: badge.name,
|
||||||
|
noteId: badge.noteId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
lessons: true,
|
lessons: true,
|
||||||
user: true
|
user: true,
|
||||||
|
badge: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateCourse = async (id, data) => {
|
export const updateCourse = async (id, data) => {
|
||||||
const { lessons, ...otherData } = data;
|
const { lessons, badge, ...otherData } = data;
|
||||||
return await prisma.course.update({
|
return await prisma.course.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
...otherData,
|
...otherData,
|
||||||
|
submissionRequired: otherData.submissionRequired || false,
|
||||||
lessons: {
|
lessons: {
|
||||||
deleteMany: {},
|
deleteMany: {},
|
||||||
create: lessons.map((lesson, index) => ({
|
create: lessons.map((lesson, index) => ({
|
||||||
@ -66,7 +80,21 @@ export const updateCourse = async (id, data) => {
|
|||||||
draftId: lesson.draftId || null,
|
draftId: lesson.draftId || null,
|
||||||
index: index
|
index: index
|
||||||
}))
|
}))
|
||||||
}
|
},
|
||||||
|
...(badge && {
|
||||||
|
badge: {
|
||||||
|
upsert: {
|
||||||
|
create: {
|
||||||
|
name: badge.name,
|
||||||
|
noteId: badge.noteId
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
name: badge.name,
|
||||||
|
noteId: badge.noteId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
lessons: {
|
lessons: {
|
||||||
@ -77,12 +105,17 @@ export const updateCourse = async (id, data) => {
|
|||||||
orderBy: {
|
orderBy: {
|
||||||
index: 'asc'
|
index: 'asc'
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
badge: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteCourse = async (id) => {
|
export const deleteCourse = async (id) => {
|
||||||
|
await prisma.badge.deleteMany({
|
||||||
|
where: { courseId: id }
|
||||||
|
});
|
||||||
|
|
||||||
return await prisma.course.delete({
|
return await prisma.course.delete({
|
||||||
where: { id },
|
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) => {
|
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({
|
return await prisma.userCourse.upsert({
|
||||||
where: {
|
where: {
|
||||||
userId_courseId: {
|
userId_courseId: {
|
||||||
@ -27,10 +45,7 @@ export const createOrUpdateUserCourse = async (userId, courseId, data) => {
|
|||||||
courseId,
|
courseId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
update: {
|
update: updateData,
|
||||||
...data,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
},
|
|
||||||
create: {
|
create: {
|
||||||
userId,
|
userId,
|
||||||
courseId,
|
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) => {
|
export const checkCourseCompletion = async (userId, courseId) => {
|
||||||
const course = await prisma.course.findUnique({
|
const course = await prisma.course.findUnique({
|
||||||
where: { id: courseId },
|
where: { id: courseId },
|
||||||
@ -72,10 +101,19 @@ export const checkCourseCompletion = async (userId, courseId) => {
|
|||||||
lesson.userLessons.length > 0 && lesson.userLessons[0].completed
|
lesson.userLessons.length > 0 && lesson.userLessons[0].completed
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const existingUserCourse = await prisma.userCourse.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_courseId: {
|
||||||
|
userId,
|
||||||
|
courseId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (allLessonsCompleted) {
|
if (allLessonsCompleted) {
|
||||||
await createOrUpdateUserCourse(userId, courseId, {
|
await createOrUpdateUserCourse(userId, courseId, {
|
||||||
completed: true,
|
completed: true,
|
||||||
completedAt: new Date()
|
...(existingUserCourse?.completed ? {} : { completedAt: new Date() })
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
@ -20,6 +20,11 @@ export const getAllUsers = async () => {
|
|||||||
lesson: true,
|
lesson: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
userBadges: {
|
||||||
|
include: {
|
||||||
|
badge: true
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -47,6 +52,11 @@ export const getUserById = async (id) => {
|
|||||||
},
|
},
|
||||||
nip05: true,
|
nip05: true,
|
||||||
lightningAddress: true,
|
lightningAddress: true,
|
||||||
|
userBadges: {
|
||||||
|
include: {
|
||||||
|
badge: true
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -74,6 +84,11 @@ export const getUserByPubkey = async (pubkey) => {
|
|||||||
},
|
},
|
||||||
nip05: true,
|
nip05: true,
|
||||||
lightningAddress: true,
|
lightningAddress: true,
|
||||||
|
userBadges: {
|
||||||
|
include: {
|
||||||
|
badge: true
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -235,28 +250,45 @@ export const expireUserSubscriptions = async (userIds) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getUserByEmail = async (email) => {
|
export const getUserByEmail = async (email) => {
|
||||||
return await prisma.user.findUnique({
|
if (!email || typeof email !== 'string') {
|
||||||
where: { email },
|
console.error('Invalid email parameter:', email);
|
||||||
include: {
|
return null;
|
||||||
role: true,
|
}
|
||||||
purchased: {
|
|
||||||
include: {
|
try {
|
||||||
course: true,
|
return await prisma.user.findUnique({
|
||||||
resource: true,
|
where: {
|
||||||
},
|
email: email.toLowerCase().trim()
|
||||||
},
|
},
|
||||||
userCourses: {
|
include: {
|
||||||
include: {
|
role: true,
|
||||||
course: true,
|
purchased: {
|
||||||
},
|
include: {
|
||||||
},
|
course: true,
|
||||||
userLessons: {
|
resource: true,
|
||||||
include: {
|
},
|
||||||
lesson: true,
|
},
|
||||||
},
|
userCourses: {
|
||||||
},
|
include: {
|
||||||
nip05: true,
|
course: true,
|
||||||
lightningAddress: 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 { useQuery } from '@tanstack/react-query';
|
||||||
import { getAllCommits } from '@/lib/github';
|
import { getAllCommits } from '@/lib/github';
|
||||||
|
|
||||||
export function useFetchGithubCommits(username) {
|
export function useFetchGithubCommits(session, onCommitReceived) {
|
||||||
const fetchCommits = async () => {
|
const accessToken = session?.account?.access_token;
|
||||||
const sixMonthsAgo = new Date();
|
|
||||||
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
|
|
||||||
|
|
||||||
const commits = [];
|
|
||||||
for await (const commit of getAllCommits(username, sixMonthsAgo)) {
|
|
||||||
commits.push(commit);
|
|
||||||
}
|
|
||||||
return commits;
|
|
||||||
};
|
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['githubCommits', username],
|
queryKey: ['githubCommits', accessToken],
|
||||||
queryFn: fetchCommits,
|
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
|
staleTime: 1000 * 60 * 30, // 30 minutes
|
||||||
refetchInterval: 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,
|
completed: true,
|
||||||
completedAt: new Date().toISOString(),
|
completedAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
update()
|
update();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to update course ${courseId} completion status:`, 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();
|
const alreadyCompleted = await checkOrCreateUserLesson();
|
||||||
if (!alreadyCompleted && videoDuration && !completedRef.current && videoPlayed && (paidCourse === false || (paidCourse && decryptionPerformed))) {
|
if (!alreadyCompleted && videoDuration && !completedRef.current && videoPlayed && (paidCourse === false || (paidCourse && decryptionPerformed))) {
|
||||||
setIsTracking(true);
|
setIsTracking(true);
|
||||||
console.log('🎥 Starting video tracking - Duration:', videoDuration);
|
|
||||||
timerRef.current = setInterval(() => {
|
timerRef.current = setInterval(() => {
|
||||||
setTimeSpent(prevTime => {
|
setTimeSpent(prevTime => {
|
||||||
const newTime = prevTime + 1;
|
const newTime = prevTime + 1;
|
||||||
// console.log(`⏱️ Time spent: ${newTime}s / ${videoDuration}s (${((newTime/videoDuration)*100).toFixed(1)}%)`);
|
|
||||||
return newTime;
|
return newTime;
|
||||||
});
|
});
|
||||||
}, 1000);
|
}, 1000);
|
||||||
@ -104,8 +102,8 @@ const useTrackVideoLesson = ({lessonId, videoDuration, courseId, videoPlayed, pa
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAdmin) return;
|
if (isAdmin) return;
|
||||||
|
|
||||||
if (videoDuration && timeSpent >= Math.round(videoDuration * 0.9) && !completedRef.current) {
|
if (videoDuration && timeSpent >= Math.round(videoDuration * 0.8) && !completedRef.current) {
|
||||||
console.log('🎯 Video reached 90% threshold - Marking as completed');
|
console.log('🎯 Video reached 80% threshold - Marking as completed');
|
||||||
markLessonAsCompleted();
|
markLessonAsCompleted();
|
||||||
}
|
}
|
||||||
}, [timeSpent, videoDuration, markLessonAsCompleted, isAdmin]);
|
}, [timeSpent, videoDuration, markLessonAsCompleted, isAdmin]);
|
||||||
|
@ -4,7 +4,7 @@ import { throttling } from "@octokit/plugin-throttling";
|
|||||||
const ThrottledOctokit = Octokit.plugin(throttling);
|
const ThrottledOctokit = Octokit.plugin(throttling);
|
||||||
|
|
||||||
const octokit = new ThrottledOctokit({
|
const octokit = new ThrottledOctokit({
|
||||||
auth: process.env.NEXT_PUBLIC_GITHUB_ACCESS_KEY,
|
auth: process.env.NEXT_PUBLIC_GITHUB_API,
|
||||||
throttle: {
|
throttle: {
|
||||||
onRateLimit: (retryAfter, options, octokit, retryCount) => {
|
onRateLimit: (retryAfter, options, octokit, retryCount) => {
|
||||||
octokit.log.warn(`Request quota exhausted for request ${options.method} ${options.url}`);
|
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) {
|
export async function* getAllCommits(accessToken, since) {
|
||||||
let page = 1;
|
const auth = accessToken || process.env.NEXT_PUBLIC_GITHUB_API;
|
||||||
|
|
||||||
while (true) {
|
const octokit = new ThrottledOctokit({
|
||||||
try {
|
auth,
|
||||||
const { data: repos } = await octokit.repos.listForUser({
|
throttle: {
|
||||||
username,
|
onRateLimit: (retryAfter, options, octokit, retryCount) => {
|
||||||
per_page: 100,
|
octokit.log.warn(`Request quota exhausted for request ${options.method} ${options.url}`);
|
||||||
page,
|
if (retryCount < 2) {
|
||||||
});
|
octokit.log.info(`Retrying after ${retryAfter} seconds!`);
|
||||||
|
return true;
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
onSecondaryRateLimit: (retryAfter, options, octokit) => {
|
||||||
|
octokit.log.warn(`Secondary rate limit hit for request ${options.method} ${options.url}`);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
page++;
|
// First, get the authenticated user's information
|
||||||
} catch (error) {
|
const { data: user } = await octokit.users.getAuthenticated();
|
||||||
console.error("Error fetching repositories:", error.message);
|
|
||||||
break;
|
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 NextAuth from "next-auth";
|
||||||
import CredentialsProvider from "next-auth/providers/credentials";
|
import CredentialsProvider from "next-auth/providers/credentials";
|
||||||
import EmailProvider from "next-auth/providers/email";
|
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 { PrismaAdapter } from "@next-auth/prisma-adapter";
|
||||||
import prisma from "@/db/prisma";
|
import prisma from "@/db/prisma";
|
||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
import { findKind0Fields } from "@/utils/nostr";
|
import { findKind0Fields } from "@/utils/nostr";
|
||||||
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'
|
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
|
||||||
import { bytesToHex } from '@noble/hashes/utils'
|
import { bytesToHex } from '@noble/hashes/utils';
|
||||||
import { updateUser, getUserByPubkey, createUser, getUserByEmail } from "@/db/models/userModels";
|
import { updateUser, getUserByPubkey, createUser, getUserById, getUserByEmail } from "@/db/models/userModels";
|
||||||
import { createRole } from "@/db/models/roleModels";
|
import { createRole } from "@/db/models/roleModels";
|
||||||
import appConfig from "@/config/appConfig";
|
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({
|
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();
|
await ndk.connect();
|
||||||
const user = ndk.getUser({ pubkey });
|
const user = ndk.getUser({ pubkey });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const profile = await user.fetchProfile();
|
const profile = await user.fetchProfile();
|
||||||
|
const fields = await findKind0Fields(profile);
|
||||||
// Check if user exists, create if not
|
|
||||||
let dbUser = await getUserByPubkey(pubkey);
|
let dbUser = await getUserByPubkey(pubkey);
|
||||||
|
|
||||||
if (dbUser) {
|
if (dbUser) {
|
||||||
const fields = await findKind0Fields(profile);
|
// Update existing user if kind0 fields differ
|
||||||
// Only update 'avatar' or 'username' if they are different from kind0 fields on the dbUser
|
if (fields.avatar !== dbUser.avatar || fields.username !== dbUser.username) {
|
||||||
if (fields.avatar !== dbUser.avatar) {
|
const updates = {
|
||||||
const updatedUser = await updateUser(dbUser.id, { avatar: fields.avatar });
|
...(fields.avatar !== dbUser.avatar && { avatar: fields.avatar }),
|
||||||
if (updatedUser) {
|
...(fields.username !== dbUser.username && {
|
||||||
dbUser = await getUserByPubkey(pubkey);
|
username: fields.username,
|
||||||
}
|
name: fields.username
|
||||||
} else if (fields.username !== dbUser.username) {
|
})
|
||||||
const updatedUser = await updateUser(dbUser.id, { username: fields.username });
|
};
|
||||||
if (updatedUser) {
|
await updateUser(dbUser.id, updates);
|
||||||
dbUser = await getUserByPubkey(pubkey);
|
dbUser = await getUserByPubkey(pubkey);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// add the kind0 fields to the user
|
|
||||||
const combinedUser = { ...dbUser, kind0: fields };
|
|
||||||
|
|
||||||
return combinedUser;
|
|
||||||
} else {
|
} else {
|
||||||
// Create user
|
// Create new user
|
||||||
if (profile) {
|
const username = fields.username || pubkey.slice(0, 8);
|
||||||
const fields = await findKind0Fields(profile);
|
const payload = {
|
||||||
const payload = { pubkey, username: fields.username, avatar: fields.avatar };
|
pubkey,
|
||||||
|
username,
|
||||||
|
avatar: fields.avatar,
|
||||||
|
name: username
|
||||||
|
};
|
||||||
|
|
||||||
if (appConfig.authorPubkeys.includes(pubkey)) {
|
dbUser = await createUser(payload);
|
||||||
// create a new author role for this user
|
|
||||||
const createdUser = await createUser(payload);
|
|
||||||
const role = await createRole({
|
|
||||||
userId: createdUser.id,
|
|
||||||
admin: true,
|
|
||||||
subscribed: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!role) {
|
// Create author role if applicable
|
||||||
console.error("Failed to create role");
|
if (appConfig.authorPubkeys.includes(pubkey)) {
|
||||||
return null;
|
const role = await createRole({
|
||||||
}
|
userId: dbUser.id,
|
||||||
|
admin: true,
|
||||||
|
subscribed: false,
|
||||||
|
});
|
||||||
|
|
||||||
const updatedUser = await updateUser(createdUser.id, { role: role.id });
|
if (role) {
|
||||||
if (!updatedUser) {
|
await updateUser(dbUser.id, { role: role.id });
|
||||||
console.error("Failed to update user");
|
dbUser = await getUserByPubkey(pubkey);
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fullUser = await getUserByPubkey(pubkey);
|
|
||||||
|
|
||||||
return { ...fullUser, kind0: fields };
|
|
||||||
} else {
|
|
||||||
dbUser = await createUser(payload);
|
|
||||||
return { ...dbUser, kind0: fields };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { ...dbUser, kind0: fields };
|
||||||
} catch (error) {
|
} 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 = {
|
export const authOptions = {
|
||||||
adapter: PrismaAdapter(prisma),
|
adapter: PrismaAdapter(prisma),
|
||||||
providers: [
|
providers: [
|
||||||
|
// Nostr login provider
|
||||||
CredentialsProvider({
|
CredentialsProvider({
|
||||||
id: "nostr",
|
id: "nostr",
|
||||||
name: "Nostr",
|
name: "Nostr",
|
||||||
credentials: {
|
credentials: {
|
||||||
pubkey: { label: "Public Key", type: "text" },
|
pubkey: { label: "Public Key", type: "text" }
|
||||||
},
|
},
|
||||||
authorize: async (credentials) => {
|
authorize: async (credentials) => {
|
||||||
if (credentials?.pubkey) {
|
if (!credentials?.pubkey) return null;
|
||||||
return await authorize(credentials.pubkey);
|
return await syncNostrProfile(credentials.pubkey);
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// 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({
|
EmailProvider({
|
||||||
server: {
|
server: {
|
||||||
host: process.env.EMAIL_SERVER_HOST,
|
host: process.env.EMAIL_SERVER_HOST,
|
||||||
@ -112,129 +144,198 @@ export const authOptions = {
|
|||||||
pass: process.env.EMAIL_SERVER_PASSWORD
|
pass: process.env.EMAIL_SERVER_PASSWORD
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
from: process.env.EMAIL_FROM,
|
from: process.env.EMAIL_FROM
|
||||||
sendVerificationRequest: async ({ identifier, url, provider }) => {
|
}),
|
||||||
// Use nodemailer to send the email
|
|
||||||
const transport = nodemailer.createTransport(provider.server);
|
// Github provider with ephemeral keypair generation
|
||||||
await transport.sendMail({
|
GithubProvider({
|
||||||
to: identifier,
|
clientId: process.env.GITHUB_CLIENT_ID,
|
||||||
from: provider.from,
|
clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
||||||
subject: `Sign in to ${new URL(url).host}`,
|
profile: async (profile) => {
|
||||||
text: `Sign in to ${new URL(url).host}\n${url}\n\n`,
|
const keys = generateEphemeralKeypair();
|
||||||
html: `<p>Sign in to <strong>${new URL(url).host}</strong></p><p><a href="${url}">Sign in</a></p>`,
|
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: {
|
callbacks: {
|
||||||
async jwt({ token, user, account, trigger }) {
|
// Move email handling to the signIn callback
|
||||||
if (trigger === "update" && account?.provider !== "anonymous") {
|
async signIn({ user, account }) {
|
||||||
// if we trigger an update call the authorize function again
|
// Only handle email provider sign ins
|
||||||
const newUser = await authorize(token.user.pubkey);
|
if (account?.provider === "email") {
|
||||||
token.user = newUser;
|
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();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// if we sign up with email and we don't have a pubkey or privkey, we need to generate them
|
return true; // Allow other provider sign ins
|
||||||
if (trigger === "signUp" && account?.provider === "email" && !user.pubkey && !user.privkey) {
|
},
|
||||||
const sk = generateSecretKey();
|
async session({ session, user, token }) {
|
||||||
const pubkey = getPublicKey(sk);
|
const userData = token.user || user;
|
||||||
const privkey = bytesToHex(sk);
|
|
||||||
|
|
||||||
// Update the user in the database
|
if (userData) {
|
||||||
await prisma.user.update({
|
const fullUser = await getUserById(userData.id);
|
||||||
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
|
session.user = {
|
||||||
user.pubkey = pubkey;
|
...session.user,
|
||||||
user.privkey = privkey;
|
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) {
|
if (user) {
|
||||||
token.user = user;
|
token.user = user;
|
||||||
if (user.pubkey && user.privkey) {
|
|
||||||
token.pubkey = user.pubkey;
|
|
||||||
token.privkey = user.privkey;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (account?.provider === 'anonymous') {
|
if (account) {
|
||||||
token.isAnonymous = true;
|
token.account = account;
|
||||||
}
|
}
|
||||||
return token;
|
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: {
|
||||||
session: { strategy: "jwt" },
|
strategy: 'jwt',
|
||||||
jwt: {
|
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||||
signingKey: process.env.JWT_SECRET,
|
|
||||||
},
|
},
|
||||||
pages: {
|
debug: process.env.NODE_ENV === 'development',
|
||||||
signIn: "/auth/signin",
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default NextAuth(authOptions);
|
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 storedPubkey = localStorage.getItem('anonymousPubkey')
|
||||||
const storedPrivkey = localStorage.getItem('anonymousPrivkey')
|
const storedPrivkey = localStorage.getItem('anonymousPrivkey')
|
||||||
|
|
||||||
const result = await signIn("anonymous", {
|
try {
|
||||||
pubkey: storedPubkey,
|
const result = await signIn("anonymous", {
|
||||||
privkey: storedPrivkey,
|
pubkey: storedPubkey,
|
||||||
redirect: false
|
privkey: storedPrivkey,
|
||||||
})
|
redirect: false,
|
||||||
|
callbackUrl: '/'
|
||||||
|
});
|
||||||
|
|
||||||
if (result?.ok) {
|
if (result?.ok) {
|
||||||
// Fetch the session to get the pubkey and privkey
|
// Wait a moment for the session to be updated
|
||||||
const session = await getSession()
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
if (session?.pubkey && session?.privkey) {
|
|
||||||
localStorage.setItem('anonymousPubkey', session.pubkey)
|
// Fetch the session
|
||||||
localStorage.setItem('anonymousPrivkey', session.privkey)
|
const session = await getSession();
|
||||||
router.push('/')
|
|
||||||
} else {
|
if (session?.user?.pubkey && session?.user?.privkey) {
|
||||||
console.error("Pubkey or privkey not found in session")
|
localStorage.setItem('anonymousPubkey', session.user.pubkey);
|
||||||
}
|
localStorage.setItem('anonymousPrivkey', session.user.privkey);
|
||||||
// Redirect or update UI as needed
|
router.push('/');
|
||||||
} else {
|
} else {
|
||||||
// Handle error
|
console.error("Session data incomplete:", session);
|
||||||
console.error("Anonymous login failed:", result?.error)
|
}
|
||||||
|
} 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 (
|
return (
|
||||||
<div className="w-[100vw] min-bottom-bar:w-[86vw] mx-auto mt-24 flex flex-col justify-center">
|
<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
|
rounded
|
||||||
onClick={() => setShowEmailInput(!showEmailInput)}
|
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 && (
|
{showEmailInput && (
|
||||||
<form onSubmit={handleEmailSignIn} className="flex flex-col items-center bg-gray-700 w-fit mx-auto p-4 rounded-lg">
|
<form onSubmit={handleEmailSignIn} className="flex flex-col items-center bg-gray-700 w-fit mx-auto p-4 rounded-lg">
|
||||||
<InputText
|
<InputText
|
||||||
|
@ -145,3 +145,7 @@ code {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* hide attribution */
|
||||||
|
div.react-flow__attribution {
|
||||||
|
display: none !important;
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user