mirror of
https://github.com/AustinKelsay/plebdevs.git
synced 2025-04-19 19:01:19 +00:00
video lesson tracking for courses, new userlesson table, also github experiment
This commit is contained in:
parent
2e25beea71
commit
f7bbf93f95
326
package-lock.json
generated
326
package-lock.json
generated
@ -17,6 +17,9 @@
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
"@nostr-dev-kit/ndk": "^2.10.0",
|
||||
"@nostr-dev-kit/ndk-cache-dexie": "^2.5.1",
|
||||
"@octokit/plugin-retry": "^5.0.0",
|
||||
"@octokit/plugin-throttling": "^6.0.0",
|
||||
"@octokit/rest": "^19.0.7",
|
||||
"@prisma/client": "^5.17.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@tanstack/react-query": "^5.51.21",
|
||||
@ -24,6 +27,7 @@
|
||||
"@uiw/react-md-editor": "^3.11.0",
|
||||
"axios": "^1.7.2",
|
||||
"bech32": "^2.0.0",
|
||||
"chart.js": "^4.4.4",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cors": "^2.8.5",
|
||||
@ -1838,6 +1842,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz",
|
||||
"integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lightninglabs/lnc-core": {
|
||||
"version": "0.3.1-alpha",
|
||||
"resolved": "https://registry.npmjs.org/@lightninglabs/lnc-core/-/lnc-core-0.3.1-alpha.tgz",
|
||||
@ -2173,6 +2183,253 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/auth-token": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-3.0.4.tgz",
|
||||
"integrity": "sha512-TWFX7cZF2LXoCvdmJWY7XVPi74aSY0+FfBZNSXEXFkMpjcqsQwDSYVv5FhRFaI0V1ECnwbz4j59T/G+rXNWaIQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/core": {
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/core/-/core-4.2.4.tgz",
|
||||
"integrity": "sha512-rYKilwgzQ7/imScn3M9/pFfUf4I1AZEH3KhyJmtPdE2zfaXAn2mFfUy4FbKewzc2We5y/LlKLj36fWJLKC2SIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@octokit/auth-token": "^3.0.0",
|
||||
"@octokit/graphql": "^5.0.0",
|
||||
"@octokit/request": "^6.0.0",
|
||||
"@octokit/request-error": "^3.0.0",
|
||||
"@octokit/types": "^9.0.0",
|
||||
"before-after-hook": "^2.2.0",
|
||||
"universal-user-agent": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/endpoint": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-7.0.6.tgz",
|
||||
"integrity": "sha512-5L4fseVRUsDFGR00tMWD/Trdeeihn999rTMGRMC1G/Ldi1uWlWJzI98H4Iak5DB/RVvQuyMYKqSK/R6mbSOQyg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@octokit/types": "^9.0.0",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"universal-user-agent": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/graphql": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-5.0.6.tgz",
|
||||
"integrity": "sha512-Fxyxdy/JH0MnIB5h+UQ3yCoh1FG4kWXfFKkpWqjZHw/p+Kc8Y44Hu/kCgNBT6nU1shNumEchmW/sUO1JuQnPcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@octokit/request": "^6.0.0",
|
||||
"@octokit/types": "^9.0.0",
|
||||
"universal-user-agent": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/openapi-types": {
|
||||
"version": "18.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-18.1.1.tgz",
|
||||
"integrity": "sha512-VRaeH8nCDtF5aXWnjPuEMIYf1itK/s3JYyJcWFJT8X9pSNnBtriDf7wlEWsGuhPLl4QIH4xM8fqTXDwJ3Mu6sw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@octokit/plugin-paginate-rest": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-6.1.2.tgz",
|
||||
"integrity": "sha512-qhrmtQeHU/IivxucOV1bbI/xZyC/iOBhclokv7Sut5vnejAIAEXVcGQeRpQlU39E0WwK9lNvJHphHri/DB6lbQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@octokit/tsconfig": "^1.0.2",
|
||||
"@octokit/types": "^9.2.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@octokit/core": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/plugin-request-log": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-1.0.4.tgz",
|
||||
"integrity": "sha512-mLUsMkgP7K/cnFEw07kWqXGF5LKrOkD+lhCrKvPHXWDywAwuDUeDwWBpc69XK3pNX0uKiVt8g5z96PJ6z9xCFA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@octokit/core": ">=3"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/plugin-rest-endpoint-methods": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-7.2.3.tgz",
|
||||
"integrity": "sha512-I5Gml6kTAkzVlN7KCtjOM+Ruwe/rQppp0QU372K1GP7kNOYEKe8Xn5BW4sE62JAHdwpq95OQK/qGNyKQMUzVgA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@octokit/types": "^10.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@octokit/core": ">=3"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/types": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-10.0.0.tgz",
|
||||
"integrity": "sha512-Vm8IddVmhCgU1fxC1eyinpwqzXPEYu0NrYzD3YZjlGjyftdLBTeqNblRC0jmJmgxbJIsQlyogVeGnrNaaMVzIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@octokit/openapi-types": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/plugin-retry": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-5.0.5.tgz",
|
||||
"integrity": "sha512-sB1RWMhSrre02Atv95K6bhESlJ/sPdZkK/wE/w1IdSCe0yM6FxSjksLa6T7aAvxvxlLKzQEC4KIiqpqyov1Tbg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@octokit/request-error": "^4.0.1",
|
||||
"@octokit/types": "^10.0.0",
|
||||
"bottleneck": "^2.15.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@octokit/core": ">=3"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/plugin-retry/node_modules/@octokit/request-error": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-4.0.2.tgz",
|
||||
"integrity": "sha512-uqwUEmZw3x4I9DGYq9fODVAAvcLsPQv97NRycP6syEFu5916M189VnNBW2zANNwqg3OiligNcAey7P0SET843w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@octokit/types": "^10.0.0",
|
||||
"deprecation": "^2.0.0",
|
||||
"once": "^1.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/plugin-retry/node_modules/@octokit/types": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-10.0.0.tgz",
|
||||
"integrity": "sha512-Vm8IddVmhCgU1fxC1eyinpwqzXPEYu0NrYzD3YZjlGjyftdLBTeqNblRC0jmJmgxbJIsQlyogVeGnrNaaMVzIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@octokit/openapi-types": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/plugin-throttling": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-6.1.0.tgz",
|
||||
"integrity": "sha512-JqMbTiPC0sUSTsLQsdq3JVx1mx8UtTo5mwR80YqPXE93+XhevvSyOR1rO2Z+NbO/r0TK4hqFJSSi/9oIZBxZTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@octokit/types": "^9.0.0",
|
||||
"bottleneck": "^2.15.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@octokit/core": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/request": {
|
||||
"version": "6.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/request/-/request-6.2.8.tgz",
|
||||
"integrity": "sha512-ow4+pkVQ+6XVVsekSYBzJC0VTVvh/FCTUUgTsboGq+DTeWdyIFV8WSCdo0RIxk6wSkBTHqIK1mYuY7nOBXOchw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@octokit/endpoint": "^7.0.0",
|
||||
"@octokit/request-error": "^3.0.0",
|
||||
"@octokit/types": "^9.0.0",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"node-fetch": "^2.6.7",
|
||||
"universal-user-agent": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/request-error": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-3.0.3.tgz",
|
||||
"integrity": "sha512-crqw3V5Iy2uOU5Np+8M/YexTlT8zxCfI+qu+LxUB7SZpje4Qmx3mub5DfEKSO8Ylyk0aogi6TYdf6kxzh2BguQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@octokit/types": "^9.0.0",
|
||||
"deprecation": "^2.0.0",
|
||||
"once": "^1.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/request/node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/rest": {
|
||||
"version": "19.0.13",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-19.0.13.tgz",
|
||||
"integrity": "sha512-/EzVox5V9gYGdbAI+ovYj3nXQT1TtTHRT+0eZPcuC05UFSWO3mdO9UY1C0i2eLF9Un1ONJkAk+IEtYGAC+TahA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@octokit/core": "^4.2.1",
|
||||
"@octokit/plugin-paginate-rest": "^6.1.2",
|
||||
"@octokit/plugin-request-log": "^1.0.4",
|
||||
"@octokit/plugin-rest-endpoint-methods": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/tsconfig": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/tsconfig/-/tsconfig-1.0.2.tgz",
|
||||
"integrity": "sha512-I0vDR0rdtP8p2lGMzvsJzbhdOWy405HcGovrspJ8RRibHnyRgggUSNO5AIox5LmqiwmatHKYsvj6VGFHkqS7lA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@octokit/types": {
|
||||
"version": "9.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-9.3.2.tgz",
|
||||
"integrity": "sha512-D4iHGTdAnEEVsB8fl95m1hiz7D5YiRdQ9b/OEb3BYRVwbLsGHcRVPz+u+BgRLNk0Q0/4iZCBqDN96j2XNxfXrA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@octokit/openapi-types": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@panva/hkdf": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
|
||||
@ -5442,6 +5699,12 @@
|
||||
"integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/before-after-hook": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz",
|
||||
"integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@ -5460,6 +5723,12 @@
|
||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/bottleneck": {
|
||||
"version": "2.19.5",
|
||||
"resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz",
|
||||
"integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bowser": {
|
||||
"version": "2.11.0",
|
||||
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz",
|
||||
@ -5678,6 +5947,18 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.4.4",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.4.tgz",
|
||||
"integrity": "sha512-emICKGBABnxhMjUjlYRR12PmOXhJ2eJjEHL2/dZlWjxRAZT1D8xplLFq5M0tMQK8ja+wBS/tuVEJB5C6r7VxJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
@ -6108,6 +6389,12 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deprecation": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz",
|
||||
"integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
@ -8780,6 +9067,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-object": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
||||
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-regex": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
|
||||
@ -10774,7 +11070,6 @@
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
@ -13068,6 +13363,12 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/trim-lines": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
||||
@ -13449,6 +13750,12 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/universal-user-agent": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz",
|
||||
"integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz",
|
||||
@ -13690,6 +13997,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.94.0",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz",
|
||||
@ -13831,6 +14144,16 @@
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@ -14037,7 +14360,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
|
@ -18,6 +18,9 @@
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
"@nostr-dev-kit/ndk": "^2.10.0",
|
||||
"@nostr-dev-kit/ndk-cache-dexie": "^2.5.1",
|
||||
"@octokit/plugin-retry": "^5.0.0",
|
||||
"@octokit/plugin-throttling": "^6.0.0",
|
||||
"@octokit/rest": "^19.0.7",
|
||||
"@prisma/client": "^5.17.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@tanstack/react-query": "^5.51.21",
|
||||
@ -25,6 +28,7 @@
|
||||
"@uiw/react-md-editor": "^3.11.0",
|
||||
"axios": "^1.7.2",
|
||||
"bech32": "^2.0.0",
|
||||
"chart.js": "^4.4.4",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cors": "^2.8.5",
|
||||
|
@ -66,57 +66,6 @@ CREATE TABLE "Role" (
|
||||
CONSTRAINT "Role_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Purchase" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"courseId" TEXT,
|
||||
"resourceId" TEXT,
|
||||
"amountPaid" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Purchase_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Lesson" (
|
||||
"id" TEXT NOT NULL,
|
||||
"courseId" TEXT,
|
||||
"resourceId" TEXT,
|
||||
"draftId" TEXT,
|
||||
"index" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Lesson_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DraftLesson" (
|
||||
"id" TEXT NOT NULL,
|
||||
"courseDraftId" TEXT NOT NULL,
|
||||
"resourceId" TEXT,
|
||||
"draftId" TEXT,
|
||||
"index" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "DraftLesson_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Course" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"price" INTEGER NOT NULL DEFAULT 0,
|
||||
"noteId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Course_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Resource" (
|
||||
"id" TEXT NOT NULL,
|
||||
@ -148,6 +97,18 @@ CREATE TABLE "Draft" (
|
||||
CONSTRAINT "Draft_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Course" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"price" INTEGER NOT NULL DEFAULT 0,
|
||||
"noteId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Course_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CourseDraft" (
|
||||
"id" TEXT NOT NULL,
|
||||
@ -163,6 +124,60 @@ CREATE TABLE "CourseDraft" (
|
||||
CONSTRAINT "CourseDraft_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Lesson" (
|
||||
"id" TEXT NOT NULL,
|
||||
"courseId" TEXT,
|
||||
"resourceId" TEXT,
|
||||
"draftId" TEXT,
|
||||
"index" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Lesson_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DraftLesson" (
|
||||
"id" TEXT NOT NULL,
|
||||
"courseDraftId" TEXT NOT NULL,
|
||||
"resourceId" TEXT,
|
||||
"draftId" TEXT,
|
||||
"index" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "DraftLesson_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "UserLesson" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"lessonId" TEXT NOT NULL,
|
||||
"opened" BOOLEAN NOT NULL DEFAULT false,
|
||||
"completed" BOOLEAN NOT NULL DEFAULT false,
|
||||
"openedAt" TIMESTAMP(3),
|
||||
"completedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "UserLesson_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Purchase" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"courseId" TEXT,
|
||||
"resourceId" TEXT,
|
||||
"amountPaid" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Purchase_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_pubkey_key" ON "User"("pubkey");
|
||||
|
||||
@ -187,11 +202,14 @@ CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provi
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Role_userId_key" ON "Role"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Resource_noteId_key" ON "Resource"("noteId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Course_noteId_key" ON "Course"("noteId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Resource_noteId_key" ON "Resource"("noteId");
|
||||
CREATE UNIQUE INDEX "UserLesson_userId_lessonId_key" ON "UserLesson"("userId", "lessonId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -203,13 +221,16 @@ ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId"
|
||||
ALTER TABLE "Role" ADD CONSTRAINT "Role_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Purchase" ADD CONSTRAINT "Purchase_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "Resource" ADD CONSTRAINT "Resource_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Purchase" ADD CONSTRAINT "Purchase_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "Draft" ADD CONSTRAINT "Draft_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Purchase" ADD CONSTRAINT "Purchase_resourceId_fkey" FOREIGN KEY ("resourceId") REFERENCES "Resource"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "Course" ADD CONSTRAINT "Course_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CourseDraft" ADD CONSTRAINT "CourseDraft_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Lesson" ADD CONSTRAINT "Lesson_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@ -230,13 +251,16 @@ ALTER TABLE "DraftLesson" ADD CONSTRAINT "DraftLesson_resourceId_fkey" FOREIGN K
|
||||
ALTER TABLE "DraftLesson" ADD CONSTRAINT "DraftLesson_draftId_fkey" FOREIGN KEY ("draftId") REFERENCES "Draft"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Course" ADD CONSTRAINT "Course_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "UserLesson" ADD CONSTRAINT "UserLesson_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Resource" ADD CONSTRAINT "Resource_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "UserLesson" ADD CONSTRAINT "UserLesson_lessonId_fkey" FOREIGN KEY ("lessonId") REFERENCES "Lesson"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Draft" ADD CONSTRAINT "Draft_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "Purchase" ADD CONSTRAINT "Purchase_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CourseDraft" ADD CONSTRAINT "CourseDraft_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "Purchase" ADD CONSTRAINT "Purchase_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Purchase" ADD CONSTRAINT "Purchase_resourceId_fkey" FOREIGN KEY ("resourceId") REFERENCES "Resource"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
@ -27,6 +27,7 @@ model User {
|
||||
sessions Session[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
userLessons UserLesson[]
|
||||
}
|
||||
|
||||
model Session {
|
||||
@ -78,57 +79,6 @@ model Role {
|
||||
nwc String?
|
||||
}
|
||||
|
||||
model Purchase {
|
||||
id String @id @default(uuid())
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String
|
||||
course Course? @relation(fields: [courseId], references: [id])
|
||||
courseId String?
|
||||
resource Resource? @relation(fields: [resourceId], references: [id])
|
||||
resourceId String?
|
||||
amountPaid Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Lesson {
|
||||
id String @id @default(uuid())
|
||||
courseId String?
|
||||
course Course? @relation(fields: [courseId], references: [id])
|
||||
resourceId String?
|
||||
resource Resource? @relation(fields: [resourceId], references: [id])
|
||||
draftId String?
|
||||
draft Draft? @relation(fields: [draftId], references: [id])
|
||||
index Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model DraftLesson {
|
||||
id String @id @default(uuid())
|
||||
courseDraftId String
|
||||
courseDraft CourseDraft @relation(fields: [courseDraftId], references: [id])
|
||||
resourceId String?
|
||||
resource Resource? @relation(fields: [resourceId], references: [id])
|
||||
draftId String?
|
||||
draft Draft? @relation(fields: [draftId], references: [id])
|
||||
index Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Course {
|
||||
id String @id
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
price Int @default(0)
|
||||
lessons Lesson[]
|
||||
purchases Purchase[]
|
||||
noteId String? @unique
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Resource {
|
||||
id String @id // Client generates UUID
|
||||
userId String
|
||||
@ -162,6 +112,18 @@ model Draft {
|
||||
lessons Lesson[]
|
||||
}
|
||||
|
||||
model Course {
|
||||
id String @id
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
price Int @default(0)
|
||||
lessons Lesson[]
|
||||
purchases Purchase[]
|
||||
noteId String? @unique
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model CourseDraft {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
@ -174,4 +136,60 @@ model CourseDraft {
|
||||
topics String[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Lesson {
|
||||
id String @id @default(uuid())
|
||||
courseId String?
|
||||
course Course? @relation(fields: [courseId], references: [id])
|
||||
resourceId String?
|
||||
resource Resource? @relation(fields: [resourceId], references: [id])
|
||||
draftId String?
|
||||
draft Draft? @relation(fields: [draftId], references: [id])
|
||||
index Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
userLessons UserLesson[]
|
||||
}
|
||||
|
||||
model DraftLesson {
|
||||
id String @id @default(uuid())
|
||||
courseDraftId String
|
||||
courseDraft CourseDraft @relation(fields: [courseDraftId], references: [id])
|
||||
resourceId String?
|
||||
resource Resource? @relation(fields: [resourceId], references: [id])
|
||||
draftId String?
|
||||
draft Draft? @relation(fields: [draftId], references: [id])
|
||||
index Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model UserLesson {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
lessonId String
|
||||
lesson Lesson @relation(fields: [lessonId], references: [id])
|
||||
opened Boolean @default(false)
|
||||
completed Boolean @default(false)
|
||||
openedAt DateTime?
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([userId, lessonId])
|
||||
}
|
||||
|
||||
model Purchase {
|
||||
id String @id @default(uuid())
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String
|
||||
course Course? @relation(fields: [courseId], references: [id])
|
||||
courseId String?
|
||||
resource Resource? @relation(fields: [resourceId], references: [id])
|
||||
resourceId String?
|
||||
amountPaid Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
77
src/components/charts/GithubContributionChart.js
Normal file
77
src/components/charts/GithubContributionChart.js
Normal file
@ -0,0 +1,77 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { getContributions } from '../../lib/github';
|
||||
|
||||
const GithubContributionChart = ({ username }) => {
|
||||
const [contributionData, setContributionData] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
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(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await getContributions(username, (data) => {
|
||||
setContributionData(data);
|
||||
setLoading(false);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching contribution data:", error);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [username]);
|
||||
|
||||
const calendar = generateCalendar();
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h2 className="text-xl font-bold mb-4">Github Contributions for {username}</h2>
|
||||
{loading && <p>Loading contribution data...</p>}
|
||||
<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-500 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;
|
@ -136,14 +136,12 @@ export default function CourseDetailsNew({ processedEvent, paidCourse, lessons,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className='text-xl text-gray-200 mb-4 mt-4 max-mob:text-base'>{processedEvent.description && (
|
||||
<div className='mt-4'>
|
||||
{processedEvent.description.split('\n').map((line, index) => (
|
||||
<p key={index}>{line}</p>
|
||||
))}
|
||||
</div>
|
||||
<div className='text-xl text-gray-200 mb-4 mt-4 max-mob:text-base'>{processedEvent.description && (
|
||||
processedEvent.description.split('\n').map((line, index) => (
|
||||
<p key={index}>{line}</p>
|
||||
))
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center'>
|
||||
<Image
|
||||
@ -172,11 +170,11 @@ export default function CourseDetailsNew({ processedEvent, paidCourse, lessons,
|
||||
<div className='flex space-x-2 mt-4 sm:mt-0'>
|
||||
<GenericButton onClick={() => router.push(`/details/${processedEvent.id}/edit`)} label="Edit" severity='warning' outlined />
|
||||
<GenericButton onClick={handleDelete} label="Delete" severity='danger' outlined />
|
||||
<GenericButton outlined icon="pi pi-external-link" onClick={() => window.open(`https://nostr.band/${nAddress}`, '_blank')} tooltip={ isMobileView ? null : "View Nostr Event" } tooltipOptions={{ position: paidCourse ? 'left' : 'right' }} />
|
||||
<GenericButton outlined icon="pi pi-external-link" onClick={() => window.open(`https://nostr.band/${nAddress}`, '_blank')} tooltip={isMobileView ? null : "View Nostr Event"} tooltipOptions={{ position: paidCourse ? 'left' : 'right' }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex space-x-2 mt-4 sm:mt-0'>
|
||||
<GenericButton className='my-2' outlined icon="pi pi-external-link" onClick={() => window.open(`https://nostr.band/${nAddress}`, '_blank')} tooltip={ isMobileView ? null : "View Nostr Event" } tooltipOptions={{ position: paidCourse ? 'left' : 'right' }} />
|
||||
<GenericButton className='my-2' outlined icon="pi pi-external-link" onClick={() => window.open(`https://nostr.band/${nAddress}`, '_blank')} tooltip={isMobileView ? null : "View Nostr Event"} tooltipOptions={{ position: paidCourse ? 'left' : 'right' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { Tag } from "primereact/tag";
|
||||
import Image from "next/image";
|
||||
import ZapDisplay from "@/components/zaps/ZapDisplay";
|
||||
@ -11,6 +11,7 @@ import dynamic from "next/dynamic";
|
||||
import { Divider } from "primereact/divider";
|
||||
import appConfig from "@/config/appConfig";
|
||||
import useWindowWidth from "@/hooks/useWindowWidth";
|
||||
import useTrackVideoLesson from '@/hooks/tracking/useTrackVideoLesson';
|
||||
|
||||
const MDDisplay = dynamic(
|
||||
() => import("@uiw/react-markdown-preview"),
|
||||
@ -19,13 +20,43 @@ const MDDisplay = dynamic(
|
||||
}
|
||||
);
|
||||
|
||||
const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid }) => {
|
||||
const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted }) => {
|
||||
const [zapAmount, setZapAmount] = useState(0);
|
||||
const [nAddress, setNAddress] = useState(null);
|
||||
const { zaps, zapsLoading, zapsError } = useZapsQuery({ event: lesson, type: "lesson" });
|
||||
const { returnImageProxy } = useImageProxy();
|
||||
const windowWidth = useWindowWidth();
|
||||
const isMobileView = windowWidth <= 768;
|
||||
const [videoDuration, setVideoDuration] = useState(null);
|
||||
const [videoPlayed, setVideoPlayed] = useState(false);
|
||||
const mdDisplayRef = useRef(null);
|
||||
|
||||
const { isCompleted, isTracking } = useTrackVideoLesson({
|
||||
lessonId: lesson?.d,
|
||||
videoDuration,
|
||||
courseId: course?.d,
|
||||
videoPlayed
|
||||
});
|
||||
|
||||
const checkDuration = useCallback(() => {
|
||||
const videoElement = mdDisplayRef.current?.querySelector('video');
|
||||
if (videoElement && videoElement.readyState >= 1) {
|
||||
setVideoDuration(Math.round(videoElement.duration));
|
||||
|
||||
// Add event listener for play event
|
||||
videoElement.addEventListener('play', () => {
|
||||
setVideoPlayed(true);
|
||||
});
|
||||
} else if (videoElement) {
|
||||
setTimeout(checkDuration, 100);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCompleted) {
|
||||
setCompleted(lesson.id);
|
||||
}
|
||||
}, [isCompleted, lesson.id]); // Remove setCompleted from dependencies
|
||||
|
||||
useEffect(() => {
|
||||
if (!zaps || zapsLoading || zapsError) return;
|
||||
@ -43,12 +74,19 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid }) => {
|
||||
setNAddress(addr);
|
||||
}, [lesson]);
|
||||
|
||||
useEffect(() => {
|
||||
if (decryptionPerformed && isPaid) {
|
||||
const timer = setTimeout(checkDuration, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [decryptionPerformed, isPaid, checkDuration]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (isPaid && decryptionPerformed) {
|
||||
return (
|
||||
<>
|
||||
<div ref={mdDisplayRef}>
|
||||
<MDDisplay className='p-0 rounded-lg w-full' source={lesson.content} />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
} else if (isPaid && !decryptionPerformed) {
|
||||
return (
|
||||
|
52
src/db/models/userLessonModels.js
Normal file
52
src/db/models/userLessonModels.js
Normal file
@ -0,0 +1,52 @@
|
||||
import prisma from "@/db/prisma";
|
||||
|
||||
export const getUserLessons = async (userId) => {
|
||||
return await prisma.userLesson.findMany({
|
||||
where: { userId },
|
||||
include: { lesson: true },
|
||||
});
|
||||
};
|
||||
|
||||
export const getUserLesson = async (userId, lessonId) => {
|
||||
return await prisma.userLesson.findUnique({
|
||||
where: {
|
||||
userId_lessonId: {
|
||||
userId,
|
||||
lessonId,
|
||||
},
|
||||
},
|
||||
include: { lesson: true },
|
||||
});
|
||||
};
|
||||
|
||||
export const createOrUpdateUserLesson = async (userId, lessonId, data) => {
|
||||
console.log(`Creating or updating user lesson for user ${userId} and lesson ${lessonId} with data:`, data);
|
||||
return await prisma.userLesson.upsert({
|
||||
where: {
|
||||
userId_lessonId: {
|
||||
userId,
|
||||
lessonId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
lessonId,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteUserLesson = async (userId, lessonId) => {
|
||||
return await prisma.userLesson.delete({
|
||||
where: {
|
||||
userId_lessonId: {
|
||||
userId,
|
||||
lessonId,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
102
src/hooks/tracking/useTrackVideoLesson.js
Normal file
102
src/hooks/tracking/useTrackVideoLesson.js
Normal file
@ -0,0 +1,102 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import axios from 'axios';
|
||||
|
||||
const useTrackVideoLesson = ({lessonId, videoDuration, courseId, videoPlayed}) => {
|
||||
const [isCompleted, setIsCompleted] = useState(false);
|
||||
const [timeSpent, setTimeSpent] = useState(0);
|
||||
const [isTracking, setIsTracking] = useState(false);
|
||||
const timerRef = useRef(null);
|
||||
const { data: session } = useSession();
|
||||
const completedRef = useRef(false);
|
||||
|
||||
// Check if the lesson is already completed or create a new UserLesson record
|
||||
const checkOrCreateUserLesson = useCallback(async () => {
|
||||
if (!session?.user) return false;
|
||||
try {
|
||||
const response = await axios.get(`/api/users/${session.user.id}/lessons/${lessonId}?courseId=${courseId}`);
|
||||
if (response.status === 200 && response?.data) {
|
||||
// Case 1: UserLesson record exists
|
||||
if (response?.data?.completed) {
|
||||
// Lesson is already completed
|
||||
setIsCompleted(true);
|
||||
completedRef.current = true;
|
||||
return true;
|
||||
} else {
|
||||
// Lesson exists but is not completed
|
||||
return false;
|
||||
}
|
||||
} else if (response.status === 204) {
|
||||
// Case 2: UserLesson record doesn't exist, create a new one
|
||||
await axios.post(`/api/users/${session.user.id}/lessons?courseId=${courseId}`, {
|
||||
// currently the only id we get is the resource id which associates to the lesson
|
||||
resourceId: lessonId,
|
||||
opened: true,
|
||||
openedAt: new Date().toISOString(),
|
||||
});
|
||||
return false;
|
||||
} else {
|
||||
console.error('Error checking or creating UserLesson:', response.statusText);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking or creating UserLesson:', error);
|
||||
return false;
|
||||
}
|
||||
}, [session, lessonId]);
|
||||
|
||||
const markLessonAsCompleted = useCallback(async () => {
|
||||
if (!session?.user || completedRef.current) return;
|
||||
completedRef.current = true;
|
||||
|
||||
try {
|
||||
const response = await axios.put(`/api/users/${session.user.id}/lessons/${lessonId}?courseId=${courseId}`, {
|
||||
completed: true,
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
setIsCompleted(true);
|
||||
setIsTracking(false);
|
||||
} else {
|
||||
console.error('Failed to mark lesson as completed:', response.statusText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error marking lesson as completed:', error);
|
||||
}
|
||||
}, [lessonId, session]);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeTracking = async () => {
|
||||
const alreadyCompleted = await checkOrCreateUserLesson();
|
||||
// Case 3: Start tracking if the lesson is not completed, video duration is available, and video has been played
|
||||
if (!alreadyCompleted && videoDuration && !completedRef.current && videoPlayed) {
|
||||
console.log(`Tracking started for lesson ${lessonId}, video duration: ${videoDuration} seconds, video played: ${videoPlayed}`);
|
||||
setIsTracking(true);
|
||||
timerRef.current = setInterval(() => {
|
||||
setTimeSpent(prevTime => prevTime + 1);
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
initializeTracking();
|
||||
|
||||
// Cleanup function to clear the interval when the component unmounts
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, [lessonId, videoDuration, checkOrCreateUserLesson, videoPlayed]);
|
||||
|
||||
useEffect(() => {
|
||||
// Case 4: Mark lesson as completed when 90% of the video is watched
|
||||
if (videoDuration && timeSpent >= Math.round(videoDuration * 0.9) && !completedRef.current) {
|
||||
markLessonAsCompleted();
|
||||
}
|
||||
}, [timeSpent, videoDuration, markLessonAsCompleted]);
|
||||
|
||||
return { isCompleted, isTracking };
|
||||
};
|
||||
|
||||
export default useTrackVideoLesson;
|
75
src/lib/github.js
Normal file
75
src/lib/github.js
Normal file
@ -0,0 +1,75 @@
|
||||
const { Octokit } = require("@octokit/rest");
|
||||
const { throttling } = require("@octokit/plugin-throttling");
|
||||
const ThrottledOctokit = Octokit.plugin(throttling);
|
||||
|
||||
const ACCESS_TOKEN = process.env.NEXT_PUBLIC_GITHUB_ACCESS_KEY;
|
||||
|
||||
const octokit = new ThrottledOctokit({
|
||||
auth: ACCESS_TOKEN,
|
||||
throttle: {
|
||||
onRateLimit: (retryAfter, options, octokit, retryCount) => {
|
||||
octokit.log.warn(`Request quota exhausted for request ${options.method} ${options.url}`);
|
||||
if (retryCount < 2) {
|
||||
octokit.log.info(`Retrying after ${retryAfter} seconds!`);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
onSecondaryRateLimit: (retryAfter, options, octokit) => {
|
||||
octokit.log.warn(`Secondary rate limit hit for request ${options.method} ${options.url}`);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
async function getContributions(username, updateCallback) {
|
||||
const sixMonthsAgo = new Date();
|
||||
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
|
||||
const sinceDate = sixMonthsAgo.toISOString();
|
||||
|
||||
const contributionData = {};
|
||||
|
||||
try {
|
||||
const repos = await octokit.paginate(octokit.repos.listForUser, {
|
||||
username,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
console.log(`Fetched ${repos.length} repositories for ${username}`);
|
||||
|
||||
// Call updateCallback immediately after fetching repos
|
||||
updateCallback({});
|
||||
|
||||
for (const repo of repos) {
|
||||
console.log(`Fetching commits for ${repo.name}`);
|
||||
try {
|
||||
const commits = await octokit.paginate(octokit.repos.listCommits, {
|
||||
owner: repo.owner.login,
|
||||
repo: repo.name,
|
||||
author: username,
|
||||
since: sinceDate,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
console.log(`Fetched ${commits.length} commits for ${repo.name}`);
|
||||
|
||||
commits.forEach(commit => {
|
||||
const date = commit.commit.author.date.split('T')[0];
|
||||
contributionData[date] = (contributionData[date] || 0) + 1;
|
||||
// Call the update callback after processing each commit
|
||||
updateCallback({...contributionData});
|
||||
});
|
||||
} catch (repoError) {
|
||||
console.error(`Error fetching commits for ${repo.name}:`, repoError.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Final contribution data:', contributionData);
|
||||
return contributionData;
|
||||
} catch (error) {
|
||||
console.error("Error fetching contribution data:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export { getContributions };
|
||||
|
54
src/pages/api/users/[slug]/lessons/[resourceSlug].js
Normal file
54
src/pages/api/users/[slug]/lessons/[resourceSlug].js
Normal file
@ -0,0 +1,54 @@
|
||||
import { getUserLesson, createOrUpdateUserLesson, deleteUserLesson } from "@/db/models/userLessonModels";
|
||||
import { getResourceById } from "@/db/models/resourceModels";
|
||||
|
||||
// todo somehow make it to where we can get lesson slug in this endpoint
|
||||
export default async function handler(req, res) {
|
||||
const { method } = req;
|
||||
const { slug, resourceSlug, courseId } = req.query;
|
||||
switch (method) {
|
||||
case "GET":
|
||||
try {
|
||||
const resource = await getResourceById(resourceSlug);
|
||||
const lesson = resource?.lessons.find((lesson) => lesson.courseId === courseId);
|
||||
const lessonId = lesson?.id;
|
||||
const userLesson = await getUserLesson(slug, lessonId);
|
||||
if (userLesson) {
|
||||
res.status(200).json(userLesson);
|
||||
} else {
|
||||
res.status(204).end();
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
break;
|
||||
|
||||
case "PUT":
|
||||
try {
|
||||
const data = req.body;
|
||||
const resource = await getResourceById(resourceSlug);
|
||||
const lesson = resource?.lessons.find((lesson) => lesson.courseId === courseId);
|
||||
const lessonId = lesson?.id;
|
||||
const updatedUserLesson = await createOrUpdateUserLesson(slug, lessonId, data);
|
||||
res.status(200).json(updatedUserLesson);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
break;
|
||||
|
||||
case "DELETE":
|
||||
try {
|
||||
const resource = await getResourceById(resourceSlug);
|
||||
const lesson = resource?.lessons.find((lesson) => lesson.courseId === courseId);
|
||||
const lessonId = lesson?.id;
|
||||
await deleteUserLesson(slug, lessonId);
|
||||
res.status(204).end();
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
res.setHeader("Allow", ["GET", "PUT", "DELETE"]);
|
||||
res.status(405).end(`Method ${method} Not Allowed`);
|
||||
}
|
||||
}
|
36
src/pages/api/users/[slug]/lessons/index.js
Normal file
36
src/pages/api/users/[slug]/lessons/index.js
Normal file
@ -0,0 +1,36 @@
|
||||
import { getUserLessons, createOrUpdateUserLesson } from "@/db/models/userLessonModels";
|
||||
import { getResourceById } from "@/db/models/resourceModels";
|
||||
|
||||
// todo somehow make it to where we can get lesson slug in this endpoint
|
||||
export default async function handler(req, res) {
|
||||
const { method } = req;
|
||||
const { slug, courseId } = req.query;
|
||||
const userId = slug;
|
||||
switch (method) {
|
||||
case "GET":
|
||||
try {
|
||||
const userLessons = await getUserLessons(userId);
|
||||
res.status(200).json(userLessons);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
break;
|
||||
|
||||
case "POST":
|
||||
try {
|
||||
const { resourceId, ...data } = req.body;
|
||||
const resource = await getResourceById(resourceId);
|
||||
const lesson = resource?.lessons.find((lesson) => lesson.courseId === courseId);
|
||||
const lessonId = lesson?.id;
|
||||
const userLesson = await createOrUpdateUserLesson(userId, lessonId, data);
|
||||
res.status(201).json(userLesson);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
res.setHeader("Allow", ["GET", "POST"]);
|
||||
res.status(405).end(`Method ${method} Not Allowed`);
|
||||
}
|
||||
}
|
@ -10,6 +10,7 @@ import { useSession } from 'next-auth/react';
|
||||
import { nip04, nip19 } from 'nostr-tools';
|
||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||
import { Accordion, AccordionTab } from 'primereact/accordion';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { useDecryptContent } from "@/hooks/encryption/useDecryptContent";
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
@ -58,12 +59,9 @@ const useLessons = (ndk, fetchAuthor, lessonIds, pubkey) => {
|
||||
const [lessons, setLessons] = useState([]);
|
||||
const [uniqueLessons, setUniqueLessons] = useState([]);
|
||||
|
||||
console.log('lessonIds', lessonIds);
|
||||
|
||||
useEffect(() => {
|
||||
if (lessonIds.length > 0) {
|
||||
const fetchLesson = async (lessonId) => {
|
||||
console.log('lessonId', lessonId);
|
||||
try {
|
||||
await ndk.connect();
|
||||
const filter = { "#d": [lessonId], kinds:[30023, 30402], authors: [pubkey] };
|
||||
@ -143,6 +141,12 @@ const Course = () => {
|
||||
const { showToast } = useToast();
|
||||
const [paidCourse, setPaidCourse] = useState(false);
|
||||
const [expandedIndex, setExpandedIndex] = useState(null);
|
||||
const [completedLessons, setCompletedLessons] = useState([]);
|
||||
|
||||
const setCompleted = (lessonId) => {
|
||||
console.log('setting completed', lessonId);
|
||||
setCompletedLessons(prev => [...prev, lessonId]);
|
||||
}
|
||||
|
||||
const fetchAuthor = useCallback(async (pubkey) => {
|
||||
const author = await ndk.getUser({ pubkey });
|
||||
@ -155,6 +159,10 @@ const Course = () => {
|
||||
const { lessons, uniqueLessons, setLessons } = useLessons(ndk, fetchAuthor, lessonIds, course?.pubkey);
|
||||
const { decryptionPerformed, loading } = useDecryption(session, paidCourse, course, lessons, setLessons);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('lessonIds', lessonIds);
|
||||
}, [lessonIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (course?.price && course?.price > 0) {
|
||||
setPaidCourse(true);
|
||||
@ -230,14 +238,15 @@ const Course = () => {
|
||||
accordiontab: { className: 'border-none' },
|
||||
}}
|
||||
header={
|
||||
<div className="flex align-items-center justify-content-between w-full">
|
||||
<div className="flex align-items-center justify-between w-full">
|
||||
<span id={`lesson-${index}`} className="font-bold text-xl">{`Lesson ${index + 1}: ${lesson.title}`}</span>
|
||||
{completedLessons.includes(lesson.id) ? <Tag severity="success" value="Completed" /> : null}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="w-full py-4 rounded-b-lg">
|
||||
{lesson.type === 'video' ?
|
||||
<VideoLesson lesson={lesson} course={course} decryptionPerformed={decryptionPerformed} isPaid={paidCourse} /> :
|
||||
<VideoLesson lesson={lesson} course={course} decryptionPerformed={decryptionPerformed} isPaid={paidCourse} setCompleted={setCompleted} /> :
|
||||
<DocumentLesson lesson={lesson} course={course} decryptionPerformed={decryptionPerformed} isPaid={paidCourse} />
|
||||
}
|
||||
</div>
|
||||
|
13
src/pages/testr.js
Normal file
13
src/pages/testr.js
Normal file
@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import GithubContributionChart from '@/components/charts/GithubContributionChart';
|
||||
|
||||
const Testr = () => {
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<GithubContributionChart username="austinkelsay" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Testr;
|
||||
|
Loading…
x
Reference in New Issue
Block a user