Add Frontend Code
This commit is contained in:
parent
c46545f329
commit
e85703df8f
@ -45,6 +45,7 @@ Atlas Librarian is a modular system designed to process, organize, and make sear
|
|||||||
|
|
||||||
```
|
```
|
||||||
atlas/
|
atlas/
|
||||||
|
|-- lexikon/ # Frontend app
|
||||||
├── librarian/
|
├── librarian/
|
||||||
│ ├── atlas-librarian/ # Main application
|
│ ├── atlas-librarian/ # Main application
|
||||||
│ ├── librarian-core/ # Core functionality and storage
|
│ ├── librarian-core/ # Core functionality and storage
|
||||||
|
3
lexikon/.env.local.example
Normal file
3
lexikon/.env.local.example
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
NEXT_PUBLIC_SUPABASE_URL=your-project-url
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
|
44
lexikon/.gitignore
vendored
Normal file
44
lexikon/.gitignore
vendored
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# lockfiles
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
.env
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
|
||||||
|
# other
|
||||||
|
.github
|
||||||
|
# _legacy
|
0
lexikon/.husky/pre-commit
Normal file
0
lexikon/.husky/pre-commit
Normal file
2
lexikon/.npmrc
Normal file
2
lexikon/.npmrc
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
public-hoist-pattern[]=*@heroui/*
|
||||||
|
package-lock=true
|
21
lexikon/LICENSE
Normal file
21
lexikon/LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2023 Next UI
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
156
lexikon/eslint.config.mjs
Normal file
156
lexikon/eslint.config.mjs
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { fixupConfigRules, fixupPluginRules } from "@eslint/compat";
|
||||||
|
import typescriptEslint from "@typescript-eslint/eslint-plugin";
|
||||||
|
import tsParser from "@typescript-eslint/parser";
|
||||||
|
import _import from "eslint-plugin-import";
|
||||||
|
import jsxA11Y from "eslint-plugin-jsx-a11y";
|
||||||
|
import prettier from "eslint-plugin-prettier";
|
||||||
|
import react from "eslint-plugin-react";
|
||||||
|
import unusedImports from "eslint-plugin-unused-imports";
|
||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import globals from "globals";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: __dirname,
|
||||||
|
recommendedConfig: js.configs.recommended,
|
||||||
|
allConfig: js.configs.all,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores([
|
||||||
|
".next/**",
|
||||||
|
"node_modules/**",
|
||||||
|
"out/**",
|
||||||
|
"dist/**",
|
||||||
|
"**/index.ts",
|
||||||
|
"**/index.d.ts",
|
||||||
|
]),
|
||||||
|
{
|
||||||
|
extends: fixupConfigRules(
|
||||||
|
compat.extends(
|
||||||
|
"plugin:react/recommended",
|
||||||
|
"plugin:prettier/recommended",
|
||||||
|
"plugin:react-hooks/recommended",
|
||||||
|
"plugin:jsx-a11y/recommended",
|
||||||
|
"plugin:@next/next/recommended"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
plugins: {
|
||||||
|
react: fixupPluginRules(react),
|
||||||
|
"unused-imports": unusedImports,
|
||||||
|
import: fixupPluginRules(_import),
|
||||||
|
"@typescript-eslint": typescriptEslint,
|
||||||
|
"jsx-a11y": fixupPluginRules(jsxA11Y),
|
||||||
|
prettier: fixupPluginRules(prettier),
|
||||||
|
},
|
||||||
|
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...Object.fromEntries(
|
||||||
|
Object.entries(globals.browser).map(([key]) => [key, "off"])
|
||||||
|
),
|
||||||
|
...globals.node,
|
||||||
|
},
|
||||||
|
|
||||||
|
parser: tsParser,
|
||||||
|
ecmaVersion: 12,
|
||||||
|
sourceType: "module",
|
||||||
|
|
||||||
|
parserOptions: {
|
||||||
|
ecmaFeatures: {
|
||||||
|
jsx: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: "detect",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
rules: {
|
||||||
|
"no-console": "warn",
|
||||||
|
"react/prop-types": "off",
|
||||||
|
"react/jsx-uses-react": "off",
|
||||||
|
"react/react-in-jsx-scope": "off",
|
||||||
|
"react-hooks/exhaustive-deps": "off",
|
||||||
|
"@next/next/no-assign-module-variable": "off",
|
||||||
|
"jsx-a11y/click-events-have-key-events": "warn",
|
||||||
|
"jsx-a11y/interactive-supports-focus": "warn",
|
||||||
|
"prettier/prettier": "warn",
|
||||||
|
|
||||||
|
// Disable the built‑in unused vars rules:
|
||||||
|
"no-unused-vars": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": "off",
|
||||||
|
|
||||||
|
// Enable unused imports rule:
|
||||||
|
"unused-imports/no-unused-imports": "error",
|
||||||
|
|
||||||
|
// (Optional) If you want to also flag unused variables (excluding imports)
|
||||||
|
// you can use the plugin’s no-unused-vars rule:
|
||||||
|
// "unused-imports/no-unused-vars": [
|
||||||
|
// "warn",
|
||||||
|
// { vars: "all", varsIgnorePattern: "^_", args: "after-used", argsIgnorePattern: "^_" }
|
||||||
|
// ],
|
||||||
|
|
||||||
|
"import/order": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
groups: [
|
||||||
|
"type",
|
||||||
|
"builtin",
|
||||||
|
"object",
|
||||||
|
"external",
|
||||||
|
"internal",
|
||||||
|
"parent",
|
||||||
|
"sibling",
|
||||||
|
"index",
|
||||||
|
],
|
||||||
|
pathGroups: [
|
||||||
|
{
|
||||||
|
pattern: "~/**",
|
||||||
|
group: "external",
|
||||||
|
position: "after",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"newlines-between": "always",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"react/self-closing-comp": "warn",
|
||||||
|
"react/jsx-sort-props": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
callbacksLast: true,
|
||||||
|
shorthandFirst: true,
|
||||||
|
noSortAlphabetically: false,
|
||||||
|
reservedFirst: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"padding-line-between-statements": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
blankLine: "always",
|
||||||
|
prev: "*",
|
||||||
|
next: "return",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
blankLine: "always",
|
||||||
|
prev: ["const", "let", "var"],
|
||||||
|
next: "*",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
blankLine: "any",
|
||||||
|
prev: ["const", "let", "var"],
|
||||||
|
next: ["const", "let", "var"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"react/no-unknown-property": "warn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
33
lexikon/next.config.mjs
Normal file
33
lexikon/next.config.mjs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import createMDX from "@next/mdx";
|
||||||
|
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
// Configure `pageExtensions` to include markdown and MDX files
|
||||||
|
pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],
|
||||||
|
// Optionally, add any other Next.js config below
|
||||||
|
reactStrictMode: true,
|
||||||
|
allowedDevOrigins: ["*", "os-alpha.local"],
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "moodle.fhgr.ch",
|
||||||
|
port: "",
|
||||||
|
pathname: "/**",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const withMDX = createMDX({
|
||||||
|
options: {
|
||||||
|
remarkPlugins: [
|
||||||
|
["remark-gfm", { strict: true, singleTilde: true }],
|
||||||
|
["remark-math", { strict: true }],
|
||||||
|
],
|
||||||
|
rehypePlugins: [["rehype-katex", { strict: true, throwOnError: true }]],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge MDX config with Next.js config
|
||||||
|
export default withMDX(nextConfig);
|
123
lexikon/package.json
Normal file
123
lexikon/package.json
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
{
|
||||||
|
"name": "lexikon",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --turbopack -H 0.0.0.0 -p 3000",
|
||||||
|
"build": "next build --turbopack",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"prepare": "husky"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.18.6",
|
||||||
|
"@codemirror/lang-javascript": "^6.2.3",
|
||||||
|
"@codemirror/lang-markdown": "^6.3.2",
|
||||||
|
"@codemirror/lang-python": "^6.1.7",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.2",
|
||||||
|
"@codemirror/view": "^6.36.5",
|
||||||
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
|
"@heroui/button": "2.2.18-beta.2",
|
||||||
|
"@heroui/code": "2.2.14-beta.2",
|
||||||
|
"@heroui/input": "2.4.18-beta.2",
|
||||||
|
"@heroui/kbd": "2.2.14-beta.2",
|
||||||
|
"@heroui/link": "2.2.15-beta.2",
|
||||||
|
"@heroui/listbox": "2.3.17-beta.2",
|
||||||
|
"@heroui/navbar": "2.2.16-beta.2",
|
||||||
|
"@heroui/react": "2.8.0-beta.2",
|
||||||
|
"@heroui/snippet": "2.2.19-beta.2",
|
||||||
|
"@heroui/switch": "2.2.16-beta.2",
|
||||||
|
"@heroui/system": "2.4.14-beta.2",
|
||||||
|
"@heroui/theme": "2.4.14-beta.2",
|
||||||
|
"@mdx-js/loader": "^3.1.0",
|
||||||
|
"@mdx-js/react": "^3.1.0",
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"@next/mdx": "^15.3.1",
|
||||||
|
"@react-aria/ssr": "3.9.8",
|
||||||
|
"@react-aria/visually-hidden": "3.8.22",
|
||||||
|
"@react-three/drei": "^10.0.6",
|
||||||
|
"@react-three/fiber": "^9.1.2",
|
||||||
|
"@react-types/shared": "3.29.0",
|
||||||
|
"@supabase/ssr": "^0.6.1",
|
||||||
|
"@supabase/supabase-js": "^2.49.4",
|
||||||
|
"@tailwindcss/postcss": "^4.1.4",
|
||||||
|
"@tanstack/react-query": "^5.75.5",
|
||||||
|
"@types/codemirror": "^5.60.15",
|
||||||
|
"@types/three": "^0.175.0",
|
||||||
|
"@typescript-eslint/typescript-estree": "^8.30.1",
|
||||||
|
"@uiw/codemirror-themes-all": "^4.23.10",
|
||||||
|
"@uiw/react-codemirror": "^4.23.10",
|
||||||
|
"clsx": "2.1.1",
|
||||||
|
"framer-motion": "^12.9.2",
|
||||||
|
"globals": "^16.0.0",
|
||||||
|
"intl-messageformat": "^10.7.16",
|
||||||
|
"katex": "^0.16.22",
|
||||||
|
"lucide-react": "^0.501.0",
|
||||||
|
"markdown-to-jsx": "^7.7.4",
|
||||||
|
"ml-kmeans": "^6.0.0",
|
||||||
|
"monaco-editor": "^0.52.2",
|
||||||
|
"motion": "^12.7.4",
|
||||||
|
"next": "^15.3.1",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-dom": "19.1.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-monaco-editor": "^0.58.0",
|
||||||
|
"react-swipeable": "^7.0.2",
|
||||||
|
"react-syntax-highlighter": "^15.6.1",
|
||||||
|
"react-three-fiber": "^6.0.13",
|
||||||
|
"react-use-measure": "^2.1.7",
|
||||||
|
"rehype-katex": "^7.0.1",
|
||||||
|
"rehype-sanitize": "^6.0.0",
|
||||||
|
"rehype-slug": "^6.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"remark-math": "^6.0.0",
|
||||||
|
"swr": "^2.3.3",
|
||||||
|
"tailwind-merge": "^3.2.0",
|
||||||
|
"tailwind-variants": "1.0.0",
|
||||||
|
"tailwindcss": "4.1.4",
|
||||||
|
"three": "^0.175.0",
|
||||||
|
"three-stdlib": "^2.36.0",
|
||||||
|
"zustand": "^5.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/compat": "^1.2.8",
|
||||||
|
"@next/eslint-plugin-next": "^15.3.1",
|
||||||
|
"@react-types/shared": "^3.28.0",
|
||||||
|
"@tailwindcss/postcss": "^4.0.14",
|
||||||
|
"@types/mdx": "^2.0.13",
|
||||||
|
"@types/node": "22.14.1",
|
||||||
|
"@types/react": "^19.1.2",
|
||||||
|
"@types/react-dom": "^19.1.2",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
|
"@typescript-eslint/eslint-plugin": "8.30.1",
|
||||||
|
"@typescript-eslint/parser": "8.30.1",
|
||||||
|
"eslint": "^9.25.0",
|
||||||
|
"eslint-config-prettier": "10.1.2",
|
||||||
|
"eslint-plugin-import": "^2.31.0",
|
||||||
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
|
"eslint-plugin-node": "^11.1.0",
|
||||||
|
"eslint-plugin-prettier": "5.2.6",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-unused-imports": "4.1.4",
|
||||||
|
"husky": "^9.1.7",
|
||||||
|
"lint-staged": "^15.5.1",
|
||||||
|
"postcss": "^8.5.3",
|
||||||
|
"prettier": "^3.5.3",
|
||||||
|
"raw-loader": "^4.0.2",
|
||||||
|
"tailwindcss": "4.0.14",
|
||||||
|
"typescript": "5.8.3"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"core-js"
|
||||||
|
],
|
||||||
|
"overrides": {
|
||||||
|
"highlight.js@>=9.0.0 <10.4.1": ">=10.4.1",
|
||||||
|
"highlight.js@<9.18.2": ">=9.18.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac"
|
||||||
|
}
|
11440
lexikon/pnpm-lock.yaml
generated
Normal file
11440
lexikon/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
5
lexikon/postcss.config.mjs
Normal file
5
lexikon/postcss.config.mjs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: ["@tailwindcss/postcss"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
BIN
lexikon/public/assets/examples/hero.jpg
Normal file
BIN
lexikon/public/assets/examples/hero.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.0 MiB |
BIN
lexikon/public/assets/examples/user.png
Normal file
BIN
lexikon/public/assets/examples/user.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.2 MiB |
BIN
lexikon/public/favicon.ico
Normal file
BIN
lexikon/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
28
lexikon/src/app/(auth)/confirm/route.ts
Normal file
28
lexikon/src/app/(auth)/confirm/route.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { type EmailOtpType } from "@supabase/supabase-js";
|
||||||
|
import { type NextRequest } from "next/server";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { supaServer } from "shared/api/supabase/server";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const token_hash = searchParams.get("token_hash");
|
||||||
|
const type = searchParams.get("type") as EmailOtpType | null;
|
||||||
|
const next = searchParams.get("next") ?? "/";
|
||||||
|
|
||||||
|
if (token_hash && type) {
|
||||||
|
const supabase = await supaServer();
|
||||||
|
|
||||||
|
const { error } = await supabase.auth.verifyOtp({
|
||||||
|
type,
|
||||||
|
token_hash,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
// redirect user to specified redirect URL or root of app
|
||||||
|
redirect(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// redirect the user to an error page with some instructions
|
||||||
|
redirect("/error");
|
||||||
|
}
|
189
lexikon/src/app/SideNavContent.tsx
Normal file
189
lexikon/src/app/SideNavContent.tsx
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
"use client";
|
||||||
|
import { Divider, Select, SelectItem, Skeleton } from "@heroui/react";
|
||||||
|
import { AnimatePresence } from "framer-motion";
|
||||||
|
import LibrarianLink from "features/librarian/LibrarianLink";
|
||||||
|
import {
|
||||||
|
CourseListGroupHeader,
|
||||||
|
CourseListItem,
|
||||||
|
} from "features/study/components/CourseList";
|
||||||
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
import SearchField from "shared/ui/SearchField";
|
||||||
|
import { container } from "shared/styles/variants";
|
||||||
|
import useCoursesModulesStore from "features/study/stores/coursesStore";
|
||||||
|
import { CourseModule } from "shared/domain/course";
|
||||||
|
import useTermStore from "features/study/stores/termStore";
|
||||||
|
import { Term } from "shared/domain/term";
|
||||||
|
import { ChevronsUpDown } from "lucide-react";
|
||||||
|
|
||||||
|
export default function SidenavContent() {
|
||||||
|
const { terms, selectedTerm, setSelectedTerm, fetchTerms } = useTermStore();
|
||||||
|
|
||||||
|
// error is now string | null
|
||||||
|
const { coursesModules, isLoading, error, loadCoursesModules } =
|
||||||
|
useCoursesModulesStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Fetch terms on mount
|
||||||
|
fetchTerms();
|
||||||
|
|
||||||
|
// Set the first term as selected if available
|
||||||
|
if (terms.length > 0) {
|
||||||
|
const firstTerm = terms[0];
|
||||||
|
if (!selectedTerm) {
|
||||||
|
setSelectedTerm(firstTerm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [fetchTerms]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedTerm) return;
|
||||||
|
loadCoursesModules(selectedTerm);
|
||||||
|
}, [selectedTerm, loadCoursesModules]);
|
||||||
|
|
||||||
|
// Debounced search value
|
||||||
|
const [searchValue, setSearchValue] = useState("");
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = setTimeout(() => {
|
||||||
|
setDebouncedSearch(searchValue);
|
||||||
|
}, 100); // 100ms debounce
|
||||||
|
return () => clearTimeout(handler);
|
||||||
|
}, [searchValue]);
|
||||||
|
|
||||||
|
const handleSearchChange = useCallback((value: string) => {
|
||||||
|
setSearchValue(value);
|
||||||
|
}, []);
|
||||||
|
const handleSearchClear = useCallback(() => {
|
||||||
|
setSearchValue("");
|
||||||
|
}, []);
|
||||||
|
// Filter modules by debounced search value (case-insensitive, matches name or code)
|
||||||
|
const filteredCoursesModules =
|
||||||
|
coursesModules?.filter((courseModule) => {
|
||||||
|
if (!debouncedSearch.trim()) return true;
|
||||||
|
const q = debouncedSearch.trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
courseModule.module_name.toLowerCase().includes(q) ||
|
||||||
|
(courseModule.module_code?.toLowerCase?.().includes(q) ?? false)
|
||||||
|
);
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
// Separate filtered modules into favorites and others
|
||||||
|
const favoriteCoursesModules: CourseModule[] =
|
||||||
|
filteredCoursesModules.filter(
|
||||||
|
(courseModule) => courseModule.is_user_favorite
|
||||||
|
);
|
||||||
|
const otherCoursesModules: CourseModule[] = filteredCoursesModules.filter(
|
||||||
|
(courseModule) => !courseModule.is_user_favorite
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={container({ dir: "col" })}>
|
||||||
|
<LibrarianLink href="/librarian" />
|
||||||
|
|
||||||
|
{/* Use theme-aware background color for divider */}
|
||||||
|
<Divider className="my-2 bg-default-200" />
|
||||||
|
|
||||||
|
{/* Search Field */}
|
||||||
|
<SearchField
|
||||||
|
placeholder="Type to search"
|
||||||
|
value={searchValue}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
onClear={handleSearchClear}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{terms.length > 0 ? (
|
||||||
|
<TermSelector
|
||||||
|
terms={terms}
|
||||||
|
selectedTerm={selectedTerm}
|
||||||
|
onSelectTerm={setSelectedTerm}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Skeleton
|
||||||
|
className="h-10 w-full rounded-lg mt-4"
|
||||||
|
style={{ marginTop: "0.5rem" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Course List */}
|
||||||
|
<div className="flex flex-col flex-1 w-full shrink-0 gap-2 overflow-y-auto scrollbar-hide">
|
||||||
|
{isLoading && (
|
||||||
|
// Show skeletons while loading
|
||||||
|
<div className="flex flex-col gap-2 px-4">
|
||||||
|
<Skeleton className="h-8 w-3/5 rounded-lg" />
|
||||||
|
<Skeleton className="h-10 w-full rounded-lg" />
|
||||||
|
<Skeleton className="h-10 w-full rounded-lg" />
|
||||||
|
<Skeleton className="h-8 w-3/5 rounded-lg mt-4" />
|
||||||
|
<Skeleton className="h-10 w-full rounded-lg" />
|
||||||
|
<Skeleton className="h-10 w-full rounded-lg" />
|
||||||
|
<Skeleton className="h-10 w-full rounded-lg" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
// Display the error message string
|
||||||
|
<div className="px-4 text-danger">Error: {error}</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && !error && coursesModules && (
|
||||||
|
// Render favorites then all Modules with toggle
|
||||||
|
<>
|
||||||
|
{favoriteCoursesModules.length > 0 && (
|
||||||
|
<>
|
||||||
|
<CourseListGroupHeader title="Favorites" />
|
||||||
|
<AnimatePresence>
|
||||||
|
{favoriteCoursesModules.map((cm, i) => (
|
||||||
|
<CourseListItem
|
||||||
|
courseModule={cm}
|
||||||
|
key={i}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<CourseListGroupHeader title="Courses" />
|
||||||
|
{otherCoursesModules.map((cm, i) => (
|
||||||
|
<CourseListItem courseModule={cm} key={i} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TermSelector = ({
|
||||||
|
terms,
|
||||||
|
selectedTerm,
|
||||||
|
onSelectTerm,
|
||||||
|
}: {
|
||||||
|
terms: Term[];
|
||||||
|
selectedTerm: Term | undefined;
|
||||||
|
onSelectTerm: (term: Term) => void;
|
||||||
|
}) => {
|
||||||
|
const defaultKey = selectedTerm?.semester_id ?? terms[0]?.semester_id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
disableSelectorIconRotation
|
||||||
|
placeholder="Select a term"
|
||||||
|
selectorIcon={<ChevronsUpDown />}
|
||||||
|
selectedKeys={defaultKey !== undefined ? [String(defaultKey)] : []}
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const selectedKey = Array.from(keys)[0];
|
||||||
|
const foundTerm = terms.find(
|
||||||
|
(term) => term.semester_id === Number(selectedKey)
|
||||||
|
);
|
||||||
|
if (foundTerm) {
|
||||||
|
onSelectTerm(foundTerm);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
trigger: "cursor-pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{terms.map((term) => (
|
||||||
|
<SelectItem key={String(term.semester_id)}>
|
||||||
|
{term.semester_code}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
};
|
94
lexikon/src/app/api/(study)/course/route.ts
Normal file
94
lexikon/src/app/api/(study)/course/route.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import { deleteSummary, createSummary, updateSummary } from "@/src/features/study/queries/CRUD-Summaries";
|
||||||
|
import { CourseSummary } from "@/src/shared/domain/summary";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
interface RequestBody {
|
||||||
|
moduleid?: number;
|
||||||
|
termid?: number;
|
||||||
|
newChapterName?: string;
|
||||||
|
summary?: CourseSummary;
|
||||||
|
newContent?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/summaries
|
||||||
|
* Deletes the given chapter summary.
|
||||||
|
*/
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
try {
|
||||||
|
const body: RequestBody = await request.json();
|
||||||
|
|
||||||
|
if (!body.summary) {
|
||||||
|
return NextResponse.json({ error: "Missing summary" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const result = await deleteSummary(body.summary);
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in DELETE /api/summaries:", error);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to delete summary" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/summaries
|
||||||
|
* Creates a new chapter summary.
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body: RequestBody = await request.json();
|
||||||
|
const { moduleid, termid, newChapterName } = body;
|
||||||
|
|
||||||
|
if (!moduleid || !termid || !newChapterName) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing parameters" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const chapter = await createSummary(moduleid, String(termid), newChapterName);
|
||||||
|
|
||||||
|
return NextResponse.json(chapter);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in POST /api/summaries:", error);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to create summary" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/summaries
|
||||||
|
* Updates summary content or renames a chapter.
|
||||||
|
*/
|
||||||
|
export async function PATCH(request: Request) {
|
||||||
|
try {
|
||||||
|
const body: RequestBody = await request.json();
|
||||||
|
|
||||||
|
if (body.summary && body.newContent) {
|
||||||
|
const result = await updateSummary(body.summary, {
|
||||||
|
newName: body.newChapterName,
|
||||||
|
content: body.newContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: "Invalid parameters" }, { status: 400 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in PATCH /api/summaries:", error);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to update summary" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
35
lexikon/src/app/api/(study)/terms/route.ts
Normal file
35
lexikon/src/app/api/(study)/terms/route.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { fetchTerms } from "features/study/queries/CRUD-Terms";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const moduleidParam = url.searchParams.get("moduleid");
|
||||||
|
|
||||||
|
if (!moduleidParam) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing moduleid parameter" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const moduleid = parseInt(moduleidParam, 10);
|
||||||
|
|
||||||
|
if (isNaN(moduleid)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid moduleid parameter" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const terms = await fetchTerms(moduleid);
|
||||||
|
|
||||||
|
return NextResponse.json(terms);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in GET /api/terms:", error);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch terms" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
17
lexikon/src/app/api/librarian/crawler/index/route.ts
Normal file
17
lexikon/src/app/api/librarian/crawler/index/route.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { getMockedMoodleIndex } from "features/librarian/queries/crawlerQueries";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const moodleIndex = await getMockedMoodleIndex();
|
||||||
|
|
||||||
|
return NextResponse.json(moodleIndex ?? []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in GET /api/librarian:", error);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to Get MoodleIndex" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
14
lexikon/src/app/dashboard/page.tsx
Normal file
14
lexikon/src/app/dashboard/page.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@heroui/react";
|
||||||
|
import { emptyBucket } from "shared/api/supabase";
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full w-full items-center justify-center gap-4 p-4">
|
||||||
|
<Button color="danger" onPress={() => emptyBucket()}>
|
||||||
|
Clear Storage Bucket
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
31
lexikon/src/app/error.tsx
Normal file
31
lexikon/src/app/error.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export default function Error({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error;
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
// Log the error to an error reporting service
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
console.error(error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Something went wrong!</h2>
|
||||||
|
<button
|
||||||
|
onClick={
|
||||||
|
// Attempt to recover by trying to re-render the segment
|
||||||
|
() => reset()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
58
lexikon/src/app/layout.tsx
Normal file
58
lexikon/src/app/layout.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import "shared/styles/globals.css";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { Metadata, Viewport } from "next";
|
||||||
|
import { fontSans } from "shared/config/fonts";
|
||||||
|
import { siteConfig } from "shared/config/site";
|
||||||
|
import { Providers } from "./providers";
|
||||||
|
import { SideNav } from "../features/navigation/sidenav/SideNav";
|
||||||
|
import SidenavContent from "./SideNavContent";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: {
|
||||||
|
default: siteConfig.name,
|
||||||
|
template: `%s - ${siteConfig.name}`,
|
||||||
|
},
|
||||||
|
description: siteConfig.description,
|
||||||
|
icons: {
|
||||||
|
icon: "/favicon.ico",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: [
|
||||||
|
{ media: "(prefers-color-scheme: light)", color: "white" },
|
||||||
|
{ media: "(prefers-color-scheme: dark)", color: "black" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html suppressHydrationWarning lang="en">
|
||||||
|
<head />
|
||||||
|
<body
|
||||||
|
className={clsx(
|
||||||
|
"min-h-dvh bg-background font-sans antialiased inset-0 m-0 p-0",
|
||||||
|
fontSans.variable
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Providers
|
||||||
|
themeProps={{ attribute: "class", defaultTheme: "dark" }}
|
||||||
|
>
|
||||||
|
<div className="relative flex w-screen h-dvh max-h-dvh overflow-hidden">
|
||||||
|
<SideNav>
|
||||||
|
<SidenavContent />
|
||||||
|
</SideNav>
|
||||||
|
<main className="flex flex-col grow w-full h-full transition-all duration-300 overflow-hidden pb-16 sm:pb-0">
|
||||||
|
{/* Main content area */}
|
||||||
|
<div className="grow overflow-auto">{children}</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</Providers>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
44
lexikon/src/app/librarian/aisanitizer/page.tsx
Normal file
44
lexikon/src/app/librarian/aisanitizer/page.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
'use client';
|
||||||
|
import { Spinner } from '@heroui/react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import ExtractorDataTable from '../components/output/ExtractorDataTable';
|
||||||
|
export default function Page() {
|
||||||
|
const [latestResult, setLatestResult] = useState<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadLatestResult = async () => {
|
||||||
|
const result = await fetch(
|
||||||
|
`http://localhost:8000/api/runs/AISanitizer/latest`
|
||||||
|
);
|
||||||
|
setLatestResult(await result.json());
|
||||||
|
};
|
||||||
|
loadLatestResult();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!latestResult) {
|
||||||
|
return <Spinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When no data is found, show a message
|
||||||
|
if (
|
||||||
|
latestResult.data === null ||
|
||||||
|
latestResult.data === undefined ||
|
||||||
|
(Array.isArray(latestResult.data) && latestResult.data.length === 0)
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full w-full items-center gap-2 justify-center">
|
||||||
|
<p className="text-lg text-danger-500">No data found.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full w-full">
|
||||||
|
<div className="flex flex-col h-full w-full">
|
||||||
|
{latestResult && (
|
||||||
|
<ExtractorDataTable data={latestResult.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
44
lexikon/src/app/librarian/chunker/page.tsx
Normal file
44
lexikon/src/app/librarian/chunker/page.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
'use client';
|
||||||
|
import { Spinner } from '@heroui/react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import ChunkerDataTable from '../components/output/ChunkerDataTable';
|
||||||
|
export default function Page() {
|
||||||
|
const [latestResult, setLatestResult] = useState<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadLatestResult = async () => {
|
||||||
|
const result = await fetch(
|
||||||
|
`http://localhost:8000/api/runs/Chunker/latest`
|
||||||
|
);
|
||||||
|
setLatestResult(await result.json());
|
||||||
|
};
|
||||||
|
loadLatestResult();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!latestResult) {
|
||||||
|
return <Spinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When no data is found, show a message
|
||||||
|
if (
|
||||||
|
latestResult.data === null ||
|
||||||
|
latestResult.data === undefined ||
|
||||||
|
(Array.isArray(latestResult.data) && latestResult.data.length === 0)
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full w-full items-center gap-2 justify-center">
|
||||||
|
<p className="text-lg text-danger-500">No data found.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full w-full">
|
||||||
|
<div className="flex flex-col h-full w-full">
|
||||||
|
{latestResult && (
|
||||||
|
<ChunkerDataTable chunkerData={latestResult?.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
41
lexikon/src/app/librarian/components/ReuseOutputButton.tsx
Normal file
41
lexikon/src/app/librarian/components/ReuseOutputButton.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@heroui/button';
|
||||||
|
|
||||||
|
export default function ReuseOutputButton({
|
||||||
|
prevRunId,
|
||||||
|
nextWorker,
|
||||||
|
flowId,
|
||||||
|
onStarted,
|
||||||
|
}: {
|
||||||
|
prevRunId: string;
|
||||||
|
nextWorker: string;
|
||||||
|
flowId: string;
|
||||||
|
onStarted?: (runId: string) => void;
|
||||||
|
}) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleClick = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/worker/${nextWorker}/submit/${prevRunId}/chain`,
|
||||||
|
{ method: 'POST' }
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
setLoading(false);
|
||||||
|
if (onStarted && data?.run_id) onStarted(data.run_id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className="mt-3"
|
||||||
|
color="primary"
|
||||||
|
onPress={handleClick}
|
||||||
|
isDisabled={loading}
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? 'Chaining...'
|
||||||
|
: `Reuse Previous Output & Run "${nextWorker}"`}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
'use client';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import MDXPreview from 'shared/ui/markdown/MDXPreview';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000';
|
||||||
|
|
||||||
|
export default function WorkerOutputMarkdown({ runId }: { runId: string }) {
|
||||||
|
const [markdown, setMarkdown] = useState<string>('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchMarkdown = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/runs/${runId}/artifact`);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Failed to fetch markdown');
|
||||||
|
}
|
||||||
|
let md_raw = await res.json();
|
||||||
|
md_raw = md_raw.replace(/\\n/g, '\n');
|
||||||
|
md_raw = md_raw.replace('"', '');
|
||||||
|
setMarkdown(md_raw);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchMarkdown();
|
||||||
|
}, [runId]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div className="text-red-500">Error: {error}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="prose max-w-full">
|
||||||
|
<MDXPreview content={markdown} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
27
lexikon/src/app/librarian/components/WorkerStateChip.tsx
Normal file
27
lexikon/src/app/librarian/components/WorkerStateChip.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
'use client';
|
||||||
|
import { Chip } from '@heroui/react';
|
||||||
|
|
||||||
|
const STATE_STYLES: Record<string, string> = {
|
||||||
|
PENDING: 'bg-gray-400 text-gray-900',
|
||||||
|
RUNNING: 'bg-blue-500 text-white animate-pulse',
|
||||||
|
COMPLETED: 'bg-green-500 text-white',
|
||||||
|
FAILED: 'bg-red-500 text-white',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATE_LABELS: Record<string, string> = {
|
||||||
|
PENDING: 'Pending',
|
||||||
|
RUNNING: 'Running',
|
||||||
|
COMPLETED: 'Completed',
|
||||||
|
FAILED: 'Failed',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WorkerStateChip({ state }: { state: string }) {
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${STATE_STYLES[state] || 'bg-gray-200'}`}
|
||||||
|
title={state}
|
||||||
|
>
|
||||||
|
{STATE_LABELS[state] || state}
|
||||||
|
</Chip>
|
||||||
|
);
|
||||||
|
}
|
164
lexikon/src/app/librarian/components/output/ChunkerDataTable.tsx
Normal file
164
lexikon/src/app/librarian/components/output/ChunkerDataTable.tsx
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectItem,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableColumn,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@heroui/react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface ChunkerData {
|
||||||
|
terms: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
courses: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
chunks: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tokens: number;
|
||||||
|
}[];
|
||||||
|
images: string[];
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChunkerDataTable({
|
||||||
|
chunkerData,
|
||||||
|
}: {
|
||||||
|
chunkerData: ChunkerData;
|
||||||
|
}) {
|
||||||
|
if (!chunkerData || !chunkerData.terms || chunkerData.terms.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 rounded shadow-md">
|
||||||
|
<p className="text-lg text-default-500">No data</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Initialize selected term to the first term in the program
|
||||||
|
const [selectedTerm, setSelectedTerm] = useState<ChunkedTerm | null>(
|
||||||
|
chunkerData.terms[0] || null
|
||||||
|
);
|
||||||
|
|
||||||
|
const [selectedCourse, setSelectedCourse] =
|
||||||
|
useState<ChunkedCourse | null>(null); // Initialize to null
|
||||||
|
|
||||||
|
const [flattenedChunks, setFlattenedChunks] = useState<FlattenedChunk[]>(
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getFlattenedChunks = (course: ChunkedCourse | null) => {
|
||||||
|
return course?.chunks.map((chunk) => ({
|
||||||
|
id: chunk.id,
|
||||||
|
name: chunk.name,
|
||||||
|
tokens: chunk.tokens,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedTerm) {
|
||||||
|
setSelectedCourse(selectedTerm.courses[0] || null);
|
||||||
|
}
|
||||||
|
}, [selectedTerm]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedTerm && selectedCourse) {
|
||||||
|
setFlattenedChunks(getFlattenedChunks(selectedCourse));
|
||||||
|
}
|
||||||
|
}, [selectedCourse]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 rounded shadow-md">
|
||||||
|
{/* Term Selector */}
|
||||||
|
<div className="flex flex-row gap-4">
|
||||||
|
<Select
|
||||||
|
className="mt-1 block w-full rounded-md shadow-sm focus:ring focus:ring-primary text-lg"
|
||||||
|
selectedKeys={selectedTerm ? [selectedTerm.id] : []}
|
||||||
|
label="Select Term"
|
||||||
|
labelPlacement="outside"
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const key = Array.from(keys)[0];
|
||||||
|
setSelectedTerm(
|
||||||
|
typeof key === 'string' ? key : String(key)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{chunkerData.terms.map((term) => (
|
||||||
|
<SelectItem key={term.id} className="text-lg">
|
||||||
|
{term.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Course Selector */}
|
||||||
|
<Select
|
||||||
|
className="mt-1 block w-full rounded-md shadow-sm focus:ring focus:ring-primary text-lg"
|
||||||
|
selectedKeys={selectedCourse ? [selectedCourse.id] : []}
|
||||||
|
label="Select Course"
|
||||||
|
labelPlacement="outside"
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const key = Array.from(keys)[0];
|
||||||
|
const courseId =
|
||||||
|
typeof key === 'string' ? key : String(key);
|
||||||
|
const courseToSet =
|
||||||
|
selectedTerm?.courses.find(
|
||||||
|
(c) => c.id === courseId
|
||||||
|
) || null;
|
||||||
|
setSelectedCourse(courseToSet);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(selectedTerm?.courses || []).map((course) => (
|
||||||
|
<SelectItem key={course.id} className="text-lg">
|
||||||
|
{course.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* Courses Table */}
|
||||||
|
{selectedTerm && selectedCourse && flattenedChunks && (
|
||||||
|
<div className="my-12 overflow-hidden rounded-xl">
|
||||||
|
<div className="overflow-auto max-h-[60dvh]">
|
||||||
|
<Table className="min-w-full">
|
||||||
|
<TableHeader className="bg-default-50 sticky top-0 z-10">
|
||||||
|
<TableColumn className="px-6 py-3 text-left text-xs font-medium text-default-500 uppercase">
|
||||||
|
Name
|
||||||
|
</TableColumn>
|
||||||
|
<TableColumn className="px-6 py-3 text-left text-xs font-medium text-default-500 uppercase">
|
||||||
|
Tokens
|
||||||
|
</TableColumn>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody className="bg-default divide-y divide-default-200 max-w-full">
|
||||||
|
{flattenedChunks.map((chunk, i) => {
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={i}
|
||||||
|
className={`border-default-200`}
|
||||||
|
>
|
||||||
|
<TableCell
|
||||||
|
className={`px-6 whitespace-nowrap truncate max-w-xs text-lg font-semibold`}
|
||||||
|
>
|
||||||
|
{chunk.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
className={`px-6 whitespace-nowrap truncate max-w-xs`}
|
||||||
|
>
|
||||||
|
{chunk.tokens}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,127 @@
|
|||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectItem,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableColumn,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@heroui/react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface DegreeProgram {
|
||||||
|
terms: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
courses: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
hero_image: string;
|
||||||
|
content_ressource_id: string;
|
||||||
|
files: string[];
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DegreeProgramTable({ program }: { program: DegreeProgram }) {
|
||||||
|
if (!program || !program.terms || program.terms.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 rounded shadow-md">
|
||||||
|
<p className="text-lg text-default-500">
|
||||||
|
No data
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Initialize selected term to the first term in the program
|
||||||
|
const [selectedTermId, setSelectedTermId] = useState(
|
||||||
|
program.terms[0]?.id || ''
|
||||||
|
);
|
||||||
|
// Find the term object corresponding to the selected ID
|
||||||
|
const selectedTerm =
|
||||||
|
program.terms.find((term) => term.id === selectedTermId) ||
|
||||||
|
program.terms[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 rounded shadow-md">
|
||||||
|
{/* Term Selector */}
|
||||||
|
<label className="block text-lg font-medium text-default-700">
|
||||||
|
Select Term:
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
className="mt-1 block w-full rounded-md shadow-sm focus:ring focus:ring-primary text-lg"
|
||||||
|
selectedKeys={selectedTermId ? [selectedTermId] : []}
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const key = Array.from(keys)[0];
|
||||||
|
setSelectedTermId(
|
||||||
|
typeof key === 'string' ? key : String(key)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{program.terms.map((term) => (
|
||||||
|
<SelectItem key={term.id} className="text-lg">
|
||||||
|
{term.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Courses Table */}
|
||||||
|
{selectedTerm && (
|
||||||
|
<div className="mt-6 overflow-x-auto">
|
||||||
|
<Table className="min-w-full divide-y divide-default-200">
|
||||||
|
<TableHeader className="bg-default-50">
|
||||||
|
<TableColumn className="px-6 py-3 text-left text-xs font-medium text-default-500 uppercase">
|
||||||
|
Course Name
|
||||||
|
</TableColumn>
|
||||||
|
<TableColumn className="px-6 py-3 text-left text-xs font-medium text-default-500 uppercase">
|
||||||
|
Hero Image
|
||||||
|
</TableColumn>
|
||||||
|
<TableColumn className="px-6 py-3 text-left text-xs font-medium text-default-500 uppercase">
|
||||||
|
Content ID
|
||||||
|
</TableColumn>
|
||||||
|
<TableColumn className="px-6 py-3 text-left text-xs font-medium text-default-500 uppercase">
|
||||||
|
Files
|
||||||
|
</TableColumn>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody className="bg-default divide-y divide-default-200">
|
||||||
|
{selectedTerm.courses.map((course) => (
|
||||||
|
<TableRow key={course.id}>
|
||||||
|
<TableCell className="px-6 py-4 whitespace-nowrap text-lg font-medium">
|
||||||
|
{course.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-6 py-4 whitespace-nowrap">
|
||||||
|
{course.hero_image ? (
|
||||||
|
<img
|
||||||
|
src={course.hero_image}
|
||||||
|
alt={course.name}
|
||||||
|
className="h-16 w-16 object-cover rounded"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-base text-default-500">
|
||||||
|
No image
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-6 py-4 whitespace-nowrap">
|
||||||
|
{course.content_ressource_id}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-6 py-4 whitespace-nowrap">
|
||||||
|
{course.files &&
|
||||||
|
course.files.length > 0 ? (
|
||||||
|
course.files.join(', ')
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-default-500">
|
||||||
|
None
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,93 @@
|
|||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectItem,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableColumn,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@heroui/react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface DownloadData {
|
||||||
|
terms: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
courses: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DownloadDataTable({
|
||||||
|
downloadData,
|
||||||
|
}: {
|
||||||
|
downloadData: DownloadData;
|
||||||
|
}) {
|
||||||
|
if (!downloadData || !downloadData.terms) {
|
||||||
|
return <div>No data available</div>;
|
||||||
|
}
|
||||||
|
// Initialize selected term to the first term in the program
|
||||||
|
const [selectedTermId, setSelectedTermId] = useState(
|
||||||
|
downloadData.terms[0]?.id || ''
|
||||||
|
);
|
||||||
|
// Find the term object corresponding to the selected ID
|
||||||
|
const selectedTerm =
|
||||||
|
downloadData.terms.find((term) => term.id === selectedTermId) ||
|
||||||
|
downloadData.terms[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 rounded shadow-md">
|
||||||
|
{/* Term Selector */}
|
||||||
|
<label className="block text-lg font-medium text-default-700">
|
||||||
|
Select Term:
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
className="mt-1 block w-full rounded-md shadow-sm focus:ring focus:ring-primary text-lg"
|
||||||
|
selectedKeys={selectedTermId ? [selectedTermId] : []}
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const key = Array.from(keys)[0];
|
||||||
|
setSelectedTermId(
|
||||||
|
typeof key === 'string' ? key : String(key)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{downloadData.terms.map((term) => (
|
||||||
|
<SelectItem key={term.id} className="text-lg">
|
||||||
|
{term.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Courses Table */}
|
||||||
|
{selectedTerm && (
|
||||||
|
<div className="mt-6 overflow-x-auto">
|
||||||
|
<Table className="min-w-full divide-y divide-default-200">
|
||||||
|
<TableHeader className="bg-default-50">
|
||||||
|
<TableColumn className="px-6 py-3 text-left text-xs font-medium text-default-500 uppercase">
|
||||||
|
Course Name
|
||||||
|
</TableColumn>
|
||||||
|
<TableColumn className="px-6 py-3 text-left text-xs font-medium text-default-500 uppercase">
|
||||||
|
ID
|
||||||
|
</TableColumn>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody className="bg-default divide-y divide-default-200">
|
||||||
|
{selectedTerm.courses.map((course) => (
|
||||||
|
<TableRow key={course.id}>
|
||||||
|
<TableCell className="px-6 py-4 whitespace-nowrap text-lg font-medium">
|
||||||
|
{course.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-6 py-4 whitespace-nowrap">
|
||||||
|
{course.id}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,238 @@
|
|||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectItem,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableColumn,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@heroui/react';
|
||||||
|
import { FileText, Image } from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
type FlattenedFiles = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
chapter_name: string;
|
||||||
|
type: 'content' | 'media';
|
||||||
|
}[];
|
||||||
|
|
||||||
|
type ExtractedCourse = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
chapters: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
content_files: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
|
media_files: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
interface ExtractData {
|
||||||
|
terms: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
courses: ExtractedCourse[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExtractorDataTable({ data }: { data: ExtractData }) {
|
||||||
|
if (!data || !data.terms || data.terms.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 rounded shadow-md">
|
||||||
|
<p className="text-lg text-default-500">No data</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize selected term to the first term in the program
|
||||||
|
const [selectedTermId, setSelectedTermId] = useState(
|
||||||
|
data.terms[0]?.id || ''
|
||||||
|
);
|
||||||
|
// Find the term object corresponding to the selected ID
|
||||||
|
const selectedTerm =
|
||||||
|
data.terms.find((term) => term.id === selectedTermId) ||
|
||||||
|
(data.terms.length > 0 ? data.terms[0] : null);
|
||||||
|
|
||||||
|
const [selectedCourse, setSelectedCourse] =
|
||||||
|
useState<ExtractedCourse | null>(null); // Initialize to null
|
||||||
|
|
||||||
|
const [flattenedFiles, setFlattenedFiles] = useState<FlattenedFiles>([]);
|
||||||
|
|
||||||
|
// Effect 1: Ensure selectedCourse is valid for the current selectedTerm,
|
||||||
|
// and updates it if the selectedTerm changes and the course becomes invalid,
|
||||||
|
// or to set an initial course.
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedTerm) {
|
||||||
|
const courseIsInCurrentTerm = selectedTerm.courses.some(
|
||||||
|
(c) => c.id === selectedCourse?.id
|
||||||
|
);
|
||||||
|
if (!courseIsInCurrentTerm) {
|
||||||
|
// If selectedCourse is not in the current selectedTerm (e.g., term changed, initial load, or course became invalid)
|
||||||
|
// then set to the first course of the current selectedTerm.
|
||||||
|
setSelectedCourse(selectedTerm.courses[0] || null);
|
||||||
|
}
|
||||||
|
// If courseIsInCurrentTerm is true, it means:
|
||||||
|
// 1. User selected a course within the current term - their selection is preserved.
|
||||||
|
// 2. Term changed, and the previous selectedCourse happens to be valid in the new term - selection preserved.
|
||||||
|
} else {
|
||||||
|
// No term selected (e.g., data.terms is empty)
|
||||||
|
setSelectedCourse(null);
|
||||||
|
}
|
||||||
|
}, [selectedTerm, selectedCourse?.id]); // Re-run if selectedTerm object changes or selectedCourse.id changes
|
||||||
|
|
||||||
|
// Effect 2: Update flattenedFiles when selectedCourse changes
|
||||||
|
useEffect(() => {
|
||||||
|
setFlattenedFiles(getFlattenedFiles(selectedCourse));
|
||||||
|
}, [selectedCourse]); // Only depends on selectedCourse
|
||||||
|
|
||||||
|
// Instead of having a list of chapters, with a list of content and media files, we want to have a single list of files, but keep the chapter name
|
||||||
|
const getFlattenedFiles = (
|
||||||
|
course: ExtractedCourse | null
|
||||||
|
): FlattenedFiles => {
|
||||||
|
const flattenedFiles: FlattenedFiles = [];
|
||||||
|
if (!course || !course.chapters) {
|
||||||
|
return flattenedFiles;
|
||||||
|
}
|
||||||
|
course.chapters.forEach((chapter) => {
|
||||||
|
chapter.content_files.forEach((file) => {
|
||||||
|
flattenedFiles.push({
|
||||||
|
id: file.id,
|
||||||
|
name: file.name,
|
||||||
|
chapter_name: chapter.name,
|
||||||
|
type: 'content',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
chapter.media_files.forEach((file) => {
|
||||||
|
flattenedFiles.push({
|
||||||
|
id: file.id,
|
||||||
|
name: file.name,
|
||||||
|
chapter_name: chapter.name,
|
||||||
|
type: 'media',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return flattenedFiles;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 rounded shadow-md">
|
||||||
|
{/* Term Selector */}
|
||||||
|
<div className="flex flex-row gap-4">
|
||||||
|
<Select
|
||||||
|
className="mt-1 block w-full rounded-md shadow-sm focus:ring focus:ring-primary text-lg"
|
||||||
|
selectedKeys={selectedTermId ? [selectedTermId] : []}
|
||||||
|
label="Select Term"
|
||||||
|
labelPlacement="outside"
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const key = Array.from(keys)[0];
|
||||||
|
setSelectedTermId(
|
||||||
|
typeof key === 'string' ? key : String(key)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.terms.map((term) => (
|
||||||
|
<SelectItem key={term.id} className="text-lg">
|
||||||
|
{term.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Course Selector */}
|
||||||
|
<Select
|
||||||
|
className="mt-1 block w-full rounded-md shadow-sm focus:ring focus:ring-primary text-lg"
|
||||||
|
selectedKeys={selectedCourse ? [selectedCourse.id] : []}
|
||||||
|
label="Select Course"
|
||||||
|
labelPlacement="outside"
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const key = Array.from(keys)[0];
|
||||||
|
const courseId =
|
||||||
|
typeof key === 'string' ? key : String(key);
|
||||||
|
const courseToSet =
|
||||||
|
selectedTerm?.courses.find(
|
||||||
|
(c) => c.id === courseId
|
||||||
|
) || null;
|
||||||
|
setSelectedCourse(courseToSet);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(selectedTerm?.courses || []).map((course) => (
|
||||||
|
<SelectItem key={course.id} className="text-lg">
|
||||||
|
{course.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Courses Table */}
|
||||||
|
{selectedTerm && selectedCourse && (
|
||||||
|
<div className="my-12 overflow-hidden rounded-xl">
|
||||||
|
<div className="overflow-auto max-h-[60dvh]">
|
||||||
|
<Table className="min-w-full">
|
||||||
|
<TableHeader className="bg-default-50 sticky top-0 z-10">
|
||||||
|
<TableColumn className="px-6 py-3 text-left text-xs font-medium text-default-500 uppercase">
|
||||||
|
Name
|
||||||
|
</TableColumn>
|
||||||
|
<TableColumn className="px-6 py-3 text-left text-xs font-medium text-default-500 uppercase">
|
||||||
|
Chapter
|
||||||
|
</TableColumn>
|
||||||
|
<TableColumn className="px-6 py-3 text-left text-xs font-medium text-default-500 uppercase">
|
||||||
|
Type
|
||||||
|
</TableColumn>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody className="bg-default divide-y divide-default-200 max-w-full">
|
||||||
|
{flattenedFiles.map((file, i) => {
|
||||||
|
const isNewChapter =
|
||||||
|
file.chapter_name !=
|
||||||
|
flattenedFiles[i - 1]
|
||||||
|
?.chapter_name && i;
|
||||||
|
const nextChapterNew =
|
||||||
|
flattenedFiles[i + 1]?.chapter_name !=
|
||||||
|
file.chapter_name && i;
|
||||||
|
const pd = isNewChapter
|
||||||
|
? 'py-4 pt-6'
|
||||||
|
: nextChapterNew
|
||||||
|
? 'py-4 pb-6'
|
||||||
|
: 'py-4';
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={i}
|
||||||
|
className={`border-default-200 ${
|
||||||
|
isNewChapter ? 'border-t-2' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<TableCell
|
||||||
|
className={`px-6 whitespace-nowrap truncate max-w-xs text-lg font-semibold ${pd} ${file.type === 'media' ? 'text-secondary' : 'text-primary'}`}
|
||||||
|
>
|
||||||
|
{file.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
className={`px-6 whitespace-nowrap text-default-500 truncate max-w-xs text-sm ${pd}`}
|
||||||
|
>
|
||||||
|
{file.chapter_name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
className={`px-6 whitespace-nowrap truncate max-w-xs ${pd}`}
|
||||||
|
>
|
||||||
|
{file.type === 'content' ? (
|
||||||
|
<FileText className="text-primary" />
|
||||||
|
) : (
|
||||||
|
<Image className="text-secondary" />
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
82
lexikon/src/app/librarian/crawler/page.tsx
Normal file
82
lexikon/src/app/librarian/crawler/page.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
'use client';
|
||||||
|
import { Button, Spinner } from '@heroui/react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import DegreeProgramTable from '../components/output/DegreeProgramTable';
|
||||||
|
export default function Page() {
|
||||||
|
const [latestResult, setLatestResult] = useState<any>(null);
|
||||||
|
const [isCrawling, setIsCrawling] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadLatestResult = async () => {
|
||||||
|
const result = await fetch(
|
||||||
|
`http://localhost:8000/api/runs/Crawler/latest`
|
||||||
|
);
|
||||||
|
setLatestResult(await result.json());
|
||||||
|
};
|
||||||
|
loadLatestResult();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const submitCrawler = async () => {
|
||||||
|
setIsCrawling(true);
|
||||||
|
const body = {
|
||||||
|
data: {
|
||||||
|
name: 'Computational and Data Science',
|
||||||
|
id: '1157',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = await fetch(
|
||||||
|
`http://localhost:8000/api/worker/Crawler/submit`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setLatestResult(await result.json());
|
||||||
|
setIsCrawling(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!latestResult) {
|
||||||
|
return <Spinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When no degree program is found, show a message with a button to start a new crawl
|
||||||
|
if (
|
||||||
|
latestResult.data?.degree_program === null ||
|
||||||
|
latestResult.data?.degree_program === undefined
|
||||||
|
) {
|
||||||
|
return isCrawling ? (
|
||||||
|
<div className="flex flex-col h-full w-full items-center gap-2 justify-center">
|
||||||
|
<>
|
||||||
|
<Spinner />
|
||||||
|
<p className="text-lg text-default-700">
|
||||||
|
Crawling in progress...
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col h-full w-full items-center gap-2 justify-center">
|
||||||
|
<p className="text-lg text-danger-500">
|
||||||
|
No data found. Please start a new crawl.
|
||||||
|
</p>
|
||||||
|
<Button color="primary" onPress={submitCrawler}>
|
||||||
|
Start Crawl
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full w-full overflow-y-scroll">
|
||||||
|
<div className="flex flex-col h-full w-full">
|
||||||
|
{latestResult && (
|
||||||
|
<DegreeProgramTable
|
||||||
|
program={latestResult.data?.degree_program}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
44
lexikon/src/app/librarian/downloader/page.tsx
Normal file
44
lexikon/src/app/librarian/downloader/page.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
'use client';
|
||||||
|
import { Spinner } from '@heroui/react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import DownloadDataTable from '../components/output/DownloadDataTable';
|
||||||
|
export default function Page() {
|
||||||
|
const [latestResult, setLatestResult] = useState<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadLatestResult = async () => {
|
||||||
|
const result = await fetch(
|
||||||
|
`http://localhost:8000/api/runs/Downloader/latest`
|
||||||
|
);
|
||||||
|
setLatestResult(await result.json());
|
||||||
|
};
|
||||||
|
loadLatestResult();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!latestResult) {
|
||||||
|
return <Spinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When no data is found, show a message
|
||||||
|
if (
|
||||||
|
latestResult.data === null ||
|
||||||
|
latestResult.data === undefined ||
|
||||||
|
(Array.isArray(latestResult.data) && latestResult.data.length === 0)
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full w-full items-center gap-2 justify-center">
|
||||||
|
<p className="text-lg text-danger-500">No data found.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full w-full overflow-y-scroll">
|
||||||
|
<div className="flex flex-col h-full w-full">
|
||||||
|
{latestResult && (
|
||||||
|
<DownloadDataTable downloadData={latestResult.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
44
lexikon/src/app/librarian/extractor/page.tsx
Normal file
44
lexikon/src/app/librarian/extractor/page.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
'use client';
|
||||||
|
import { Spinner } from '@heroui/react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import ExtractorDataTable from '../components/output/ExtractorDataTable';
|
||||||
|
export default function Page() {
|
||||||
|
const [latestResult, setLatestResult] = useState<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadLatestResult = async () => {
|
||||||
|
const result = await fetch(
|
||||||
|
`http://localhost:8000/api/runs/Extractor/latest`
|
||||||
|
);
|
||||||
|
setLatestResult(await result.json());
|
||||||
|
};
|
||||||
|
loadLatestResult();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!latestResult) {
|
||||||
|
return <Spinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When no data is found, show a message
|
||||||
|
if (
|
||||||
|
latestResult.data === null ||
|
||||||
|
latestResult.data === undefined ||
|
||||||
|
(Array.isArray(latestResult.data) && latestResult.data.length === 0)
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full w-full items-center gap-2 justify-center">
|
||||||
|
<p className="text-lg text-danger-500">No data found.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full w-full">
|
||||||
|
<div className="flex flex-col h-full w-full">
|
||||||
|
{latestResult && (
|
||||||
|
<ExtractorDataTable data={latestResult?.data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
286
lexikon/src/app/librarian/layout.tsx
Normal file
286
lexikon/src/app/librarian/layout.tsx
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@heroui/button';
|
||||||
|
import { Spinner } from '@heroui/react';
|
||||||
|
import { TopToolbar } from 'features/navigation/TopToolBar';
|
||||||
|
import { ArrowDown, ArrowRight, LinkIcon, Rotate3D } from 'lucide-react'; // Import LinkIcon, ArrowDown, ArrowRight
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
SequenceValidationResult,
|
||||||
|
validateWorkerSequence,
|
||||||
|
} from 'shared/utils/sequenceValidator';
|
||||||
|
|
||||||
|
interface WorkerMeta {
|
||||||
|
name: string;
|
||||||
|
input: string; // The Input Type of the Worker
|
||||||
|
output: string; // The Output Type of the Worker
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
const [url, setUrl] = useState<string>('');
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [workers, setWorkers] = useState<WorkerMeta[]>([]);
|
||||||
|
const [selectedWorkerName, setSelectedWorkerName] = useState<string | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [selectedWorkerIndex, setSelectedWorkerIndex] = useState<number>(-1); // New state for index
|
||||||
|
const [inputOutputColors, setInputOutputColors] = useState<
|
||||||
|
Record<string, string>
|
||||||
|
>({});
|
||||||
|
const [isChaining, setIsChaining] = useState(false); // State for chain button loading
|
||||||
|
|
||||||
|
// Infer selected worker from the route (case-insensitive, match by lowercased name)
|
||||||
|
useEffect(() => {
|
||||||
|
const match = pathname.match(/^\/librarian\/?([^\/?#]*)/i);
|
||||||
|
if (match && match[1] && workers.length > 0) {
|
||||||
|
const routeName = match[1].toLowerCase();
|
||||||
|
const foundIndex = workers.findIndex(
|
||||||
|
(w) => w.name.toLowerCase() === routeName
|
||||||
|
);
|
||||||
|
if (foundIndex !== -1) {
|
||||||
|
setSelectedWorkerName(workers[foundIndex].name);
|
||||||
|
setSelectedWorkerIndex(foundIndex);
|
||||||
|
} else {
|
||||||
|
setSelectedWorkerName(null);
|
||||||
|
setSelectedWorkerIndex(-1);
|
||||||
|
}
|
||||||
|
} else if (workers.length > 0) {
|
||||||
|
setSelectedWorkerName(null); // Or select none if URL is just /librarian
|
||||||
|
setSelectedWorkerIndex(-1);
|
||||||
|
} else {
|
||||||
|
setSelectedWorkerName(null);
|
||||||
|
setSelectedWorkerIndex(-1);
|
||||||
|
}
|
||||||
|
}, [pathname, workers, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchWorkers = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://localhost:8000/api/worker/`);
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error(`Error fetching workers: ${res.status}`);
|
||||||
|
setWorkers([]);
|
||||||
|
setInputOutputColors({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data: WorkerMeta[] = await res.json();
|
||||||
|
if (data && data.length > 0) {
|
||||||
|
const validationResult: SequenceValidationResult =
|
||||||
|
validateWorkerSequence(data);
|
||||||
|
if (validationResult.isValid) {
|
||||||
|
setWorkers(validationResult.sequence);
|
||||||
|
gen_input_output_colors(validationResult.sequence);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
'Failed to build worker sequence:',
|
||||||
|
validationResult.error
|
||||||
|
);
|
||||||
|
setWorkers([]);
|
||||||
|
setInputOutputColors({});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setWorkers([]);
|
||||||
|
setInputOutputColors({});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Exception while fetching workers:', error);
|
||||||
|
setWorkers([]);
|
||||||
|
setInputOutputColors({});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchWorkers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const gen_input_output_colors = (currentWorkers: WorkerMeta[]) => {
|
||||||
|
const new_input_output_colors: Record<string, string> = {};
|
||||||
|
const uniqueTypesSet = new Set<string>();
|
||||||
|
|
||||||
|
currentWorkers.forEach((worker) => {
|
||||||
|
uniqueTypesSet.add(worker.input);
|
||||||
|
uniqueTypesSet.add(worker.output);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort unique types for deterministic color assignment
|
||||||
|
const sortedUniqueTypes = Array.from(uniqueTypesSet).sort();
|
||||||
|
|
||||||
|
const goldenRatioConjugate = 0.618033988749895; // (Math.sqrt(5) - 1) / 2
|
||||||
|
const saturation = 0.7; // Adjusted for better visibility
|
||||||
|
const lightness = 0.55; // Adjusted for better visibility
|
||||||
|
|
||||||
|
sortedUniqueTypes.forEach((type, index) => {
|
||||||
|
// Generate hue systematically
|
||||||
|
const hue = (index * goldenRatioConjugate) % 1;
|
||||||
|
new_input_output_colors[type] = `hsl(${hue * 360}, ${
|
||||||
|
saturation * 100
|
||||||
|
}%, ${lightness * 100}%)`;
|
||||||
|
});
|
||||||
|
setInputOutputColors(new_input_output_colors);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChainOutput = async (
|
||||||
|
currentWorkerName: string,
|
||||||
|
previousWorkerName: string
|
||||||
|
) => {
|
||||||
|
setIsChaining(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`http://localhost:8000/api/worker/${currentWorkerName}/submit/${previousWorkerName}/chain/latest`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorText = await res.text();
|
||||||
|
console.error(
|
||||||
|
`Error chaining output: ${res.status} - ${errorText}`
|
||||||
|
);
|
||||||
|
alert(`Error chaining output: ${res.status} - ${errorText}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Output chained successfully, now refresh the current route
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Exception while chaining output:', error);
|
||||||
|
alert(`An exception occurred while chaining output: ${error}`);
|
||||||
|
} finally {
|
||||||
|
setIsChaining(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-full h-dvh items-center overflow-hidden">
|
||||||
|
<TopToolbar />
|
||||||
|
<div className="flex flex-row h-full w-full">
|
||||||
|
<div className="flex flex-col h-full w-80 border-r border-default-200 justify-start p-6 space-y-4 overflow-y-auto">
|
||||||
|
{workers.length === 0 ? (
|
||||||
|
<div className="flex justify-center items-center h-full">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-0">
|
||||||
|
<li className="flex w-full items-center pb-6">
|
||||||
|
<Button
|
||||||
|
variant="faded"
|
||||||
|
color="secondary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full text-xl"
|
||||||
|
onPress={() => {
|
||||||
|
router.push('/librarian/vspace');
|
||||||
|
}}
|
||||||
|
startContent={<Rotate3D />}
|
||||||
|
>
|
||||||
|
VSpace
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{workers.map((worker, i) => {
|
||||||
|
const isActive = i <= selectedWorkerIndex;
|
||||||
|
const isCurrent = i === selectedWorkerIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="flex items-start mb-2"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center mr-4">
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-center w-8 h-8 rounded-full border-2 transition-all duration-300 ease-in-out ${
|
||||||
|
isActive
|
||||||
|
? 'bg-primary border-primary text-primary-foreground'
|
||||||
|
: 'border-default-400 text-default-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</div>
|
||||||
|
{i < workers.length - 1 && (
|
||||||
|
<div
|
||||||
|
className={`w-px h-full min-h-[7rem] mt-1 transition-all duration-300 ease-in-out ${
|
||||||
|
i < selectedWorkerIndex
|
||||||
|
? 'bg-primary'
|
||||||
|
: 'bg-default-300'
|
||||||
|
}`}
|
||||||
|
></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col pt-1">
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
className={`flex flex-col items-start h-auto py-1 px-3 text-left transition-all duration-300 ease-in-out ${
|
||||||
|
isCurrent
|
||||||
|
? 'text-primary'
|
||||||
|
: 'text-foreground'
|
||||||
|
}`}
|
||||||
|
onPress={() => {
|
||||||
|
router.push(
|
||||||
|
`/librarian/${worker.name.toLowerCase()}`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center text-xs text-default-500">
|
||||||
|
<ArrowDown
|
||||||
|
size={12}
|
||||||
|
className="mr-1"
|
||||||
|
/>{' '}
|
||||||
|
Input: {worker.input}
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-lg my-0.5">
|
||||||
|
{worker.name}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center text-xs text-default-500">
|
||||||
|
<ArrowRight
|
||||||
|
size={12}
|
||||||
|
className="mr-1"
|
||||||
|
/>{' '}
|
||||||
|
Output: {worker.output}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
{isCurrent &&
|
||||||
|
selectedWorkerIndex > 0 &&
|
||||||
|
workers[
|
||||||
|
selectedWorkerIndex - 1
|
||||||
|
] && (
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
size="sm"
|
||||||
|
className="mt-2 self-start ml-3"
|
||||||
|
startContent={
|
||||||
|
<LinkIcon
|
||||||
|
size={14}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
isLoading={isChaining}
|
||||||
|
onPress={() =>
|
||||||
|
handleChainOutput(
|
||||||
|
worker.name,
|
||||||
|
workers[
|
||||||
|
selectedWorkerIndex -
|
||||||
|
1
|
||||||
|
].name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Chain from{' '}
|
||||||
|
{
|
||||||
|
workers[
|
||||||
|
selectedWorkerIndex -
|
||||||
|
1
|
||||||
|
].name
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col flex-1 items-center justify-center overflow-hidden min-h-full">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
3
lexikon/src/app/librarian/page.tsx
Normal file
3
lexikon/src/app/librarian/page.tsx
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export default function Page() {
|
||||||
|
return <div>Please select a worker</div>;
|
||||||
|
}
|
24
lexikon/src/app/librarian/vspace/data.ts
Normal file
24
lexikon/src/app/librarian/vspace/data.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
// src/types.ts
|
||||||
|
export interface DataPoint {
|
||||||
|
chunk_1024_embeddings_id: number;
|
||||||
|
chunk: string;
|
||||||
|
course_id: number;
|
||||||
|
file_id: number;
|
||||||
|
source_file: string | null;
|
||||||
|
file_type: string;
|
||||||
|
object_id: string | null;
|
||||||
|
// embedding: number[]; // Für den Plot nicht direkt benötigt
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
cluster: string; // Als String für kategoriale Farben
|
||||||
|
hover_text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoadedData {
|
||||||
|
columns: string[];
|
||||||
|
index: number[];
|
||||||
|
data: any[][];
|
||||||
|
}
|
14
lexikon/src/app/librarian/vspace/page.tsx
Normal file
14
lexikon/src/app/librarian/vspace/page.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
'use client';
|
||||||
|
import React from 'react';
|
||||||
|
import TsnePlot from '../../../features/tsnePlot/TsnePlot';
|
||||||
|
|
||||||
|
const HomePage: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Optional: Header oder andere UI-Elemente */}
|
||||||
|
<TsnePlot />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HomePage;
|
204
lexikon/src/app/librarian/vspace/page_old.tsx
Normal file
204
lexikon/src/app/librarian/vspace/page_old.tsx
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
'use client'; // Ensure this is at the top if not already present
|
||||||
|
import TsnePlotView, {
|
||||||
|
DataPoint,
|
||||||
|
} from 'features/tsnePlot/tsnePlotView'; // Corrected import path
|
||||||
|
import { kmeans } from 'ml-kmeans'; // Import kmeans
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
// Define a more specific type for your t-SNE data structure if possible
|
||||||
|
interface TsneDataType {
|
||||||
|
columns: string[];
|
||||||
|
index: number[];
|
||||||
|
data: (string | number | number[] | null)[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-defined color palette for clusters
|
||||||
|
const clusterColors: { [key: string]: string } = {};
|
||||||
|
|
||||||
|
// Function to generate a color if not predefined, or get a consistent random color based on clusterId
|
||||||
|
const getClusterColor = (clusterId: string): string => {
|
||||||
|
if (clusterColors[clusterId]) {
|
||||||
|
return clusterColors[clusterId];
|
||||||
|
}
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < clusterId.length; i++) {
|
||||||
|
hash = clusterId.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
hash = hash & hash; // Convert to 32bit integer
|
||||||
|
}
|
||||||
|
const color = `#${`00000${(hash & 0xffffff).toString(16)}`.slice(-6)}`;
|
||||||
|
clusterColors[clusterId] = color; // Cache for future use
|
||||||
|
return color;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to calculate Sum of Squared Errors (SSE)
|
||||||
|
const calculateSSE = (
|
||||||
|
points: number[][],
|
||||||
|
assignments: number[],
|
||||||
|
centroids: number[][]
|
||||||
|
): number => {
|
||||||
|
let sse = 0;
|
||||||
|
points.forEach((point, i) => {
|
||||||
|
const centroid = centroids[assignments[i]];
|
||||||
|
if (point && centroid) {
|
||||||
|
const distance = point.reduce(
|
||||||
|
(sum, val, dim) => sum + (val - centroid[dim]) ** 2,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
sse += distance;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return sse;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to transform data and apply k-means
|
||||||
|
const processDataWithKMeans = (rawData: TsneDataType): DataPoint[] => {
|
||||||
|
console.log(
|
||||||
|
'Raw data for clustering (first 5 items, showing elements 10, 11, 12):',
|
||||||
|
rawData.data.slice(0, 5).map((item) => [item[10], item[11], item[12]])
|
||||||
|
); // Log first 5 position data candidates
|
||||||
|
const positions = rawData.data
|
||||||
|
.map((item) => [item[10], item[11], item[12]] as number[])
|
||||||
|
.filter(
|
||||||
|
(pos) =>
|
||||||
|
Array.isArray(pos) &&
|
||||||
|
pos.length === 3 &&
|
||||||
|
pos.every((p) => typeof p === 'number' && !isNaN(p)) // Ensure they are valid numbers
|
||||||
|
);
|
||||||
|
|
||||||
|
if (positions.length === 0) {
|
||||||
|
console.error('No valid position data found for clustering.');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Elbow method to find optimal k
|
||||||
|
const sseValues: { k: number; sse: number }[] = [];
|
||||||
|
const maxK = Math.min(10, positions.length); // Test up to 10 clusters or number of points
|
||||||
|
console.log(`Running Elbow method for k from 2 to ${maxK}`);
|
||||||
|
|
||||||
|
for (let k = 2; k <= maxK; k++) {
|
||||||
|
try {
|
||||||
|
const ans = kmeans(positions, k, { initialization: 'kmeans++' });
|
||||||
|
const sse = calculateSSE(
|
||||||
|
positions,
|
||||||
|
ans.clusters,
|
||||||
|
ans.centroids // Corrected: ans.centroids is already number[][]
|
||||||
|
);
|
||||||
|
sseValues.push({ k, sse });
|
||||||
|
console.log(`SSE for k=${k}: ${sse}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error running kmeans for k=${k}:`, error);
|
||||||
|
// If k is too large for the number of unique points, kmeans might fail.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let optimalK = maxK > 1 ? 2 : 1; // Default to 2 clusters if possible
|
||||||
|
if (sseValues.length > 1) {
|
||||||
|
// Find the elbow: a simple approach is to look for a significant drop in SSE decrease rate
|
||||||
|
// This is a heuristic. A more robust method might involve analyzing the second derivative or a threshold.
|
||||||
|
let maxDiff = 0;
|
||||||
|
let bestKIndex = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < sseValues.length - 1; i++) {
|
||||||
|
const diff = sseValues[i].sse - sseValues[i + 1].sse;
|
||||||
|
if (i > 0) {
|
||||||
|
const prevDiff = sseValues[i - 1].sse - sseValues[i].sse;
|
||||||
|
// If the current drop is much smaller than the previous one, consider the previous k as elbow
|
||||||
|
if (prevDiff > diff * 2 && prevDiff > maxDiff) {
|
||||||
|
maxDiff = prevDiff;
|
||||||
|
bestKIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If no clear elbow by the above heuristic, pick k where SSE reduction starts to diminish
|
||||||
|
// Or, more simply, pick the k that had the largest single drop from k-1 to k, after the first one.
|
||||||
|
if (bestKIndex === 0 && sseValues.length > 1) {
|
||||||
|
// Fallback if the above heuristic didn't find a clear elbow
|
||||||
|
optimalK = sseValues[0].k; // Start with the first k
|
||||||
|
let largestDrop = 0;
|
||||||
|
for (let i = 0; i < sseValues.length - 1; i++) {
|
||||||
|
const drop = sseValues[i].sse - sseValues[i + 1].sse;
|
||||||
|
if (drop > largestDrop) {
|
||||||
|
largestDrop = drop;
|
||||||
|
optimalK = sseValues[i + 1].k;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (sseValues[bestKIndex]) {
|
||||||
|
optimalK = sseValues[bestKIndex].k;
|
||||||
|
}
|
||||||
|
} else if (sseValues.length === 1) {
|
||||||
|
optimalK = sseValues[0].k;
|
||||||
|
}
|
||||||
|
console.log(`Optimal k determined: ${optimalK}`);
|
||||||
|
|
||||||
|
// Run k-means with optimal k
|
||||||
|
const finalResult = kmeans(positions, optimalK, {
|
||||||
|
initialization: 'kmeans++',
|
||||||
|
});
|
||||||
|
|
||||||
|
let dataPointIndex = 0;
|
||||||
|
return rawData.data.map((item, originalIndex) => {
|
||||||
|
const posArray = [item[10], item[11], item[12]];
|
||||||
|
let clusterId = `cluster-undefined`;
|
||||||
|
let finalPosition: [number, number, number];
|
||||||
|
|
||||||
|
// Check if this point was included in clustering (it might have been filtered out if invalid)
|
||||||
|
if (
|
||||||
|
Array.isArray(posArray) &&
|
||||||
|
posArray.length === 3 &&
|
||||||
|
posArray.every((p) => typeof p === 'number' && !isNaN(p))
|
||||||
|
) {
|
||||||
|
finalPosition = posArray as [number, number, number];
|
||||||
|
if (dataPointIndex < finalResult.clusters.length) {
|
||||||
|
clusterId = `cluster-${finalResult.clusters[dataPointIndex]}`;
|
||||||
|
}
|
||||||
|
dataPointIndex++;
|
||||||
|
} else {
|
||||||
|
// Fallback for invalid or filtered out points
|
||||||
|
finalPosition = [
|
||||||
|
(Math.random() - 0.5) * 10,
|
||||||
|
(Math.random() - 0.5) * 10,
|
||||||
|
(Math.random() - 0.5) * 10,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(item[3] || `point-${originalIndex}`), // item[3] is 'id'
|
||||||
|
position: finalPosition,
|
||||||
|
color: getClusterColor(clusterId),
|
||||||
|
clusterId: clusterId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function VSpacePage() {
|
||||||
|
const [plotData, setPlotData] = useState<DataPoint[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const typedTsneData: TsneDataType = tsneDataJson as TsneDataType;
|
||||||
|
const processedData = processDataWithKMeans(typedTsneData);
|
||||||
|
setPlotData(processedData);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (plotData.length === 0) {
|
||||||
|
return <div>Loading and processing data...</div>; // Or some other loading indicator
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100dvh',
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1 style={{ textAlign: 'center', padding: '20px' }}>
|
||||||
|
VSpace Page with K-Means Clustering
|
||||||
|
</h1>
|
||||||
|
<div style={{ flexGrow: 1 }}>
|
||||||
|
<TsnePlotView data={plotData} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
56347
lexikon/src/app/librarian/vspace/tsne_data.ts
Normal file
56347
lexikon/src/app/librarian/vspace/tsne_data.ts
Normal file
File diff suppressed because it is too large
Load Diff
76
lexikon/src/app/librarian/vspace/tsne_types.ts
Normal file
76
lexikon/src/app/librarian/vspace/tsne_types.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
// TypeScript-Typen für vspace tsne.json
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Definiert die exakten Spaltennamen und deren Reihenfolge.
|
||||||
|
*/
|
||||||
|
type VSpaceColumns = [
|
||||||
|
'chunk_1024_embeddings_id',
|
||||||
|
'chunk',
|
||||||
|
'course_id',
|
||||||
|
'file_id',
|
||||||
|
'source_file',
|
||||||
|
'file_type',
|
||||||
|
'object_id',
|
||||||
|
'embedding',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
'x',
|
||||||
|
'y',
|
||||||
|
'z',
|
||||||
|
'cluster',
|
||||||
|
'hover_text',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repräsentiert eine einzelne Datenzeile als Tupel,
|
||||||
|
* wobei jeder Eintrag dem Typ der entsprechenden Spalte entspricht.
|
||||||
|
*/
|
||||||
|
type DataRowTuple = [
|
||||||
|
number, // chunk_1024_embeddings_id
|
||||||
|
string, // chunk
|
||||||
|
number, // course_id
|
||||||
|
number, // file_id
|
||||||
|
string | null, // source_file
|
||||||
|
string, // file_type
|
||||||
|
string | null, // object_id
|
||||||
|
number[], // embedding
|
||||||
|
string, // created_at (ISO-Datumsstring, z.B. "2024-05-03T09:34:39.000Z")
|
||||||
|
string, // updated_at (ISO-Datumsstring)
|
||||||
|
number, // x
|
||||||
|
number, // y
|
||||||
|
number, // z
|
||||||
|
number, // cluster
|
||||||
|
string, // hover_text
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface für die gesamte Struktur der JSON-Datei.
|
||||||
|
*/
|
||||||
|
export interface VSpaceTSNEData {
|
||||||
|
columns: VSpaceColumns;
|
||||||
|
index: number[];
|
||||||
|
data: DataRowTuple[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface für ein einzelnes Datenelement, wenn es in ein
|
||||||
|
* Objekt umgewandelt wird (Spaltennamen als Schlüssel).
|
||||||
|
* Dies ist oft nützlich für die Verarbeitung der Daten.
|
||||||
|
*/
|
||||||
|
export interface ProcessedDataItem {
|
||||||
|
chunk_1024_embeddings_id: number;
|
||||||
|
chunk: string;
|
||||||
|
course_id: number;
|
||||||
|
file_id: number;
|
||||||
|
source_file: string | null;
|
||||||
|
file_type: string;
|
||||||
|
object_id: string | null;
|
||||||
|
embedding: number[];
|
||||||
|
created_at: string; // Erwäge die Verwendung von `Date`, falls du die Strings parst
|
||||||
|
updated_at: string; // Erwäge die Verwendung von `Date`, falls du die Strings parst
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
cluster: number;
|
||||||
|
hover_text: string;
|
||||||
|
}
|
26
lexikon/src/app/not-found.tsx
Normal file
26
lexikon/src/app/not-found.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "404: Page Not Found",
|
||||||
|
description: "The page you are looking for could not be found.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<main className="flex flex-col items-center justify-center min-h-[100dvh] gap-4 p-6">
|
||||||
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
|
<h1 className="text-4xl font-bold">404 - Page Not Found</h1>
|
||||||
|
<p className="text-lg text-gray-500">
|
||||||
|
The page you are looking for does not exist.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
className="px-4 py-2 text-white transition-colors rounded-md bg-primary hover:bg-primary/80"
|
||||||
|
href="/"
|
||||||
|
>
|
||||||
|
Return Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
9
lexikon/src/app/page.tsx
Normal file
9
lexikon/src/app/page.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import SidenavContent from "./SideNavContent";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-full px-60 py-40 h-full overflow-hidden">
|
||||||
|
<SidenavContent />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
35
lexikon/src/app/providers.tsx
Normal file
35
lexikon/src/app/providers.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ThemeProviderProps } from "next-themes";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { HeroUIProvider } from "@heroui/system";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||||
|
import { ToastProvider } from "@heroui/react";
|
||||||
|
import ResponsiveProvider from "shared/provider/ResponsiveProvider";
|
||||||
|
|
||||||
|
export interface ProvidersProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
themeProps?: ThemeProviderProps;
|
||||||
|
}
|
||||||
|
declare module "@react-types/shared" {
|
||||||
|
interface RouterConfig {
|
||||||
|
routerOptions: NonNullable<
|
||||||
|
Parameters<ReturnType<typeof useRouter>["push"]>[1]
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Providers({ children, themeProps }: ProvidersProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HeroUIProvider navigate={router.push}>
|
||||||
|
<NextThemesProvider {...themeProps}>
|
||||||
|
<ResponsiveProvider>{children}</ResponsiveProvider>
|
||||||
|
<ToastProvider />
|
||||||
|
</NextThemesProvider>
|
||||||
|
</HeroUIProvider>
|
||||||
|
);
|
||||||
|
}
|
41
lexikon/src/app/study/[courseId]/layout.tsx
Normal file
41
lexikon/src/app/study/[courseId]/layout.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { TopToolbar } from "features/navigation/TopToolBar";
|
||||||
|
import CourseActions from "features/study/components/CourseActions";
|
||||||
|
import CoursePath from "features/study/components/CoursePath";
|
||||||
|
import StudyProvider from "features/study/provider/StudyProvider";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { container } from "shared/styles/variants";
|
||||||
|
import { cn } from "shared/utils/tailwind";
|
||||||
|
|
||||||
|
interface CourseLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
// summary: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CourseLayout({ children }: CourseLayoutProps) {
|
||||||
|
const params = useParams<{ courseId: string }>();
|
||||||
|
const courseId = params.courseId;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"flex flex-col w-full h-full items-center overflow-hidden"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<StudyProvider courseId={Number(courseId)}>
|
||||||
|
<TopToolbar>
|
||||||
|
<CoursePath />
|
||||||
|
<CourseActions />
|
||||||
|
</TopToolbar>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
container({ dir: "col" }),
|
||||||
|
"flex-1 min-h-0 justify-start overflow-y-auto scrollbar-hide"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</StudyProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
40
lexikon/src/app/study/[courseId]/page.tsx
Normal file
40
lexikon/src/app/study/[courseId]/page.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
import { Spinner } from "@heroui/react";
|
||||||
|
import CourseHero from "features/study/components/CourseHero";
|
||||||
|
import { StudyContext } from "features/study/provider/StudyProvider";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useContext, useEffect } from "react";
|
||||||
|
|
||||||
|
export default function CoursePage() {
|
||||||
|
const { course, summaries } = useContext(StudyContext)!;
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (course && summaries.length > 0) {
|
||||||
|
// Redirect to first summary
|
||||||
|
const firstSummary = summaries[0];
|
||||||
|
router.push(
|
||||||
|
`/study/${course.course_id}/summary/${firstSummary.summary_id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [course, summaries, router]);
|
||||||
|
|
||||||
|
if (!course) return <Spinner />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full h-full flex-col items-center justify-center grow">
|
||||||
|
{summaries.length === 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="absolute text-primary-500 font-semibold bg-primary/20 p-12 backdrop-blur-xl rounded-2xl text-lg text-center z-10">
|
||||||
|
This course has no summaries yet.
|
||||||
|
<br />
|
||||||
|
Create a new summary to get started.
|
||||||
|
</div>
|
||||||
|
<CourseHero full />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Spinner />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
import { CourseContent } from "features/study/components/CourseContent";
|
||||||
|
import CourseEdit from "features/study/components/CourseEdit";
|
||||||
|
import CourseHero from "features/study/components/CourseHero";
|
||||||
|
import { useSummary } from "features/study/hooks/useSummary";
|
||||||
|
import { StudyContext } from "features/study/provider/StudyProvider";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { useContext, useEffect } from "react";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const ctx = useContext(StudyContext);
|
||||||
|
if (!ctx)
|
||||||
|
throw new Error("CourseActions must be used within StudyProvider");
|
||||||
|
|
||||||
|
const params = useParams<{ summaryId: string }>();
|
||||||
|
const summaryId = params.summaryId;
|
||||||
|
const { selectedSummary, mode } = ctx;
|
||||||
|
const { content } = useSummary(selectedSummary);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Update the URL when the selected summary changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedSummary) {
|
||||||
|
// Find the summary with the same ID as the one in the URL
|
||||||
|
const found = ctx.summaries.find(
|
||||||
|
(s) => s.summary_id === Number(summaryId)
|
||||||
|
);
|
||||||
|
if (found) {
|
||||||
|
ctx.setSelectedSummary(found);
|
||||||
|
} else {
|
||||||
|
// If no summary is found, redirect to the first summary
|
||||||
|
const firstSummary = ctx.summaries[0];
|
||||||
|
if (firstSummary) {
|
||||||
|
ctx.setSelectedSummary(firstSummary);
|
||||||
|
router.replace(
|
||||||
|
`/study/${ctx.course?.course_id}/summary/${firstSummary.summary_id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = selectedSummary.summary_id;
|
||||||
|
if (id === undefined || id === Number(summaryId)) return;
|
||||||
|
|
||||||
|
router.replace(`/study/${ctx.course?.course_id}/summary/${id}`);
|
||||||
|
}, [selectedSummary, summaryId, ctx.course?.course_id, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{mode === "edit" ? (
|
||||||
|
<CourseEdit />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CourseHero />
|
||||||
|
<CourseContent value={content} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
171
lexikon/src/features/librarian/LibrarianLink.tsx
Normal file
171
lexikon/src/features/librarian/LibrarianLink.tsx
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useRef, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Librarian" button — encyclopedia letters cycling in hover background
|
||||||
|
* Letters change only on hover via randomString fill, reactively masked by cursor position.
|
||||||
|
*/
|
||||||
|
export default function LibrarianLink({ href }: { href: string }) {
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const lettersRef = useRef<HTMLDivElement>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Character generators
|
||||||
|
const chars =
|
||||||
|
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789αβγδεζηθικλμξπρστυφχψωΓΔΘΛΞΠΣΦΨΩ";
|
||||||
|
const randomChar = () => chars[Math.floor(Math.random() * chars.length)];
|
||||||
|
const randomString = (length: number) =>
|
||||||
|
Array.from({ length })
|
||||||
|
.map(() => randomChar())
|
||||||
|
.join(" "); // space after each char for wrapping
|
||||||
|
|
||||||
|
// Track pointer for CSS mask and update letters on move
|
||||||
|
useEffect(() => {
|
||||||
|
const btn = buttonRef.current;
|
||||||
|
const letters = lettersRef.current;
|
||||||
|
|
||||||
|
if (!btn || !letters) return;
|
||||||
|
|
||||||
|
// Accept either PointerEvent or Touch
|
||||||
|
const handleMove = (e: PointerEvent | Touch) => {
|
||||||
|
const rect = btn.getBoundingClientRect();
|
||||||
|
// Access clientX/Y which are present on both types
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
|
||||||
|
letters.style.setProperty("--x", `${x}px`);
|
||||||
|
letters.style.setProperty("--y", `${y}px`);
|
||||||
|
letters.innerText = randomString(1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handler specifically for touch events
|
||||||
|
const handleTouchMove = (e: TouchEvent) => {
|
||||||
|
// Prevent default scroll behavior if needed
|
||||||
|
// e.preventDefault();
|
||||||
|
if (e.touches.length > 0) {
|
||||||
|
handleMove(e.touches[0]); // Pass the first touch point
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Use named functions for add/remove event listeners
|
||||||
|
const handlePointerEnter = () => { letters.style.opacity = "1"; };
|
||||||
|
const handlePointerLeave = () => { letters.style.opacity = "0"; };
|
||||||
|
|
||||||
|
btn.addEventListener("pointerenter", handlePointerEnter);
|
||||||
|
btn.addEventListener("pointerleave", handlePointerLeave);
|
||||||
|
btn.addEventListener("pointermove", handleMove);
|
||||||
|
// Mark touchmove as passive for better performance
|
||||||
|
btn.addEventListener("touchmove", handleTouchMove, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
btn.removeEventListener("pointerenter", handlePointerEnter);
|
||||||
|
btn.removeEventListener("pointerleave", handlePointerLeave);
|
||||||
|
btn.removeEventListener("pointermove", handleMove);
|
||||||
|
btn.removeEventListener("touchmove", handleTouchMove);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 3D tilt feedback
|
||||||
|
useEffect(() => {
|
||||||
|
const btn = buttonRef.current;
|
||||||
|
|
||||||
|
if (!btn) return;
|
||||||
|
const handle3D = (e: PointerEvent) => {
|
||||||
|
const rect = btn.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
const midX = rect.width / 2;
|
||||||
|
const midY = rect.height / 2;
|
||||||
|
const rotY = ((x - midX) / midX) * 12;
|
||||||
|
const rotX = ((midY - y) / midY) * 12;
|
||||||
|
|
||||||
|
btn.style.transform = `perspective(800px) rotateX(${rotX}deg) rotateY(${rotY}deg)`;
|
||||||
|
};
|
||||||
|
const reset3D = () => {
|
||||||
|
btn.style.transform = `perspective(800px) rotateX(0deg) rotateY(0deg)`;
|
||||||
|
};
|
||||||
|
|
||||||
|
btn.addEventListener("pointermove", handle3D);
|
||||||
|
btn.addEventListener("pointerleave", reset3D);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
btn.removeEventListener("pointermove", handle3D);
|
||||||
|
btn.removeEventListener("pointerleave", reset3D);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Navigate to href on click
|
||||||
|
const handleNavigate = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
router.push(href);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Click ripple
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
const btn = buttonRef.current;
|
||||||
|
|
||||||
|
if (!btn) return;
|
||||||
|
const circle = document.createElement("span");
|
||||||
|
const rect = btn.getBoundingClientRect();
|
||||||
|
const size = Math.max(rect.width, rect.height);
|
||||||
|
const x = e.clientX - rect.left - size / 2;
|
||||||
|
const y = e.clientY - rect.top - size / 2;
|
||||||
|
|
||||||
|
circle.className = "ripple";
|
||||||
|
Object.assign(circle.style, {
|
||||||
|
width: `${size}px`,
|
||||||
|
height: `${size}px`,
|
||||||
|
left: `${x}px`,
|
||||||
|
top: `${y}px`,
|
||||||
|
});
|
||||||
|
btn.appendChild(circle);
|
||||||
|
setTimeout(() => btn.removeChild(circle), 600);
|
||||||
|
|
||||||
|
// Navigate to href
|
||||||
|
handleNavigate(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
// TODO: Add Background color, because it is transparent and looks awful in light mode
|
||||||
|
<div className="flex items-center justify-center pt-2">
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
className="relative overflow-hidden px-6 py-4 rounded-xl font-semibold tracking-widest text-cyan-100 shadow-[0_0_45px_rgba(0,255,255,0.15)] transition-transform focus:outline-none backdrop-blur-sm group"
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
{/* Cycling letters overlay */}
|
||||||
|
<div
|
||||||
|
ref={lettersRef}
|
||||||
|
className="absolute inset-0 p-1 text-xs leading-tight text-white break-all opacity-0 transition-opacity duration-300 group-hover:text-cyan-200"
|
||||||
|
style={{
|
||||||
|
WebkitMaskImage: `radial-gradient(circle at var(--x, 50%) var(--y, 50%), rgba(255,255,255,1) 20%, rgba(255,255,255,0.25) 50%, transparent)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Label */}
|
||||||
|
<span className="relative z-10 select-none text-lg md:text-xl lg:text-2xl">
|
||||||
|
Librarian
|
||||||
|
</span>
|
||||||
|
<style jsx>{`
|
||||||
|
.ripple {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 255, 255, 0.4);
|
||||||
|
transform: scale(0);
|
||||||
|
animation: rippleEffect 0.6s ease-out;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
@keyframes rippleEffect {
|
||||||
|
to {
|
||||||
|
transform: scale(4);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
85
lexikon/src/features/librarian/components/IndexTable.tsx
Normal file
85
lexikon/src/features/librarian/components/IndexTable.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableColumn,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
Select,
|
||||||
|
SelectItem,
|
||||||
|
Image,
|
||||||
|
} from "@heroui/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { example_hero_image } from "shared/config/mockData";
|
||||||
|
import { MoodleIndex, TermIndex } from "shared/domain/librarian";
|
||||||
|
import { container } from "shared/styles/variants";
|
||||||
|
|
||||||
|
const tableHeaders = ["id", "name", "image"];
|
||||||
|
const fallbackImage = example_hero_image;
|
||||||
|
|
||||||
|
export default function IndexTable({ index }: { index: MoodleIndex }) {
|
||||||
|
const degProg = index.degree_program;
|
||||||
|
const [selectedSemesterId, setSelectedSemesterId] = useState<string>(
|
||||||
|
degProg.semesters[0]?.id ?? ""
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the selected semester object
|
||||||
|
const selectedSemester: TermIndex | undefined = degProg.semesters.find(
|
||||||
|
(s) => s.id === selectedSemesterId
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={container({ dir: "col" })}>
|
||||||
|
<Select
|
||||||
|
label="Semester"
|
||||||
|
selectedKeys={
|
||||||
|
selectedSemesterId ? [selectedSemesterId] : []
|
||||||
|
}
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
// keys is a Set<string | number> in NextUI/HeroUI
|
||||||
|
const id = Array.from(keys)[0];
|
||||||
|
// Ensure the id is treated as a string
|
||||||
|
setSelectedSemesterId(String(id));
|
||||||
|
}}
|
||||||
|
className="max-w-xs mb-4"
|
||||||
|
>
|
||||||
|
{degProg.semesters.map((term) => (
|
||||||
|
// Removed the unsupported 'data' prop
|
||||||
|
<SelectItem key={term.id}>{term.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Table selectionMode="multiple">
|
||||||
|
<TableHeader>
|
||||||
|
{tableHeaders.map((header) => (
|
||||||
|
<TableColumn key={header}>{header}</TableColumn>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{/* Provide an empty array fallback if selectedSemester is undefined */}
|
||||||
|
{(selectedSemester?.courses ?? []).map((course) => (
|
||||||
|
<TableRow key={course.name}>
|
||||||
|
<TableCell>{course.id}</TableCell>
|
||||||
|
<TableCell>{course.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex justify-center items-center w-full h-full">
|
||||||
|
<Image
|
||||||
|
src={
|
||||||
|
course.hero_image ||
|
||||||
|
fallbackImage
|
||||||
|
}
|
||||||
|
alt={course.name}
|
||||||
|
className="w-16 h-16 object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
137
lexikon/src/features/librarian/mocks.ts
Normal file
137
lexikon/src/features/librarian/mocks.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { MoodleIndex } from 'shared/domain/librarian/moodleIndex';
|
||||||
|
|
||||||
|
const MOODLE_INDEX_MOCK_DATA: MoodleIndex = {
|
||||||
|
degree_program: {
|
||||||
|
id: '1157',
|
||||||
|
name: 'Computational and Data Science',
|
||||||
|
semesters: [
|
||||||
|
{
|
||||||
|
id: '1745',
|
||||||
|
name: 'FS25',
|
||||||
|
courses: [
|
||||||
|
{
|
||||||
|
id: '18863',
|
||||||
|
name: 'Programmierung und Prompt Engineering II',
|
||||||
|
activity_type: '',
|
||||||
|
hero_image:
|
||||||
|
'https://moodle.fhgr.ch/pluginfile.php/1159522/course/overviewfiles/PythonBooks.PNG',
|
||||||
|
content_ressource_id: '1159522',
|
||||||
|
files: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '18240',
|
||||||
|
name: 'Effiziente Algorithmen',
|
||||||
|
activity_type: '',
|
||||||
|
hero_image: '',
|
||||||
|
content_ressource_id: '1125554',
|
||||||
|
files: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '18237',
|
||||||
|
name: 'Mathematik II',
|
||||||
|
activity_type: '',
|
||||||
|
hero_image:
|
||||||
|
'https://moodle.fhgr.ch/pluginfile.php/1125458/course/overviewfiles/Integration_Differential_b.png',
|
||||||
|
content_ressource_id: '1125458',
|
||||||
|
files: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '18236',
|
||||||
|
name: '2025 FS FHGR CDS Numerische Methoden',
|
||||||
|
activity_type: '',
|
||||||
|
hero_image: '',
|
||||||
|
content_ressource_id: '1125426',
|
||||||
|
files: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '18228',
|
||||||
|
name: 'Datenbanken und Datenverarbeitung',
|
||||||
|
activity_type: '',
|
||||||
|
hero_image: '',
|
||||||
|
content_ressource_id: '1125170',
|
||||||
|
files: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '1746',
|
||||||
|
name: 'HS24',
|
||||||
|
courses: [
|
||||||
|
{
|
||||||
|
id: '18030',
|
||||||
|
name: 'Bootcamp Wissenschaftliches Arbeiten',
|
||||||
|
activity_type: '',
|
||||||
|
hero_image: '',
|
||||||
|
content_ressource_id: '1090544',
|
||||||
|
files: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '17527',
|
||||||
|
name: 'Einführung in Data Science',
|
||||||
|
activity_type: '',
|
||||||
|
hero_image:
|
||||||
|
'https://moodle.fhgr.ch/pluginfile.php/1059194/course/overviewfiles/cds1010.jpg',
|
||||||
|
content_ressource_id: '1059194',
|
||||||
|
files: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '17526',
|
||||||
|
name: 'Einführung in Computational Science',
|
||||||
|
activity_type: '',
|
||||||
|
hero_image:
|
||||||
|
'https://moodle.fhgr.ch/pluginfile.php/1059162/course/overviewfiles/cds_intro_sim.jpg',
|
||||||
|
content_ressource_id: '1059162',
|
||||||
|
files: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '17525',
|
||||||
|
name: 'Mathematik I',
|
||||||
|
activity_type: '',
|
||||||
|
hero_image:
|
||||||
|
'https://moodle.fhgr.ch/pluginfile.php/1059130/course/overviewfiles/AdobeStock_452512134.png',
|
||||||
|
content_ressource_id: '1059130',
|
||||||
|
files: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '17507',
|
||||||
|
name: 'Programmierung und Prompt Engineering',
|
||||||
|
activity_type: '',
|
||||||
|
hero_image:
|
||||||
|
'https://moodle.fhgr.ch/pluginfile.php/1058554/course/overviewfiles/10714013_33861.jpg',
|
||||||
|
content_ressource_id: '1058554',
|
||||||
|
files: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '17505',
|
||||||
|
name: 'Algorithmen und Datenstrukturen',
|
||||||
|
activity_type: '',
|
||||||
|
hero_image:
|
||||||
|
'https://moodle.fhgr.ch/pluginfile.php/1058490/course/overviewfiles/Bild1.png',
|
||||||
|
content_ressource_id: '1058490',
|
||||||
|
files: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '17503',
|
||||||
|
name: 'Computer Science',
|
||||||
|
activity_type: '',
|
||||||
|
hero_image:
|
||||||
|
'https://moodle.fhgr.ch/pluginfile.php/1058426/course/overviewfiles/Titelbild.jpg',
|
||||||
|
content_ressource_id: '1058426',
|
||||||
|
files: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
timestamp: '2025-04-27T14:20:11.354825+00:00',
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: Remove this function when the crawler is ready
|
||||||
|
export async function getMockedMoodleIndex(): Promise<MoodleIndex | undefined> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
// Return a copy to prevent accidental mutation of the mock data
|
||||||
|
return resolve(JSON.parse(JSON.stringify(MOODLE_INDEX_MOCK_DATA)));
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
}
|
30
lexikon/src/features/librarian/queries.ts
Normal file
30
lexikon/src/features/librarian/queries.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { MoodleIndex } from 'shared/domain/librarian/moodleIndex';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000';
|
||||||
|
|
||||||
|
async function fetchMoodleIndexFromApi(): Promise<MoodleIndex | undefined> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/librarian/moodle-index`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch moodle index');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching moodle index:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMoodleIndex(): Promise<MoodleIndex | undefined> {
|
||||||
|
return fetchMoodleIndexFromApi();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMoodleIndex() {
|
||||||
|
return useQuery<MoodleIndex | undefined, Error>({
|
||||||
|
queryKey: ['moodleIndex'],
|
||||||
|
queryFn: getMoodleIndex,
|
||||||
|
});
|
||||||
|
}
|
104
lexikon/src/features/librarian/tasks/api.ts
Normal file
104
lexikon/src/features/librarian/tasks/api.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { LOCAL_API_BASE } from 'shared/config/api';
|
||||||
|
import {
|
||||||
|
TaskInfo,
|
||||||
|
DownloadRequestCourse,
|
||||||
|
} from 'shared/domain/librarian/task';
|
||||||
|
|
||||||
|
type SummaryResponse = any;
|
||||||
|
|
||||||
|
async function startTaskApi<TRequest>(
|
||||||
|
endpoint: string,
|
||||||
|
payload: TRequest
|
||||||
|
): Promise<TaskInfo> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${LOCAL_API_BASE}/${endpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error(
|
||||||
|
`API Error starting task ${endpoint}: ${response.status} ${response.statusText}`,
|
||||||
|
errorText
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`Failed to start task ${endpoint}: ${response.statusText}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error starting task:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTaskStatusApi(
|
||||||
|
endpoint: string,
|
||||||
|
taskId: string
|
||||||
|
): Promise<TaskInfo> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${LOCAL_API_BASE}/${endpoint}/status/${taskId}`
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error(
|
||||||
|
`API Error getting status for ${endpoint} task ${taskId}: ${response.status} ${response.statusText}`,
|
||||||
|
errorText
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`Failed to get status for task ${endpoint}/${taskId}: ${response.statusText}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting task status:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const taskApi = {
|
||||||
|
startCrawl: async (noCache: boolean = false): Promise<TaskInfo> => {
|
||||||
|
return startTaskApi('crawl', { no_cache: noCache });
|
||||||
|
},
|
||||||
|
|
||||||
|
getCrawlStatus: async (taskId: string): Promise<TaskInfo> => {
|
||||||
|
return getTaskStatusApi('crawl', taskId);
|
||||||
|
},
|
||||||
|
|
||||||
|
startDownload: async (
|
||||||
|
courses: DownloadRequestCourse[]
|
||||||
|
): Promise<TaskInfo> => {
|
||||||
|
return startTaskApi('download', { courses });
|
||||||
|
},
|
||||||
|
|
||||||
|
getDownloadStatus: async (taskId: string): Promise<TaskInfo> => {
|
||||||
|
return getTaskStatusApi('download', taskId);
|
||||||
|
},
|
||||||
|
|
||||||
|
getSummary: async (
|
||||||
|
termId: string,
|
||||||
|
courseId: string
|
||||||
|
): Promise<SummaryResponse> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${LOCAL_API_BASE}/summaries/${termId}/${courseId}`
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error(
|
||||||
|
`API Error getting summary for ${termId}/${courseId}: ${response.status} ${response.statusText}`,
|
||||||
|
errorText
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`Failed to get summary for ${termId}/${courseId}: ${response.statusText}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting summary:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
@ -0,0 +1,77 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Progress } from '@heroui/react';
|
||||||
|
import { useTaskStatus } from '../hooks';
|
||||||
|
import { taskHelpers } from '../helpers';
|
||||||
|
|
||||||
|
interface TaskProgressProps {
|
||||||
|
taskId: string;
|
||||||
|
taskType: string;
|
||||||
|
progressAriaLabel?: string;
|
||||||
|
onComplete?: (status: 'success' | 'error', error?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TaskProgress({
|
||||||
|
taskId,
|
||||||
|
taskType,
|
||||||
|
progressAriaLabel = 'Task progress',
|
||||||
|
onComplete,
|
||||||
|
}: TaskProgressProps) {
|
||||||
|
const { taskInfo, error } = useTaskStatus(taskId, taskType);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (taskInfo && taskHelpers.isTaskComplete(taskInfo) && onComplete) {
|
||||||
|
onComplete(taskInfo.state, taskHelpers.formatTaskError(taskInfo));
|
||||||
|
}
|
||||||
|
}, [taskInfo, onComplete]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-full items-center justify-center gap-4 p-4">
|
||||||
|
<p>{`Error: ${error.message}`}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!taskInfo) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-full items-center justify-center gap-4 p-4">
|
||||||
|
<p>Loading task information...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (taskInfo.state === 'running') {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-full items-center justify-center gap-4 p-4">
|
||||||
|
<Progress
|
||||||
|
value={taskHelpers.getProgressPercentage(taskInfo)}
|
||||||
|
showValueLabel
|
||||||
|
className="w-full"
|
||||||
|
aria-label={progressAriaLabel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (taskInfo.state === 'error') {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-full items-center justify-center gap-4 p-4">
|
||||||
|
<p>{taskHelpers.formatTaskError(taskInfo)}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (taskInfo.state === 'success') {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-full items-center justify-center gap-4 p-4">
|
||||||
|
<p>Task completed successfully.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-full items-center justify-center gap-4 p-4">
|
||||||
|
<p>Task status: {taskInfo.state}.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
19
lexikon/src/features/librarian/tasks/helpers.ts
Normal file
19
lexikon/src/features/librarian/tasks/helpers.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { TaskInfo } from 'shared/domain/librarian/task';
|
||||||
|
|
||||||
|
export const taskHelpers = {
|
||||||
|
isTaskComplete: (taskInfo: TaskInfo): boolean => {
|
||||||
|
return taskInfo.state === 'success' || taskInfo.state === 'error';
|
||||||
|
},
|
||||||
|
|
||||||
|
getProgressPercentage: (taskInfo: TaskInfo): number => {
|
||||||
|
return taskInfo.progress ? Math.round(taskInfo.progress * 100) : 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
formatTaskError: (taskInfo: TaskInfo): string => {
|
||||||
|
return taskInfo.detail || 'An unknown error occurred';
|
||||||
|
},
|
||||||
|
|
||||||
|
getDownloadLinks: (taskInfo: TaskInfo): string[] => {
|
||||||
|
return taskInfo.download_links || [];
|
||||||
|
},
|
||||||
|
};
|
44
lexikon/src/features/librarian/tasks/hooks.ts
Normal file
44
lexikon/src/features/librarian/tasks/hooks.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { TaskInfo } from 'shared/domain/librarian/task';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { taskApi } from './api';
|
||||||
|
|
||||||
|
export function useTaskStatus(taskId: string | null, taskType: string) {
|
||||||
|
const [taskInfo, setTaskInfo] = useState<TaskInfo | null>(null);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const handleUpdate = useCallback((info: TaskInfo) => {
|
||||||
|
setTaskInfo(info);
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleError = useCallback((err: any) => {
|
||||||
|
console.error('TaskWebSocket Error:', err);
|
||||||
|
const newError =
|
||||||
|
err instanceof Error
|
||||||
|
? err
|
||||||
|
: new Error(
|
||||||
|
String(err.message || 'WebSocket connection error')
|
||||||
|
);
|
||||||
|
setError(newError);
|
||||||
|
setTaskInfo(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { data, isError, error: queryError } = useQuery(
|
||||||
|
['taskStatus', taskId, taskType],
|
||||||
|
() => taskApi.getTaskStatusApi(taskType, taskId!),
|
||||||
|
{
|
||||||
|
enabled: !!taskId,
|
||||||
|
onSuccess: handleUpdate,
|
||||||
|
onError: handleError,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isError) {
|
||||||
|
handleError(queryError);
|
||||||
|
}
|
||||||
|
}, [isError, queryError, handleError]);
|
||||||
|
|
||||||
|
return { taskInfo: data || taskInfo, error };
|
||||||
|
}
|
70
lexikon/src/features/librarian/tasks/websocket.ts
Normal file
70
lexikon/src/features/librarian/tasks/websocket.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { LOCAL_API_BASE } from 'shared/config/api';
|
||||||
|
import { TaskInfo } from 'shared/domain/librarian/task';
|
||||||
|
|
||||||
|
export class TaskWebSocket {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private taskId: string;
|
||||||
|
private taskType: string;
|
||||||
|
private onUpdate: (taskInfo: TaskInfo) => void;
|
||||||
|
private onError: (error: any) => void;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
taskId: string,
|
||||||
|
taskType: string,
|
||||||
|
onUpdate: (taskInfo: TaskInfo) => void,
|
||||||
|
onError: (error: any) => void
|
||||||
|
) {
|
||||||
|
this.taskId = taskId;
|
||||||
|
this.taskType = taskType;
|
||||||
|
this.onUpdate = onUpdate;
|
||||||
|
this.onError = onError;
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.ws = new WebSocket(
|
||||||
|
`ws://${LOCAL_API_BASE.replace(/^http/, 'ws')}/${this.taskType}/ws/${this.taskId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const taskInfo = JSON.parse(event.data);
|
||||||
|
this.onUpdate(taskInfo);
|
||||||
|
|
||||||
|
if (
|
||||||
|
taskInfo.state === 'success' ||
|
||||||
|
taskInfo.state === 'error'
|
||||||
|
) {
|
||||||
|
this.disconnect();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.onError(new Error('Failed to parse WebSocket message'));
|
||||||
|
this.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = (errorEvent) => {
|
||||||
|
const error = new Error('WebSocket error');
|
||||||
|
(error as any).event = errorEvent;
|
||||||
|
this.onError(error);
|
||||||
|
this.disconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = (event) => {
|
||||||
|
if (event.wasClean === false) {
|
||||||
|
this.onError(
|
||||||
|
new Error(
|
||||||
|
`WebSocket closed unexpectedly: ${event.code} ${event.reason || ''}`.trim()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.ws = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
19
lexikon/src/features/navigation/ToolBar.tsx
Normal file
19
lexikon/src/features/navigation/ToolBar.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type ReactNode } from "react";
|
||||||
|
import { tv } from "tailwind-variants";
|
||||||
|
|
||||||
|
|
||||||
|
const toolbar = tv({
|
||||||
|
base: "bg-default-50 sticky flex items-center h-18 px-4 w-full shrink-0",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function Toolbar({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className={toolbar()}>
|
||||||
|
<div className="flex items-center justify-end grow gap-2 h-18">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
27
lexikon/src/features/navigation/TopToolBar.tsx
Normal file
27
lexikon/src/features/navigation/TopToolBar.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type ReactNode } from "react";
|
||||||
|
import Toolbar from "./ToolBar";
|
||||||
|
import { useSidenavStore } from "./sidenav/store";
|
||||||
|
import { SidebarClose, SidebarOpen } from "lucide-react";
|
||||||
|
import IconButton from "shared/ui/primitives/IconButton";
|
||||||
|
|
||||||
|
type TopToolbarProps = {
|
||||||
|
children?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TopToolbar({ children }: TopToolbarProps) {
|
||||||
|
const { open, toggleSidenav } = useSidenavStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Toolbar>
|
||||||
|
{!open && (
|
||||||
|
<IconButton
|
||||||
|
icon={open ? SidebarClose : SidebarOpen}
|
||||||
|
onClick={toggleSidenav}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="grow flex items-center justify-end gap-2 h-full overflow-hidden">{children}</div>
|
||||||
|
</Toolbar>
|
||||||
|
);
|
||||||
|
}
|
113
lexikon/src/features/navigation/UserArea.tsx
Normal file
113
lexikon/src/features/navigation/UserArea.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dropdown,
|
||||||
|
DropdownItem,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownTrigger,
|
||||||
|
User,
|
||||||
|
Button,
|
||||||
|
} from "@heroui/react";
|
||||||
|
import { LogOut, Settings } from "lucide-react";
|
||||||
|
import { useContext, useState } from "react";
|
||||||
|
import { useAuth, signOut } from "shared/api/supabase";
|
||||||
|
import { ResponsiveContext } from "shared/provider/ResponsiveProvider";
|
||||||
|
import LoginModal from "shared/ui/auth/LoginModal";
|
||||||
|
import ConfirmModal from "shared/ui/primitives/ConfirmModal";
|
||||||
|
|
||||||
|
export default function UserArea(
|
||||||
|
props: {
|
||||||
|
className?: string;
|
||||||
|
} & React.ComponentProps<typeof DropdownTrigger>
|
||||||
|
) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const isMobile = useContext(ResponsiveContext);
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
const [isLoginOpen, setLoginOpen] = useState(false);
|
||||||
|
const [isLogoutOpen, setLogoutOpen] = useState(false);
|
||||||
|
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||||
|
|
||||||
|
const userData = {
|
||||||
|
image: user?.user_metadata?.avatar_url,
|
||||||
|
name: user?.user_metadata?.full_name,
|
||||||
|
email: user?.email,
|
||||||
|
};
|
||||||
|
|
||||||
|
// If not logged in, show login button
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
className={props.className}
|
||||||
|
onPress={() => setLoginOpen(true)}
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
<LoginModal
|
||||||
|
isOpen={isLoginOpen}
|
||||||
|
onClose={() => setLoginOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If logged in, show user dropdown
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dropdown placement="bottom-end">
|
||||||
|
<DropdownTrigger>
|
||||||
|
<User
|
||||||
|
as={"button"}
|
||||||
|
name={userData.name}
|
||||||
|
isFocusable
|
||||||
|
className={"transition-transform " + props.className}
|
||||||
|
avatarProps={{ src: userData.image }}
|
||||||
|
description={isMobile ? undefined : userData.email}
|
||||||
|
/>
|
||||||
|
</DropdownTrigger>
|
||||||
|
<DropdownMenu aria-label="Profile Actions" variant="flat">
|
||||||
|
<DropdownItem key="profile" className="h-14 gap-2">
|
||||||
|
<p className="font-semibold">Signed in as</p>
|
||||||
|
<p className="font-semibold">{user?.email}</p>
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem
|
||||||
|
key="settings"
|
||||||
|
startContent={<Settings className="text-xl" />}
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem
|
||||||
|
key="logout"
|
||||||
|
color="danger"
|
||||||
|
startContent={<LogOut />}
|
||||||
|
onPress={() => setLogoutOpen(true)}
|
||||||
|
>
|
||||||
|
Log Out
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Dropdown>
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={isLogoutOpen}
|
||||||
|
onOpenChange={setLogoutOpen}
|
||||||
|
title="Log out?"
|
||||||
|
confirmText="Log out"
|
||||||
|
confirmButtonColor="danger"
|
||||||
|
onConfirm={async () => {
|
||||||
|
setIsLoggingOut(true);
|
||||||
|
try {
|
||||||
|
await signOut();
|
||||||
|
} catch (e) {
|
||||||
|
// Optionally show error feedback
|
||||||
|
} finally {
|
||||||
|
setIsLoggingOut(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isConfirmLoading={isLoggingOut}
|
||||||
|
>
|
||||||
|
Are you sure you want to log out?
|
||||||
|
</ConfirmModal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
58
lexikon/src/features/navigation/sidenav/SideNav.tsx
Normal file
58
lexikon/src/features/navigation/sidenav/SideNav.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Divider } from "@heroui/react";
|
||||||
|
import { PanelLeftClose, PanelLeftOpen, Pin, PinOff } from "lucide-react";
|
||||||
|
import React from "react";
|
||||||
|
import UserArea from "../UserArea";
|
||||||
|
import { useSidenavStore } from "./store";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import IconButton from "shared/ui/primitives/IconButton";
|
||||||
|
import { ThemeSwitch } from "shared/ui/themeSwitch";
|
||||||
|
|
||||||
|
const sidenavVariants = {
|
||||||
|
open: { width: "20rem" },
|
||||||
|
closed: { width: "0rem" },
|
||||||
|
};
|
||||||
|
const sidenavTransition = { duration: 0.5, ease: [0.25, 0.1, 0.25, 1] };
|
||||||
|
|
||||||
|
export const SideNav = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const { open, pinned, togglePinned, toggleSidenav } = useSidenavStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
animate={open ? "open" : "closed"}
|
||||||
|
className={`${pinned ? "relative" : "fixed top-0 left-0"} h-dvh overflow-hidden z-40 shrink-0`}
|
||||||
|
initial={false}
|
||||||
|
transition={sidenavTransition}
|
||||||
|
variants={sidenavVariants}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col h-dvh bg-default-50 text-default-900 p-4 gap-2 border-r border-default-200 w-80">
|
||||||
|
<div className="flex items-center justify-between h-10">
|
||||||
|
<IconButton
|
||||||
|
onClick={toggleSidenav}
|
||||||
|
icon={open ? PanelLeftClose : PanelLeftOpen}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
onClick={togglePinned}
|
||||||
|
icon={pinned ? Pin : PinOff}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Use theme-aware background color for divider */}
|
||||||
|
<Divider className="my-2 bg-default-200" />
|
||||||
|
|
||||||
|
{/* Content area */}
|
||||||
|
<div className={"h-full overflow-hidden py-4"}>{children}</div>
|
||||||
|
|
||||||
|
{/* Use theme-aware background color for divider */}
|
||||||
|
<Divider className="my-2 bg-default-200" />
|
||||||
|
|
||||||
|
{/* Footer with User Info and Theme Switch */}
|
||||||
|
<div className="flex justify-between items-center gap-2 shrink-0">
|
||||||
|
<UserArea />
|
||||||
|
<ThemeSwitch />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
24
lexikon/src/features/navigation/sidenav/store.ts
Normal file
24
lexikon/src/features/navigation/sidenav/store.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
|
|
||||||
|
export interface SidenavState {
|
||||||
|
open: boolean;
|
||||||
|
pinned: boolean;
|
||||||
|
toggleSidenav: () => void;
|
||||||
|
togglePinned: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSidenavStore = create<SidenavState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
open: false,
|
||||||
|
pinned: false,
|
||||||
|
toggleSidenav: () => set((state) => ({ open: !state.open })),
|
||||||
|
togglePinned: () => set((state) => ({ pinned: !state.pinned })),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'sidenav-storage', // name of the item in the storage (must be unique)
|
||||||
|
storage: createJSONStorage(() => localStorage), // (optional) by default, 'localStorage' is used
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
176
lexikon/src/features/study/components/CourseActions.tsx
Normal file
176
lexikon/src/features/study/components/CourseActions.tsx
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
"use client";
|
||||||
|
import { addToast } from "@heroui/toast";
|
||||||
|
// filepath: src/features/study/components/_course/SummaryActions.tsx
|
||||||
|
import React, { useContext } from "react";
|
||||||
|
import { useRouter, useParams } from "next/navigation";
|
||||||
|
import { Input } from "@heroui/react";
|
||||||
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Edit,
|
||||||
|
Eye,
|
||||||
|
Plus,
|
||||||
|
Save,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import ConfirmModal from "shared/ui/primitives/ConfirmModal";
|
||||||
|
import IconButton from "shared/ui/primitives/IconButton";
|
||||||
|
import { StudyContext } from "../provider/StudyProvider";
|
||||||
|
|
||||||
|
export default function CourseActions() {
|
||||||
|
const ctx = useContext(StudyContext);
|
||||||
|
if (!ctx) throw new Error("CourseActions must be used within StudyProvider");
|
||||||
|
|
||||||
|
const {
|
||||||
|
mode,
|
||||||
|
busy,
|
||||||
|
dirty,
|
||||||
|
onModeChange,
|
||||||
|
navigateSummary,
|
||||||
|
selectedSummary,
|
||||||
|
setSelectedSummary,
|
||||||
|
onSave,
|
||||||
|
onCreate,
|
||||||
|
onDelete,
|
||||||
|
summaryName,
|
||||||
|
summaries,
|
||||||
|
} = ctx;
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams<{ courseId: string }>();
|
||||||
|
const courseId = params.courseId;
|
||||||
|
|
||||||
|
// Local modal states
|
||||||
|
const [isNewOpen, setIsNewOpen] = React.useState(false);
|
||||||
|
const [isDeleteOpen, setIsDeleteOpen] = React.useState(false);
|
||||||
|
const [newName, setNewName] = React.useState("");
|
||||||
|
|
||||||
|
const handleNavigate = (direction: "next" | "prev") => {
|
||||||
|
if (!selectedSummary || !summaries.length || !courseId) return;
|
||||||
|
const currentIndex = summaries.findIndex(
|
||||||
|
(summary) => summary.summary_id === selectedSummary.summary_id
|
||||||
|
);
|
||||||
|
let newIndex = currentIndex;
|
||||||
|
if (direction === "next" && currentIndex < summaries.length - 1) {
|
||||||
|
newIndex = currentIndex + 1;
|
||||||
|
}
|
||||||
|
if (direction === "prev" && currentIndex > 0) {
|
||||||
|
newIndex = currentIndex - 1;
|
||||||
|
}
|
||||||
|
if (newIndex !== currentIndex && summaries[newIndex]) {
|
||||||
|
setSelectedSummary(summaries[newIndex]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// After creating a summary, navigate to the new summary
|
||||||
|
const handleCreate = async (name: string) => {
|
||||||
|
const before = summaries.length;
|
||||||
|
await onCreate(name);
|
||||||
|
// Wait for summaries to update (could use effect, but simple polling for now)
|
||||||
|
setTimeout(() => {
|
||||||
|
const after = ctx.summaries;
|
||||||
|
if (after.length > before && courseId) {
|
||||||
|
const newSummary = after[after.length - 1];
|
||||||
|
router.push(
|
||||||
|
`/study/${courseId}/summary/${newSummary.summary_id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<IconButton icon={ChevronLeft} onClick={() => handleNavigate("prev")} />
|
||||||
|
<IconButton
|
||||||
|
icon={ChevronRight}
|
||||||
|
onClick={() => handleNavigate("next")}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={Plus}
|
||||||
|
color="primary"
|
||||||
|
onClick={() => setIsNewOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{mode === "edit" ? (
|
||||||
|
<IconButton
|
||||||
|
color={dirty ? "warning" : "success"}
|
||||||
|
icon={Save}
|
||||||
|
disabled={busy}
|
||||||
|
busy={busy}
|
||||||
|
onClick={async () => {
|
||||||
|
// Use the real handleSave for feedback, not the context's onSave (which is void)
|
||||||
|
const result = await ctx.handleSave?.();
|
||||||
|
if (!result || !result.error) {
|
||||||
|
addToast({
|
||||||
|
title: "Saved",
|
||||||
|
description: "Summary saved successfully.",
|
||||||
|
color: "success",
|
||||||
|
timeout: 2500,
|
||||||
|
shouldShowTimeoutProgress: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
addToast({
|
||||||
|
title: "Save failed",
|
||||||
|
description:
|
||||||
|
result.error ||
|
||||||
|
"An error occurred while saving.",
|
||||||
|
color: "danger",
|
||||||
|
timeout: 4000,
|
||||||
|
shouldShowTimeoutProgress: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<IconButton
|
||||||
|
color="danger"
|
||||||
|
icon={Trash2}
|
||||||
|
disabled={busy || !selectedSummary}
|
||||||
|
onClick={() => setIsDeleteOpen(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
icon={mode === "edit" ? Eye : Edit}
|
||||||
|
color={"secondary"}
|
||||||
|
onClick={() => onModeChange(mode === "edit" ? "view" : "edit")}
|
||||||
|
disabled={!selectedSummary && mode === "edit"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal – Neues Kapitel */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={isNewOpen}
|
||||||
|
onOpenChange={setIsNewOpen}
|
||||||
|
title="Create New Chapter"
|
||||||
|
onConfirm={() => {
|
||||||
|
if (!newName.trim()) return;
|
||||||
|
handleCreate(newName);
|
||||||
|
}}
|
||||||
|
confirmText="Create"
|
||||||
|
confirmButtonColor="primary"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
placeholder="Chapter name"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</ConfirmModal>
|
||||||
|
|
||||||
|
{/* Modal – Kapitel löschen */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={isDeleteOpen}
|
||||||
|
onOpenChange={setIsDeleteOpen}
|
||||||
|
title="Delete Chapter"
|
||||||
|
onConfirm={onDelete}
|
||||||
|
confirmText="Delete"
|
||||||
|
confirmButtonColor="danger"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Are you sure you want to delete chapter "
|
||||||
|
{selectedSummary?.chapter}"?
|
||||||
|
</p>
|
||||||
|
</ConfirmModal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
19
lexikon/src/features/study/components/CourseContent.tsx
Normal file
19
lexikon/src/features/study/components/CourseContent.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Spinner } from "@heroui/react";
|
||||||
|
import MDXPreview from "shared/ui/markdown/MDXPreview";
|
||||||
|
|
||||||
|
export const CourseContent = ({ value }: { value: string }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{value === null ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<MDXPreview
|
||||||
|
content={value}
|
||||||
|
className="w-full max-w-sm sm:max-w-xl md:max-w-2xl lg:max-w-4xl px-4"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
42
lexikon/src/features/study/components/CourseEdit.tsx
Normal file
42
lexikon/src/features/study/components/CourseEdit.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import { StudyContext } from "../provider/StudyProvider";
|
||||||
|
import { useSummary } from "../hooks/useSummary";
|
||||||
|
import { Input, Spinner } from "@heroui/react";
|
||||||
|
import Editor from "shared/ui/code/Editor";
|
||||||
|
|
||||||
|
export default function CourseEdit() {
|
||||||
|
const { selectedSummary, summaryName, setSummaryName } =
|
||||||
|
useContext(StudyContext)!;
|
||||||
|
|
||||||
|
const { content, setContent } = useSummary(selectedSummary);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"flex flex-1 flex-col w-full min-h-0 overflow-hidden p-4 bg-default-100"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{content === null ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
classNames={{
|
||||||
|
input: "text-2xl font-bold p-2",
|
||||||
|
innerWrapper: "py-4",
|
||||||
|
}}
|
||||||
|
className="bg-default-100"
|
||||||
|
placeholder="Chapter title"
|
||||||
|
size="lg"
|
||||||
|
value={summaryName}
|
||||||
|
variant="underlined"
|
||||||
|
onChange={(e) => {
|
||||||
|
setSummaryName(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Editor value={content} onChange={setContent} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
30
lexikon/src/features/study/components/CourseHero.tsx
Normal file
30
lexikon/src/features/study/components/CourseHero.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Image } from "@heroui/react";
|
||||||
|
import { useContext } from "react";
|
||||||
|
import { example_hero_image } from "shared/config/mockData";
|
||||||
|
import { StudyContext } from "../provider/StudyProvider";
|
||||||
|
|
||||||
|
const CourseHero = ({ full }: { full?: boolean }) => {
|
||||||
|
const { course } = useContext(StudyContext)!;
|
||||||
|
const imageSrc = course?.hero_image || example_hero_image;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative flex flex-col w-full overflow-hidden shrink-0 ${full ? "h-full" : "h-70"}`}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
removeWrapper
|
||||||
|
alt={`Hero image`}
|
||||||
|
className="absolute w-full h-full object-cover rounded-none z-0"
|
||||||
|
src={imageSrc}
|
||||||
|
/>
|
||||||
|
{/* Transparent overlay */}
|
||||||
|
<div className="grow scale-110 bg-linear-to-t to-transparent from-background from-5% z-1" />
|
||||||
|
|
||||||
|
{/* Course title and description */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CourseHero;
|
278
lexikon/src/features/study/components/CourseList.tsx
Normal file
278
lexikon/src/features/study/components/CourseList.tsx
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button, Link, PressEvent } from "@heroui/react";
|
||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
AnimatePresence,
|
||||||
|
motion,
|
||||||
|
useMotionValue,
|
||||||
|
useTransform,
|
||||||
|
useAnimation,
|
||||||
|
} from "framer-motion";
|
||||||
|
import { Star } from "lucide-react";
|
||||||
|
import useCoursesModulesStore from "../stores/coursesStore";
|
||||||
|
import { CourseModule } from "shared/domain/course";
|
||||||
|
import { toggleCourseFavorite } from "../queries/CRUD-Courses";
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Shared helpers
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
/** Drag distance (in px) required to toggle favorite on mobile swipe item */
|
||||||
|
const DRAG_THRESHOLD = 120;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small wrapper around the Lucide Star so we don’t have to repeat the
|
||||||
|
* `fill={filled ? "currentColor" : "none"}` boiler‑plate everywhere.
|
||||||
|
*/
|
||||||
|
const FavoriteStar: React.FC<{ filled: boolean; size?: number }> = ({
|
||||||
|
filled,
|
||||||
|
size = 20,
|
||||||
|
}) => (
|
||||||
|
<Star
|
||||||
|
className="text-default-600 pointer-events-none"
|
||||||
|
fill={filled ? "currentColor" : "none"}
|
||||||
|
size={size}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export type CourseListItemProps = {
|
||||||
|
courseModule: CourseModule;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CourseListItem: React.FC<CourseListItemProps> = ({
|
||||||
|
courseModule,
|
||||||
|
}: {
|
||||||
|
courseModule: CourseModule;
|
||||||
|
}) => {
|
||||||
|
const [fav, setFav] = useState<boolean>(!!courseModule.is_user_favorite);
|
||||||
|
const { reloadCoursesModules: reload } = useCoursesModulesStore();
|
||||||
|
const handlefavoriteToggle = async (e: PressEvent) => {
|
||||||
|
try {
|
||||||
|
const newState = await toggleCourseFavorite(courseModule, !fav);
|
||||||
|
setFav(newState);
|
||||||
|
reload();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to toggle favorite status:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={courseModule.course_id}
|
||||||
|
layout
|
||||||
|
animate={{ opacity: 1, scale: 1, x: 0 }}
|
||||||
|
className="flex relative group items-center justify-between w-full gap-2 overflow-clip"
|
||||||
|
exit={{ opacity: 0, scale: 0, x: -20 }}
|
||||||
|
initial={{ opacity: 0, scale: 0, x: -20 }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
className="flex items-center w-full justify-between px-4 py-2 rounded-lg shrink-0"
|
||||||
|
href={`/study/${courseModule.course_id}`}
|
||||||
|
// href={`/study/${courseModule.course_id}-${courseModule.module_code}`}
|
||||||
|
>
|
||||||
|
<span className="text-lg font-medium text-foreground flex-1 truncate">
|
||||||
|
{courseModule.module_name}
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-medium text-default-600 flex group-hover:hidden">
|
||||||
|
{courseModule.module_code}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<div className="shrink-0 pointer-events-none group-hover:flex items-center justify-end hidden absolute right-4 w-1/2 bg-gradient-to-r from-transparent to-default-50">
|
||||||
|
<Button
|
||||||
|
isIconOnly
|
||||||
|
variant="light"
|
||||||
|
onPress={handlefavoriteToggle}
|
||||||
|
className="pointer-events-auto"
|
||||||
|
>
|
||||||
|
<FavoriteStar filled={fav} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MobileCourseListItemProps = {
|
||||||
|
courseModule: CourseModule;
|
||||||
|
onClick?: () => void;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
export const MobileCourseListItem: React.FC<MobileCourseListItemProps> = ({
|
||||||
|
courseModule,
|
||||||
|
className = "",
|
||||||
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const handlePress = () => {
|
||||||
|
router.push(
|
||||||
|
// `/study/${courseModule.course_id}-${courseModule.module_code}`
|
||||||
|
`/study/${courseModule.course_id}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className={`flex justify-between rounded-2xl items-center p-2 px-4 hover:bg-neutral-800 transition-colors duration-150 w-full h-12 max-h-12 shrink-0 ${className}`}
|
||||||
|
variant="light"
|
||||||
|
onPress={handlePress}
|
||||||
|
>
|
||||||
|
<span className="text-white text-start text-base font-medium flex-grow truncate pr-4">
|
||||||
|
{courseModule.module_name}
|
||||||
|
</span>
|
||||||
|
<span className="text-neutral-500 text-base font-medium">
|
||||||
|
{courseModule.module_code}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Swipeable item for mobile list with drag-to-fav logic
|
||||||
|
export type SwipeableModuleItemProps = {
|
||||||
|
courseModule: CourseModule;
|
||||||
|
onSwipe: (courseModule: CourseModule) => void;
|
||||||
|
onClick?: () => void;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
export const SwipeableModuleItem: React.FC<SwipeableModuleItemProps> = ({
|
||||||
|
courseModule,
|
||||||
|
onSwipe,
|
||||||
|
className = "",
|
||||||
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const isDraggingRef = useRef<boolean>(false);
|
||||||
|
const suppressClickRef = useRef<boolean>(false);
|
||||||
|
const x = useMotionValue(0);
|
||||||
|
const controls = useAnimation();
|
||||||
|
const [favState, setFavState] = useState<boolean>(
|
||||||
|
!!courseModule.is_user_favorite
|
||||||
|
);
|
||||||
|
const [previewFav, setPreviewFav] = useState<boolean>(favState);
|
||||||
|
const isOverRef = useRef<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fav = !!courseModule.is_user_favorite;
|
||||||
|
setFavState(fav);
|
||||||
|
setPreviewFav(fav);
|
||||||
|
}, [courseModule.is_user_favorite]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
x.on("change", (latest: number) => {
|
||||||
|
// only animate icon scale during active drag
|
||||||
|
if (!isDraggingRef.current) return;
|
||||||
|
if (latest > DRAG_THRESHOLD && !isOverRef.current) {
|
||||||
|
isOverRef.current = true;
|
||||||
|
setPreviewFav(!favState);
|
||||||
|
controls.start({
|
||||||
|
scale: [1.4, 1],
|
||||||
|
transition: {
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (latest <= DRAG_THRESHOLD && isOverRef.current) {
|
||||||
|
isOverRef.current = false;
|
||||||
|
setPreviewFav(favState);
|
||||||
|
controls.start({
|
||||||
|
scale: [1.4, 1],
|
||||||
|
transition: {
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [x, controls, favState]);
|
||||||
|
const bgOpacity = useTransform(x, [0, DRAG_THRESHOLD], [0, 1]);
|
||||||
|
const handleClick = () => {
|
||||||
|
if (suppressClickRef.current) return;
|
||||||
|
router.push(`/study/${courseModule.course_id}`);
|
||||||
|
};
|
||||||
|
const actionIcon = <FavoriteStar filled={previewFav} size={24} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
key={courseModule.course_id}
|
||||||
|
layout
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
className="relative overflow-hidden shrink-0"
|
||||||
|
exit={{ scale: 0.5 }}
|
||||||
|
initial={{ scale: 0.5 }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 bg-primary z-0 rounded-2xl"
|
||||||
|
style={{ opacity: bgOpacity }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
animate={controls}
|
||||||
|
className="absolute inset-y-0 left-4 flex items-center z-10"
|
||||||
|
>
|
||||||
|
{actionIcon}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="relative z-20 bg-default-50 active:bg-default-300 rounded-2xl"
|
||||||
|
drag="x"
|
||||||
|
dragConstraints={{ left: 0, right: DRAG_THRESHOLD }}
|
||||||
|
dragElastic={0.2}
|
||||||
|
dragTransition={{ bounceStiffness: 500, bounceDamping: 15 }}
|
||||||
|
style={{ x }}
|
||||||
|
whileDrag={{ cursor: "grabbing" }}
|
||||||
|
onDragEnd={() => {
|
||||||
|
suppressClickRef.current = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
suppressClickRef.current = false;
|
||||||
|
}, 300);
|
||||||
|
if (previewFav !== favState) {
|
||||||
|
setFavState(previewFav);
|
||||||
|
onSwipe(courseModule);
|
||||||
|
}
|
||||||
|
isOverRef.current = false;
|
||||||
|
x.set(0);
|
||||||
|
isDraggingRef.current = false;
|
||||||
|
}}
|
||||||
|
onDragStart={() => {
|
||||||
|
isDraggingRef.current = true;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MobileCourseListItem
|
||||||
|
className={className}
|
||||||
|
courseModule={courseModule}
|
||||||
|
onClick={handleClick}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CourseListHeaderProps = {
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CourseListGroupHeader: React.FC<CourseListHeaderProps> = ({
|
||||||
|
title,
|
||||||
|
}) => (
|
||||||
|
<div className="flex items-center w-full pt-1 px-4">
|
||||||
|
<h3 className="text-base font-medium text-default-500">{title}</h3>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ModuleListGroupProps = {
|
||||||
|
title: string;
|
||||||
|
items: CourseListItemProps[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const ModuleListGroup: React.FC<ModuleListGroupProps> = ({ title, items }) => (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<CourseListGroupHeader title={title} />
|
||||||
|
{items.map((item) => (
|
||||||
|
<CourseListItem key={item.courseModule.course_id} {...item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ModuleListGroup;
|
76
lexikon/src/features/study/components/CoursePath.tsx
Normal file
76
lexikon/src/features/study/components/CoursePath.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
import { useContext } from "react";
|
||||||
|
import { StudyContext } from "../provider/StudyProvider";
|
||||||
|
import { Select, SelectItem } from "@heroui/react";
|
||||||
|
import { ChevronsUpDown } from "lucide-react";
|
||||||
|
import type { CourseSummary } from "shared/domain/summary";
|
||||||
|
|
||||||
|
type SummarySelectorProps = {
|
||||||
|
summaries: CourseSummary[];
|
||||||
|
selectedSummary: CourseSummary | null;
|
||||||
|
summaryName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SummarySelector = ({
|
||||||
|
summaries,
|
||||||
|
selectedSummary,
|
||||||
|
summaryName,
|
||||||
|
}: SummarySelectorProps) => {
|
||||||
|
const { setSelectedSummary } = useContext(StudyContext)!;
|
||||||
|
const defaultKey = selectedSummary?.summary_id ?? summaries[0]?.summary_id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
disableSelectorIconRotation
|
||||||
|
placeholder="No summaries"
|
||||||
|
selectorIcon={<ChevronsUpDown />}
|
||||||
|
selectedKeys={defaultKey !== undefined ? [String(defaultKey)] : []}
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const selectedKey = Array.from(keys)[0];
|
||||||
|
const found = summaries.find(
|
||||||
|
(s) => s.summary_id === Number(selectedKey)
|
||||||
|
);
|
||||||
|
if (found) {
|
||||||
|
setSelectedSummary(found);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
trigger: "w-48 text-default-500 font-semibold",
|
||||||
|
value: "text-default-500 font-semibold",
|
||||||
|
}}
|
||||||
|
className="w-48 text-default-500 font-semibold"
|
||||||
|
>
|
||||||
|
{summaries.map((summary) => (
|
||||||
|
<SelectItem key={String(summary.summary_id)}>
|
||||||
|
{selectedSummary &&
|
||||||
|
summary.summary_id === selectedSummary.summary_id
|
||||||
|
? summaryName
|
||||||
|
: summary.chapter}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CoursePath() {
|
||||||
|
const {
|
||||||
|
course,
|
||||||
|
summaries,
|
||||||
|
selectedSummary,
|
||||||
|
summaryName,
|
||||||
|
setSelectedSummary,
|
||||||
|
} = useContext(StudyContext)!;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 max-h-10 h-10 grow">
|
||||||
|
<span className="text-default-700 font-semibold">
|
||||||
|
{course?.module_name}
|
||||||
|
</span>
|
||||||
|
<span className="text-default-500 font-semibold">/</span>
|
||||||
|
<SummarySelector
|
||||||
|
summaries={summaries}
|
||||||
|
summaryName={summaryName}
|
||||||
|
selectedSummary={selectedSummary}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
30
lexikon/src/features/study/hooks/useCourse.ts
Normal file
30
lexikon/src/features/study/hooks/useCourse.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { CourseModule } from "shared/domain/course";
|
||||||
|
import { fetchCourse } from "../queries/CRUD-Courses";
|
||||||
|
|
||||||
|
export function useCourse(courseid: number) {
|
||||||
|
const [course, setCourse] = useState<CourseModule | null>(null);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!courseid) return;
|
||||||
|
setLoading(true);
|
||||||
|
fetchCourse(courseid).then((course) => {
|
||||||
|
if (!course) {
|
||||||
|
setError("Course not found");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCourse(course);
|
||||||
|
setLoading(false);
|
||||||
|
}).catch((error) => {
|
||||||
|
setError(error.message);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [courseid]);
|
||||||
|
|
||||||
|
return { course, loading, error };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useCourse;
|
53
lexikon/src/features/study/hooks/useSummaries.ts
Normal file
53
lexikon/src/features/study/hooks/useSummaries.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
// Use the shared CourseSummary type
|
||||||
|
import { CourseSummary } from "shared/domain/summary";
|
||||||
|
// Import the server action
|
||||||
|
import { fetchSummaries } from "../queries/CRUD-Summaries";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side hook to fetch summaries for a given course.
|
||||||
|
* Disables fetching if courseId is falsy.
|
||||||
|
*/
|
||||||
|
export function useSummaries(courseId: number) {
|
||||||
|
const [summaries, setSummaries] = useState<CourseSummary[]>([]);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Fetch summaries from the server
|
||||||
|
const fetch = async () => {
|
||||||
|
if (!courseId) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await fetchSummaries(courseId);
|
||||||
|
|
||||||
|
// Sort by chapter name
|
||||||
|
data.sort((a, b) => {
|
||||||
|
const nameA = (a.chapter ?? "").toLowerCase();
|
||||||
|
const nameB = (b.chapter ?? "").toLowerCase();
|
||||||
|
if (nameA < nameB) return -1;
|
||||||
|
if (nameA > nameB) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
setSummaries(data);
|
||||||
|
} catch (error: any) {
|
||||||
|
setError(error.message ?? "An unexpected error occurred");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch on mount and when courseId changes
|
||||||
|
useEffect(() => {
|
||||||
|
fetch();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [courseId]);
|
||||||
|
|
||||||
|
// Expose a reload/refetch function
|
||||||
|
const refetch = fetch;
|
||||||
|
|
||||||
|
return { summaries, loading, error, refetch };
|
||||||
|
}
|
57
lexikon/src/features/study/hooks/useSummary.ts
Normal file
57
lexikon/src/features/study/hooks/useSummary.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useCallback } from "react";
|
||||||
|
import { CourseSummary } from "shared/domain/summary";
|
||||||
|
import { useSummaryStore } from "../stores/summaryStore";
|
||||||
|
|
||||||
|
|
||||||
|
interface useSummaryProps {
|
||||||
|
content: string;
|
||||||
|
setContent: (content: string) => void;
|
||||||
|
saveContent: () => Promise<{ error?: string } | undefined>;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
renameSummary: (name: string, termCode: string) => Promise<{ error?: string } | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Custom hook to manage the content of the current summary (singleton/global)
|
||||||
|
export function useSummary(summary: CourseSummary | null): useSummaryProps {
|
||||||
|
const summaryId = summary?.summary_id;
|
||||||
|
const content = useSummaryStore((s) => (summaryId ? s.content[summaryId] ?? "" : ""));
|
||||||
|
const loading = useSummaryStore((s) => (summaryId ? s.loading[summaryId] ?? false : false));
|
||||||
|
const error = useSummaryStore((s) => (summaryId ? s.error[summaryId] ?? null : null));
|
||||||
|
const setContent = useCallback((c: string) => {
|
||||||
|
if (summaryId) useSummaryStore.getState().setContent(summaryId, c);
|
||||||
|
}, [summaryId]);
|
||||||
|
const fetchContent = useSummaryStore((s) => s.fetchContent);
|
||||||
|
const saveContent = useCallback(async () => {
|
||||||
|
if (summary && summaryId) {
|
||||||
|
return await useSummaryStore.getState().saveContent(summary);
|
||||||
|
}
|
||||||
|
return { error: "No summary selected" };
|
||||||
|
}, [summary, summaryId]);
|
||||||
|
|
||||||
|
const renameSummary = useCallback(async (name: string, termCode: string) => {
|
||||||
|
if (summary && summaryId) {
|
||||||
|
return await useSummaryStore.getState().renameSummary(summary, name);
|
||||||
|
}
|
||||||
|
return { error: "No summary selected" };
|
||||||
|
}, [summary, summaryId]);
|
||||||
|
|
||||||
|
// Fetch content when summary changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (summary && summaryId) {
|
||||||
|
fetchContent(summary);
|
||||||
|
}
|
||||||
|
}, [summary, summaryId, fetchContent]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
setContent,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
saveContent,
|
||||||
|
renameSummary,
|
||||||
|
};
|
||||||
|
}
|
151
lexikon/src/features/study/provider/StudyProvider.tsx
Normal file
151
lexikon/src/features/study/provider/StudyProvider.tsx
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
"use client";
|
||||||
|
import { createContext, useState, useEffect, ReactNode } from "react";
|
||||||
|
import { CourseSummary } from "shared/domain/summary";
|
||||||
|
import {
|
||||||
|
createSummary,
|
||||||
|
deleteSummary,
|
||||||
|
updateSummary,
|
||||||
|
} from "../queries/CRUD-Summaries";
|
||||||
|
import { useSummaries } from "../hooks/useSummaries";
|
||||||
|
import useCourse from "../hooks/useCourse";
|
||||||
|
import { CourseModule } from "shared/domain/course";
|
||||||
|
import useTermStore from "../stores/termStore";
|
||||||
|
import { useSummary } from "../hooks/useSummary";
|
||||||
|
|
||||||
|
interface StudyContextProps {
|
||||||
|
course: CourseModule | null;
|
||||||
|
summaries: CourseSummary[];
|
||||||
|
selectedSummary: CourseSummary | null;
|
||||||
|
setSelectedSummary: (summary: CourseSummary | null) => void;
|
||||||
|
navigateSummary: (direction: "next" | "prev") => void;
|
||||||
|
mode: "view" | "edit";
|
||||||
|
onModeChange: (mode: "view" | "edit") => void;
|
||||||
|
dirty: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
setBusy: (busy: boolean) => void;
|
||||||
|
onCreate: (name: string) => Promise<void>;
|
||||||
|
onSave: () => Promise<{ success: boolean; error?: string }>;
|
||||||
|
onDelete: () => Promise<void>;
|
||||||
|
handleSave?: () => Promise<{ success: boolean; error?: string }>;
|
||||||
|
summaryName: string;
|
||||||
|
setSummaryName: (name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StudyContext = createContext<StudyContextProps | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
interface StudyProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
courseId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StudyProvider({
|
||||||
|
children,
|
||||||
|
courseId,
|
||||||
|
}: StudyProviderProps) {
|
||||||
|
const {
|
||||||
|
summaries,
|
||||||
|
loading: summariesLoading,
|
||||||
|
error: summariesError,
|
||||||
|
refetch,
|
||||||
|
} = useSummaries(courseId);
|
||||||
|
const { course } = useCourse(courseId);
|
||||||
|
|
||||||
|
const [selectedSummary, setSelectedSummary] =
|
||||||
|
useState<CourseSummary | null>(null);
|
||||||
|
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [mode, setMode] = useState<"view" | "edit">("view");
|
||||||
|
const [summaryName, setSummaryName] = useState(
|
||||||
|
selectedSummary?.chapter || ""
|
||||||
|
);
|
||||||
|
const chapterCount = summaries.length;
|
||||||
|
const { selectedTerm } = useTermStore();
|
||||||
|
const { content } = useSummary(selectedSummary);
|
||||||
|
|
||||||
|
// Keep summaryName in sync with selectedSummary
|
||||||
|
useEffect(() => {
|
||||||
|
setSummaryName(selectedSummary?.chapter || "");
|
||||||
|
}, [selectedSummary]);
|
||||||
|
|
||||||
|
// Only create and refetch, let the component handle navigation
|
||||||
|
const handleCreate = async (name: string) => {
|
||||||
|
await createSummary(courseId, name, selectedTerm?.semester_code ?? "");
|
||||||
|
await refetch();
|
||||||
|
};
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!selectedSummary) return;
|
||||||
|
await deleteSummary(selectedSummary);
|
||||||
|
await refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only update state, let the component handle navigation
|
||||||
|
const navigateSummary = (direction: "next" | "prev") => {
|
||||||
|
if (!selectedSummary) return;
|
||||||
|
const currentIndex = summaries.findIndex(
|
||||||
|
(summary) => summary.summary_id === selectedSummary.summary_id
|
||||||
|
);
|
||||||
|
let newIndex = currentIndex;
|
||||||
|
if (direction === "next" && currentIndex < chapterCount - 1) {
|
||||||
|
newIndex = currentIndex + 1;
|
||||||
|
}
|
||||||
|
if (direction === "prev" && currentIndex > 0) {
|
||||||
|
newIndex = currentIndex - 1;
|
||||||
|
}
|
||||||
|
if (newIndex !== currentIndex && summaries[newIndex]) {
|
||||||
|
setSelectedSummary(summaries[newIndex]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save handler that updates summary using updateSummary only
|
||||||
|
const handleSave = async (): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}> => {
|
||||||
|
if (!selectedSummary)
|
||||||
|
return { success: false, error: "No summary selected" };
|
||||||
|
const error = await updateSummary(selectedSummary, {
|
||||||
|
newName: summaryName,
|
||||||
|
content: content,
|
||||||
|
});
|
||||||
|
if (!error) {
|
||||||
|
setDirty(false);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
// Ensure error is always a string
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
typeof error === "string"
|
||||||
|
? error
|
||||||
|
: (error?.error ?? "Unknown error"),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const contextValue = {
|
||||||
|
course,
|
||||||
|
selectedSummary,
|
||||||
|
setSelectedSummary,
|
||||||
|
navigateSummary,
|
||||||
|
mode,
|
||||||
|
onModeChange: setMode,
|
||||||
|
dirty,
|
||||||
|
busy: busy || summariesLoading,
|
||||||
|
setBusy,
|
||||||
|
onCreate: handleCreate,
|
||||||
|
onSave: handleSave,
|
||||||
|
onDelete: handleDelete,
|
||||||
|
handleSave,
|
||||||
|
summaryName,
|
||||||
|
setSummaryName,
|
||||||
|
summaries,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StudyContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</StudyContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
64
lexikon/src/features/study/queries/CRUD-Courses.ts
Normal file
64
lexikon/src/features/study/queries/CRUD-Courses.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { supaBrowser as supabase } from "shared/api/supabase";
|
||||||
|
import { CourseModule } from "shared/domain/course";
|
||||||
|
import { Term } from "shared/domain/term";
|
||||||
|
|
||||||
|
export async function fetchCourse(courseid: number): Promise<CourseModule | null> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.schema("library")
|
||||||
|
.from("mv_course_with_module") // TODO: MAke a better view
|
||||||
|
.select("*")
|
||||||
|
.eq("course_id", courseid)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return data as CourseModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCoursesModules(term: Term): Promise<CourseModule[]> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.schema("library")
|
||||||
|
.from("mv_course_with_module") // TODO: MAke a better view
|
||||||
|
.select("*")
|
||||||
|
.eq("semester_id", term.semester_id)
|
||||||
|
|
||||||
|
const { data: favs, error: favsError } = await supabase
|
||||||
|
.schema("library")
|
||||||
|
.from("module_favorites")
|
||||||
|
.select("module_id, user_uuid")
|
||||||
|
.eq("user_uuid", (await supabase.auth.getUser()).data.user?.id);
|
||||||
|
|
||||||
|
if (!favsError) {
|
||||||
|
return data!.map((courseModule) => {
|
||||||
|
const is_user_favorite = favs.some(
|
||||||
|
(fav) => fav.module_id === courseModule.module_id
|
||||||
|
);
|
||||||
|
return { ...courseModule, is_user_favorite };
|
||||||
|
}) as CourseModule[];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return data as CourseModule[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the “favorite” flag for the current user.
|
||||||
|
* Returns true if the RPC says the row is now a favorite.
|
||||||
|
*/
|
||||||
|
export async function toggleCourseFavorite(
|
||||||
|
courseModule: CourseModule,
|
||||||
|
isfavorite: boolean,
|
||||||
|
): Promise<boolean> {
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.schema("library")
|
||||||
|
.rpc("upsert_module_favorites", {
|
||||||
|
p_favorite_state: isfavorite,
|
||||||
|
p_module_id: courseModule.module_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return data === "true";
|
||||||
|
}
|
221
lexikon/src/features/study/queries/CRUD-Summaries.ts
Normal file
221
lexikon/src/features/study/queries/CRUD-Summaries.ts
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
import { supaBrowser } from 'shared/api/supabase/browser';
|
||||||
|
import { getNewSummaryPath, getSummaryPath, storagePaths } from "shared/config/paths";
|
||||||
|
import { CourseSummary } from "shared/domain/summary";
|
||||||
|
|
||||||
|
|
||||||
|
export async function getSummaryContent(
|
||||||
|
summary: CourseSummary,
|
||||||
|
) {
|
||||||
|
|
||||||
|
if (!summary.bucket_id || !summary.object_path) {
|
||||||
|
console.error("Invalid summary file reference");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error: fetchError } = await supaBrowser.storage
|
||||||
|
.from(summary.bucket_id)
|
||||||
|
.download(summary.object_path);
|
||||||
|
|
||||||
|
if (fetchError || !data) {
|
||||||
|
console.error("Failed to download summary file", fetchError);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await data.text();
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new chapter summary for a given course and name.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function createSummary(
|
||||||
|
courseId: number,
|
||||||
|
name: string,
|
||||||
|
termCode: string,
|
||||||
|
): Promise<CourseSummary | undefined> {
|
||||||
|
if (!courseId || !name.trim() || !termCode) {
|
||||||
|
console.error(
|
||||||
|
"Missing parameters or empty chapter name",
|
||||||
|
courseId,
|
||||||
|
name,
|
||||||
|
termCode,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const filePath = getSummaryPath(
|
||||||
|
termCode,
|
||||||
|
courseId,
|
||||||
|
name.trim().replaceAll(" ", "-"),
|
||||||
|
);
|
||||||
|
const bucketId = storagePaths.moduleContent.bucket;
|
||||||
|
// 1. Upload a empty markdown file to Supabase, so we have an objectid
|
||||||
|
const { data: storageData, error: storageError } = await supaBrowser.storage
|
||||||
|
.from(bucketId)
|
||||||
|
.upload(filePath, `# ${name}`, {
|
||||||
|
cacheControl: "3600",
|
||||||
|
upsert: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (storageError) {
|
||||||
|
console.error("Error uploading file:", storageError);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!storageData) {
|
||||||
|
console.error("No data returned from Supabase");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const createdAtPath = storageData.path;
|
||||||
|
const objectId = storageData.id;
|
||||||
|
|
||||||
|
// 2. Add an entry of the chapter to the database
|
||||||
|
const { data: createdChapter, error: upsertError } = await supaBrowser
|
||||||
|
.schema("library")
|
||||||
|
.rpc("upsert_summary_chapter", {
|
||||||
|
p_chapter: name,
|
||||||
|
p_course_id: courseId,
|
||||||
|
p_filename: createdAtPath,
|
||||||
|
p_object_uuid: objectId,
|
||||||
|
})
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (upsertError) {
|
||||||
|
console.error("Error upserting chapter:", upsertError);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!createdChapter) {
|
||||||
|
console.error("No data returned from Supabase");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdChapter as CourseSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a chapter summary and entry from the database and storage.
|
||||||
|
*/
|
||||||
|
export async function deleteSummary(summary: CourseSummary): Promise<CourseSummary | null> {
|
||||||
|
if (!summary || !summary.summary_id) {
|
||||||
|
console.error("Missing summary or summary_id");
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { data, error } = await supaBrowser
|
||||||
|
.schema("library")
|
||||||
|
.rpc("delete_summary", { p_summary_id: summary.summary_id! });
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a chapter summary: can rename and/or update content.
|
||||||
|
* If both are provided, rename happens first.
|
||||||
|
*/
|
||||||
|
export async function updateSummary(
|
||||||
|
summary: CourseSummary,
|
||||||
|
options: {
|
||||||
|
newName?: string;
|
||||||
|
content?: string;
|
||||||
|
}
|
||||||
|
): Promise<{ error?: string } | undefined> {
|
||||||
|
if (!summary) {
|
||||||
|
return { error: "Missing summary" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { newName, content } = options;
|
||||||
|
|
||||||
|
// Handle rename if all required params are present
|
||||||
|
if (
|
||||||
|
newName && newName.trim() !== ""
|
||||||
|
) {
|
||||||
|
const currentPath = summary.object_path;
|
||||||
|
if (!currentPath) {
|
||||||
|
return { error: "summary.object_path is null or undefined" };
|
||||||
|
}
|
||||||
|
// Extract directory and replace filename
|
||||||
|
const newFilePath = getNewSummaryPath(currentPath, newName);
|
||||||
|
const bucketId = storagePaths.moduleContent.bucket;
|
||||||
|
const { error: storageError } = await supaBrowser.storage
|
||||||
|
.from(bucketId)
|
||||||
|
.move(currentPath, newFilePath);
|
||||||
|
if (storageError) {
|
||||||
|
return { error: storageError.message || "Error moving file" };
|
||||||
|
}
|
||||||
|
// Rename chapter via RPC; returns trimmed chapter name
|
||||||
|
const { data: trimmedChapter, error: renameError } = await supaBrowser
|
||||||
|
.schema("library")
|
||||||
|
.rpc<string, { p_new_chapter: string; p_summary_id: number }>(
|
||||||
|
"rename_chapter",
|
||||||
|
{ p_new_chapter: newName.trim(), p_summary_id: summary.summary_id! }
|
||||||
|
)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (renameError) {
|
||||||
|
return { error: renameError.message || "Error renaming chapter" };
|
||||||
|
}
|
||||||
|
if (trimmedChapter === null) {
|
||||||
|
return { error: "Error renaming chapter" };
|
||||||
|
}
|
||||||
|
// Update summary with trimmed name and new path
|
||||||
|
summary.chapter = trimmedChapter;
|
||||||
|
summary.object_path = newFilePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Updating summary content", {
|
||||||
|
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle content update if requested
|
||||||
|
if (typeof content === "string") {
|
||||||
|
const path = summary.object_path;
|
||||||
|
const bucketId = summary.bucket_id;
|
||||||
|
|
||||||
|
|
||||||
|
const { data: uploadResult, error } = await supaBrowser.storage
|
||||||
|
.from(bucketId!)
|
||||||
|
.update(path!, content, {
|
||||||
|
cacheControl: "3600",
|
||||||
|
upsert: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.debug("Upload result", uploadResult);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return { error: error.message || "Error updating file" };
|
||||||
|
}
|
||||||
|
if (!uploadResult) {
|
||||||
|
return { error: "No data returned from Supabase" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all chapter summaries for a course.
|
||||||
|
* @param courseId The ID of the course to fetch summaries for.
|
||||||
|
*/
|
||||||
|
export async function fetchSummaries(courseId: number): Promise<CourseSummary[]> {
|
||||||
|
|
||||||
|
const { data, error } = await supaBrowser
|
||||||
|
.schema("library")
|
||||||
|
.from("summary")
|
||||||
|
.select("*")
|
||||||
|
.eq("course_id", courseId ?? 0);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return (data ?? []) as CourseSummary[];
|
||||||
|
}
|
57
lexikon/src/features/study/queries/CRUD-Terms.ts
Normal file
57
lexikon/src/features/study/queries/CRUD-Terms.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { supaServer } from "shared/api/supabase/server";
|
||||||
|
import { Term } from "shared/domain/term";
|
||||||
|
import { supaBrowser as supabase } from "shared/api/supabase/browser";
|
||||||
|
|
||||||
|
export async function fetchTerms(): Promise<Term[]> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.schema("library")
|
||||||
|
.from("semester")
|
||||||
|
.select("*")
|
||||||
|
// Sorting by year, then by FS before HS
|
||||||
|
.order("semester_code", { ascending: false });
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
if (!data) return [];
|
||||||
|
|
||||||
|
// Custom sort: by year, then FS before HS
|
||||||
|
return (data as Term[]).sort((a: Term, b: Term) => {
|
||||||
|
const parse = (code: string) => {
|
||||||
|
// code: e.g. "FS24" or "HS23"
|
||||||
|
const match = code.match(/^(FS|HS)(\d{2})$/);
|
||||||
|
if (!match) return { sem: "", year: 0 };
|
||||||
|
const [, sem, year] = match;
|
||||||
|
return { sem, year: Number(year) };
|
||||||
|
};
|
||||||
|
const aParsed = parse(a.semester_code);
|
||||||
|
const bParsed = parse(b.semester_code);
|
||||||
|
|
||||||
|
// Sort by year descending
|
||||||
|
if (aParsed.year !== bParsed.year) {
|
||||||
|
return bParsed.year - aParsed.year;
|
||||||
|
}
|
||||||
|
// FS before HS
|
||||||
|
if (aParsed.sem !== bParsed.sem) {
|
||||||
|
return aParsed.sem === "FS" ? -1 : 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return all terms that belong to a module
|
||||||
|
*/
|
||||||
|
export async function fetchModuleTerms(moduleId: number): Promise<Term[]> {
|
||||||
|
if (!Number.isFinite(moduleId)) throw new Error("Invalid moduleId");
|
||||||
|
|
||||||
|
const supabase =
|
||||||
|
await supaServer();
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.schema("library")
|
||||||
|
.rpc("get_terms_per_module", { p_module_id: moduleId });
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
if (!data) return [];
|
||||||
|
|
||||||
|
return data as Term[];
|
||||||
|
}
|
68
lexikon/src/features/study/stores/coursesStore.ts
Normal file
68
lexikon/src/features/study/stores/coursesStore.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
import { CourseModule } from "shared/domain/course";
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { fetchCoursesModules } from "../queries/CRUD-Courses";
|
||||||
|
import { Term } from "shared/domain/term";
|
||||||
|
|
||||||
|
interface CoursesModulesState {
|
||||||
|
coursesModules: CourseModule[];
|
||||||
|
isLoading: boolean;
|
||||||
|
term: Term | null;
|
||||||
|
error: string | null;
|
||||||
|
loadCoursesModules: (term: Term) => Promise<void>;
|
||||||
|
reloadCoursesModules: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCoursesModulesStore = create<CoursesModulesState>((set) => ({
|
||||||
|
coursesModules: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
term: null,
|
||||||
|
loadCoursesModules: async (term) => {
|
||||||
|
set({ isLoading: true, error: null }); // Reset error on new load attempt
|
||||||
|
try {
|
||||||
|
const coursesModules = await fetchCoursesModules(term);
|
||||||
|
|
||||||
|
set({ coursesModules: coursesModules, isLoading: false, term: term });
|
||||||
|
} catch (err) {
|
||||||
|
// Extract the error message for serialization
|
||||||
|
let errorMessage = "An unknown error occurred";
|
||||||
|
if (err instanceof Error) {
|
||||||
|
errorMessage = err.message;
|
||||||
|
} else if (typeof err === 'string') {
|
||||||
|
errorMessage = err;
|
||||||
|
} else if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') {
|
||||||
|
// Handle cases where the error might be an object with a message property
|
||||||
|
errorMessage = err.message;
|
||||||
|
}
|
||||||
|
set({ error: errorMessage, isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reloadCoursesModules: async () => {
|
||||||
|
|
||||||
|
const { term } = useCoursesModulesStore.getState();
|
||||||
|
if (term) {
|
||||||
|
set({ isLoading: true, error: null }); // Reset error on new load attempt
|
||||||
|
try {
|
||||||
|
const coursesModules = await fetchCoursesModules(term);
|
||||||
|
|
||||||
|
set({ coursesModules: coursesModules, isLoading: false });
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
// Extract the error message for serialization
|
||||||
|
let errorMessage = "An unknown error occurred";
|
||||||
|
if (err instanceof Error) {
|
||||||
|
errorMessage = err.message;
|
||||||
|
} else if (typeof err === 'string') {
|
||||||
|
errorMessage = err;
|
||||||
|
} else if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') {
|
||||||
|
// Handle cases where the error might be an object with a message property
|
||||||
|
errorMessage = err.message;
|
||||||
|
}
|
||||||
|
set({ error: errorMessage, isLoading: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default useCoursesModulesStore;
|
45
lexikon/src/features/study/stores/summaryStore.ts
Normal file
45
lexikon/src/features/study/stores/summaryStore.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { CourseSummary } from "shared/domain/summary";
|
||||||
|
import {
|
||||||
|
getSummaryContent
|
||||||
|
} from "../queries/CRUD-Summaries";
|
||||||
|
|
||||||
|
interface SummaryState {
|
||||||
|
content: Record<number, string>;
|
||||||
|
loading: Record<number, boolean>;
|
||||||
|
error: Record<number, string | null>;
|
||||||
|
setContent: (summaryId: number, content: string) => void;
|
||||||
|
fetchContent: (summary: CourseSummary) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSummaryStore = create<SummaryState>((set) => ({
|
||||||
|
content: {},
|
||||||
|
loading: {},
|
||||||
|
error: {},
|
||||||
|
setContent: (summaryId, content) =>
|
||||||
|
set((state) => ({
|
||||||
|
content: { ...state.content, [summaryId]: content },
|
||||||
|
})),
|
||||||
|
fetchContent: async (summary) => {
|
||||||
|
if (!summary?.summary_id) return;
|
||||||
|
set((state) => ({
|
||||||
|
loading: { ...state.loading, [summary.summary_id!]: true },
|
||||||
|
error: { ...state.error, [summary.summary_id!]: null },
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
const content = await getSummaryContent(summary);
|
||||||
|
set((state) => ({
|
||||||
|
content: { ...state.content, [summary.summary_id!]: content ?? "" },
|
||||||
|
}));
|
||||||
|
} catch (e: any) {
|
||||||
|
set((state) => ({
|
||||||
|
error: { ...state.error, [summary.summary_id!]: e?.message || "Failed to load summary" },
|
||||||
|
content: { ...state.content, [summary.summary_id!]: "" },
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
set((state) => ({
|
||||||
|
loading: { ...state.loading, [summary.summary_id!]: false },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
35
lexikon/src/features/study/stores/termStore.ts
Normal file
35
lexikon/src/features/study/stores/termStore.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { Term } from "shared/domain/term";
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { fetchTerms } from "../queries/CRUD-Terms";
|
||||||
|
|
||||||
|
interface TermsState {
|
||||||
|
terms: Term[];
|
||||||
|
selectedTerm: Term | undefined;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
fetchTerms: () => Promise<void>;
|
||||||
|
setSelectedTerm: (term: Term) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTermStore = create<TermsState>((set) => ({
|
||||||
|
terms: [],
|
||||||
|
selectedTerm: undefined,
|
||||||
|
setSelectedTerm: (term: Term) => {
|
||||||
|
set({ selectedTerm: term });
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
fetchTerms: async () => {
|
||||||
|
set({ isLoading: true, error: null }); // Reset error state on new fetch
|
||||||
|
try {
|
||||||
|
const terms = await fetchTerms();
|
||||||
|
set({ terms, selectedTerm: terms[0] || undefined, isLoading: false });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Ensure error is always an Error object
|
||||||
|
const error = err instanceof Error ? err : new Error(String(err));
|
||||||
|
set({ error: error, isLoading: false, terms: [] }); // Clear terms on error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default useTermStore;
|
268
lexikon/src/features/tsnePlot/TsnePlot.tsx
Normal file
268
lexikon/src/features/tsnePlot/TsnePlot.tsx
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
// src/components/TsnePlot.tsx
|
||||||
|
import { OrbitControls } from '@react-three/drei';
|
||||||
|
import { Canvas } from '@react-three/fiber';
|
||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { DataPoint, LoadedData } from '../../app/librarian/vspace/data'; // Pfad anpassen
|
||||||
|
|
||||||
|
interface ScatterPointProps {
|
||||||
|
position: [number, number, number];
|
||||||
|
color: THREE.ColorRepresentation;
|
||||||
|
pointData: DataPoint;
|
||||||
|
onPointerOver: (data: DataPoint | null, event: any) => void;
|
||||||
|
onPointerOut: () => void;
|
||||||
|
size?: number;
|
||||||
|
hoveredCluster: string | null; // Added to know which cluster is active
|
||||||
|
}
|
||||||
|
|
||||||
|
const ScatterPoint: React.FC<ScatterPointProps> = ({
|
||||||
|
position,
|
||||||
|
color,
|
||||||
|
pointData,
|
||||||
|
onPointerOver,
|
||||||
|
onPointerOut,
|
||||||
|
size = 0.5, // Standardgröße der Punkte
|
||||||
|
hoveredCluster,
|
||||||
|
}) => {
|
||||||
|
const meshRef = useRef<THREE.Mesh>(null!);
|
||||||
|
|
||||||
|
// Determine if the point is part of the currently hovered cluster
|
||||||
|
const isInHoveredCluster = pointData.cluster === hoveredCluster;
|
||||||
|
// Determine if any cluster is currently being hovered
|
||||||
|
const isAnyClusterHovered = hoveredCluster !== null;
|
||||||
|
|
||||||
|
// Calculate opacity:
|
||||||
|
// - If a cluster is hovered and this point is NOT in it, dim the point.
|
||||||
|
// - Otherwise (point is in the hovered cluster OR no cluster is hovered), full opacity.
|
||||||
|
const opacity = isAnyClusterHovered && !isInHoveredCluster ? 0.1 : 1.0; // Dimmed to 0.1, was 0.05
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh
|
||||||
|
ref={meshRef}
|
||||||
|
position={position}
|
||||||
|
// Points are always visible, opacity handles the emphasis
|
||||||
|
visible={true}
|
||||||
|
onPointerOver={(event) => {
|
||||||
|
event.stopPropagation(); // Wichtig für Hover-Effekte mit mehreren Objekten
|
||||||
|
onPointerOver(pointData, event.nativeEvent);
|
||||||
|
}}
|
||||||
|
onPointerOut={() => {
|
||||||
|
onPointerOut();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<sphereGeometry args={[size, 16, 8]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={color}
|
||||||
|
opacity={opacity}
|
||||||
|
transparent={true} // Set transparent if opacity is less than 1
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TooltipState {
|
||||||
|
visible: boolean;
|
||||||
|
content: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TsnePlot: React.FC = () => {
|
||||||
|
const [dataPoints, setDataPoints] = useState<DataPoint[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [tooltip, setTooltip] = useState<TooltipState>({
|
||||||
|
visible: false,
|
||||||
|
content: '',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
const [hoveredCluster, setHoveredCluster] = useState<string | null>(null); // State for hovered cluster
|
||||||
|
|
||||||
|
// Farbpalette für Cluster
|
||||||
|
const clusterColors: { [key: string]: THREE.Color } = useMemo(
|
||||||
|
() => ({
|
||||||
|
'0': new THREE.Color(0xff0000), // Rot
|
||||||
|
'1': new THREE.Color(0x00ff00), // Grün
|
||||||
|
'2': new THREE.Color(0x0000ff), // Blau
|
||||||
|
'3': new THREE.Color(0xffff00), // Gelb
|
||||||
|
'4': new THREE.Color(0xff00ff), // Magenta
|
||||||
|
'5': new THREE.Color(0x00ffff), // Cyan
|
||||||
|
'6': new THREE.Color(0xffa500), // Orange
|
||||||
|
'7': new THREE.Color(0x800080), // Lila
|
||||||
|
// Füge bei Bedarf mehr Farben für mehr Cluster hinzu
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const defaultColor = useMemo(() => new THREE.Color(0xaaaaaa), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const response = await fetch('/tsne_data.json');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const jsonData: LoadedData = await response.json();
|
||||||
|
|
||||||
|
const columns = jsonData.columns;
|
||||||
|
const parsedPoints = jsonData.data.map((rowData) => {
|
||||||
|
const point: any = {};
|
||||||
|
columns.forEach((col, index) => {
|
||||||
|
point[col] = rowData[index];
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...point,
|
||||||
|
x: Number(point.x),
|
||||||
|
y: Number(point.y),
|
||||||
|
z: Number(point.z),
|
||||||
|
cluster: String(point.cluster), // Sicherstellen, dass Cluster ein String ist
|
||||||
|
} as DataPoint;
|
||||||
|
});
|
||||||
|
setDataPoints(parsedPoints);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
console.error('Fehler beim Laden der Daten:', e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Log hoveredCluster changes
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('hoveredCluster changed:', hoveredCluster);
|
||||||
|
}, [hoveredCluster]);
|
||||||
|
|
||||||
|
const handlePointerOver = (data: DataPoint | null, event: MouseEvent) => {
|
||||||
|
if (data) {
|
||||||
|
console.log('Pointer Over:', data.cluster, data); // Log on pointer over
|
||||||
|
const formattedHoverText = data.hover_text.replace(/<br>/g, '\n');
|
||||||
|
setTooltip({
|
||||||
|
visible: true,
|
||||||
|
content: formattedHoverText,
|
||||||
|
x: event.clientX + 10,
|
||||||
|
y: event.clientY + 10,
|
||||||
|
});
|
||||||
|
setHoveredCluster(data.cluster); // Set hovered cluster
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerOut = () => {
|
||||||
|
console.log('Pointer Out'); // Log on pointer out
|
||||||
|
setTooltip((prev) => ({ ...prev, visible: false }));
|
||||||
|
setHoveredCluster(null); // Reset hovered cluster
|
||||||
|
};
|
||||||
|
|
||||||
|
// Skalierungsfaktor für die Koordinaten. Muss ggf. angepasst werden.
|
||||||
|
const scaleFactor = 1; // Siehe Erklärung im vorherigen Codebeispiel
|
||||||
|
|
||||||
|
if (loading)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: 'white',
|
||||||
|
textAlign: 'center',
|
||||||
|
paddingTop: '20px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Lade Daten...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (error)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: 'red',
|
||||||
|
textAlign: 'center',
|
||||||
|
paddingTop: '20px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Fehler beim Laden der Daten: {error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{tooltip.visible && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: `${tooltip.x}px`,
|
||||||
|
top: `${tooltip.y}px`,
|
||||||
|
backgroundColor: 'rgba(40, 40, 40, 0.9)', // Dunklerer, leicht transparenter Hintergrund
|
||||||
|
color: '#f0f0f0', // Helle Textfarbe
|
||||||
|
padding: '12px 16px', // Mehr Padding
|
||||||
|
borderRadius: '8px', // Größerer Radius
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 1000,
|
||||||
|
fontSize: '16px', // Etwas größere Schrift
|
||||||
|
fontWeight: '500', // Normale Schriftstärke
|
||||||
|
fontFamily:
|
||||||
|
'"Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', // Moderne Schriftart
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
maxWidth: '350px', // Etwas breiter
|
||||||
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)', // Deutlicherer Schatten
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.1)', // Subtiler Rand
|
||||||
|
backdropFilter: 'blur(4px)', // Hintergrundunschärfe (wenn vom Browser unterstützt)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tooltip.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Canvas
|
||||||
|
camera={{
|
||||||
|
position: [0, 0, 150],
|
||||||
|
fov: 75,
|
||||||
|
near: 0.1,
|
||||||
|
far: 2000,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: '#1a1a1a',
|
||||||
|
width: '100vw',
|
||||||
|
height: '100vh',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ambientLight intensity={0.7} />
|
||||||
|
<directionalLight position={[50, 50, 50]} intensity={0.8} />
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Statt InstancedMesh hier einzelne Meshes für jeden Punkt.
|
||||||
|
Für SEHR viele Punkte (>10k) wäre InstancedMesh oder Points besser.
|
||||||
|
Aber für einige Tausend ist das oft noch handhabbar und einfacher für Hover-Events.
|
||||||
|
Wenn die Performance ein Problem wird, muss man hier auf InstancedMesh umstellen.
|
||||||
|
Siehe Kommentar unten für einen alternativen InstancedMesh-Ansatz.
|
||||||
|
*/}
|
||||||
|
{dataPoints.map((point, index) => (
|
||||||
|
<ScatterPoint
|
||||||
|
key={point.chunk_1024_embeddings_id || index} // Eindeutiger Key
|
||||||
|
position={[
|
||||||
|
point.x * scaleFactor,
|
||||||
|
point.y * scaleFactor,
|
||||||
|
point.z * scaleFactor,
|
||||||
|
]}
|
||||||
|
color={clusterColors[point.cluster] || defaultColor}
|
||||||
|
pointData={point}
|
||||||
|
onPointerOver={handlePointerOver}
|
||||||
|
onPointerOut={handlePointerOut}
|
||||||
|
size={8} // Kleinere Punkte, da wir viele haben könnten
|
||||||
|
hoveredCluster={hoveredCluster} // Pass hovered cluster
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<OrbitControls
|
||||||
|
enableDamping
|
||||||
|
dampingFactor={0.05}
|
||||||
|
screenSpacePanning={false}
|
||||||
|
minDistance={10}
|
||||||
|
maxDistance={1000}
|
||||||
|
/>
|
||||||
|
</Canvas>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TsnePlot;
|
13
lexikon/src/features/tsnePlot/hoverStore.ts
Normal file
13
lexikon/src/features/tsnePlot/hoverStore.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { create } from "zustand/react";
|
||||||
|
|
||||||
|
interface HoverState {
|
||||||
|
hoveredClusterId: string | null;
|
||||||
|
setHoveredClusterId: (clusterId: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useHoverStore = create<HoverState>((set) => ({
|
||||||
|
hoveredClusterId: null,
|
||||||
|
setHoveredClusterId: (clusterId) => set({ hoveredClusterId: clusterId }),
|
||||||
|
}));
|
119
lexikon/src/features/tsnePlot/tsnePlotView.tsx
Normal file
119
lexikon/src/features/tsnePlot/tsnePlotView.tsx
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { OrbitControls, Sphere } from '@react-three/drei';
|
||||||
|
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { useHoverStore } from './hoverStore';
|
||||||
|
|
||||||
|
interface DataPoint {
|
||||||
|
id: string;
|
||||||
|
position: [number, number, number];
|
||||||
|
color: string;
|
||||||
|
clusterId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClusterPointProps {
|
||||||
|
point: DataPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ClusterPoint: React.FC<ClusterPointProps> = ({ point, ...props }) => {
|
||||||
|
const ref = useRef<THREE.Mesh>(null!);
|
||||||
|
const { hoveredClusterId, setHoveredClusterId } = useHoverStore();
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
|
const isVisible =
|
||||||
|
hoveredClusterId === null || hoveredClusterId === point.clusterId;
|
||||||
|
|
||||||
|
useFrame(() => {
|
||||||
|
if (
|
||||||
|
ref.current &&
|
||||||
|
ref.current.material instanceof THREE.MeshStandardMaterial
|
||||||
|
) {
|
||||||
|
(ref.current.material as THREE.MeshStandardMaterial).opacity =
|
||||||
|
isVisible ? 1 : 0.2;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sphere
|
||||||
|
ref={ref}
|
||||||
|
args={[0.1, 16, 16]}
|
||||||
|
position={point.position}
|
||||||
|
onPointerOver={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsHovered(true);
|
||||||
|
if (point.clusterId) {
|
||||||
|
setHoveredClusterId(point.clusterId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerOut={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsHovered(false);
|
||||||
|
setHoveredClusterId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<meshStandardMaterial
|
||||||
|
attach="material"
|
||||||
|
color={point.color}
|
||||||
|
transparent
|
||||||
|
opacity={isVisible ? 1 : 0.2}
|
||||||
|
/>
|
||||||
|
</Sphere>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PlotPointsProps {
|
||||||
|
data: DataPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlotPoints: React.FC<PlotPointsProps> = ({ data }) => {
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{data.map((point) => (
|
||||||
|
<ClusterPoint key={point.id} point={point} />
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Controls = () => {
|
||||||
|
// Use OrbitControlsProps for the ref type if possible, or the specific implementation type
|
||||||
|
const controlsRef = useRef<any>(null); // Using any temporarily to bypass the complex type issue
|
||||||
|
const { camera, gl } = useThree();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const domElement = gl.domElement;
|
||||||
|
const currentControls = controlsRef.current;
|
||||||
|
|
||||||
|
if (currentControls) {
|
||||||
|
// ... (existing comments and logic for OrbitControls)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// ... (cleanup logic)
|
||||||
|
};
|
||||||
|
}, [gl.domElement, camera]);
|
||||||
|
|
||||||
|
return <OrbitControls ref={controlsRef} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TsnePlotViewProps {
|
||||||
|
data: DataPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TsnePlotView: React.FC<TsnePlotViewProps> = ({ data }) => {
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100%', width: '100%' }}>
|
||||||
|
<Canvas camera={{ position: [0, 0, 15], fov: 50 }}>
|
||||||
|
<ambientLight intensity={0.7} />
|
||||||
|
<pointLight position={[10, 10, 10]} intensity={1} />
|
||||||
|
<Controls />
|
||||||
|
<PlotPoints data={data} />
|
||||||
|
</Canvas>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TsnePlotView;
|
||||||
|
export type { DataPoint };
|
9
lexikon/src/mdx-components.tsx
Normal file
9
lexikon/src/mdx-components.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import type { MDXComponents } from "mdx/types";
|
||||||
|
import { mdxComponents } from "shared/ui/markdown/mdxComponents";
|
||||||
|
|
||||||
|
export function useMDXComponents(components: MDXComponents): MDXComponents {
|
||||||
|
return {
|
||||||
|
...components,
|
||||||
|
...mdxComponents,
|
||||||
|
};
|
||||||
|
}
|
14
lexikon/src/middleware.ts
Normal file
14
lexikon/src/middleware.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
// This file re-exports the authentication middleware for Next.js
|
||||||
|
import { updateSession } from "shared/api/supabase/server/middleware";
|
||||||
|
|
||||||
|
// Rename updateSession to middleware for Next.js to recognize
|
||||||
|
export { updateSession as middleware };
|
||||||
|
|
||||||
|
// Optional: configure which paths the middleware should apply to
|
||||||
|
// By default, middleware runs on all paths; adjust matcher to exclude static files or specific routes
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
// All paths except Next.js internals and public routes
|
||||||
|
"/((?!_next/static|_next/image|favicon.ico|login|confirm|error|api).*)",
|
||||||
|
],
|
||||||
|
};
|
1
lexikon/src/shared/api/librarian/client.ts
Normal file
1
lexikon/src/shared/api/librarian/client.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
// fetch wrapper to FastAPI gateway
|
1
lexikon/src/shared/api/librarian/hooks.ts
Normal file
1
lexikon/src/shared/api/librarian/hooks.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
// useCrawlIndex, useDownloadTaskStatus
|
36
lexikon/src/shared/api/supabase/browser.ts
Normal file
36
lexikon/src/shared/api/supabase/browser.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
"use client"; // must run in the browser
|
||||||
|
import { createBrowserClient } from "@supabase/ssr";
|
||||||
|
|
||||||
|
export const supaBrowser = createBrowserClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
);
|
||||||
|
|
||||||
|
export async function signInWithGoogle(
|
||||||
|
redirectTo = `${window.location.origin}/auth/callback`,
|
||||||
|
) {
|
||||||
|
const { error, data } = await supaBrowser.auth.signInWithOAuth({
|
||||||
|
provider: "google",
|
||||||
|
options: { redirectTo },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
// the page will redirect automatically; data is only for tests
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signOut() {
|
||||||
|
const { error } = await supaBrowser.auth.signOut();
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans the module-content bucket by removing all files.
|
||||||
|
* Only use during development.
|
||||||
|
*/
|
||||||
|
export async function emptyBucket() {
|
||||||
|
const { data, error } = await supaBrowser.storage.emptyBucket('module-content');
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
}
|
41
lexikon/src/shared/api/supabase/hooks.ts
Normal file
41
lexikon/src/shared/api/supabase/hooks.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type Session, type User } from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { supaBrowser } from "./browser";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook to subscribe to Supabase Auth state in browser.
|
||||||
|
* Returns the current user session, user object, and loading status.
|
||||||
|
*/
|
||||||
|
export function useAuth() {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [session, setSession] = useState<Session | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Get initial session
|
||||||
|
supaBrowser.auth.getSession().then(({ data }: { data: { session: Session | null } }) => {
|
||||||
|
setSession(data.session);
|
||||||
|
setUser(data.session?.user ?? null);
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
// Listen for auth changes
|
||||||
|
const {
|
||||||
|
data: { subscription },
|
||||||
|
} = supaBrowser.auth.onAuthStateChange((_: unknown, session: Session | null) => {
|
||||||
|
setSession(session);
|
||||||
|
setUser(session?.user ?? null);
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
};
|
||||||
|
}, [supaBrowser]);
|
||||||
|
|
||||||
|
return { user, session, isLoading };
|
||||||
|
}
|
2
lexikon/src/shared/api/supabase/index.ts
Normal file
2
lexikon/src/shared/api/supabase/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./browser";
|
||||||
|
export * from "./hooks";
|
25
lexikon/src/shared/api/supabase/server/db.ts
Normal file
25
lexikon/src/shared/api/supabase/server/db.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { createServerClient } from "@supabase/ssr";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
export async function supaServer() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
|
return createServerClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll: () =>
|
||||||
|
cookieStore.getAll().map(({ name, value }) => ({ name, value })),
|
||||||
|
setAll: (cookiesToSet) => {
|
||||||
|
// Set cookies received from Supabase
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
cookieStore.set(name, value, options),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
2
lexikon/src/shared/api/supabase/server/index.ts
Normal file
2
lexikon/src/shared/api/supabase/server/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./db";
|
||||||
|
export * from "./login";
|
45
lexikon/src/shared/api/supabase/server/login.ts
Normal file
45
lexikon/src/shared/api/supabase/server/login.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { supaServer } from "./db";
|
||||||
|
|
||||||
|
export async function login(formData: FormData) {
|
||||||
|
const supabase = await supaServer();
|
||||||
|
|
||||||
|
// type-casting here for convenience
|
||||||
|
// in practice, you should validate your inputs
|
||||||
|
const data = {
|
||||||
|
email: formData.get("email") as string,
|
||||||
|
password: formData.get("password") as string,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { error } = await supabase.auth.signInWithPassword(data);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
redirect("/error");
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signup(formData: FormData) {
|
||||||
|
const supabase = await supaServer();
|
||||||
|
|
||||||
|
// type-casting here for convenience
|
||||||
|
// in practice, you should validate your inputs
|
||||||
|
const data = {
|
||||||
|
email: formData.get("email") as string,
|
||||||
|
password: formData.get("password") as string,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { error } = await supabase.auth.signUp(data);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
redirect("/error");
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
redirect("/");
|
||||||
|
}
|
74
lexikon/src/shared/api/supabase/server/middleware.ts
Normal file
74
lexikon/src/shared/api/supabase/server/middleware.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { createServerClient } from "@supabase/ssr";
|
||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
|
||||||
|
export async function updateSession(request: NextRequest) {
|
||||||
|
let supabaseResponse = NextResponse.next({
|
||||||
|
request,
|
||||||
|
});
|
||||||
|
|
||||||
|
const supabase = createServerClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return request.cookies.getAll();
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
request.cookies.set(name, value),
|
||||||
|
);
|
||||||
|
supabaseResponse = NextResponse.next({
|
||||||
|
request,
|
||||||
|
});
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
supabaseResponse.cookies.set(name, value, options),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Do not run code between createServerClient and
|
||||||
|
// supabase.auth.getUser(). A simple mistake could make it very hard to debug
|
||||||
|
// issues with users being randomly logged out.
|
||||||
|
|
||||||
|
// IMPORTANT: DO NOT REMOVE auth.getUser()
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
|
||||||
|
// TODO: Fix this and add Login logic
|
||||||
|
// if (
|
||||||
|
// !user &&
|
||||||
|
// !request.nextUrl.pathname.startsWith("/login") &&
|
||||||
|
// !request.nextUrl.pathname.startsWith("/confirm") &&
|
||||||
|
// !request.nextUrl.pathname.startsWith("/error") &&
|
||||||
|
// !request.nextUrl.pathname.startsWith("/auth") &&
|
||||||
|
// !request.nextUrl.pathname.startsWith("/api")
|
||||||
|
// ) {
|
||||||
|
// // no user, potentially respond by redirecting the user to the login page
|
||||||
|
// const url = request.nextUrl.clone();
|
||||||
|
|
||||||
|
// url.pathname = "/login";
|
||||||
|
|
||||||
|
// return NextResponse.redirect(url);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// IMPORTANT: You *must* return the supabaseResponse object as it is.
|
||||||
|
// If you're creating a new response object with NextResponse.next() make sure to:
|
||||||
|
// 1. Pass the request in it, like so:
|
||||||
|
// const myNewResponse = NextResponse.next({ request })
|
||||||
|
// 2. Copy over the cookies, like so:
|
||||||
|
// myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())
|
||||||
|
// 3. Change the myNewResponse object to fit your needs, but avoid changing
|
||||||
|
// the cookies!
|
||||||
|
// 4. Finally:
|
||||||
|
// return myNewResponse
|
||||||
|
// If this is not done, you may be causing the browser and server to go out
|
||||||
|
// of sync and terminate the user's session prematurely!
|
||||||
|
|
||||||
|
return supabaseResponse;
|
||||||
|
}
|
2
lexikon/src/shared/config/api.ts
Normal file
2
lexikon/src/shared/config/api.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
// export const LOCAL_API_BASE = 'http://localhost:8000/api';
|
||||||
|
export const LOCAL_API_BASE = 'os-machina.local:8000/v1';
|
11
lexikon/src/shared/config/fonts.ts
Normal file
11
lexikon/src/shared/config/fonts.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Fira_Code as FontMono, Inter as FontSans } from "next/font/google";
|
||||||
|
|
||||||
|
export const fontSans = FontSans({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-sans",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fontMono = FontMono({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-mono",
|
||||||
|
});
|
4
lexikon/src/shared/config/mockData.ts
Normal file
4
lexikon/src/shared/config/mockData.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export const example_summary =
|
||||||
|
"https://raw.githubusercontent.com/DotNaos/TGI13/refs/heads/main/Informatik/Man-in-the-middle.md";
|
||||||
|
|
||||||
|
export const example_hero_image = "https://moodle.fhgr.ch/pluginfile.php/1058426/course/overviewfiles/Titelbild.jpg";
|
30
lexikon/src/shared/config/paths.ts
Normal file
30
lexikon/src/shared/config/paths.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
export type StoragePaths = typeof storagePaths;
|
||||||
|
|
||||||
|
export const storagePaths = {
|
||||||
|
moduleContent: {
|
||||||
|
bucket: "module-content",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the storage path for a summary file.
|
||||||
|
* @param termCode The term code (e.g., 'FS25')
|
||||||
|
* @param courseId The course ID
|
||||||
|
* @param summaryName The summary name (should be sanitized)
|
||||||
|
*/
|
||||||
|
export const getSummaryPath = (
|
||||||
|
termCode: string,
|
||||||
|
courseId: number,
|
||||||
|
summaryName: string,
|
||||||
|
): string => {
|
||||||
|
return `${termCode}/courses/${courseId}/summaries/summary_${summaryName}.mdx`;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const getNewSummaryPath = (
|
||||||
|
currentPath: string,
|
||||||
|
newName: string,
|
||||||
|
): string => {
|
||||||
|
const dir = currentPath.substring(0, currentPath.lastIndexOf("/"));
|
||||||
|
return `${dir}/summary_${newName.trim().replaceAll(" ", "-")}.mdx`;
|
||||||
|
};
|
6
lexikon/src/shared/config/site.ts
Normal file
6
lexikon/src/shared/config/site.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export type SiteConfig = typeof siteConfig;
|
||||||
|
|
||||||
|
export const siteConfig = {
|
||||||
|
name: "Lexikon",
|
||||||
|
description: "A comprehensive resource for knowledge and learning.",
|
||||||
|
};
|
13
lexikon/src/shared/domain/course.ts
Normal file
13
lexikon/src/shared/domain/course.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Database } from "shared/types/db";
|
||||||
|
import { StudyModule } from "./module";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a course in the system using the generated database type
|
||||||
|
*/
|
||||||
|
export type Course = Database["library"]["Tables"]["courses"]["Row"] & {
|
||||||
|
is_user_enrolled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CourseModule = Course & StudyModule & {
|
||||||
|
is_user_favorite: boolean;
|
||||||
|
}
|
13
lexikon/src/shared/domain/librarian/index.d.ts
vendored
Normal file
13
lexikon/src/shared/domain/librarian/index.d.ts
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
export type {
|
||||||
|
TaskInfo,
|
||||||
|
DownloadRequestCourse,
|
||||||
|
SummaryResponse,
|
||||||
|
DownloadRequest,
|
||||||
|
} from './task';
|
||||||
|
export type {
|
||||||
|
MoodleIndex,
|
||||||
|
CourseIndex,
|
||||||
|
TermIndex,
|
||||||
|
DegreeProgramIndex,
|
||||||
|
FileEntryIndex,
|
||||||
|
} from './moodleIndex';
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user