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