Add Frontend Code

This commit is contained in:
DotNaos 2025-06-12 16:36:02 +02:00
parent c46545f329
commit e85703df8f
134 changed files with 76840 additions and 0 deletions

View File

@ -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

View 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
View 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

View File

2
lexikon/.npmrc Normal file
View File

@ -0,0 +1,2 @@
public-hoist-pattern[]=*@heroui/*
package-lock=true

21
lexikon/LICENSE Normal file
View 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
View 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 builtin 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 plugins 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
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
lexikon/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View 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");
}

View 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>
);
};

View 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 },
);
}
}

View 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 },
);
}
}

View 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 },
);
}
}

View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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('&quot;', '');
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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -0,0 +1,3 @@
export default function Page() {
return <div>Please select a worker</div>;
}

View 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[][];
}

View 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;

View 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>
);
}

File diff suppressed because it is too large Load Diff

View 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;
}

View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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} />
</>
)}
</>
);
}

View 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>
);
}

View 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>
);
}

View 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);
});
}

View 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,
});
}

View 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;
}
},
};

View File

@ -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>
);
}

View 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 || [];
},
};

View 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 };
}

View 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;
}
}
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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>
);
};

View 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
}
)
)

View 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 &quot;
{selectedSummary?.chapter}&quot;?
</p>
</ConfirmModal>
</>
);
}

View 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"
/>
)}
</>
);
};

View 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>
);
}

View 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;

View 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 swipeitem */
const DRAG_THRESHOLD = 120;
/**
* Small wrapper around the Lucide Star so we dont have to repeat the
* `fill={filled ? "currentColor" : "none"}` boilerplate 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;

View 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>
);
}

View 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;

View 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 };
}

View 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,
};
}

View 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>
);
}

View 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.
* Returnstrue 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";
}

View 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[];
}

View 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[];
}

View 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;

View 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 },
}));
}
}
}));

View 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;

View 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;

View 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 }),
}));

View 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 };

View 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
View 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).*)",
],
};

View File

@ -0,0 +1 @@
// fetch wrapper to FastAPI gateway

View File

@ -0,0 +1 @@
// useCrawlIndex, useDownloadTaskStatus

View 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;
}

View 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 };
}

View File

@ -0,0 +1,2 @@
export * from "./browser";
export * from "./hooks";

View 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),
);
},
},
},
);
}

View File

@ -0,0 +1,2 @@
export * from "./db";
export * from "./login";

View 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("/");
}

View 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;
}

View File

@ -0,0 +1,2 @@
// export const LOCAL_API_BASE = 'http://localhost:8000/api';
export const LOCAL_API_BASE = 'os-machina.local:8000/v1';

View 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",
});

View 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";

View 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`;
};

View File

@ -0,0 +1,6 @@
export type SiteConfig = typeof siteConfig;
export const siteConfig = {
name: "Lexikon",
description: "A comprehensive resource for knowledge and learning.",
};

View 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;
}

View 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