From ee2f10e08067760ef7d33f45eb054cd961a9772e Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 21 Feb 2026 18:50:29 +0100 Subject: [PATCH] Add jury assignment transfer, cap redistribution, and learning hub overhaul - Add getTransferCandidates/transferAssignments procedures for targeted assignment moves between jurors with TOCTOU guards and audit logging - Add getOverCapPreview/redistributeOverCap for auto-redistributing assignments when a juror's cap is lowered below their current load - Add TransferAssignmentsDialog (2-step: select projects, pick destinations) - Extend InlineMemberCap with over-cap detection and redistribute banner - Extend getReassignmentHistory to show ASSIGNMENT_TRANSFER and CAP_REDISTRIBUTE events - Learning hub: replace ResourceType/CohortLevel enums with accessJson JSONB, add coverImageKey, resource detail pages for jury/mentor, shared renderer - Migration: 20260221200000_learning_hub_overhaul Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 334 +++++--- package.json | 4 +- .../migration.sql | 16 + prisma/schema.prisma | 26 +- src/app/(admin)/admin/learning/[id]/page.tsx | 580 +++++++------ src/app/(admin)/admin/learning/new/page.tsx | 418 ++++----- src/app/(admin)/admin/learning/page.tsx | 337 +++++--- .../(admin)/admin/rounds/[roundId]/page.tsx | 381 ++++++++- src/app/(jury)/jury/learning/[id]/page.tsx | 122 +++ src/app/(jury)/jury/learning/page.tsx | 62 +- .../(mentor)/mentor/resources/[id]/page.tsx | 122 +++ src/app/(mentor)/mentor/resources/page.tsx | 53 +- src/components/shared/block-editor.tsx | 4 +- src/components/shared/resource-renderer.tsx | 71 ++ src/server/routers/assignment.ts | 798 +++++++++++++++++- src/server/routers/learningResource.ts | 260 +++--- 16 files changed, 2643 insertions(+), 945 deletions(-) create mode 100644 prisma/migrations/20260221200000_learning_hub_overhaul/migration.sql create mode 100644 src/app/(jury)/jury/learning/[id]/page.tsx create mode 100644 src/app/(mentor)/mentor/resources/[id]/page.tsx create mode 100644 src/components/shared/resource-renderer.tsx diff --git a/package-lock.json b/package-lock.json index 4eb8855..f04fed9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,14 +11,12 @@ "@anthropic-ai/sdk": "^0.78.0", "@auth/prisma-adapter": "^2.7.4", "@blocknote/core": "^0.46.2", - "@blocknote/mantine": "^0.46.2", "@blocknote/react": "^0.46.2", + "@blocknote/shadcn": "^0.46.2", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^3.9.1", - "@mantine/core": "^8.3.13", - "@mantine/hooks": "^8.3.13", "@notionhq/client": "^2.3.0", "@prisma/client": "^6.19.2", "@radix-ui/react-alert-dialog": "^1.1.4", @@ -246,24 +244,6 @@ } } }, - "node_modules/@blocknote/mantine": { - "version": "0.46.2", - "resolved": "https://registry.npmjs.org/@blocknote/mantine/-/mantine-0.46.2.tgz", - "integrity": "sha512-2/A82VIby8NNuQbJrXZURnGsksVMWiGWtUOfhvaawCTiB2thYDOV1XONFF1G4xZ2UreodOKLUTwhLm3u25lGrw==", - "license": "MPL-2.0", - "dependencies": { - "@blocknote/core": "0.46.2", - "@blocknote/react": "0.46.2", - "react-icons": "^5.5.0" - }, - "peerDependencies": { - "@mantine/core": "^8.3.11", - "@mantine/hooks": "^8.3.11", - "@mantine/utils": "^6.0.22", - "react": "^18.0 || ^19.0 || >= 19.0.0-rc", - "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc" - } - }, "node_modules/@blocknote/react": { "version": "0.46.2", "resolved": "https://registry.npmjs.org/@blocknote/react/-/react-0.46.2.tgz", @@ -296,6 +276,55 @@ "integrity": "sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==", "license": "MIT" }, + "node_modules/@blocknote/shadcn": { + "version": "0.46.2", + "resolved": "https://registry.npmjs.org/@blocknote/shadcn/-/shadcn-0.46.2.tgz", + "integrity": "sha512-rCmML5M814D/dcLrm5/6REQeEJKPNyygls4n751F0jBLpk8IuWsQYvGqnU6iPW2Cz+9IchA2ivg4Vltna83J1Q==", + "license": "MPL-2.0", + "dependencies": { + "@blocknote/core": "0.46.2", + "@blocknote/react": "0.46.2", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-tooltip": "^1.2.8", + "autoprefixer": "^10.4.21", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.525.0", + "react-hook-form": "^7.65.0", + "tailwind-merge": "^2.6.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || >= 19.0.0-rc", + "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc", + "tailwindcss": "^4.1.12" + } + }, + "node_modules/@blocknote/shadcn/node_modules/lucide-react": { + "version": "0.525.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.525.0.tgz", + "integrity": "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@blocknote/shadcn/node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/@date-fns/tz": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", @@ -1652,34 +1681,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@mantine/core": { - "version": "8.3.13", - "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.13.tgz", - "integrity": "sha512-ZgW4vqN4meaPyIMxzAufBvsgmJRfYZdTpsrAOcS8pWy7m9e8i685E7XsAxnwJCOIHudpvpvt+7Bx7VaIjEsYEw==", - "license": "MIT", - "dependencies": { - "@floating-ui/react": "^0.27.16", - "clsx": "^2.1.1", - "react-number-format": "^5.4.4", - "react-remove-scroll": "^2.7.1", - "react-textarea-autosize": "8.5.9", - "type-fest": "^4.41.0" - }, - "peerDependencies": { - "@mantine/hooks": "8.3.13", - "react": "^18.x || ^19.x", - "react-dom": "^18.x || ^19.x" - } - }, - "node_modules/@mantine/hooks": { - "version": "8.3.13", - "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.13.tgz", - "integrity": "sha512-7YMbMW/tR9E8m/9DbBW01+3RNApm2mE/JbRKXf9s9+KxgbjQvq0FYGWV8Y4+Sjz48AO4vtWk2qBriUTgBMKAyg==", - "license": "MIT", - "peerDependencies": { - "react": "^18.x || ^19.x" - } - }, "node_modules/@napi-rs/canvas": { "version": "0.1.80", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz", @@ -6010,6 +6011,42 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/autoprefixer": { + "version": "10.4.24", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", + "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001766", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -6091,6 +6128,18 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/bcryptjs": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", @@ -6145,6 +6194,39 @@ "integrity": "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==", "license": "MIT" }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/buffer-crc32": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", @@ -7020,6 +7102,12 @@ "fast-check": "^3.23.1" } }, + "node_modules/electron-to-chromium": { + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "license": "ISC" + }, "node_modules/emoji-mart": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz", @@ -7290,6 +7378,15 @@ "@esbuild/win32-x64": "0.27.2" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -8015,6 +8112,19 @@ "node": ">= 6" } }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/framer-motion": { "version": "11.18.2", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz", @@ -11100,6 +11210,12 @@ "dev": true, "license": "MIT" }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "license": "MIT" + }, "node_modules/nodemailer": { "version": "7.0.13", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", @@ -11628,6 +11744,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, "node_modules/preact": { "version": "10.24.3", "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", @@ -12250,16 +12372,6 @@ "react-dom": "^19.0.0" } }, - "node_modules/react-number-format": { - "version": "5.4.4", - "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.4.tgz", - "integrity": "sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA==", - "license": "MIT", - "peerDependencies": { - "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/react-phone-number-input": { "version": "3.4.14", "resolved": "https://registry.npmjs.org/react-phone-number-input/-/react-phone-number-input-3.4.14.tgz", @@ -12361,23 +12473,6 @@ } } }, - "node_modules/react-textarea-autosize": { - "version": "8.5.9", - "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz", - "integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.20.13", - "use-composed-ref": "^1.3.0", - "use-latest": "^1.2.1" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -13729,18 +13824,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -14020,6 +14103,36 @@ "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -14051,20 +14164,6 @@ } } }, - "node_modules/use-composed-ref": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz", - "integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/use-debounce": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.1.0.tgz", @@ -14077,37 +14176,6 @@ "react": "*" } }, - "node_modules/use-isomorphic-layout-effect": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", - "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-latest": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz", - "integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==", - "license": "MIT", - "dependencies": { - "use-isomorphic-layout-effect": "^1.1.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/use-sidecar": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", diff --git a/package.json b/package.json index 68a56d6..446d71e 100644 --- a/package.json +++ b/package.json @@ -24,14 +24,12 @@ "@anthropic-ai/sdk": "^0.78.0", "@auth/prisma-adapter": "^2.7.4", "@blocknote/core": "^0.46.2", - "@blocknote/mantine": "^0.46.2", "@blocknote/react": "^0.46.2", + "@blocknote/shadcn": "^0.46.2", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^3.9.1", - "@mantine/core": "^8.3.13", - "@mantine/hooks": "^8.3.13", "@notionhq/client": "^2.3.0", "@prisma/client": "^6.19.2", "@radix-ui/react-alert-dialog": "^1.1.4", diff --git a/prisma/migrations/20260221200000_learning_hub_overhaul/migration.sql b/prisma/migrations/20260221200000_learning_hub_overhaul/migration.sql new file mode 100644 index 0000000..e9d6245 --- /dev/null +++ b/prisma/migrations/20260221200000_learning_hub_overhaul/migration.sql @@ -0,0 +1,16 @@ +-- Learning Hub Overhaul: Remove ResourceType/CohortLevel enums, add accessJson + coverImageKey + +-- Drop columns that reference the enums +ALTER TABLE "LearningResource" DROP COLUMN "resourceType"; +ALTER TABLE "LearningResource" DROP COLUMN "cohortLevel"; + +-- Drop the cohortLevel index +DROP INDEX IF EXISTS "LearningResource_cohortLevel_idx"; + +-- Add new columns +ALTER TABLE "LearningResource" ADD COLUMN "accessJson" JSONB; +ALTER TABLE "LearningResource" ADD COLUMN "coverImageKey" TEXT; + +-- Drop the enum types +DROP TYPE IF EXISTS "ResourceType"; +DROP TYPE IF EXISTS "CohortLevel"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4c3a20f..e89a1bb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -115,19 +115,6 @@ enum NotificationChannel { NONE } -enum ResourceType { - PDF - VIDEO - DOCUMENT - LINK - OTHER -} - -enum CohortLevel { - ALL - SEMIFINALIST - FINALIST -} enum PartnerVisibility { ADMIN_ONLY @@ -1010,13 +997,12 @@ model NotificationEmailSetting { // ============================================================================= model LearningResource { - id String @id @default(cuid()) + id String @id @default(cuid()) programId String? // null = global resource title String - description String? @db.Text - contentJson Json? @db.JsonB // BlockNote document structure - resourceType ResourceType - cohortLevel CohortLevel @default(ALL) + description String? @db.Text + contentJson Json? @db.JsonB // BlockNote document structure + accessJson Json? @db.JsonB // Fine-grained access rules // File storage (for uploaded resources) fileName String? @@ -1025,6 +1011,9 @@ model LearningResource { bucket String? objectKey String? + // Cover image (stored in MinIO) + coverImageKey String? + // External link externalUrl String? @@ -1041,7 +1030,6 @@ model LearningResource { accessLogs ResourceAccess[] @@index([programId]) - @@index([cohortLevel]) @@index([isPublished]) @@index([sortOrder]) } diff --git a/src/app/(admin)/admin/learning/[id]/page.tsx b/src/app/(admin)/admin/learning/[id]/page.tsx index e322da1..0efd885 100644 --- a/src/app/(admin)/admin/learning/[id]/page.tsx +++ b/src/app/(admin)/admin/learning/[id]/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useParams, useRouter } from 'next/navigation' import Link from 'next/link' import dynamic from 'next/dynamic' @@ -8,15 +8,11 @@ import { trpc } from '@/lib/trpc/client' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { Textarea } from '@/components/ui/textarea' import { Switch } from '@/components/ui/switch' -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card' +import { Checkbox } from '@/components/ui/checkbox' +import { Separator } from '@/components/ui/separator' +import { Skeleton } from '@/components/ui/skeleton' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Select, SelectContent, @@ -24,6 +20,14 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, +} from '@/components/ui/sheet' import { AlertDialog, AlertDialogAction, @@ -35,46 +39,62 @@ import { AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog' -import { Skeleton } from '@/components/ui/skeleton' -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { toast } from 'sonner' import { ArrowLeft, Save, Loader2, - FileText, - Video, - Link as LinkIcon, - File, - Trash2, + Settings, Eye, + Trash2, AlertCircle, } from 'lucide-react' -// Dynamically import BlockEditor to avoid SSR issues +// Dynamically import editors to avoid SSR issues const BlockEditor = dynamic( () => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor), { ssr: false, loading: () => ( -
+
), } ) -const resourceTypeOptions = [ - { value: 'DOCUMENT', label: 'Document', icon: FileText }, - { value: 'PDF', label: 'PDF', icon: FileText }, - { value: 'VIDEO', label: 'Video', icon: Video }, - { value: 'LINK', label: 'External Link', icon: LinkIcon }, - { value: 'OTHER', label: 'Other', icon: File }, +const ResourceRenderer = dynamic( + () => import('@/components/shared/resource-renderer').then((mod) => mod.ResourceRenderer), + { + ssr: false, + loading: () => ( +
+ ), + } +) + +const ROLE_OPTIONS = [ + { value: 'JURY_MEMBER', label: 'Jury Members' }, + { value: 'MENTOR', label: 'Mentors' }, + { value: 'OBSERVER', label: 'Observers' }, + { value: 'APPLICANT', label: 'Applicants' }, + { value: 'AWARD_MASTER', label: 'Award Masters' }, ] -const cohortOptions = [ - { value: 'ALL', label: 'All Members', description: 'Visible to everyone' }, - { value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' }, - { value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' }, -] +type AccessRule = + | { type: 'everyone' } + | { type: 'roles'; roles: string[] } + | { type: 'jury_group'; juryGroupIds: string[] } + | { type: 'round'; roundIds: string[] } + +function parseAccessJson(accessJson: unknown): { mode: 'everyone' | 'roles'; roles: string[] } { + if (!accessJson || !Array.isArray(accessJson) || accessJson.length === 0) { + return { mode: 'everyone', roles: [] } + } + const firstRule = accessJson[0] as AccessRule + if (firstRule.type === 'roles' && 'roles' in firstRule) { + return { mode: 'roles', roles: firstRule.roles } + } + return { mode: 'everyone', roles: [] } +} export default function EditLearningResourcePage() { const params = useParams() @@ -89,11 +109,14 @@ export default function EditLearningResourcePage() { const [title, setTitle] = useState('') const [description, setDescription] = useState('') const [contentJson, setContentJson] = useState('') - const [resourceType, setResourceType] = useState('DOCUMENT') - const [cohortLevel, setCohortLevel] = useState('ALL') const [externalUrl, setExternalUrl] = useState('') const [isPublished, setIsPublished] = useState(false) const [programId, setProgramId] = useState(null) + const [previewing, setPreviewing] = useState(false) + + // Access rules state + const [accessMode, setAccessMode] = useState<'everyone' | 'roles'>('everyone') + const [selectedRoles, setSelectedRoles] = useState([]) // API const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' }) @@ -115,11 +138,13 @@ export default function EditLearningResourcePage() { setTitle(resource.title) setDescription(resource.description || '') setContentJson(resource.contentJson ? JSON.stringify(resource.contentJson) : '') - setResourceType(resource.resourceType) - setCohortLevel(resource.cohortLevel) setExternalUrl(resource.externalUrl || '') setIsPublished(resource.isPublished) setProgramId(resource.programId) + + const { mode, roles } = parseAccessJson(resource.accessJson) + setAccessMode(mode) + setSelectedRoles(roles) } }, [resource]) @@ -134,74 +159,88 @@ export default function EditLearningResourcePage() { await fetch(url, { method: 'PUT', body: file, - headers: { - 'Content-Type': file.type, - }, + headers: { 'Content-Type': file.type }, }) const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000' return `${minioEndpoint}/${bucket}/${objectKey}` - } catch (error) { + } catch { toast.error('Failed to upload file') - throw error + throw new Error('Upload failed') } } - const handleSubmit = async () => { + const buildAccessJson = (): AccessRule[] | null => { + if (accessMode === 'everyone') return null + if (accessMode === 'roles' && selectedRoles.length > 0) { + return [{ type: 'roles', roles: selectedRoles }] + } + return null + } + + const handleSubmit = useCallback(async () => { if (!title.trim()) { toast.error('Please enter a title') return } - if (resourceType === 'LINK' && !externalUrl) { - toast.error('Please enter an external URL') - return - } - try { await updateResource.mutateAsync({ id: resourceId, + programId, title, - description: description || undefined, + description: description || null, contentJson: contentJson ? JSON.parse(contentJson) : undefined, - resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER', - cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST', + accessJson: buildAccessJson(), externalUrl: externalUrl || null, isPublished, }) - toast.success('Resource updated successfully') - router.push('/admin/learning') + toast.success('Resource updated') } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to update resource') } - } + }, [title, description, contentJson, externalUrl, isPublished, programId, accessMode, selectedRoles, resourceId]) const handleDelete = async () => { try { await deleteResource.mutateAsync({ id: resourceId }) - toast.success('Resource deleted successfully') + toast.success('Resource deleted') router.push('/admin/learning') } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to delete resource') } } + // Ctrl+S save + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault() + handleSubmit() + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [handleSubmit]) + if (isLoading) { return ( -
-
- -
- -
-
- - +
+
+ +
+ + +
-
- - +
+
+
+ + + +
@@ -210,7 +249,7 @@ export default function EditLearningResourcePage() { if (error || !resource) { return ( -
+
Resource not found @@ -229,253 +268,250 @@ export default function EditLearningResourcePage() { } return ( -
- {/* Header */} -
- -
-
-
-

Edit Resource

-

- Update this learning resource -

-
- - - - - - - Delete Resource - - Are you sure you want to delete "{resource.title}"? This action - cannot be undone. - - - - Cancel - - {deleteResource.isPending ? ( - - ) : null} - Delete - - - - -
+
+ -
- {/* Main content */} -
- {/* Basic Info */} - - - Resource Details - - Basic information about this resource - - - -
- - setTitle(e.target.value)} - placeholder="e.g., Ocean Conservation Best Practices" - /> -
+ + + + + + + Resource Settings + + Configure publishing, access, and metadata + + -
- -