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/
 | 
			
		||||
|-- lexikon/ # Frontend app
 | 
			
		||||
├── librarian/
 | 
			
		||||
│   ├── atlas-librarian/     # Main application
 | 
			
		||||
│   ├── 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