From f7bbf93f95b9f7cc0ccfca7b21cf71e2672a6cdf Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Thu, 19 Sep 2024 15:38:36 -0500 Subject: [PATCH] video lesson tracking for courses, new userlesson table, also github experiment --- package-lock.json | 326 +++++++++++++++++- package.json | 4 + .../migration.sql | 142 ++++---- prisma/schema.prisma | 120 ++++--- .../charts/GithubContributionChart.js | 77 +++++ .../content/courses/CourseDetailsNew.js | 16 +- src/components/content/courses/VideoLesson.js | 46 ++- src/db/models/userLessonModels.js | 52 +++ src/hooks/tracking/useTrackVideoLesson.js | 102 ++++++ src/lib/github.js | 75 ++++ .../api/users/{[slug].js => [slug]/index.js} | 0 .../users/[slug]/lessons/[resourceSlug].js | 54 +++ src/pages/api/users/[slug]/lessons/index.js | 36 ++ src/pages/course/[slug]/index.js | 19 +- src/pages/testr.js | 13 + 15 files changed, 952 insertions(+), 130 deletions(-) rename prisma/migrations/{20240909215121_init => 20240919203740_init}/migration.sql (89%) create mode 100644 src/components/charts/GithubContributionChart.js create mode 100644 src/db/models/userLessonModels.js create mode 100644 src/hooks/tracking/useTrackVideoLesson.js create mode 100644 src/lib/github.js rename src/pages/api/users/{[slug].js => [slug]/index.js} (100%) create mode 100644 src/pages/api/users/[slug]/lessons/[resourceSlug].js create mode 100644 src/pages/api/users/[slug]/lessons/index.js create mode 100644 src/pages/testr.js diff --git a/package-lock.json b/package-lock.json index f51ff6a..6b80055 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 17bf6e1..3600c28 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/20240909215121_init/migration.sql b/prisma/migrations/20240919203740_init/migration.sql similarity index 89% rename from prisma/migrations/20240909215121_init/migration.sql rename to prisma/migrations/20240919203740_init/migration.sql index 068c101..921e469 100644 --- a/prisma/migrations/20240909215121_init/migration.sql +++ b/prisma/migrations/20240919203740_init/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index af91bb5..fa521d2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 } \ No newline at end of file diff --git a/src/components/charts/GithubContributionChart.js b/src/components/charts/GithubContributionChart.js new file mode 100644 index 0000000..e1417ef --- /dev/null +++ b/src/components/charts/GithubContributionChart.js @@ -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 ( +
+

Github Contributions for {username}

+ {loading &&

Loading contribution data...

} +
+ {calendar.map((day, index) => ( +
+ ))} +
+
+ Less +
+
+
+
+
+
+
+ More +
+
+ ); +}; + +export default GithubContributionChart; diff --git a/src/components/content/courses/CourseDetailsNew.js b/src/components/content/courses/CourseDetailsNew.js index 5515115..d7394c8 100644 --- a/src/components/content/courses/CourseDetailsNew.js +++ b/src/components/content/courses/CourseDetailsNew.js @@ -136,14 +136,12 @@ export default function CourseDetailsNew({ processedEvent, paidCourse, lessons, )} -

{processedEvent.description && ( -

- {processedEvent.description.split('\n').map((line, index) => ( -

{line}

- ))} -
+
{processedEvent.description && ( + processedEvent.description.split('\n').map((line, index) => ( +

{line}

+ )) )} -

+
router.push(`/details/${processedEvent.id}/edit`)} label="Edit" severity='warning' outlined /> - window.open(`https://nostr.band/${nAddress}`, '_blank')} tooltip={ isMobileView ? null : "View Nostr Event" } tooltipOptions={{ position: paidCourse ? 'left' : 'right' }} /> + window.open(`https://nostr.band/${nAddress}`, '_blank')} tooltip={isMobileView ? null : "View Nostr Event"} tooltipOptions={{ position: paidCourse ? 'left' : 'right' }} />
) : (
- window.open(`https://nostr.band/${nAddress}`, '_blank')} tooltip={ isMobileView ? null : "View Nostr Event" } tooltipOptions={{ position: paidCourse ? 'left' : 'right' }} /> + window.open(`https://nostr.band/${nAddress}`, '_blank')} tooltip={isMobileView ? null : "View Nostr Event"} tooltipOptions={{ position: paidCourse ? 'left' : 'right' }} />
)}
diff --git a/src/components/content/courses/VideoLesson.js b/src/components/content/courses/VideoLesson.js index 38362b5..b526049 100644 --- a/src/components/content/courses/VideoLesson.js +++ b/src/components/content/courses/VideoLesson.js @@ -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 ( - <> +
- +
); } else if (isPaid && !decryptionPerformed) { return ( diff --git a/src/db/models/userLessonModels.js b/src/db/models/userLessonModels.js new file mode 100644 index 0000000..6613744 --- /dev/null +++ b/src/db/models/userLessonModels.js @@ -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, + }, + }, + }); +}; diff --git a/src/hooks/tracking/useTrackVideoLesson.js b/src/hooks/tracking/useTrackVideoLesson.js new file mode 100644 index 0000000..143f78a --- /dev/null +++ b/src/hooks/tracking/useTrackVideoLesson.js @@ -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; diff --git a/src/lib/github.js b/src/lib/github.js new file mode 100644 index 0000000..0521cf9 --- /dev/null +++ b/src/lib/github.js @@ -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 }; + diff --git a/src/pages/api/users/[slug].js b/src/pages/api/users/[slug]/index.js similarity index 100% rename from src/pages/api/users/[slug].js rename to src/pages/api/users/[slug]/index.js diff --git a/src/pages/api/users/[slug]/lessons/[resourceSlug].js b/src/pages/api/users/[slug]/lessons/[resourceSlug].js new file mode 100644 index 0000000..e91a99c --- /dev/null +++ b/src/pages/api/users/[slug]/lessons/[resourceSlug].js @@ -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`); + } +} \ No newline at end of file diff --git a/src/pages/api/users/[slug]/lessons/index.js b/src/pages/api/users/[slug]/lessons/index.js new file mode 100644 index 0000000..a53bc85 --- /dev/null +++ b/src/pages/api/users/[slug]/lessons/index.js @@ -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`); + } +} \ No newline at end of file diff --git a/src/pages/course/[slug]/index.js b/src/pages/course/[slug]/index.js index 7a9efc6..170a118 100644 --- a/src/pages/course/[slug]/index.js +++ b/src/pages/course/[slug]/index.js @@ -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={ -
+
{`Lesson ${index + 1}: ${lesson.title}`} + {completedLessons.includes(lesson.id) ? : null}
} >
{lesson.type === 'video' ? - : + : }
diff --git a/src/pages/testr.js b/src/pages/testr.js new file mode 100644 index 0000000..2bcb528 --- /dev/null +++ b/src/pages/testr.js @@ -0,0 +1,13 @@ +import React from 'react'; +import GithubContributionChart from '@/components/charts/GithubContributionChart'; + +const Testr = () => { + return ( +
+ +
+ ); +}; + +export default Testr; +