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 */}
-
-