diff --git a/public/images/background.svg b/public/images/background.svg new file mode 100644 index 00000000..5476f580 --- /dev/null +++ b/public/images/background.svg @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/line-in-motion.svg b/public/images/line-in-motion.svg new file mode 100644 index 00000000..b53a81aa --- /dev/null +++ b/public/images/line-in-motion.svg @@ -0,0 +1,12 @@ + + + + line-in-motion + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/src/app/admin/AdminSidebar.tsx b/src/app/admin/AdminSidebar.tsx index 0ede23d6..7d9e1bc4 100644 --- a/src/app/admin/AdminSidebar.tsx +++ b/src/app/admin/AdminSidebar.tsx @@ -23,6 +23,9 @@ import { Images, Gavel, Vote, + Route, + BookText, + GraduationCap, } from "lucide-react"; import { @@ -185,6 +188,29 @@ const groups: AdminGroup[] = [ }, ], }, + { + title: "admin:categories.plugg", + entries: [ + { + title: "admin:programs.self", + url: "/admin/programs", + permissions: [[ActionEnum.MANAGE, TargetEnum.PLUGG]], + icon: GraduationCap, + }, + { + title: "admin:specialisations.self", + url: "/admin/specialisations", + permissions: [[ActionEnum.MANAGE, TargetEnum.PLUGG]], + icon: Route, + }, + { + title: "admin:courses.self", + url: "/admin/courses", + permissions: [[ActionEnum.MANAGE, TargetEnum.PLUGG]], + icon: BookText, + }, + ], + }, { title: "admin:categories.elections", entries: [ diff --git a/src/app/admin/albums/[id]/page.tsx b/src/app/admin/albums/[id]/page.tsx index 8ba48649..93f9e208 100644 --- a/src/app/admin/albums/[id]/page.tsx +++ b/src/app/admin/albums/[id]/page.tsx @@ -73,7 +73,7 @@ export default function AlbumPage({ params }: AlbumPageProps) { }); if (Number.isNaN(albumId)) { - return ; + return ; } if (error) { return ; diff --git a/src/app/admin/courses/CourseEditForm.tsx b/src/app/admin/courses/CourseEditForm.tsx new file mode 100644 index 00000000..f9d769c9 --- /dev/null +++ b/src/app/admin/courses/CourseEditForm.tsx @@ -0,0 +1,256 @@ +import { useEffect, useState } from "react"; +import { z } from "zod"; +import { + deleteCourseMutation, + getAllCoursesQueryKey, + getCourseOptions, + updateCourseMutation, + getCoursesByProgramYearQueryKey, + getCoursesBySpecialisationQueryKey, +} from "@/api/@tanstack/react-query.gen"; +import { AssociationTypeEnum, type CourseRead, type CourseUpdate } from "@/api"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import AdminForm from "@/widgets/AdminForm"; +import AssociatedImageManager from "@/components/AssociatedImageManager"; + +const MAX_COURSE_TITLE = 200; +const MAX_COURSE_CODE = 100; +const MAX_COURSE_DESC = 10000; + +const courseEditSchema = z.object({ + id: z.number(), + title: z.string().trim().min(1).max(MAX_COURSE_TITLE), + course_code: z.string().max(MAX_COURSE_CODE).min(1), + short_identifier: z.string().max(MAX_COURSE_TITLE).optional(), + description: z.string().max(MAX_COURSE_DESC).optional(), +}); + +interface CourseEditFormProps { + item: CourseRead | null; + onClose: () => void; +} + +export default function CourseEditForm({ onClose, item }: CourseEditFormProps) { + const { t } = useTranslation("admin"); + + const [convertedItem, setConvertedItem] = useState | null>(null); + const [associatedImageId, setAssociatedImageId] = useState( + null, + ); + + useEffect(() => { + if (item) { + const convertedItem = { + id: item.course_id, + title: item.title, + course_code: item.course_code, + short_identifier: item.short_identifier ?? "", + description: item.description ?? "", + } as z.infer; + setConvertedItem(convertedItem); + setAssociatedImageId(item.associated_img_id ?? null); + } else { + setAssociatedImageId(null); + } + }, [item]); + + const queryClient = useQueryClient(); + + async function syncCourseAfterImageChange(courseId: number) { + try { + const updatedCourse = await queryClient.fetchQuery({ + ...getCourseOptions({ path: { course_id: courseId } }), + }); + + setAssociatedImageId(updatedCourse.associated_img_id ?? null); + + queryClient.invalidateQueries({ + queryKey: getAllCoursesQueryKey(), + }); + invalidateCourseQueries(item?.program_years, item?.specialisations); + invalidateCourseQueries( + updatedCourse.program_years, + updatedCourse.specialisations, + ); + } catch { + toast.error(t("admin:associated_image.sync_error")); + } + } + + function invalidateCourseQueries( + programYears: CourseRead["program_years"] = [], + specialisations: CourseRead["specialisations"] = [], + ) { + // This only invalidates the program years and specialisations that are related to the course, + // not all the program years and specialisations. + const programYearIds = new Set( + programYears.map((py) => py.program_year_id), + ); + const specialisationIds = new Set( + specialisations.map((s) => s.specialisation_id), + ); + + for (const programYearId of programYearIds) { + queryClient.invalidateQueries({ + queryKey: getCoursesByProgramYearQueryKey({ + path: { program_year_id: programYearId }, + }), + }); + } + + for (const specialisationId of specialisationIds) { + queryClient.invalidateQueries({ + queryKey: getCoursesBySpecialisationQueryKey({ + path: { specialisation_id: specialisationId }, + }), + }); + } + } + + const updateCourse = useMutation({ + ...updateCourseMutation(), + throwOnError: false, + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: getAllCoursesQueryKey(), + }); + invalidateCourseQueries(item?.program_years, item?.specialisations); + invalidateCourseQueries(data?.program_years, data?.specialisations); + toast.success(t("courses.edit_success")); + }, + onError: (error) => { + toast.error( + typeof error?.detail === "string" + ? error.detail + : t("courses.edit_error"), + ); + onClose(); + }, + }); + + const removeCourse = useMutation({ + ...deleteCourseMutation(), + throwOnError: false, + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: getAllCoursesQueryKey(), + }); + invalidateCourseQueries(item?.program_years, item?.specialisations); + invalidateCourseQueries(data?.program_years, data?.specialisations); + toast.success(t("courses.remove_success")); + }, + onError: (error) => { + toast.error( + typeof error?.detail === "string" + ? error.detail + : t("courses.remove_error"), + ); + onClose(); + }, + }); + + function handleFormSubmit(values: z.infer) { + const updatedCourse: CourseUpdate = { + title: values.title, + course_code: values.course_code, + short_identifier: values.short_identifier?.trim() + ? values.short_identifier + : null, + description: values.description?.trim() ? values.description : null, + }; + + updateCourse.mutate( + { + path: { course_id: values.id }, + body: updatedCourse, + }, + { + onSuccess: () => { + onClose(); + }, + }, + ); + } + + function handleRemoveSubmit(data: z.infer) { + removeCourse.mutate( + { path: { course_id: data.id } }, + { + onSuccess: () => { + onClose(); + }, + }, + ); + } + + return ( + { + if (!isOpen) onClose(); + }} + inputFields={[ + { + variant: "text", + name: "title", + label: t("courses.title"), + placeholder: t("courses.title"), + colSpan: 1, + }, + { + variant: "text", + name: "course_code", + label: t("courses.course_code"), + placeholder: t("courses.course_code"), + colSpan: 1, + }, + { + variant: "text", + name: "short_identifier", + label: t("courses.short_identifier"), + placeholder: t("courses.short_identifier_placeholder"), + colSpan: 1, + }, + { + variant: "textarea", + name: "description", + label: t("courses.description"), + placeholder: t("courses.description"), + rows: 8, + colSpan: 3, + }, + ]} + zodSchema={courseEditSchema} + onSubmit={handleFormSubmit} + useDeleteButton + onDelete={handleRemoveSubmit} + showDialogButton={false} + editItem={convertedItem || undefined} + setEditItem={setConvertedItem} + customButtons={ + { + if (!item?.course_id) { + return; + } + + void syncCourseAfterImageChange(item.course_id); + }} + /> + } + requireConfirmationToDelete={true} + confirmDeleteDialogTitle={t("courses.confirm_remove")} + confirmDeleteDialogDescription={t("courses.confirm_remove_text")} + /> + ); +} diff --git a/src/app/admin/courses/CourseForm.tsx b/src/app/admin/courses/CourseForm.tsx new file mode 100644 index 00000000..8b9cc293 --- /dev/null +++ b/src/app/admin/courses/CourseForm.tsx @@ -0,0 +1,114 @@ +import { useState } from "react"; +import { z } from "zod"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + createCourseMutation, + getAllCoursesOptions, + getAllCoursesQueryKey, +} from "@/api/@tanstack/react-query.gen"; +import type { CourseCreate } from "@/api"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import AdminForm from "@/widgets/AdminForm"; + +const MAX_COURSE_TITLE = 200; +const MAX_COURSE_CODE = 100; +const MAX_COURSE_DESC = 10000; + +const courseSchema = z.object({ + title: z.string().trim().min(1).max(MAX_COURSE_TITLE), + course_code: z.string().max(MAX_COURSE_CODE).min(1), + short_identifier: z.string().max(MAX_COURSE_TITLE).optional(), + description: z.string().max(MAX_COURSE_DESC).optional(), +}); + +export default function CourseForm() { + const [open, setOpen] = useState(false); + const { t } = useTranslation("admin"); + + const queryClient = useQueryClient(); + const { data: allCourses = [] } = useQuery({ + ...getAllCoursesOptions(), + refetchOnWindowFocus: false, + }); + + const createCourse = useMutation({ + ...createCourseMutation(), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: getAllCoursesQueryKey(), + }); + toast.success(t("courses.create_success")); + setOpen(false); + }, + onError: (error) => { + toast.error( + typeof error?.detail === "string" + ? error.detail + : t("courses.create_error"), + ); + setOpen(false); + }, + }); + + function onSubmit(values: z.infer) { + const payload: CourseCreate = { + title: values.title, + course_code: values.course_code, + short_identifier: values.short_identifier?.trim() + ? values.short_identifier + : null, + description: values.description?.trim() ? values.description : null, + }; + + createCourse.mutate({ body: payload }); + } + + return ( + + ); +} diff --git a/src/app/admin/courses/[course_id]/CourseDocumentEditForm.tsx b/src/app/admin/courses/[course_id]/CourseDocumentEditForm.tsx new file mode 100644 index 00000000..6977d1e8 --- /dev/null +++ b/src/app/admin/courses/[course_id]/CourseDocumentEditForm.tsx @@ -0,0 +1,239 @@ +import { useEffect, useState } from "react"; +import { z } from "zod"; +import { + deleteCourseDocumentMutation, + getAllDocumentsFromCourseQueryKey, + getCourseQueryKey, + updateCourseDocumentMutation, +} from "@/api/@tanstack/react-query.gen"; +import { CategoryEnum, type CategoryEnum as CategoryEnumType } from "@/api"; +import type { CourseDocumentRead, CourseDocumentUpdate } from "@/api"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import AdminForm from "@/widgets/AdminForm"; +import { Button } from "@/components/ui/button"; +import { ExternalLink } from "lucide-react"; +import { buildCourseDocumentFileHref } from "@/utils/pluggHrefBuilders"; + +const courseDocumentEditSchema = z.object({ + id: z.number(), + course_id: z.number(), + title: z.string().trim().min(1), + author: z.string().trim().min(1), + category: z.enum([ + CategoryEnum.NOTES, + CategoryEnum.SUMMARY, + CategoryEnum.SOLUTIONS, + CategoryEnum.OTHER, + ]), + sub_category: z.string().optional(), +}); + +interface CourseEditFormProps { + item: CourseDocumentRead | null; + onClose: () => void; +} + +export default function CourseEditForm({ onClose, item }: CourseEditFormProps) { + const { t } = useTranslation("admin"); + + function openCourseDocument(courseDocumentId: number) { + window.open( + buildCourseDocumentFileHref(courseDocumentId), + "_blank", + "noopener,noreferrer", + ); + } + + const [convertedItem, setConvertedItem] = useState | null>(null); + + const categoryOptions: { value: CategoryEnumType; label: string }[] = [ + { + value: CategoryEnum.NOTES, + label: t("courses.course_documents.categories.notes"), + }, + { + value: CategoryEnum.SUMMARY, + label: t("courses.course_documents.categories.summary"), + }, + { + value: CategoryEnum.SOLUTIONS, + label: t("courses.course_documents.categories.solutions"), + }, + { + value: CategoryEnum.OTHER, + label: t("courses.course_documents.categories.other"), + }, + ]; + + useEffect(() => { + if (item) { + const convertedItem = { + id: item.course_document_id, + course_id: item.course_id, + title: item.title, + author: item.author, + category: item.category, + sub_category: item.sub_category ?? "", + } as z.infer; + setConvertedItem(convertedItem); + } + }, [item]); + + const queryClient = useQueryClient(); + + const updateCourseDocument = useMutation({ + ...updateCourseDocumentMutation(), + throwOnError: false, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: getAllDocumentsFromCourseQueryKey({ + path: { course_id: item?.course_id ?? convertedItem?.course_id ?? 0 }, + }), + }); + queryClient.invalidateQueries({ + queryKey: getCourseQueryKey({ + path: { course_id: item?.course_id ?? convertedItem?.course_id ?? 0 }, + }), + }); + toast.success(t("courses.course_documents.edit_success")); + }, + onError: (error) => { + toast.error( + typeof error?.detail === "string" + ? error.detail + : t("courses.course_documents.edit_error"), + ); + onClose(); + }, + }); + + const removeCourseDocument = useMutation({ + ...deleteCourseDocumentMutation(), + throwOnError: false, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: getAllDocumentsFromCourseQueryKey({ + path: { course_id: item?.course_id ?? convertedItem?.course_id ?? 0 }, + }), + }); + queryClient.invalidateQueries({ + queryKey: getCourseQueryKey({ + path: { course_id: item?.course_id ?? convertedItem?.course_id ?? 0 }, + }), + }); + toast.success(t("courses.course_documents.remove_success")); + }, + onError: (error) => { + toast.error( + typeof error?.detail === "string" + ? error.detail + : t("courses.course_documents.remove_error"), + ); + onClose(); + }, + }); + + function handleFormSubmit(values: z.infer) { + const updatedCourseDocument: CourseDocumentUpdate = { + title: values.title, + author: values.author, + category: values.category, + sub_category: values.sub_category?.trim() ? values.sub_category : null, + }; + + updateCourseDocument.mutate( + { + path: { course_document_id: values.id }, + body: updatedCourseDocument, + }, + { + onSuccess: () => { + onClose(); + }, + }, + ); + } + + function handleRemoveSubmit(data: z.infer) { + removeCourseDocument.mutate( + { path: { course_document_id: data.id } }, + { + onSuccess: () => { + onClose(); + }, + }, + ); + } + + return ( + { + if (!isOpen) onClose(); + }} + inputFields={[ + { + variant: "text", + name: "title", + label: t("courses.course_documents.title"), + placeholder: t("courses.course_documents.title"), + colSpan: 1, + }, + { + variant: "text", + name: "author", + label: t("courses.course_documents.author"), + placeholder: t("courses.course_documents.author"), + colSpan: 1, + }, + { + variant: "selectFromOptions", + name: "category", + label: t("courses.course_documents.category"), + placeholder: t("courses.course_documents.select_category"), + options: categoryOptions, + colSpan: 1, + }, + { + variant: "text", + name: "sub_category", + label: t("courses.course_documents.sub_category"), + placeholder: t("courses.course_documents.sub_category"), + colSpan: 1, + }, + ]} + zodSchema={courseDocumentEditSchema} + onSubmit={handleFormSubmit} + useDeleteButton + onDelete={handleRemoveSubmit} + showDialogButton={false} + editItem={convertedItem || undefined} + setEditItem={setConvertedItem} + customButtons={ + + } + requireConfirmationToDelete={true} + confirmDeleteDialogTitle={t("courses.course_documents.confirm_remove")} + confirmDeleteDialogDescription={t( + "courses.course_documents.confirm_remove_text", + )} + /> + ); +} diff --git a/src/app/admin/courses/[course_id]/CourseDocumentForm.tsx b/src/app/admin/courses/[course_id]/CourseDocumentForm.tsx new file mode 100644 index 00000000..8b14b031 --- /dev/null +++ b/src/app/admin/courses/[course_id]/CourseDocumentForm.tsx @@ -0,0 +1,161 @@ +import { useState } from "react"; +import { z } from "zod"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + createCourseDocumentMutation, + getAllDocumentsFromCourseQueryKey, +} from "@/api/@tanstack/react-query.gen"; +import { CategoryEnum, type CategoryEnum as CategoryEnumType } from "@/api"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import AdminForm from "@/widgets/AdminForm"; +import { MAX_DOC_FILE_SIZE_MB } from "@/constants"; + +const ALLOWED_DOC_FILE_TYPES = new Set(["application/pdf"]); + +interface CourseDocumentFormProps { + courseId: number; +} + +export default function CourseDocumentForm({ + courseId, +}: CourseDocumentFormProps) { + const [open, setOpen] = useState(false); + const { t } = useTranslation("admin"); + + const courseDocumentSchema = z.object({ + title: z.string().trim().min(1), + author: z.string().trim().min(1), + category: z.enum([ + CategoryEnum.NOTES, + CategoryEnum.SUMMARY, + CategoryEnum.SOLUTIONS, + CategoryEnum.OTHER, + ]), + sub_category: z.string().optional(), + file: z + .instanceof(File) + .refine( + (file) => file.size <= MAX_DOC_FILE_SIZE_MB * 1024 * 1024, + t("courses.course_documents.file_size_error", { + size: MAX_DOC_FILE_SIZE_MB, + }), + ) + .refine( + (file) => ALLOWED_DOC_FILE_TYPES.has(file.type), + t("courses.course_documents.file_type_error"), + ), + }); + + const queryClient = useQueryClient(); + + const categoryOptions: { value: CategoryEnumType; label: string }[] = [ + { + value: CategoryEnum.NOTES, + label: t("courses.course_documents.categories.notes"), + }, + { + value: CategoryEnum.SUMMARY, + label: t("courses.course_documents.categories.summary"), + }, + { + value: CategoryEnum.SOLUTIONS, + label: t("courses.course_documents.categories.solutions"), + }, + { + value: CategoryEnum.OTHER, + label: t("courses.course_documents.categories.other"), + }, + ]; + + const createCourseDocument = useMutation({ + ...createCourseDocumentMutation(), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: getAllDocumentsFromCourseQueryKey({ + path: { course_id: courseId }, + }), + }); + toast.success(t("courses.course_documents.create_success")); + setOpen(false); + }, + onError: (error) => { + toast.error( + typeof error?.detail === "string" + ? error.detail + : t("courses.course_documents.create_error"), + ); + setOpen(false); + }, + }); + + function onSubmit(values: z.infer) { + createCourseDocument.mutate({ + body: { + title: values.title, + author: values.author, + category: values.category, + sub_category: values.sub_category?.trim() ? values.sub_category : null, + file: values.file, + course_id: courseId, + }, + }); + } + + return ( + + ); +} diff --git a/src/app/admin/courses/[course_id]/page.tsx b/src/app/admin/courses/[course_id]/page.tsx new file mode 100644 index 00000000..8f070bd0 --- /dev/null +++ b/src/app/admin/courses/[course_id]/page.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { + getAllDocumentsFromCourseOptions, + getCourseOptions, +} from "@/api/@tanstack/react-query.gen"; +import { useQuery } from "@tanstack/react-query"; +import { type ColumnDef, createColumnHelper } from "@tanstack/react-table"; +import type { CourseDocumentRead } from "@/api"; +import CourseEditForm from "./CourseDocumentEditForm"; +import { useTranslation } from "react-i18next"; +import AdminPage from "@/widgets/AdminPage"; +import CourseForm from "./CourseDocumentForm"; +import { Button } from "@/components/ui/button"; +import { ExternalLink, List } from "lucide-react"; +import { useParams, useRouter } from "next/navigation"; +import { LoadingErrorCard } from "@/components/LoadingErrorCard"; +import { buildCourseDocumentFileHref } from "@/utils/pluggHrefBuilders"; + +export default function CourseDocumentsPage() { + const { t } = useTranslation("admin"); + + const router = useRouter(); + const params = useParams(); + const courseId = Number(params.course_id); + const validCourseId = Number.isFinite(courseId) && courseId > 0; + + function openCourseDocument(courseDocumentId: number) { + window.open( + buildCourseDocumentFileHref(courseDocumentId), + "_blank", + "noopener,noreferrer", + ); + } + + const courseQuery = useQuery({ + ...getCourseOptions({ path: { course_id: validCourseId ? courseId : 0 } }), + enabled: validCourseId, + refetchOnWindowFocus: false, + }); + + if (!validCourseId) { + return ( + + ); + } + + const translatedCategoryByValue: Record< + CourseDocumentRead["category"], + string + > = { + Notes: t("courses.course_documents.categories.notes"), + Summary: t("courses.course_documents.categories.summary"), + Solutions: t("courses.course_documents.categories.solutions"), + Other: t("courses.course_documents.categories.other"), + }; + + // Column setup + const columnHelper = createColumnHelper(); + // biome-ignore lint/suspicious/noExplicitAny: any is kind of needed here + const columns: ColumnDef[] = [ + columnHelper.accessor("title", { + header: t("courses.course_documents.title"), + cell: (info) => info.getValue(), + }), + columnHelper.accessor("file_name", { + header: t("courses.course_documents.file_name"), + cell: (info) => info.getValue() || "-", + }), + columnHelper.accessor("author", { + header: t("courses.course_documents.author"), + cell: (info) => info.getValue() || "-", + }), + columnHelper.accessor("category", { + header: t("courses.course_documents.category"), + cell: (info) => { + const category = info.getValue() as CourseDocumentRead["category"]; + return translatedCategoryByValue[category] ?? category; + }, + }), + columnHelper.accessor("sub_category", { + header: t("courses.course_documents.sub_category"), + cell: (info) => info.getValue() || "-", + }), + columnHelper.accessor("updated_at", { + header: t("courses.course_documents.updated_at"), + cell: (info) => + new Date(info.getValue()).toLocaleString("sv-SE", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }), + }), + columnHelper.display({ + id: "open_document", + header: t("courses.course_documents.open_document"), + cell: (info) => ( + + ), + }), + ]; + + return ( + + + + + } + /> + ); +} diff --git a/src/app/admin/courses/page.tsx b/src/app/admin/courses/page.tsx new file mode 100644 index 00000000..f46da843 --- /dev/null +++ b/src/app/admin/courses/page.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { getAllCoursesOptions } from "@/api/@tanstack/react-query.gen"; +import { useQuery } from "@tanstack/react-query"; +import { type ColumnDef, createColumnHelper } from "@tanstack/react-table"; +import type { CourseRead } from "@/api"; +import CourseEditForm from "./CourseEditForm"; +import { useTranslation } from "react-i18next"; +import AdminPage from "@/widgets/AdminPage"; +import CourseForm from "./CourseForm"; +import { Eye } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useRouter } from "next/navigation"; + +export default function Courses() { + const { t } = useTranslation("admin"); + + const router = useRouter(); + + function formatUpdatedAt(updatedAt: CourseRead["updated_at"]) { + return updatedAt + ? new Date(updatedAt).toLocaleString("sv-SE", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }) + : "-"; + } + + // Column setup + const columnHelper = createColumnHelper(); + // biome-ignore lint/suspicious/noExplicitAny: any is kind of needed here + const columns: ColumnDef[] = [ + columnHelper.accessor("title", { + header: t("courses.title"), + cell: (info) => info.getValue(), + }), + columnHelper.accessor("course_code", { + header: t("courses.course_code"), + cell: (info) => info.getValue() || "-", + }), + columnHelper.accessor("short_identifier", { + header: t("courses.short_identifier"), + cell: (info) => info.getValue() || "-", + }), + columnHelper.accessor("updated_at", { + header: t("courses.updated_at"), + cell: (info) => formatUpdatedAt(info.getValue()), + }), + columnHelper.display({ + id: "view_course_documents", + header: t("courses.view_course_documents"), + cell: (info) => ( +
+ +
+ ), + }), + ]; + + return ( + } + /> + ); +} diff --git a/src/app/admin/programs/ProgramEditForm.tsx b/src/app/admin/programs/ProgramEditForm.tsx new file mode 100644 index 00000000..2744127f --- /dev/null +++ b/src/app/admin/programs/ProgramEditForm.tsx @@ -0,0 +1,257 @@ +import { useEffect, useState } from "react"; +import { z } from "zod"; +import { + deleteProgramMutation, + getProgramOptions, + getAllSpecialisationsOptions, + getAllProgramsQueryKey, + updateProgramMutation, +} from "@/api/@tanstack/react-query.gen"; +import { + AssociationTypeEnum, + type ProgramRead, + type ProgramUpdate, +} from "@/api"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import AdminForm from "@/widgets/AdminForm"; +import AssociatedImageManager from "@/components/AssociatedImageManager"; + +const MAX_PROGRAM_TITLE = 100; +const MAX_PROGRAM_DESC = 10000; + +const programEditSchema = z.object({ + id: z.number(), + title_sv: z.string().trim().min(1).max(MAX_PROGRAM_TITLE), + title_en: z.string().trim().min(1).max(MAX_PROGRAM_TITLE), + description_sv: z.string().max(MAX_PROGRAM_DESC).optional(), + description_en: z.string().max(MAX_PROGRAM_DESC).optional(), + specialisation_ids: z.array(z.number()).optional(), +}); + +interface ProgramEditFormProps { + item: ProgramRead | null; + onClose: () => void; +} + +export default function ProgramEditForm({ + onClose, + item, +}: ProgramEditFormProps) { + const { t, i18n } = useTranslation("admin"); + const { data: allSpecialisations = [] } = useQuery({ + ...getAllSpecialisationsOptions(), + refetchOnWindowFocus: false, + }); + + const specialisationOptions = allSpecialisations + .map((specialisation) => ({ + value: specialisation.specialisation_id, + label: + i18n.language === "sv" + ? specialisation.title_sv + : specialisation.title_en, + })) + .sort((a, b) => a.label.localeCompare(b.label, i18n.language)); + + const [convertedItem, setConvertedItem] = useState | null>(null); + const [associatedImageId, setAssociatedImageId] = useState( + null, + ); + + useEffect(() => { + if (item) { + const convertedItem = { + id: item.program_id, + title_sv: item.title_sv, + title_en: item.title_en, + description_sv: item.description_sv ?? "", + description_en: item.description_en ?? "", + specialisation_ids: (item.specialisations ?? []).map( + (specialisation) => specialisation.specialisation_id, + ), + } as z.infer; + setConvertedItem(convertedItem); + setAssociatedImageId(item.associated_img_id ?? null); + } else { + setAssociatedImageId(null); + } + }, [item]); + + const queryClient = useQueryClient(); + + async function syncProgramAfterImageChange(programId: number) { + try { + const updatedProgram = await queryClient.fetchQuery({ + ...getProgramOptions({ path: { program_id: programId } }), + }); + + setAssociatedImageId(updatedProgram.associated_img_id ?? null); + + queryClient.invalidateQueries({ + queryKey: getAllProgramsQueryKey(), + }); + } catch { + toast.error(t("admin:associated_image.sync_error")); + } + } + + const updateProgram = useMutation({ + ...updateProgramMutation(), + throwOnError: false, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: getAllProgramsQueryKey(), + }); + toast.success(t("programs.edit_success")); + }, + onError: (error) => { + toast.error( + typeof error?.detail === "string" + ? error.detail + : t("programs.edit_error"), + ); + onClose(); + }, + }); + + const removeProgram = useMutation({ + ...deleteProgramMutation(), + throwOnError: false, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: getAllProgramsQueryKey(), + }); + toast.success(t("programs.remove_success")); + }, + onError: (error) => { + toast.error( + typeof error?.detail === "string" + ? error.detail + : t("programs.remove_error"), + ); + onClose(); + }, + }); + + function handleFormSubmit(values: z.infer) { + const updatedProgram: ProgramUpdate = { + title_sv: values.title_sv, + title_en: values.title_en, + description_sv: values.description_sv?.trim() + ? values.description_sv + : null, + description_en: values.description_en?.trim() + ? values.description_en + : null, + specialisation_ids: values.specialisation_ids ?? [], + }; + + updateProgram.mutate( + { + path: { program_id: values.id }, + body: updatedProgram, + }, + { + onSuccess: () => { + onClose(); + }, + }, + ); + } + + function handleRemoveSubmit(data: z.infer) { + removeProgram.mutate( + { path: { program_id: data.id } }, + { + onSuccess: () => { + onClose(); + }, + }, + ); + } + + return ( + { + if (!isOpen) onClose(); + }} + inputFields={[ + { + variant: "text", + name: "title_sv", + label: t("title_sv"), + placeholder: t("title_sv"), + colSpan: 2, + }, + { + variant: "text", + name: "title_en", + label: t("title_en"), + placeholder: t("title_en"), + colSpan: 2, + }, + { + variant: "textarea", + name: "description_sv", + label: t("description_sv"), + placeholder: t("description_sv"), + rows: 8, + colSpan: 2, + }, + { + variant: "textarea", + name: "description_en", + label: t("description_en"), + placeholder: t("description_en"), + rows: 8, + colSpan: 2, + }, + { + variant: "styledMultiSelect", + name: "specialisation_ids", + label: t("programs.specialisations"), + placeholder: t("programs.select_specialisations"), + options: specialisationOptions, + colSpan: 4, + }, + ]} + zodSchema={programEditSchema} + onSubmit={handleFormSubmit} + useDeleteButton + onDelete={handleRemoveSubmit} + showDialogButton={false} + editItem={convertedItem || undefined} + setEditItem={setConvertedItem} + customButtons={ + { + if (!item?.program_id) { + return; + } + + void syncProgramAfterImageChange(item.program_id); + }} + addButtonText={t("programs.add_program_image")} + /> + } + confirmDeleteDialogConfirmByTyping={true} + confirmDeleteDialogConfirmByTypingKey={ + item?.title_sv || "Delete this program" + } + requireConfirmationToDelete={true} + confirmDeleteDialogTitle={t("programs.confirm_remove")} + confirmDeleteDialogDescription={t("programs.confirm_remove_text")} + /> + ); +} diff --git a/src/app/admin/programs/ProgramForm.tsx b/src/app/admin/programs/ProgramForm.tsx new file mode 100644 index 00000000..411294eb --- /dev/null +++ b/src/app/admin/programs/ProgramForm.tsx @@ -0,0 +1,134 @@ +import { useState } from "react"; +import { z } from "zod"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + createProgramMutation, + getAllSpecialisationsOptions, + getAllProgramsQueryKey, +} from "@/api/@tanstack/react-query.gen"; +import type { ProgramCreate } from "@/api"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import AdminForm from "@/widgets/AdminForm"; + +const MAX_PROGRAM_TITLE = 100; +const MAX_PROGRAM_DESC = 10000; + +const programSchema = z.object({ + title_sv: z.string().trim().min(1).max(MAX_PROGRAM_TITLE), + title_en: z.string().trim().min(1).max(MAX_PROGRAM_TITLE), + description_sv: z.string().max(MAX_PROGRAM_DESC).optional(), + description_en: z.string().max(MAX_PROGRAM_DESC).optional(), + specialisation_ids: z.array(z.number()).optional(), +}); + +export default function ProgramForm() { + const [open, setOpen] = useState(false); + const { t, i18n } = useTranslation("admin"); + + const queryClient = useQueryClient(); + const { data: allSpecialisations = [] } = useQuery({ + ...getAllSpecialisationsOptions(), + refetchOnWindowFocus: false, + }); + + const specialisationOptions = allSpecialisations + .map((specialisation) => ({ + value: specialisation.specialisation_id, + label: + i18n.language === "sv" + ? specialisation.title_sv + : specialisation.title_en, + })) + .sort((a, b) => a.label.localeCompare(b.label, i18n.language)); + + const createProgram = useMutation({ + ...createProgramMutation(), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: getAllProgramsQueryKey(), + }); + toast.success(t("programs.create_success")); + setOpen(false); + }, + onError: (error) => { + toast.error( + typeof error?.detail === "string" + ? error.detail + : t("programs.create_error"), + ); + setOpen(false); + }, + }); + + function onSubmit(values: z.infer) { + const payload: ProgramCreate = { + title_sv: values.title_sv, + title_en: values.title_en, + description_sv: values.description_sv?.trim() ? values.description_sv : null, + description_en: values.description_en?.trim() ? values.description_en : null, + specialisation_ids: values.specialisation_ids ?? [], + }; + + createProgram.mutate({ body: payload }); + } + + return ( + + ); +} diff --git a/src/app/admin/programs/[program_id]/program-years/ProgramYearEditForm.tsx b/src/app/admin/programs/[program_id]/program-years/ProgramYearEditForm.tsx new file mode 100644 index 00000000..35e54d25 --- /dev/null +++ b/src/app/admin/programs/[program_id]/program-years/ProgramYearEditForm.tsx @@ -0,0 +1,260 @@ +import { useEffect, useState } from "react"; +import { z } from "zod"; +import { + deleteProgramYearMutation, + getAllCoursesOptions, + getAllProgramYearsQueryKey, + getProgramYearOptions, + getProgramYearsByProgramQueryKey, + updateProgramYearMutation, +} from "@/api/@tanstack/react-query.gen"; +import { + AssociationTypeEnum, + type ProgramYearRead, + type ProgramYearUpdate, +} from "@/api"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import AdminForm from "@/widgets/AdminForm"; +import AssociatedImageManager from "@/components/AssociatedImageManager"; + +const MAX_PROGRAM_YEAR_TITLE = 100; +const MAX_PROGRAM_YEAR_DESC = 10000; + +const programYearEditSchema = z.object({ + id: z.number(), + program_id: z.number(), + title_sv: z.string().trim().min(1).max(MAX_PROGRAM_YEAR_TITLE), + title_en: z.string().trim().min(1).max(MAX_PROGRAM_YEAR_TITLE), + description_sv: z.string().max(MAX_PROGRAM_YEAR_DESC).optional(), + description_en: z.string().max(MAX_PROGRAM_YEAR_DESC).optional(), + course_ids: z.array(z.number()).optional(), +}); + +interface ProgramEditFormProps { + item: ProgramYearRead | null; + onClose: () => void; +} + +export default function ProgramEditForm({ + onClose, + item, +}: ProgramEditFormProps) { + const { t } = useTranslation("admin"); + const { data: allCourses = [] } = useQuery({ + ...getAllCoursesOptions(), + refetchOnWindowFocus: false, + }); + + const courseOptions = allCourses + .map((course) => ({ + value: course.course_id, + label: course.title, + })) + .sort((a, b) => a.label.localeCompare(b.label)); + + const [convertedItem, setConvertedItem] = useState | null>(null); + const [associatedImageId, setAssociatedImageId] = useState( + null, + ); + + useEffect(() => { + if (item) { + const convertedItem = { + id: item.program_year_id, + program_id: item.program_id, + title_sv: item.title_sv, + title_en: item.title_en, + description_sv: item.description_sv ?? "", + description_en: item.description_en ?? "", + course_ids: (item.courses ?? []).map((course) => course.course_id), + } as z.infer; + setConvertedItem(convertedItem); + setAssociatedImageId(item.associated_img_id ?? null); + } else { + setAssociatedImageId(null); + } + }, [item]); + + const queryClient = useQueryClient(); + + async function syncProgramYearAfterImageChange(programYearId: number) { + try { + const updatedProgramYear = await queryClient.fetchQuery({ + ...getProgramYearOptions({ path: { program_year_id: programYearId } }), + }); + + setAssociatedImageId(updatedProgramYear.associated_img_id ?? null); + + queryClient.invalidateQueries({ + queryKey: getAllProgramYearsQueryKey(), + }); + queryClient.invalidateQueries({ + queryKey: getProgramYearsByProgramQueryKey({ + path: { program_id: updatedProgramYear.program_id }, + }), + }); + } catch { + toast.error(t("admin:associated_image.sync_error")); + } + } + + const updateProgramYear = useMutation({ + ...updateProgramYearMutation(), + throwOnError: false, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: getAllProgramYearsQueryKey(), + }); + toast.success(t("program_years.edit_success")); + }, + onError: (error) => { + toast.error( + typeof error?.detail === "string" + ? error.detail + : t("program_years.edit_error"), + ); + onClose(); + }, + }); + + const removeProgramYear = useMutation({ + ...deleteProgramYearMutation(), + throwOnError: false, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: getAllProgramYearsQueryKey(), + }); + toast.success(t("program_years.remove_success")); + }, + onError: (error) => { + toast.error( + typeof error?.detail === "string" + ? error.detail + : t("program_years.remove_error"), + ); + onClose(); + }, + }); + + function handleFormSubmit(values: z.infer) { + const updatedProgramYear: ProgramYearUpdate = { + title_sv: values.title_sv, + title_en: values.title_en, + program_id: values.program_id, + description_sv: values.description_sv?.trim() + ? values.description_sv + : null, + description_en: values.description_en?.trim() + ? values.description_en + : null, + course_ids: values.course_ids ?? [], + }; + + updateProgramYear.mutate( + { + path: { program_year_id: values.id }, + body: updatedProgramYear, + }, + { + onSuccess: () => { + onClose(); + }, + }, + ); + } + + function handleRemoveSubmit(data: z.infer) { + removeProgramYear.mutate( + { path: { program_year_id: data.id } }, + { + onSuccess: () => { + onClose(); + }, + }, + ); + } + + return ( + { + if (!isOpen) onClose(); + }} + inputFields={[ + { + variant: "text", + name: "title_sv", + label: t("program_years.title_sv"), + placeholder: t("program_years.title_sv"), + colSpan: 2, + }, + { + variant: "text", + name: "title_en", + label: t("program_years.title_en"), + placeholder: t("program_years.title_en"), + colSpan: 2, + }, + { + variant: "textarea", + name: "description_sv", + label: t("program_years.description_sv"), + placeholder: t("program_years.description_sv"), + rows: 8, + colSpan: 2, + }, + { + variant: "textarea", + name: "description_en", + label: t("program_years.description_en"), + placeholder: t("program_years.description_en"), + rows: 8, + colSpan: 2, + }, + { + variant: "styledMultiSelect", + name: "course_ids", + label: t("program_years.courses"), + placeholder: t("program_years.select_courses"), + options: courseOptions, + colSpan: 4, + }, + ]} + zodSchema={programYearEditSchema} + onSubmit={handleFormSubmit} + useDeleteButton + onDelete={handleRemoveSubmit} + showDialogButton={false} + editItem={convertedItem || undefined} + setEditItem={setConvertedItem} + customButtons={ + { + if (!item?.program_year_id) { + return; + } + + void syncProgramYearAfterImageChange(item.program_year_id); + }} + /> + } + confirmDeleteDialogConfirmByTyping={true} + confirmDeleteDialogConfirmByTypingKey={ + item?.title_sv || "Delete this program year" + } + requireConfirmationToDelete={true} + confirmDeleteDialogTitle={t("program_years.confirm_remove")} + confirmDeleteDialogDescription={t("program_years.confirm_remove_text")} + /> + ); +} diff --git a/src/app/admin/programs/[program_id]/program-years/ProgramYearForm.tsx b/src/app/admin/programs/[program_id]/program-years/ProgramYearForm.tsx new file mode 100644 index 00000000..a054e11a --- /dev/null +++ b/src/app/admin/programs/[program_id]/program-years/ProgramYearForm.tsx @@ -0,0 +1,137 @@ +import { useState } from "react"; +import { z } from "zod"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + createProgramYearMutation, + getAllCoursesOptions, + getAllProgramYearsQueryKey, +} from "@/api/@tanstack/react-query.gen"; +import type { ProgramYearCreate } from "@/api"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import AdminForm from "@/widgets/AdminForm"; +import { useParams } from "next/navigation"; + +const MAX_PROGRAM_YEAR_TITLE = 100; +const MAX_PROGRAM_YEAR_DESC = 10000; + +const programYearSchema = z.object({ + title_sv: z.string().trim().min(1).max(MAX_PROGRAM_YEAR_TITLE), + title_en: z.string().trim().min(1).max(MAX_PROGRAM_YEAR_TITLE), + description_sv: z.string().max(MAX_PROGRAM_YEAR_DESC).optional(), + description_en: z.string().max(MAX_PROGRAM_YEAR_DESC).optional(), + program_id: z.number(), + course_ids: z.array(z.number()).optional(), +}); + +export default function ProgramForm() { + const [open, setOpen] = useState(false); + const { t } = useTranslation("admin"); + const params = useParams(); + const programId = Number(params.program_id); + + const queryClient = useQueryClient(); + const { data: allCourses = [] } = useQuery({ + ...getAllCoursesOptions(), + refetchOnWindowFocus: false, + }); + + const courseOptions = allCourses + .map((course) => ({ + value: course.course_id, + label: course.title, + })) + .sort((a, b) => a.label.localeCompare(b.label)); + + const createProgramYear = useMutation({ + ...createProgramYearMutation(), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: getAllProgramYearsQueryKey(), + }); + toast.success(t("program_years.create_success")); + setOpen(false); + }, + onError: (error) => { + toast.error( + typeof error?.detail === "string" + ? error.detail + : t("program_years.create_error"), + ); + setOpen(false); + }, + }); + + function onSubmit(values: z.infer) { + const payload: ProgramYearCreate = { + title_sv: values.title_sv, + title_en: values.title_en, + program_id: values.program_id, + description_sv: values.description_sv?.trim() ? values.description_sv : null, + description_en: values.description_en?.trim() ? values.description_en : null, + course_ids: values.course_ids ?? [], + }; + + createProgramYear.mutate({ body: payload }); + } + + return ( + + ); +} diff --git a/src/app/admin/programs/[program_id]/program-years/[program_year_id]/courses/page.tsx b/src/app/admin/programs/[program_id]/program-years/[program_year_id]/courses/page.tsx new file mode 100644 index 00000000..1612afe1 --- /dev/null +++ b/src/app/admin/programs/[program_id]/program-years/[program_year_id]/courses/page.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { + getCoursesByProgramYearOptions, + getProgramYearOptions, +} from "@/api/@tanstack/react-query.gen"; +import { useQuery } from "@tanstack/react-query"; +import { type ColumnDef, createColumnHelper } from "@tanstack/react-table"; +import type { CourseRead } from "@/api"; +import { useTranslation } from "react-i18next"; +import AdminPage from "@/widgets/AdminPage"; +import { useParams, useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { ArrowLeft, Eye } from "lucide-react"; + +export default function Programs() { + const { t, i18n } = useTranslation("admin"); + const router = useRouter(); + const params = useParams(); + const programId = Number(params.program_id); + const programYearId = Number(params.program_year_id); + + const programYearQuery = useQuery({ + ...getProgramYearOptions({ path: { program_year_id: programYearId } }), + refetchOnWindowFocus: false, + }); + + const programYearTitle = + i18n.language === "sv" + ? programYearQuery.data?.title_sv + : programYearQuery.data?.title_en; + + function formatUpdatedAt(updatedAt: CourseRead["updated_at"]) { + return updatedAt + ? new Date(updatedAt).toLocaleString("sv-SE", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }) + : "-"; + } + + // Column setup + const columnHelper = createColumnHelper(); + // biome-ignore lint/suspicious/noExplicitAny: any is kind of needed here + const columns: ColumnDef[] = [ + columnHelper.accessor("title", { + header: t("program_years.courses_page.title_column"), + cell: (info) => info.getValue(), + }), + columnHelper.accessor("course_code", { + header: t("program_years.courses_page.course_code"), + cell: (info) => info.getValue() || "-", + }), + columnHelper.accessor("updated_at", { + header: t("program_years.courses_page.updated_at"), + cell: (info) => formatUpdatedAt(info.getValue()), + }), + columnHelper.display({ + id: "view_course_documents", + header: t("program_years.courses_page.view_course_documents"), + cell: (info) => ( +
+ +
+ ), + }), + ]; + + return ( + + router.push(`/admin/programs/${programId}/program-years`) + } + > + + {t("program_years.courses_page.back_to_program_years")} + + } + /> + ); +} diff --git a/src/app/admin/programs/[program_id]/program-years/page.tsx b/src/app/admin/programs/[program_id]/program-years/page.tsx new file mode 100644 index 00000000..39457077 --- /dev/null +++ b/src/app/admin/programs/[program_id]/program-years/page.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { + getProgramOptions, + getProgramYearsByProgramOptions, +} from "@/api/@tanstack/react-query.gen"; +import { useQuery } from "@tanstack/react-query"; +import { type ColumnDef, createColumnHelper } from "@tanstack/react-table"; +import type { ProgramYearRead } from "@/api"; +import ProgramEditForm from "./ProgramYearEditForm"; +import { useTranslation } from "react-i18next"; +import AdminPage from "@/widgets/AdminPage"; +import ProgramForm from "./ProgramYearForm"; +import { useParams, useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { ArrowLeft, Eye } from "lucide-react"; + +export default function Programs() { + const { t, i18n } = useTranslation("admin"); + const router = useRouter(); + const params = useParams(); + const programId = Number(params.program_id); + + const programQuery = useQuery({ + ...getProgramOptions({ path: { program_id: programId } }), + refetchOnWindowFocus: false, + }); + + const programTitle = + i18n.language === "sv" + ? programQuery.data?.title_sv + : programQuery.data?.title_en; + + // Column setup + const columnHelper = createColumnHelper(); + // biome-ignore lint/suspicious/noExplicitAny: any is kind of needed here + const columns: ColumnDef[] = [ + columnHelper.accessor("title_sv", { + header: t("program_years.title_sv"), + cell: (info) => info.getValue(), + }), + columnHelper.accessor("title_en", { + header: t("program_years.title_en"), + cell: (info) => info.getValue(), + }), + columnHelper.accessor("courses", { + header: t("program_years.courses"), + cell: (info) => info.getValue()?.length || "-", + }), + columnHelper.display({ + id: "view_courses", + header: t("program_years.view_courses"), + cell: (info) => ( +
+ +
+ ), + }), + ]; + + return ( + + + + + } + /> + ); +} diff --git a/src/app/admin/programs/[program_id]/specialisations/page.tsx b/src/app/admin/programs/[program_id]/specialisations/page.tsx new file mode 100644 index 00000000..6c2bbf35 --- /dev/null +++ b/src/app/admin/programs/[program_id]/specialisations/page.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { + getProgramOptions, + getSpecialisationsByProgramOptions, +} from "@/api/@tanstack/react-query.gen"; +import { useQuery } from "@tanstack/react-query"; +import { type ColumnDef, createColumnHelper } from "@tanstack/react-table"; +import type { SpecialisationRead } from "@/api"; +import { useTranslation } from "react-i18next"; +import AdminPage from "@/widgets/AdminPage"; +import { useParams, useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { ArrowLeft, Eye, List } from "lucide-react"; +import SpecialisationEditForm from "@/app/admin/specialisations/SpecialisationEditForm"; +import SpecialisationForm from "@/app/admin/specialisations/SpecialisationForm"; + +export default function Programs() { + const { t, i18n } = useTranslation("admin"); + const router = useRouter(); + const params = useParams(); + const programId = Number(params.program_id); + + const programQuery = useQuery({ + ...getProgramOptions({ path: { program_id: programId } }), + refetchOnWindowFocus: false, + }); + + const programTitle = + i18n.language === "sv" + ? programQuery.data?.title_sv + : programQuery.data?.title_en; + + // Column setup + const columnHelper = createColumnHelper(); + // biome-ignore lint/suspicious/noExplicitAny: any is kind of needed here + const columns: ColumnDef[] = [ + columnHelper.accessor("title_sv", { + header: t("programs.specialisations_page.title_sv"), + cell: (info) => info.getValue(), + }), + columnHelper.accessor("title_en", { + header: t("programs.specialisations_page.title_en"), + cell: (info) => info.getValue(), + }), + columnHelper.accessor("courses", { + header: t("programs.specialisations_page.courses"), + cell: (info) => info.getValue()?.length || "-", + }), + columnHelper.display({ + id: "view_courses", + header: t("programs.specialisations_page.view_courses"), + cell: (info) => ( +
+ +
+ ), + }), + ]; + + return ( + + + + + } + /> + ); +} diff --git a/src/app/admin/programs/page.tsx b/src/app/admin/programs/page.tsx new file mode 100644 index 00000000..1a3192f2 --- /dev/null +++ b/src/app/admin/programs/page.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { getAllProgramsOptions } from "@/api/@tanstack/react-query.gen"; +import { useQuery } from "@tanstack/react-query"; +import { + type ColumnDef, + createColumnHelper, + type Row, +} from "@tanstack/react-table"; +import type { ProgramRead } from "@/api"; +import ProgramEditForm from "./ProgramEditForm"; +import { useTranslation } from "react-i18next"; +import AdminPage from "@/widgets/AdminPage"; +import ProgramForm from "./ProgramForm"; +import { Button } from "@/components/ui/button"; +import { List } from "lucide-react"; +import { useRouter } from "next/navigation"; + +export default function Programs() { + const { t } = useTranslation("admin"); + const router = useRouter(); + + // Column setup + const columnHelper = createColumnHelper(); + // biome-ignore lint/suspicious/noExplicitAny: any is kind of needed here + const columns: ColumnDef[] = [ + columnHelper.accessor("title_sv", { + header: t("programs.title_sv"), + cell: (info) => info.getValue(), + }), + columnHelper.accessor("title_en", { + header: t("programs.title_en"), + cell: (info) => info.getValue(), + }), + columnHelper.accessor("program_years", { + header: t("programs.num_years"), + cell: (info) => info.getValue().length || "-", + }), + columnHelper.accessor("specialisations", { + header: t("programs.num_specialisations"), + cell: (info) => info.getValue().length || "-", + }), + columnHelper.display({ + id: "view_years", + header: t("programs.view_years"), + cell: (info) => ( +
+ +
+ ), + }), + columnHelper.display({ + id: "view_specialisations", + header: t("programs.view_specialisations"), + cell: (info) => ( +
+ +
+ ), + }), + ]; + + return ( + } + /> + ); +} diff --git a/src/app/admin/specialisations/SpecialisationEditForm.tsx b/src/app/admin/specialisations/SpecialisationEditForm.tsx new file mode 100644 index 00000000..d9079e48 --- /dev/null +++ b/src/app/admin/specialisations/SpecialisationEditForm.tsx @@ -0,0 +1,280 @@ +import { useEffect, useState } from "react"; +import { z } from "zod"; +import { + deleteSpecialisationMutation, + getAllCoursesOptions, + getAllSpecialisationsQueryKey, + getSpecialisationOptions, + getSpecialisationsByProgramQueryKey, + updateSpecialisationMutation, +} from "@/api/@tanstack/react-query.gen"; +import { + AssociationTypeEnum, + type SpecialisationRead, + type SpecialisationUpdate, +} from "@/api"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import AdminForm from "@/widgets/AdminForm"; +import AssociatedImageManager from "@/components/AssociatedImageManager"; + +const MAX_SPECIALISATION_TITLE = 100; +const MAX_SPECIALISATION_DESC = 10000; + +const specialisationEditSchema = z.object({ + id: z.number(), + title_sv: z.string().trim().min(1).max(MAX_SPECIALISATION_TITLE), + title_en: z.string().trim().min(1).max(MAX_SPECIALISATION_TITLE), + description_sv: z.string().max(MAX_SPECIALISATION_DESC).optional(), + description_en: z.string().max(MAX_SPECIALISATION_DESC).optional(), + course_ids: z.array(z.number()).optional(), +}); + +interface SpecialisationEditFormProps { + item: SpecialisationRead | null; + onClose: () => void; +} + +export default function SpecialisationEditForm({ + onClose, + item, +}: SpecialisationEditFormProps) { + const { t } = useTranslation("admin"); + const { data: allCourses = [] } = useQuery({ + ...getAllCoursesOptions(), + refetchOnWindowFocus: false, + }); + + const courseOptions = allCourses + .map((course) => ({ + value: course.course_id, + label: course.title, + })) + .sort((a, b) => a.label.localeCompare(b.label)); + + const [convertedItem, setConvertedItem] = useState | null>(null); + const [associatedImageId, setAssociatedImageId] = useState( + null, + ); + + useEffect(() => { + if (item) { + const convertedItem = { + id: item.specialisation_id, + title_sv: item.title_sv, + title_en: item.title_en, + description_sv: item.description_sv ?? "", + description_en: item.description_en ?? "", + course_ids: (item.courses ?? []).map((course) => course.course_id), + } as z.infer; + setConvertedItem(convertedItem); + setAssociatedImageId(item.associated_img_id ?? null); + } else { + setAssociatedImageId(null); + } + }, [item]); + + const queryClient = useQueryClient(); + + async function syncSpecialisationAfterImageChange(specialisationId: number) { + try { + const updatedSpecialisation = await queryClient.fetchQuery({ + ...getSpecialisationOptions({ + path: { specialisation_id: specialisationId }, + }), + }); + + setAssociatedImageId(updatedSpecialisation.associated_img_id ?? null); + + queryClient.invalidateQueries({ + queryKey: getAllSpecialisationsQueryKey(), + }); + invalidateSpecialisationsByProgramQueries(item?.programs); + invalidateSpecialisationsByProgramQueries(updatedSpecialisation.programs); + } catch { + toast.error(t("admin:associated_image.sync_error")); + } + } + + function invalidateSpecialisationsByProgramQueries( + programs: SpecialisationRead["programs"] | undefined, + ) { + // This only invalidates programs that are actually associated with the specialisation, + // not all of them (at least in the way it's currently called). + const programIds = new Set( + (programs ?? []).map((program) => program.program_id), + ); + + for (const programId of programIds) { + queryClient.invalidateQueries({ + queryKey: getSpecialisationsByProgramQueryKey({ + path: { program_id: programId }, + }), + }); + } + } + + const updateSpecialisation = useMutation({ + ...updateSpecialisationMutation(), + throwOnError: false, + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: getAllSpecialisationsQueryKey(), + }); + invalidateSpecialisationsByProgramQueries( + data?.programs ?? item?.programs, + ); + toast.success(t("specialisations.edit_success")); + }, + onError: (error) => { + toast.error( + typeof error?.detail === "string" + ? error.detail + : t("specialisations.edit_error"), + ); + onClose(); + }, + }); + + const removeSpecialisation = useMutation({ + ...deleteSpecialisationMutation(), + throwOnError: false, + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: getAllSpecialisationsQueryKey(), + }); + invalidateSpecialisationsByProgramQueries( + data?.programs ?? item?.programs, + ); + toast.success(t("specialisations.remove_success")); + }, + onError: (error) => { + toast.error( + typeof error?.detail === "string" + ? error.detail + : t("specialisations.remove_error"), + ); + onClose(); + }, + }); + + function handleFormSubmit(values: z.infer) { + const updatedSpecialisation: SpecialisationUpdate = { + title_sv: values.title_sv, + title_en: values.title_en, + description_sv: values.description_sv?.trim() + ? values.description_sv + : null, + description_en: values.description_en?.trim() + ? values.description_en + : null, + course_ids: values.course_ids ?? [], + }; + + updateSpecialisation.mutate( + { + path: { specialisation_id: values.id }, + body: updatedSpecialisation, + }, + { + onSuccess: () => { + onClose(); + }, + }, + ); + } + + function handleRemoveSubmit(data: z.infer) { + removeSpecialisation.mutate( + { path: { specialisation_id: data.id } }, + { + onSuccess: () => { + onClose(); + }, + }, + ); + } + + return ( + { + if (!isOpen) onClose(); + }} + inputFields={[ + { + variant: "text", + name: "title_sv", + label: t("specialisations.title_sv"), + placeholder: t("specialisations.title_sv"), + colSpan: 2, + }, + { + variant: "text", + name: "title_en", + label: t("specialisations.title_en"), + placeholder: t("specialisations.title_en"), + colSpan: 2, + }, + { + variant: "textarea", + name: "description_sv", + label: t("specialisations.description_sv"), + placeholder: t("specialisations.description_sv"), + rows: 8, + colSpan: 2, + }, + { + variant: "textarea", + name: "description_en", + label: t("specialisations.description_en"), + placeholder: t("specialisations.description_en"), + rows: 8, + colSpan: 2, + }, + { + variant: "styledMultiSelect", + name: "course_ids", + label: t("specialisations.courses"), + placeholder: t("specialisations.select_courses"), + options: courseOptions, + colSpan: 4, + }, + ]} + zodSchema={specialisationEditSchema} + onSubmit={handleFormSubmit} + useDeleteButton + onDelete={handleRemoveSubmit} + showDialogButton={false} + editItem={convertedItem || undefined} + setEditItem={setConvertedItem} + customButtons={ + { + if (!item?.specialisation_id) { + return; + } + + void syncSpecialisationAfterImageChange(item.specialisation_id); + }} + /> + } + confirmDeleteDialogConfirmByTyping={true} + confirmDeleteDialogConfirmByTypingKey={ + item?.title_sv || "Delete this specialisation" + } + requireConfirmationToDelete={true} + confirmDeleteDialogTitle={t("specialisations.confirm_remove")} + confirmDeleteDialogDescription={t("specialisations.confirm_remove_text")} + /> + ); +} diff --git a/src/app/admin/specialisations/SpecialisationForm.tsx b/src/app/admin/specialisations/SpecialisationForm.tsx new file mode 100644 index 00000000..3fe94622 --- /dev/null +++ b/src/app/admin/specialisations/SpecialisationForm.tsx @@ -0,0 +1,132 @@ +import { useState } from "react"; +import { z } from "zod"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + createSpecialisationMutation, + getAllCoursesOptions, + getAllSpecialisationsQueryKey, +} from "@/api/@tanstack/react-query.gen"; +import type { SpecialisationCreate } from "@/api"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import AdminForm from "@/widgets/AdminForm"; + +const MAX_SPECIALISATION_TITLE = 100; +const MAX_SPECIALISATION_DESC = 10000; + +const specialisationSchema = z.object({ + title_sv: z.string().trim().min(1).max(MAX_SPECIALISATION_TITLE), + title_en: z.string().trim().min(1).max(MAX_SPECIALISATION_TITLE), + description_sv: z.string().max(MAX_SPECIALISATION_DESC).optional(), + description_en: z.string().max(MAX_SPECIALISATION_DESC).optional(), + course_ids: z.array(z.number()).optional(), +}); + +export default function SpecialisationForm() { + const [open, setOpen] = useState(false); + const { t } = useTranslation("admin"); + + const queryClient = useQueryClient(); + const { data: allCourses = [] } = useQuery({ + ...getAllCoursesOptions(), + refetchOnWindowFocus: false, + }); + + const courseOptions = allCourses + .map((course) => ({ + value: course.course_id, + label: course.title, + })) + .sort((a, b) => a.label.localeCompare(b.label)); + + const createSpecialisation = useMutation({ + ...createSpecialisationMutation(), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: getAllSpecialisationsQueryKey(), + + }); + toast.success(t("specialisations.create_success")); + setOpen(false); + }, + onError: (error) => { + toast.error( + typeof error?.detail === "string" + ? error.detail + : t("specialisations.create_error"), + ); + setOpen(false); + }, + }); + + function onSubmit(values: z.infer) { + const payload: SpecialisationCreate = { + title_sv: values.title_sv, + title_en: values.title_en, + description_sv: values.description_sv?.trim() ? values.description_sv : null, + description_en: values.description_en?.trim() ? values.description_en : null, + course_ids: values.course_ids ?? [], + }; + + createSpecialisation.mutate({ body: payload }); + } + + return ( + + ); +} diff --git a/src/app/admin/specialisations/[specialisation_id]/courses/page.tsx b/src/app/admin/specialisations/[specialisation_id]/courses/page.tsx new file mode 100644 index 00000000..077cc64f --- /dev/null +++ b/src/app/admin/specialisations/[specialisation_id]/courses/page.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { + getCoursesBySpecialisationOptions, + getSpecialisationOptions, +} from "@/api/@tanstack/react-query.gen"; +import { useQuery } from "@tanstack/react-query"; +import { type ColumnDef, createColumnHelper } from "@tanstack/react-table"; +import type { CourseRead } from "@/api"; +import { useTranslation } from "react-i18next"; +import AdminPage from "@/widgets/AdminPage"; +import { useParams, useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { ArrowLeft, Eye } from "lucide-react"; + +export default function SpecialisationCourses() { + const { t, i18n } = useTranslation("admin"); + const router = useRouter(); + const params = useParams(); + const specialisationId = Number(params.specialisation_id); + + const specialisationQuery = useQuery({ + ...getSpecialisationOptions({ + path: { specialisation_id: specialisationId }, + }), + refetchOnWindowFocus: false, + }); + + const specialisationTitle = + i18n.language === "sv" + ? specialisationQuery.data?.title_sv + : specialisationQuery.data?.title_en; + + function formatUpdatedAt(updatedAt: CourseRead["updated_at"]) { + return updatedAt + ? new Date(updatedAt).toLocaleString("sv-SE", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }) + : "-"; + } + + // Column setup + const columnHelper = createColumnHelper(); + // biome-ignore lint/suspicious/noExplicitAny: any is kind of needed here + const columns: ColumnDef[] = [ + columnHelper.accessor("title", { + header: t("specialisations.courses_page.title_column"), + cell: (info) => info.getValue(), + }), + columnHelper.accessor("course_code", { + header: t("specialisations.courses_page.course_code"), + cell: (info) => info.getValue() || "-", + }), + columnHelper.accessor("updated_at", { + header: t("specialisations.courses_page.updated_at"), + cell: (info) => formatUpdatedAt(info.getValue()), + }), + columnHelper.display({ + id: "view_course_documents", + header: t("specialisations.courses_page.view_course_documents"), + cell: (info) => ( +
+ +
+ ), + }), + ]; + + return ( + router.push("/admin/specialisations")} + > + + {t("specialisations.courses_page.back_to_specialisations")} + + } + /> + ); +} diff --git a/src/app/admin/specialisations/page.tsx b/src/app/admin/specialisations/page.tsx new file mode 100644 index 00000000..f3b54ed1 --- /dev/null +++ b/src/app/admin/specialisations/page.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { getAllSpecialisationsOptions } from "@/api/@tanstack/react-query.gen"; +import { useQuery } from "@tanstack/react-query"; +import { type ColumnDef, createColumnHelper } from "@tanstack/react-table"; +import type { SpecialisationRead } from "@/api"; +import SpecialisationEditForm from "./SpecialisationEditForm"; +import { useTranslation } from "react-i18next"; +import AdminPage from "@/widgets/AdminPage"; +import SpecialisationForm from "./SpecialisationForm"; +import { Eye } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useRouter } from "next/navigation"; + +export default function Specialisations() { + const { t } = useTranslation("admin"); + + const router = useRouter(); + // Column setup + const columnHelper = createColumnHelper(); + // biome-ignore lint/suspicious/noExplicitAny: any is kind of needed here + const columns: ColumnDef[] = [ + columnHelper.accessor("title_sv", { + header: t("specialisations.title_sv"), + cell: (info) => info.getValue(), + }), + columnHelper.accessor("title_en", { + header: t("specialisations.title_en"), + cell: (info) => info.getValue(), + }), + columnHelper.accessor("courses", { + header: t("specialisations.courses"), + cell: (info) => info.getValue()?.length || "-", + }), + columnHelper.display({ + id: "view_courses", + header: t("specialisations.view_courses"), + cell: (info) => ( +
+ +
+ ), + }), + ]; + + return ( + } + /> + ); +} diff --git a/src/app/documents/[id]/page.tsx b/src/app/documents/[id]/page.tsx index 9b69e5b5..83cb7c8f 100644 --- a/src/app/documents/[id]/page.tsx +++ b/src/app/documents/[id]/page.tsx @@ -71,7 +71,7 @@ export default function DocumentPage({ params }: DocumentPageProps) { // Handle invalid document ID if (Number.isNaN(documentId)) { - return ; + return ; } // Show loading or error states diff --git a/src/app/i18n.tsx b/src/app/i18n.tsx index 6a1f5ab9..1c2438bc 100644 --- a/src/app/i18n.tsx +++ b/src/app/i18n.tsx @@ -15,7 +15,8 @@ export type Namespace = | "user-settings" | "notfound" | "contact" - | "cafe"; + | "cafe" + | "plugg"; // If you add more you probably also have to add them to the layout.tsx file corresponding to the page you are on // (or only the main one, try that first) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 61280580..190799ad 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -21,6 +21,7 @@ const i18nNamespaces = [ "notfound", "contact", "cafe", + "plugg", ] satisfies Namespace[]; export default async function RootLayout({ diff --git a/src/app/plugg/(main)/kurser/[course_title]/page.tsx b/src/app/plugg/(main)/kurser/[course_title]/page.tsx new file mode 100644 index 00000000..6c53e918 --- /dev/null +++ b/src/app/plugg/(main)/kurser/[course_title]/page.tsx @@ -0,0 +1,471 @@ +"use client"; + +import ImageDisplay from "@/components/ImageDisplay"; +import { getCourseByUrlTitleOptions } from "@/api/@tanstack/react-query.gen"; +import { useQuery } from "@tanstack/react-query"; +import type { CourseDocumentRead } from "@/api"; +import { useTranslation } from "react-i18next"; +import { LoadingErrorCard } from "@/components/LoadingErrorCard"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useParams, useRouter } from "next/navigation"; +import { AlertCircle, ArrowLeft, ExternalLink, FileText } from "lucide-react"; +import urlFormatter from "@/utils/urlFormatter"; +import NotFound from "@/components/NotFound"; +import { buildCourseDocumentFileHref } from "@/utils/pluggHrefBuilders"; +import PluggContactReminder from "@/components/PluggContactReminder"; +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import remarkMath from "remark-math"; +import rehypeMathjax from "rehype-mathjax"; + +const GENERAL_SUB_CATEGORY_KEY = "__general__"; + +function getCourseSlug(param: string | string[] | undefined) { + if (Array.isArray(param)) { + return param[0] ?? ""; + } + return param ?? ""; +} + +function formatDocumentDate(value: Date, locale: string) { + return new Date(value).toLocaleDateString(locale, { + year: "numeric", + month: "long", + day: "numeric", + }); +} + +function normalizeCourseCode(value: string) { + return value?.trim().toUpperCase(); +} + +function LocalizedDescription({ + text, + fallback, +}: { + text: string | null | undefined; + fallback: string; +}) { + if (!text) { + return

{fallback}

; + } + + return ( +
+ + {text} + +
+ ); +} + +function CourseDocumentList({ + documents, + isSwedish, + currentCourseCode, + authorLabel, + updatedAtLabel, + openLabel, + legacyCourseCodeWarningLabel, + onOpen, +}: { + documents: Array; + isSwedish: boolean; + currentCourseCode: string; + authorLabel: string; + updatedAtLabel: string; + openLabel: string; + legacyCourseCodeWarningLabel: string; + onOpen: (courseDocumentId: number) => void; +}) { + const locale = isSwedish ? "sv-SE" : "en-GB"; + const normalizedCurrentCourseCode = normalizeCourseCode(currentCourseCode); + + return ( + + +
    + {documents.map((document, index) => ( +
  • + {(() => { + const normalizedDocumentCode = normalizeCourseCode( + document.created_course_code, + ); + const shouldShowLegacyCodeWarning = + normalizedCurrentCourseCode.length > 0 && + normalizedDocumentCode.length > 0 && + normalizedDocumentCode !== normalizedCurrentCourseCode; + + return ( + + ); + })()} + {index < documents.length - 1 ? : null} +
  • + ))} +
+
+
+ ); +} + +export default function CoursePage() { + const { t, i18n } = useTranslation("plugg"); + const router = useRouter(); + const params = useParams(); + const isSwedish = (i18n.resolvedLanguage ?? i18n.language) + .toLowerCase() + .startsWith("sv"); + const courseSlug = urlFormatter( + decodeURIComponent(getCourseSlug(params?.course_title)), + ); + + const { + data: course, + error: courseError, + isPending, + isFetching, + } = useQuery({ + ...getCourseByUrlTitleOptions({ + path: { title: courseSlug }, + }), + refetchOnWindowFocus: false, + staleTime: 60 * 60 * 1000, // 1 hour + refetchOnMount: "always", + }); + + const translatedCategoryByValue: Record< + CourseDocumentRead["category"], + string + > = { + Notes: t("courses.documents.categories.notes"), + Summary: t("courses.documents.categories.summary"), + Solutions: t("courses.documents.categories.solutions"), + Other: t("courses.documents.categories.other"), + }; + + const categorySortOrder: Record = { + Notes: 0, + Summary: 1, + Solutions: 2, + Other: 3, + }; + + const isLoadingCourse = !course && (isPending || isFetching); + + if (isLoadingCourse) { + return ; + } + + if (courseError) { + return ; + } + + if (!course) { + const random = Math.random(); + return ; + } + + const courseDocuments = [...(course.documents ?? [])].sort((first, second) => + first.title.localeCompare(second.title, isSwedish ? "sv" : "en", { + sensitivity: "base", + }), + ); + + const groupedDocuments = (() => { + const groupedByCategory = new Map< + CourseDocumentRead["category"], + Map> + >(); + + for (const document of courseDocuments) { + const existingCategory = groupedByCategory.get(document.category); + if (!existingCategory) { + groupedByCategory.set(document.category, new Map()); + } + + const categoryMap = groupedByCategory.get(document.category); + if (!categoryMap) { + continue; + } + + const rawSubCategory = document.sub_category?.trim() ?? ""; + const subCategoryKey = rawSubCategory || GENERAL_SUB_CATEGORY_KEY; + + if (!categoryMap.has(subCategoryKey)) { + categoryMap.set(subCategoryKey, []); + } + + categoryMap.get(subCategoryKey)?.push(document); + } + + const locale = isSwedish ? "sv" : "en"; + + return [...groupedByCategory.entries()] + .map(([category, subCategoryMap]) => { + const subCategories = [...subCategoryMap.entries()] + .map(([subCategory, documents]) => ({ + id: subCategory, + label: + subCategory === GENERAL_SUB_CATEGORY_KEY + ? t("courses.documents.general_subcategory") + : subCategory, + documents, + })) + .sort((first, second) => { + // Put the "general" (uncategorized) subcategory first, then sort the rest alphabetically + if (first.id === GENERAL_SUB_CATEGORY_KEY) { + return -1; + } + + if (second.id === GENERAL_SUB_CATEGORY_KEY) { + return 1; + } + + return first.label.localeCompare(second.label, locale, { + sensitivity: "base", + }); + }); + + return { + id: category, + label: translatedCategoryByValue[category] ?? category, + subCategories, + }; + }) + .sort((first, second) => { + const rankDiff = + categorySortOrder[first.id] - categorySortOrder[second.id]; + if (rankDiff !== 0) { + return rankDiff; + } + + return first.label.localeCompare(second.label, locale, { + sensitivity: "base", + }); + }); + })(); + + const openCourseDocument = (courseDocumentId: number) => { + window.open( + buildCourseDocumentFileHref(courseDocumentId), + "_blank", + "noopener,noreferrer", + ); + }; + + const singleCourseCode = course.course_code?.trim() || ""; + const locale = isSwedish ? "sv-SE" : "en-GB"; + const courseUpdatedBadge = t("courses.updated_badge", { + date: formatDocumentDate(course.updated_at, locale), + }); + + return ( +
+
+ {course.associated_img_id ? ( + <> +
+ +
+
+ + ) : ( + <> +
+
+ + )} +
+ + +
+

+ {course.title} - {course.course_code} +

+
+ {courseUpdatedBadge} +
+
+
+
+ +
+
+
+
+ +
+
+ + +
+ +
+
+

+ {t("courses.documents.title")} +

+
+ + {groupedDocuments.length === 0 ? ( + + + {t("courses.documents.empty")} + + + ) : ( +
+ {groupedDocuments.map((categoryGroup) => { + const shouldShowTabs = categoryGroup.subCategories.length > 1; + const firstSubCategory = categoryGroup.subCategories[0]; + + return ( +
+
+

+ {categoryGroup.label} +

+
+ + {shouldShowTabs ? ( + + + {categoryGroup.subCategories.map((subCategory) => ( + + {subCategory.label} + + ))} + + + {categoryGroup.subCategories.map((subCategory) => ( + + + + ))} + + ) : firstSubCategory ? ( +
+ {firstSubCategory.id !== GENERAL_SUB_CATEGORY_KEY ? ( +

+ {firstSubCategory.label} +

+ ) : null} + + +
+ ) : null} +
+ ); + })} +
+ )} +
+
+
+ ); +} diff --git a/src/app/plugg/(main)/layout.tsx b/src/app/plugg/(main)/layout.tsx new file mode 100644 index 00000000..d8b3344f --- /dev/null +++ b/src/app/plugg/(main)/layout.tsx @@ -0,0 +1,17 @@ +import { Footer } from "@/components/Footer"; +import { NavBar } from "@/components/PluggNavBar"; +// Switch to a plugg-specific layout later + +export default function MemberLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ +
{children}
+
+
+ ); +} diff --git a/src/app/plugg/(main)/page.tsx b/src/app/plugg/(main)/page.tsx new file mode 100644 index 00000000..25049437 --- /dev/null +++ b/src/app/plugg/(main)/page.tsx @@ -0,0 +1,704 @@ +"use client"; + +import CustomTitle from "@/components/CustomTitle"; +import ImageDisplay from "@/components/ImageDisplay"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, +} from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { + getAllCoursesOptions, + getAllProgramsOptions, + getAllProgramYearsOptions, +} from "@/api/@tanstack/react-query.gen"; +import type { CourseRead, ProgramRead, ProgramYearRead } from "@/api/types.gen"; +import { + buildCourseHref, + buildProgramHref, + buildProgramYearHref, + buildSpecialisationHref, +} from "@/utils/pluggHrefBuilders"; +import { useQuery } from "@tanstack/react-query"; +import { + ArrowRight, + BookText, + Calendar, + GraduationCap, + Route, + Search, +} from "lucide-react"; +import Link from "next/link"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; + +type ProgramYearMenu = { + programYearId: number; + programId: number; + titleSv: string; + titleEn: string; + courses: CourseRead[]; +}; + +type SpecialisationMenu = { + specialisationId: number; + programId: number; + titleSv: string; + titleEn: string; +}; + +type ProgramMenu = { + programId: number; + titleSv: string; + titleEn: string; + years: ProgramYearMenu[]; + specialisations: SpecialisationMenu[]; +}; + +type ProgramYearSource = { + program_year_id: number; + program_id: number; + title_sv: string; + title_en: string; + courses?: Array; +}; + +type SpecialisationSource = { + specialisation_id: number; + title_sv: string; + title_en: string; +}; + +type SearchResultKind = + | "program" + | "program_year" + | "course" + | "specialisation"; + +type SearchResult = { + label: string; + href: string; + kind: SearchResultKind; + secondary: string | null; +}; + +function getLocalizedTitle( + isSwedish: boolean, + titleSv: string, + titleEn: string, +) { + return isSwedish ? titleSv : titleEn; +} + +function getCourseLabel(course: CourseRead) { + return course.course_code + ? `${course.course_code} - ${course.title}` + : course.title; +} + +function getCourseSearchText(course: CourseRead) { + const shortIdentifierTerms = + course.short_identifier + ?.split(",") + .map((term) => term.trim()) + .filter(Boolean) ?? []; + + return [getCourseLabel(course), ...shortIdentifierTerms] + .join(" ") + .toLocaleLowerCase(); +} + +function ensureInnerMap(root: Map>, key: number) { + const existing = root.get(key); + if (existing) { + return existing; + } + + const next = new Map(); + root.set(key, next); + return next; +} + +function addUniqueCourse(courses: CourseRead[], course: CourseRead) { + if (courses.some((existing) => existing.course_id === course.course_id)) { + return; + } + + courses.push(course); +} + +function truncateDescription(text: string, maxChars: number) { + const normalizedText = text.replace(/\s+/g, " ").trim(); + if (normalizedText.length <= maxChars) { + return normalizedText; + } + + return `${normalizedText.slice(0, maxChars).trimEnd()}...`; +} + +export default function MainLanding() { + const { t, i18n } = useTranslation(); + const [searchQuery, setSearchQuery] = React.useState(""); + + const isSwedish = (i18n.resolvedLanguage ?? i18n.language) + .toLowerCase() + .startsWith("sv"); + + const { + data: programsData, + isLoading: isLoadingPrograms, + error: programsError, + } = useQuery({ + ...getAllProgramsOptions(), + staleTime: 1000 * 60 * 60, + refetchOnWindowFocus: false, + }); + const { + data: programYearsData, + isLoading: isLoadingProgramYears, + error: programYearsError, + } = useQuery({ + ...getAllProgramYearsOptions(), + staleTime: 1000 * 60 * 60, + refetchOnWindowFocus: false, + }); + const { + data: coursesData, + isLoading: isLoadingCourses, + error: coursesError, + } = useQuery({ + ...getAllCoursesOptions(), + staleTime: 1000 * 60 * 60, + refetchOnWindowFocus: false, + }); + + const allPrograms = React.useMemo( + () => (programsData ?? []) as ProgramRead[], + [programsData], + ); + + const programCards = React.useMemo(() => { + const collator = new Intl.Collator(isSwedish ? "sv" : "en", { + sensitivity: "base", + }); + + return allPrograms + .map((program) => ({ + programId: program.program_id, + title: isSwedish ? program.title_sv : program.title_en, + titleSv: program.title_sv, + titleEn: program.title_en, + description: isSwedish + ? program.description_sv + : program.description_en, + imageId: program.associated_img_id, + })) + .sort((a, b) => { + // This sorting places "teknisk" programs first (so the most important ones) + const aPriority = a.title + .trim() + .toLocaleLowerCase() + .startsWith("teknisk") + ? 0 + : 1; + const bPriority = b.title + .trim() + .toLocaleLowerCase() + .startsWith("teknisk") + ? 0 + : 1; + + if (aPriority !== bPriority) { + return aPriority - bPriority; + } + + return collator.compare(a.title, b.title); + }); + }, [allPrograms, isSwedish]); + + const menus = React.useMemo(() => { + const programs = (programsData ?? []) as ProgramRead[]; + const programYears = (programYearsData ?? []) as ProgramYearRead[]; + const courses = (coursesData ?? []) as CourseRead[]; + + const yearBuckets = new Map>(); + const specialisationBuckets = new Map< + number, + Map + >(); + + for (const program of programs) { + ensureInnerMap(yearBuckets, program.program_id); + ensureInnerMap(specialisationBuckets, program.program_id); + } + + const addYear = (year: ProgramYearSource) => { + const yearsForProgram = yearBuckets.get(year.program_id); + if (!yearsForProgram) { + return; + } + + const existingYear = yearsForProgram.get(year.program_year_id); + if (!existingYear) { + yearsForProgram.set(year.program_year_id, { + programYearId: year.program_year_id, + programId: year.program_id, + titleSv: year.title_sv, + titleEn: year.title_en, + courses: year.courses ?? [], + }); + return; + } + + existingYear.titleSv = year.title_sv; + existingYear.titleEn = year.title_en; + for (const course of year.courses ?? []) { + addUniqueCourse(existingYear.courses, course); + } + }; + + const addSpecialisation = ( + specialisation: SpecialisationSource, + programId: number, + ) => { + const specialisationsForProgram = specialisationBuckets.get(programId); + if (!specialisationsForProgram) { + return; + } + + specialisationsForProgram.set(specialisation.specialisation_id, { + specialisationId: specialisation.specialisation_id, + programId, + titleSv: specialisation.title_sv, + titleEn: specialisation.title_en, + }); + }; + + for (const program of programs) { + for (const year of program.program_years ?? []) { + addYear(year); + } + for (const specialisation of program.specialisations ?? []) { + addSpecialisation(specialisation, program.program_id); + } + } + + for (const year of programYears) { + addYear(year); + } + + for (const course of courses) { + for (const year of course.program_years ?? []) { + addYear(year); + const yearEntry = yearBuckets + .get(year.program_id) + ?.get(year.program_year_id); + if (!yearEntry) { + continue; + } + + addUniqueCourse(yearEntry.courses, course); + } + } + + const collator = new Intl.Collator(isSwedish ? "sv" : "en", { + sensitivity: "base", + }); + + const result: ProgramMenu[] = programs.map((program) => { + const years = Array.from( + yearBuckets.get(program.program_id)?.values() ?? [], + ); + years.sort((a, b) => + collator.compare( + getLocalizedTitle(isSwedish, a.titleSv, a.titleEn), + getLocalizedTitle(isSwedish, b.titleSv, b.titleEn), + ), + ); + + for (const year of years) { + year.courses.sort((a, b) => + collator.compare(getCourseLabel(a), getCourseLabel(b)), + ); + } + + const programSpecialisations = Array.from( + specialisationBuckets.get(program.program_id)?.values() ?? [], + ); + programSpecialisations.sort((a, b) => + collator.compare( + getLocalizedTitle(isSwedish, a.titleSv, a.titleEn), + getLocalizedTitle(isSwedish, b.titleSv, b.titleEn), + ), + ); + + return { + programId: program.program_id, + titleSv: program.title_sv, + titleEn: program.title_en, + years, + specialisations: programSpecialisations, + }; + }); + + result.sort((a, b) => { + const titleA = getLocalizedTitle(isSwedish, a.titleSv, a.titleEn); + const titleB = getLocalizedTitle(isSwedish, b.titleSv, b.titleEn); + const aPriority = titleA.trim().toLocaleLowerCase().startsWith("teknisk") + ? 0 + : 1; + const bPriority = titleB.trim().toLocaleLowerCase().startsWith("teknisk") + ? 0 + : 1; + + if (aPriority !== bPriority) { + return aPriority - bPriority; + } + + return collator.compare(titleA, titleB); + }); + + return result; + }, [programsData, programYearsData, coursesData, isSwedish]); + + const allCourses = React.useMemo( + () => (coursesData ?? []) as CourseRead[], + [coursesData], + ); + + const searchTerm = React.useMemo( + () => searchQuery.trim().toLocaleLowerCase(), + [searchQuery], + ); + + const searchResults = React.useMemo(() => { + if (!searchTerm) { + return [] as SearchResult[]; + } + + const allItems: Array = []; + + for (const program of menus) { + const programTitle = getLocalizedTitle( + isSwedish, + program.titleSv, + program.titleEn, + ); + allItems.push({ + label: programTitle, + href: buildProgramHref(programTitle), + kind: "program", + secondary: null, + searchText: programTitle.toLocaleLowerCase(), + }); + + for (const year of program.years) { + const yearTitle = getLocalizedTitle( + isSwedish, + year.titleSv, + year.titleEn, + ); + allItems.push({ + label: yearTitle, + href: buildProgramYearHref(programTitle, yearTitle), + kind: "program_year", + secondary: programTitle, + searchText: `${programTitle} ${yearTitle}`.toLocaleLowerCase(), + }); + } + + for (const specialisation of program.specialisations) { + const specialisationTitle = getLocalizedTitle( + isSwedish, + specialisation.titleSv, + specialisation.titleEn, + ); + allItems.push({ + label: specialisationTitle, + href: buildSpecialisationHref(specialisationTitle), + kind: "specialisation", + secondary: programTitle, + searchText: + `${programTitle} ${specialisationTitle}`.toLocaleLowerCase(), + }); + } + } + + for (const course of allCourses) { + allItems.push({ + label: getCourseLabel(course), + href: buildCourseHref(course.title), + kind: "course", + secondary: null, + searchText: getCourseSearchText(course), + }); + } + + const seen = new Set(); + const deduped = allItems.filter((item) => { + const key = `${item.label}__${item.href}__${item.kind}`; + if (seen.has(key)) { + return false; + } + + seen.add(key); + return true; + }); + + const rankByKind: Record = { + program: 0, + program_year: 1, + specialisation: 2, + course: 3, + }; + + return deduped + .filter((item) => item.searchText.includes(searchTerm)) + .sort((a, b) => { + const startsA = a.label.toLocaleLowerCase().startsWith(searchTerm) + ? 0 + : 1; + const startsB = b.label.toLocaleLowerCase().startsWith(searchTerm) + ? 0 + : 1; + if (startsA !== startsB) { + return startsA - startsB; + } + + if (rankByKind[a.kind] !== rankByKind[b.kind]) { + return rankByKind[a.kind] - rankByKind[b.kind]; + } + + return a.label.localeCompare(b.label, isSwedish ? "sv" : "en", { + sensitivity: "base", + }); + }) + .slice(0, 24); + }, [menus, allCourses, searchTerm, isSwedish]); + + const isSearchLoading = + isLoadingPrograms || isLoadingProgramYears || isLoadingCourses; + const searchHasError = + Boolean(programsError) || + Boolean(programYearsError) || + Boolean(coursesError); + + const kindLabel: Record = { + program: t("plugg:page.search_kind_program"), + program_year: t("plugg:page.search_kind_program_year"), + specialisation: t("plugg:page.search_kind_specialisation"), + course: t("plugg:page.search_kind_course"), + }; + + const kindIcon: Record = { + program: , + program_year: , + specialisation: , + course: , + }; + + const contactEmail = t("plugg:contact_reminder.email"); + + return ( +
+
+ + + + + {t("plugg:page.intro")} + + + + +
+

+ {t("plugg:page.search_title")} +

+
+ + setSearchQuery(event.target.value)} + placeholder={t("plugg:page.search_placeholder")} + className="h-14 rounded-xl border-border/70 bg-background pl-14 text-base md:text-lg" + /> +
+ +
+ {searchTerm && + (isSearchLoading ? ( +
+ {t("plugg:navbar.loading")} +
+ ) : searchHasError ? ( +
+ {t("plugg:navbar.load_error")} +
+ ) : searchResults.length === 0 ? ( +
+ {t("plugg:page.search_empty")} +
+ ) : ( +
+ {searchResults.map((result) => ( + +
+

+ {result.label} +

+ {result.secondary ? ( +

+ {result.secondary} +

+ ) : null} +
+
+ + {kindIcon[result.kind]} + {kindLabel[result.kind]} + + +
+ + ))} +
+ ))} +
+
+ +
+
+

+ {t("plugg:page.program_list_title")} +

+
+ + {isLoadingPrograms ? ( +
+ {t("plugg:navbar.loading")} +
+ ) : programsError ? ( +
+ {t("plugg:navbar.load_error")} +
+ ) : programCards.length === 0 ? ( +
+ {t("plugg:page.program_list_empty")} +
+ ) : ( +
+ {programCards.map((program) => ( + +
+ {program.imageId ? ( + <> + +
+ + ) : ( + <> +
+
+ + )} +
+

+ {program.title} +

+
+
+ +

+ {program.description + ? truncateDescription(program.description, 130) + : t("plugg:page.program_description_fallback")} +

+ +
+ + ))} +
+ )} +
+ + + + + + + {t("plugg:page.contact_card_eyebrow")} + +

+ {t("plugg:page.contact_card_title")} +

+
+ +

{t("plugg:page.contact_card_text")}

+

+ {t("plugg:page.contact_card_note")} +

+ +
+
+ + + +

+ {t("plugg:page.advertisement_title")} +

+
+ +

{t("plugg:page.advertisement_text")}

+
+
+ + +
+
+ ); +} diff --git a/src/app/plugg/(main)/program/[program_title]/arskurser/[program_year_title]/page.tsx b/src/app/plugg/(main)/program/[program_title]/arskurser/[program_year_title]/page.tsx new file mode 100644 index 00000000..93cb36e2 --- /dev/null +++ b/src/app/plugg/(main)/program/[program_title]/arskurser/[program_year_title]/page.tsx @@ -0,0 +1,236 @@ +"use client"; + +import ImageDisplay from "@/components/ImageDisplay"; +import { getProgramYearByUrlTitleOptions } from "@/api/@tanstack/react-query.gen"; +import { useQuery } from "@tanstack/react-query"; +import type { CourseRead, ProgramYearRead } from "@/api"; +import { useTranslation } from "react-i18next"; +import { LoadingErrorCard } from "@/components/LoadingErrorCard"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { useParams, useRouter } from "next/navigation"; +import { ArrowLeft } from "lucide-react"; +import urlFormatter from "@/utils/urlFormatter"; +import NotFound from "@/components/NotFound"; +import { buildCourseHref } from "@/utils/pluggHrefBuilders"; +import InfoThumbnailCard from "@/components/InfoThumbnailCard"; +import PluggContactReminder from "@/components/PluggContactReminder"; +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import remarkMath from "remark-math"; +import rehypeMathjax from "rehype-mathjax"; +import Image from "next/image"; + +function getSlug(param: string | string[] | undefined) { + if (Array.isArray(param)) { + return param[0] ?? ""; + } + return param ?? ""; +} + +function LocalizedDescription({ + text, + fallback, +}: { + text: string | null | undefined; + fallback: string; +}) { + if (!text) { + return

{fallback}

; + } + + return ( +
+ + {text} + +
+ ); +} + +export default function ProgramPage() { + const { t, i18n } = useTranslation("plugg"); + const router = useRouter(); + const params = useParams(); + const isSwedish = (i18n.resolvedLanguage ?? i18n.language) + .toLowerCase() + .startsWith("sv"); + const programSlug = urlFormatter( + decodeURIComponent(getSlug(params?.program_title)), + ); + const programYearSlug = urlFormatter( + decodeURIComponent(getSlug(params?.program_year_title)), + ); + + const { + data: detailedProgramYear, + error: programYearError, + isPending, + isFetching, + } = useQuery({ + ...getProgramYearByUrlTitleOptions({ + path: { + program_title: programSlug, + program_year_title: programYearSlug, + }, + }), + refetchOnWindowFocus: false, + staleTime: 60 * 60 * 1000, // 1 hour + refetchOnMount: "always", + }); + + const isLoadingProgramYear = + !detailedProgramYear && (isPending || isFetching); + + if (isLoadingProgramYear) { + return ; + } + + if (programYearError) { + return ; + } + + if (!detailedProgramYear) { + const random = Math.random(); + return ; + } + + const programYear: ProgramYearRead = detailedProgramYear; + const localizedTitle = isSwedish + ? programYear.title_sv + : programYear.title_en; + const localizedDescription = isSwedish + ? programYear.description_sv + : programYear.description_en; + + const courses = [...(programYear.courses ?? [])].sort( + (a: CourseRead, b: CourseRead) => { + const firstCode = a.course_code ?? ""; + const secondCode = b.course_code ?? ""; + const codeOrder = firstCode.localeCompare(secondCode, "en", { + sensitivity: "base", + }); + if (codeOrder !== 0) { + return codeOrder; + } + + return a.title.localeCompare(b.title, isSwedish ? "sv" : "en", { + sensitivity: "base", + }); + }, + ); + + return ( +
+
+ {programYear.associated_img_id ? ( + <> +
+ +
+
+ + ) : ( + <> +
+
+ + )} + +
+ + +
+

+ {localizedTitle} +

+
+ + {courses.length} {t("program.program_year_page.courses_label")} + +
+
+
+
+ +
+
+
+
+ +
+
+ + +
+ +
+

+ {t("program.program_year_page.courses_title")} +

+ + {courses.length === 0 ? ( + + + {t("program.program_year_page.no_courses")} + + + ) : ( +
+ {courses.map((course: CourseRead) => { + const courseTitle = course.course_code + ? `${course.course_code} - ${course.title}` + : course.title; + const courseHref = buildCourseHref(course.title); + + return ( + + ); + })} +
+ )} +
+
+
+ ); +} diff --git a/src/app/plugg/(main)/program/[program_title]/page.tsx b/src/app/plugg/(main)/program/[program_title]/page.tsx new file mode 100644 index 00000000..20759af2 --- /dev/null +++ b/src/app/plugg/(main)/program/[program_title]/page.tsx @@ -0,0 +1,277 @@ +"use client"; + +import ImageDisplay from "@/components/ImageDisplay"; +import { getProgramByUrlTitleOptions } from "@/api/@tanstack/react-query.gen"; +import { useQuery } from "@tanstack/react-query"; +import type { ProgramRead, ProgramYearRead, SpecialisationRead } from "@/api"; +import { useTranslation } from "react-i18next"; +import { LoadingErrorCard } from "@/components/LoadingErrorCard"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { useParams, useRouter } from "next/navigation"; +import { ArrowLeft } from "lucide-react"; +import urlFormatter from "@/utils/urlFormatter"; +import NotFound from "@/components/NotFound"; +import InfoThumbnailCard from "@/components/InfoThumbnailCard"; +import PluggContactReminder from "@/components/PluggContactReminder"; +import { + buildProgramYearHref, + buildSpecialisationHref, +} from "@/utils/pluggHrefBuilders"; +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import remarkMath from "remark-math"; +import rehypeMathjax from "rehype-mathjax"; +import Image from "next/image"; + +function getProgramSlug(param: string | string[] | undefined) { + if (Array.isArray(param)) { + return param[0] ?? ""; + } + return param ?? ""; +} + +function LocalizedDescription({ + text, + fallback, +}: { + text: string | null | undefined; + fallback: string; +}) { + if (!text) { + return

{fallback}

; + } + + return ( +
+ + {text} + +
+ ); +} + +export default function ProgramPage() { + const { t, i18n } = useTranslation("plugg"); + const router = useRouter(); + const params = useParams(); + const isSwedish = (i18n.resolvedLanguage ?? i18n.language) + .toLowerCase() + .startsWith("sv"); + const programSlug = urlFormatter( + decodeURIComponent(getProgramSlug(params?.program_title)), + ); + + const { + data: detailedProgram, + error: programError, + isPending, + isFetching, + } = useQuery({ + ...getProgramByUrlTitleOptions({ + path: { title: programSlug }, + }), + refetchOnWindowFocus: false, + staleTime: 60 * 60 * 1000, // 1 hour + refetchOnMount: "always", + }); + + const isLoadingProgram = !detailedProgram && (isPending || isFetching); + + if (isLoadingProgram) { + return ; + } + + if (programError) { + return ; + } + + if (!detailedProgram) { + const random = Math.random(); + return ; + } + + const program: ProgramRead = detailedProgram; + const localizedTitle = isSwedish ? program.title_sv : program.title_en; + const localizedDescription = isSwedish + ? program.description_sv + : program.description_en; + + const programYears = [...(program.program_years ?? [])].sort((a, b) => { + const first = isSwedish ? a.title_sv : a.title_en; + const second = isSwedish ? b.title_sv : b.title_en; + // Swedish has a different sorting order than English, kinda + return first.localeCompare(second, isSwedish ? "sv" : "en", { + sensitivity: "base", + }); + }); + + const specialisations = [...(program.specialisations ?? [])].sort((a, b) => { + const first = isSwedish ? a.title_sv : a.title_en; + const second = isSwedish ? b.title_sv : b.title_en; + return first.localeCompare(second, isSwedish ? "sv" : "en", { + sensitivity: "base", + }); + }); + + return ( +
+
+ {program.associated_img_id ? ( + <> +
+ +
+
+ + ) : ( + <> +
+
+ + )} + +
+ + +
+

+ {localizedTitle} +

+
+ + {programYears.length} {t("program.program_page.years_label")} + + + {specialisations.length} {t("program.specialisations")} + +
+
+
+
+ +
+
+
+
+ +
+
+ + +
+ +
+

+ {t("program.program_page.program_years_title")} +

+ + {programYears.length === 0 ? ( + + + {t("program.program_page.program_years_empty")} + + + ) : ( +
+ {programYears.map((year: ProgramYearRead) => { + const yearTitle = isSwedish ? year.title_sv : year.title_en; + const yearDescription = isSwedish + ? year.description_sv + : year.description_en; + const yearHref = buildProgramYearHref( + localizedTitle, + yearTitle, + ); + + return ( + + ); + })} +
+ )} +
+ +
+

+ {t("program.specialisations")} +

+ + {specialisations.length === 0 ? ( + + + {t("program.program_page.specialisations_empty")} + + + ) : ( +
+ {specialisations.map((specialisation: SpecialisationRead) => { + const specialisationTitle = isSwedish + ? specialisation.title_sv + : specialisation.title_en; + const specialisationDescription = isSwedish + ? specialisation.description_sv + : specialisation.description_en; + const specialisationHref = + buildSpecialisationHref(specialisationTitle); + + return ( + + ); + })} +
+ )} +
+
+
+ ); +} diff --git a/src/app/plugg/(main)/specialiseringar/[specialisation_title]/page.tsx b/src/app/plugg/(main)/specialiseringar/[specialisation_title]/page.tsx new file mode 100644 index 00000000..18da3c85 --- /dev/null +++ b/src/app/plugg/(main)/specialiseringar/[specialisation_title]/page.tsx @@ -0,0 +1,226 @@ +"use client"; + +import ImageDisplay from "@/components/ImageDisplay"; +import { getSpecialisationByUrlTitleOptions } from "@/api/@tanstack/react-query.gen"; +import { useQuery } from "@tanstack/react-query"; +import type { CourseRead } from "@/api"; +import { useTranslation } from "react-i18next"; +import { LoadingErrorCard } from "@/components/LoadingErrorCard"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { useParams, useRouter } from "next/navigation"; +import { ArrowLeft } from "lucide-react"; +import urlFormatter from "@/utils/urlFormatter"; +import NotFound from "@/components/NotFound"; +import { buildCourseHref } from "@/utils/pluggHrefBuilders"; +import InfoThumbnailCard from "@/components/InfoThumbnailCard"; +import PluggContactReminder from "@/components/PluggContactReminder"; +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import remarkMath from "remark-math"; +import rehypeMathjax from "rehype-mathjax"; +import Image from "next/image"; + +function getSlug(param: string | string[] | undefined) { + if (Array.isArray(param)) { + return param[0] ?? ""; + } + return param ?? ""; +} + +function LocalizedDescription({ + text, + fallback, +}: { + text: string | null | undefined; + fallback: string; +}) { + if (!text) { + return

{fallback}

; + } + + return ( +
+ + {text} + +
+ ); +} + +export default function SpecialisationPage() { + const { t, i18n } = useTranslation("plugg"); + const router = useRouter(); + const params = useParams(); + const isSwedish = (i18n.resolvedLanguage ?? i18n.language) + .toLowerCase() + .startsWith("sv"); + const specialisationSlug = urlFormatter( + decodeURIComponent(getSlug(params?.specialisation_title)), + ); + + const { + data: specialisation, + error: specialisationError, + isPending, + isFetching, + } = useQuery({ + ...getSpecialisationByUrlTitleOptions({ + path: { + title: specialisationSlug, + }, + }), + refetchOnWindowFocus: false, + staleTime: 60 * 60 * 1000, // 1 hour + refetchOnMount: "always", + }); + + const isLoadingSpecialisation = !specialisation && (isPending || isFetching); + + if (isLoadingSpecialisation) { + return ; + } + + if (specialisationError) { + return ; + } + + if (!specialisation) { + const random = Math.random(); + return ; + } + const localizedTitle = isSwedish + ? specialisation.title_sv + : specialisation.title_en; + const localizedDescription = isSwedish + ? specialisation.description_sv + : specialisation.description_en; + + const courses = [...(specialisation.courses ?? [])].sort( + (a: CourseRead, b: CourseRead) => { + const firstCode = a.course_code ?? ""; + const secondCode = b.course_code ?? ""; + const codeOrder = firstCode.localeCompare(secondCode, "en", { + sensitivity: "base", + }); + if (codeOrder !== 0) { + return codeOrder; + } + + return a.title.localeCompare(b.title, isSwedish ? "sv" : "en", { + sensitivity: "base", + }); + }, + ); + + return ( +
+
+ {specialisation.associated_img_id ? ( + <> +
+ +
+
+ + ) : ( + <> +
+
+ + )} + +
+ + +
+

+ {localizedTitle} +

+
+ + {courses.length} {t("plugg:specialisations.courses_label")} + +
+
+
+
+ +
+
+
+
+ +
+
+ + +
+ +
+

+ {t("plugg:specialisations.courses_title")} +

+ {courses.length === 0 ? ( + + + {t("plugg:specialisations.no_courses")} + + + ) : ( +
+ {courses.map((course: CourseRead) => { + const courseTitle = course.course_code + ? `${course.course_code} - ${course.title}` + : course.title; + const courseHref = buildCourseHref(course.title); + + return ( + + ); + })} +
+ )} +
+
+
+ ); +} diff --git a/src/app/plugg/kursdokument/[id]/page.tsx b/src/app/plugg/kursdokument/[id]/page.tsx new file mode 100644 index 00000000..f49063ee --- /dev/null +++ b/src/app/plugg/kursdokument/[id]/page.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { use, useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { getCourseDocumentFileByIdOptions } from "@/api/@tanstack/react-query.gen"; +import { LoadingErrorCard } from "@/components/LoadingErrorCard"; + +interface DocumentPageProps { + params: Promise<{ + id: string; + }>; +} + +export default function DocumentPage({ params }: DocumentPageProps) { + const [hasProcessed, setHasProcessed] = useState(false); + const [documentUrl, setDocumentUrl] = useState(null); + const resolvedParams = use(params); + const documentId = Number.parseInt(resolvedParams.id, 10); + + const { + data: response, + isLoading, + error, + } = useQuery({ + ...getCourseDocumentFileByIdOptions({ + path: { course_document_id: documentId }, + }), + enabled: !Number.isNaN(documentId), + }); + + // Process document once when data is available + useEffect(() => { + if (response && !hasProcessed) { + processDocument(); + setHasProcessed(true); + } + }, [response, hasProcessed]); + + // Clean up the blob URL when component unmounts + useEffect(() => { + return () => { + if (documentUrl) { + URL.revokeObjectURL(documentUrl); + } + }; + }, [documentUrl]); + + async function processDocument() { + try { + if (!response) { + throw new Error("No file data available"); + } + + let blob: Blob; + if (response instanceof File) { + const arrayBuffer = await response.arrayBuffer(); + blob = new Blob([arrayBuffer], { type: "application/pdf" }); + } else if (response instanceof Blob) { + blob = new Blob([response], { type: "application/pdf" }); + } else { + blob = new Blob([response as BlobPart], { type: "application/pdf" }); + } + + // Create object URL for inline display + const fileUrl = URL.createObjectURL(blob); + setDocumentUrl(fileUrl); + } catch (error) { + console.error("Error processing document:", error); + } + } + + // Handle invalid document ID + if (Number.isNaN(documentId)) { + return ; + } + + // Show loading or error states + if (isLoading) { + return ; + } + if (!response || error) { + return ; + } + + // Inline PDF display, no container div + return documentUrl ? ( + +

+ Your browser does not support embedded PDFs.{" "} + Click here to download the PDF. +

+
+ ) : ( + <> +

Loading Document...

+

+ The document will open in your browser's PDF viewer. +

+ + ); +} diff --git a/src/components/AssociatedImageManager.tsx b/src/components/AssociatedImageManager.tsx new file mode 100644 index 00000000..3cb83178 --- /dev/null +++ b/src/components/AssociatedImageManager.tsx @@ -0,0 +1,206 @@ +"use client"; + +import type { AssociationTypeEnum } from "@/api"; +import { + deleteAssociatedImageMutation, + uploadAssociatedImageMutation, +} from "@/api/@tanstack/react-query.gen"; +import ImageDisplay from "@/components/ImageDisplay"; +import { ConfirmDeleteDialog } from "@/components/ConfirmDeleteDialog"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import getErrorMessage from "@/help_functions/getErrorMessage"; +import { useMutation } from "@tanstack/react-query"; +import { Eye, ImagePlus, Trash2, Upload } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; + +interface AssociatedImageManagerProps { + associationType: AssociationTypeEnum; + associationId: number | null; + associatedImageId: number | null; + onImageChanged?: () => void; + addButtonText?: string; +} + +export default function AssociatedImageManager({ + associationType, + associationId, + associatedImageId, + onImageChanged, + addButtonText, +}: AssociatedImageManagerProps) { + const { t } = useTranslation(); + const [uploadDialogOpen, setUploadDialogOpen] = useState(false); + const [previewOpen, setPreviewOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [fileToUpload, setFileToUpload] = useState(null); + + useEffect(() => { + if (!uploadDialogOpen) { + setFileToUpload(null); + } + }, [uploadDialogOpen]); + + const uploadAssociatedImage = useMutation({ + ...uploadAssociatedImageMutation(), + onSuccess: () => { + toast.success(t("admin:associated_image.upload_success")); + setUploadDialogOpen(false); + onImageChanged?.(); + }, + onError: (error) => { + toast.error(getErrorMessage(error, (key) => t(key))); + }, + }); + + const deleteAssociatedImage = useMutation({ + ...deleteAssociatedImageMutation(), + onSuccess: () => { + toast.success(t("admin:associated_image.delete_success")); + onImageChanged?.(); + }, + onError: (error) => { + toast.error(getErrorMessage(error, (key) => t(key))); + }, + }); + + function handleUpload() { + if (!fileToUpload || associationId === null) { + return; + } + + uploadAssociatedImage.mutate({ + body: { + file: fileToUpload, + }, + query: { + association_type: associationType, + association_id: associationId, + }, + }); + } + + function handleDelete() { + if (associatedImageId === null) { + return; + } + + deleteAssociatedImage.mutate({ + path: { + id: associatedImageId, + }, + }); + } + + if (associatedImageId === null) { + return ( + <> + + + + + + {t("admin:associated_image.add_title")} + {associationId === null ? ( + + {t("admin:associated_image.no_target")} + + ) : null} + + + { + setFileToUpload(event.target.files?.[0] ?? null); + }} + /> + + + + + + + + + ); + } + + return ( +
+ + + + + + + + + {t("admin:associated_image.preview_title")} + + +
+ +
+
+
+
+ ); +} diff --git a/src/components/ConfirmDeleteDialog.tsx b/src/components/ConfirmDeleteDialog.tsx index 7d8f79d3..a71bd552 100644 --- a/src/components/ConfirmDeleteDialog.tsx +++ b/src/components/ConfirmDeleteDialog.tsx @@ -83,7 +83,7 @@ export function ConfirmDeleteDialog({
{confirmByTypingText ?? - t("admin:remove_confirm_by_typing", { + t("admin:remove_confirm_by_typing_default", { key: confirmByTypingKey, })} diff --git a/src/components/ImageDisplay.tsx b/src/components/ImageDisplay.tsx index 2731ab5b..c06c213d 100644 --- a/src/components/ImageDisplay.tsx +++ b/src/components/ImageDisplay.tsx @@ -5,6 +5,8 @@ import { getImageStreamOptions, getNewsImageOptions, getNewsImageStreamOptions, + getAssociatedImageOptions, + getAssociatedImageStreamOptions, } from "@/api/@tanstack/react-query.gen"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import Image from "next/image"; @@ -12,7 +14,7 @@ import type { ImageProps as NextImageProps } from "next/image"; import type React from "react"; import { useCallback, useEffect, useRef, useState } from "react"; -export type ImageKind = "image" | "news" | "event" | "user"; +export type ImageKind = "image" | "news" | "event" | "user" | "associated_img"; export type ImageSize = "small" | "medium" | "large" | "original"; export interface ImageDisplayProps extends Omit { @@ -40,6 +42,13 @@ export function useImageBlobActions(type: ImageKind, imageId?: number | null) { }); // case "event": // case "user": + case "associated_img": + if (devMode) { + return getAssociatedImageStreamOptions({ path: { img_id: imageId } }); + } + return getAssociatedImageOptions({ + path: { img_id: imageId, size: "original" }, + }); default: if (devMode) { return getImageStreamOptions({ path: { img_id: imageId } }); @@ -143,6 +152,23 @@ export default function ImageDisplay({ break; // case "event": // case "user": + case "associated_img": + if (devMode) { + queryOptions = { + ...getAssociatedImageStreamOptions({ path: { img_id: imageId } }), + enabled: !!imageId && enabled, + refetchOnWindowFocus: false, + }; + } else { + queryOptions = { + ...getAssociatedImageOptions({ + path: { img_id: imageId, size: size }, + }), + enabled: !!imageId && enabled, + refetchOnWindowFocus: false, + }; + } + break; default: if (devMode) { queryOptions = { diff --git a/src/components/InfoThumbnailCard.tsx b/src/components/InfoThumbnailCard.tsx new file mode 100644 index 00000000..2b0b3230 --- /dev/null +++ b/src/components/InfoThumbnailCard.tsx @@ -0,0 +1,76 @@ +import ImageDisplay from "@/components/ImageDisplay"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import Link from "next/link"; + +type InfoThumbnailCardProps = { + title: string; + description: string | null; + imageId: number | null; + emptyDescriptionText: string; + href: string; + descriptionMaxChars?: number; +}; + +function truncateDescription(text: string, maxChars: number) { + const normalizedText = text.replace(/\s+/g, " ").trim(); + if (normalizedText.length <= maxChars) { + return normalizedText; + } + + return `${normalizedText.slice(0, maxChars).trimEnd()}...`; +} + +export default function InfoThumbnailCard({ + title, + description, + imageId, + emptyDescriptionText, + href, + descriptionMaxChars = 90, +}: InfoThumbnailCardProps) { + const truncatedDescription = description + ? truncateDescription(description, descriptionMaxChars) + : null; + + return ( + + {imageId ? ( +
+ +
+ ) : null} + + + + {title} + + + + + {truncatedDescription ? ( +

+ {truncatedDescription} +

+ ) : ( +

+ {emptyDescriptionText} +

+ )} +
+
+ ); +} diff --git a/src/components/LoadingErrorCard.tsx b/src/components/LoadingErrorCard.tsx index ca053a59..9eb8b0a4 100644 --- a/src/components/LoadingErrorCard.tsx +++ b/src/components/LoadingErrorCard.tsx @@ -6,13 +6,14 @@ import { useEffect, useState, type FC } from "react"; import { Button } from "./ui/button"; import { useRouter } from "next/navigation"; import getErrorMessage from "@/help_functions/getErrorMessage"; +import { ApiError } from "@/types/api-error"; function getRandomMessage() { return `main:loading.flavor_${Math.floor(Math.random() * 8) + 1}`; } interface LoadingErrorCardProps { - error?: Error | string; + error?: ApiError | string; isLoading?: boolean; loadingMessage?: string; errorHomeButton?: boolean; diff --git a/src/components/NavBar.tsx b/src/components/NavBar.tsx index ea210e04..1ac18a99 100644 --- a/src/components/NavBar.tsx +++ b/src/components/NavBar.tsx @@ -82,7 +82,7 @@ export function NavBar() { router.push("/"); }, onError: (error: DefaultError) => { - toast.error(error.message || t("navbar.logoutError", "Logout failed")); + toast.error(error.detail || t("navbar.logoutError", "Logout failed")); }, }); diff --git a/src/components/PluggContactReminder.tsx b/src/components/PluggContactReminder.tsx new file mode 100644 index 00000000..69754ef3 --- /dev/null +++ b/src/components/PluggContactReminder.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import Link from "next/link"; +import { useTranslation } from "react-i18next"; + +export default function PluggContactReminder({ + className, +}: { + className?: string; +}) { + const { t } = useTranslation("plugg"); + const email = t("contact_reminder.email"); + + return ( + + + + {t("contact_reminder.title")} + + + {t("contact_reminder.text")} + + + + ); +} diff --git a/src/components/PluggNavBar.tsx b/src/components/PluggNavBar.tsx new file mode 100644 index 00000000..91663ea4 --- /dev/null +++ b/src/components/PluggNavBar.tsx @@ -0,0 +1,814 @@ +"use client"; + +import * as React from "react"; +import { cn } from "@/lib/utils"; +import FLogga from "@/assets/f-logga"; +import Link from "next/link"; +import { ArrowLeft, Menu, Search } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useTranslation } from "react-i18next"; +import LanguageSwitcher from "@/components/LanguageSwitcher"; +import ThemeToggle from "@/components/ThemeToggle"; +import { useQuery } from "@tanstack/react-query"; +import { + getAllProgramsOptions, + getAllProgramYearsOptions, + getAllCoursesOptions, +} from "@/api/@tanstack/react-query.gen"; +import type { CourseRead, ProgramRead, ProgramYearRead } from "@/api/types.gen"; +import { + Sheet, + SheetContent, + SheetTrigger, + SheetClose, + SheetTitle, +} from "@/components/ui/sheet"; +import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + NavigationMenu, + NavigationMenuContent, + NavigationMenuItem, + NavigationMenuList, + NavigationMenuTrigger, +} from "@/components/ui/navigation-menu"; +import { + buildCourseHref, + buildProgramHref, + buildProgramYearHref, + buildSpecialisationHref, +} from "@/utils/pluggHrefBuilders"; + +type ProgramYearMenu = { + programYearId: number; + programId: number; + titleSv: string; + titleEn: string; + courses: CourseRead[]; +}; + +type SpecialisationMenu = { + specialisationId: number; + programId: number; + titleSv: string; + titleEn: string; +}; + +type ProgramMenu = { + programId: number; + titleSv: string; + titleEn: string; + years: ProgramYearMenu[]; + specialisations: SpecialisationMenu[]; +}; + +type ProgramYearSource = { + program_year_id: number; + program_id: number; + title_sv: string; + title_en: string; + courses?: Array; +}; + +type SpecialisationSource = { + specialisation_id: number; + title_sv: string; + title_en: string; +}; + +function getLocalizedTitle( + isSwedish: boolean, + titleSv: string, + titleEn: string, +) { + return isSwedish ? titleSv : titleEn; +} + +function getCourseLabel(course: CourseRead) { + return course.course_code + ? `${course.course_code} - ${course.title}` + : course.title; +} + +function getCourseSearchText(course: CourseRead) { + const shortIdentifierTerms = + course.short_identifier + ?.split(",") + .map((term) => term.trim()) + .filter(Boolean) ?? []; + + return [getCourseLabel(course), ...shortIdentifierTerms] + .join(" ") + .toLocaleLowerCase(); +} + +function ensureInnerMap(root: Map>, key: number) { + const existing = root.get(key); + if (existing) { + return existing; + } + const next = new Map(); + root.set(key, next); + return next; +} + +function addUniqueCourse(courses: CourseRead[], course: CourseRead) { + if (courses.some((existing) => existing.course_id === course.course_id)) { + return; + } + courses.push(course); +} + +function useProgramMenus(isSwedish: boolean) { + const queryOptions = { + staleTime: 1000 * 60 * 60, // 1 hour + refetchOnWindowFocus: false, + }; + + const { + data: programsData, + isLoading: isLoadingPrograms, + error: programsError, + } = useQuery({ + ...getAllProgramsOptions(), + ...queryOptions, + }); + const { + data: programYearsData, + isLoading: isLoadingProgramYears, + error: programYearsError, + } = useQuery({ + ...getAllProgramYearsOptions(), + ...queryOptions, + }); + const { + data: coursesData, + isLoading: isLoadingCourses, + error: coursesError, + } = useQuery({ + ...getAllCoursesOptions(), + ...queryOptions, + }); + + const menus = React.useMemo(() => { + const programs = (programsData ?? []) as ProgramRead[]; + const programYears = (programYearsData ?? []) as ProgramYearRead[]; + const courses = (coursesData ?? []) as CourseRead[]; + + const yearBuckets = new Map>(); + const specialisationBuckets = new Map< + number, + Map + >(); + + for (const program of programs) { + ensureInnerMap(yearBuckets, program.program_id); + ensureInnerMap(specialisationBuckets, program.program_id); + } + + const addYear = (year: ProgramYearSource) => { + const yearsForProgram = yearBuckets.get(year.program_id); + if (!yearsForProgram) { + return; + } + + const existingYear = yearsForProgram.get(year.program_year_id); + if (!existingYear) { + yearsForProgram.set(year.program_year_id, { + programYearId: year.program_year_id, + programId: year.program_id, + titleSv: year.title_sv, + titleEn: year.title_en, + courses: [...(year.courses ?? [])], // shallow clone to avoid mutating original data just in case + }); + return; + } + + existingYear.titleSv = year.title_sv; + existingYear.titleEn = year.title_en; + for (const course of year.courses ?? []) { + addUniqueCourse(existingYear.courses, course); + } + }; + + const addSpecialisation = ( + specialisation: SpecialisationSource, + program_id: number, + ) => { + const specialisationsForProgram = specialisationBuckets.get(program_id); + if (!specialisationsForProgram) { + return; + } + + specialisationsForProgram.set(specialisation.specialisation_id, { + specialisationId: specialisation.specialisation_id, + programId: program_id, + titleSv: specialisation.title_sv, + titleEn: specialisation.title_en, + }); + }; + + for (const program of programs) { + for (const year of program.program_years ?? []) { + addYear(year); + } + for (const specialisation of program.specialisations ?? []) { + addSpecialisation(specialisation, program.program_id); + } + } + + for (const year of programYears) { + addYear(year); + } + + for (const course of courses) { + for (const year of course.program_years ?? []) { + addYear(year); + const yearEntry = yearBuckets + .get(year.program_id) + ?.get(year.program_year_id); + if (!yearEntry) { + continue; + } + addUniqueCourse(yearEntry.courses, course); + } + } + + const collator = new Intl.Collator(isSwedish ? "sv" : "en", { + sensitivity: "base", + }); + + const result: ProgramMenu[] = programs.map((program) => { + const years = Array.from( + yearBuckets.get(program.program_id)?.values() ?? [], + ); + years.sort((a, b) => + collator.compare( + getLocalizedTitle(isSwedish, a.titleSv, a.titleEn), + getLocalizedTitle(isSwedish, b.titleSv, b.titleEn), + ), + ); + + for (const year of years) { + year.courses = [...year.courses].sort((a, b) => + collator.compare(getCourseLabel(a), getCourseLabel(b)), + ); + } + + const programSpecialisations = Array.from( + specialisationBuckets.get(program.program_id)?.values() ?? [], + ); + programSpecialisations.sort((a, b) => + collator.compare( + getLocalizedTitle(isSwedish, a.titleSv, a.titleEn), + getLocalizedTitle(isSwedish, b.titleSv, b.titleEn), + ), + ); + + return { + programId: program.program_id, + titleSv: program.title_sv, + titleEn: program.title_en, + years, + specialisations: programSpecialisations, + }; + }); + + result.sort((a, b) => { + const titleA = getLocalizedTitle(isSwedish, a.titleSv, a.titleEn); + const titleB = getLocalizedTitle(isSwedish, b.titleSv, b.titleEn); + const aPriority = titleA.trim().toLocaleLowerCase().startsWith("teknisk") + ? 0 + : 1; + const bPriority = titleB.trim().toLocaleLowerCase().startsWith("teknisk") + ? 0 + : 1; + + if (aPriority !== bPriority) { + return aPriority - bPriority; + } + + const specialisationCountDiff = + b.specialisations.length - a.specialisations.length; + + if (specialisationCountDiff !== 0) { + return specialisationCountDiff; + } + + return collator.compare(titleA, titleB); + }); + + return result; + }, [programsData, programYearsData, coursesData, isSwedish]); + + return { + menus, + allCourses: (coursesData ?? []) as CourseRead[], + isLoading: isLoadingPrograms || isLoadingProgramYears || isLoadingCourses, + hasError: + Boolean(programsError) || + Boolean(programYearsError) || + Boolean(coursesError), + }; +} + +function MobileNavLink({ + href, + children, + className, +}: { + href: string; + children: React.ReactNode; + className?: string; +}) { + return ( + + + {children} + + + ); +} + +export function NavBar() { + const { t } = useTranslation(); + + return ( +
+
+
+
+ +
+ + + +
+ +
+ +
+ +
+ + + +
+ + + + + + + + {t("plugg:navbar.mobile_navigation")} + + +
+ +
+
+
+
+
+
+
+ ); +} + +export function NavBarMenu({ isMobile = false }: { isMobile?: boolean }) { + const { t, i18n } = useTranslation(); + const [searchQuery, setSearchQuery] = React.useState(""); + const isSwedish = (i18n.resolvedLanguage ?? i18n.language) + .toLowerCase() + .startsWith("sv"); + const maxVisiblePrograms = 3; + + const { menus, allCourses, isLoading, hasError } = useProgramMenus(isSwedish); + const visiblePrograms = menus.slice(0, maxVisiblePrograms); + const overflowPrograms = menus.slice(maxVisiblePrograms); + const searchTerm = React.useMemo( + () => searchQuery.trim().toLocaleLowerCase(), + [searchQuery], + ); + + const searchResults = React.useMemo(() => { + if (!searchTerm) { + return [] as Array<{ label: string; href: string }>; + } + + const allItems: Array<{ label: string; href: string; searchText: string }> = + []; + + for (const program of menus) { + const programTitle = getLocalizedTitle( + isSwedish, + program.titleSv, + program.titleEn, + ); + allItems.push({ + label: programTitle, + href: buildProgramHref(programTitle), + searchText: programTitle.toLocaleLowerCase(), + }); + + for (const year of program.years) { + const yearTitle = getLocalizedTitle( + isSwedish, + year.titleSv, + year.titleEn, + ); + allItems.push({ + label: `${programTitle} - ${yearTitle}`, + href: buildProgramYearHref(programTitle, yearTitle), + searchText: `${programTitle} ${yearTitle}`.toLocaleLowerCase(), + }); + } + + for (const specialisation of program.specialisations) { + const specialisationTitle = getLocalizedTitle( + isSwedish, + specialisation.titleSv, + specialisation.titleEn, + ); + allItems.push({ + label: specialisationTitle, + href: buildSpecialisationHref(specialisationTitle), + searchText: specialisationTitle.toLocaleLowerCase(), + }); + } + } + + for (const course of allCourses) { + allItems.push({ + label: getCourseLabel(course), + href: buildCourseHref(course.title), + searchText: getCourseSearchText(course), + }); + } + + const seen = new Set(); + const deduped = allItems.filter((item) => { + const key = `${item.label}__${item.href}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); + + return deduped + .filter((item) => item.searchText.includes(searchTerm)) + .slice(0, 5); + }, [menus, allCourses, searchTerm, isSwedish]); + + if (isLoading && menus.length === 0) { + return ( +
+ {t("plugg:navbar.loading")} +
+ ); + } + + if (hasError && menus.length === 0) { + return ( +
+ {t("plugg:navbar.load_error")} +
+ ); + } + + if (menus.length === 0) { + return ( +
+ {t("plugg:navbar.empty")} +
+ ); + } + + const searchField = ( +
+ + setSearchQuery(event.target.value)} + placeholder={t("plugg:navbar.search_placeholder")} + className="pl-9" + /> + {searchTerm && ( +
+ {searchResults.length > 0 ? ( +
+ {searchResults.map((result) => { + if (isMobile) { + return ( + + + {result.label} + + + ); + } + + return ( + + {result.label} + + ); + })} +
+ ) : ( +
+ {t("plugg:navbar.search_no_results")} +
+ )} +
+ )} +
+ ); + + if (isMobile) { + return ( +
+ {searchField} + {menus.map((program) => ( +
+ + {getLocalizedTitle(isSwedish, program.titleSv, program.titleEn)} + + + {program.years.map((year) => ( +
+ + {getLocalizedTitle(isSwedish, year.titleSv, year.titleEn)} + +
+ ))} + + {program.specialisations.length > 0 && ( +
+

+ {t("plugg:navbar.specialisations")} +

+
+ {program.specialisations.map((specialisation) => ( + + {getLocalizedTitle( + isSwedish, + specialisation.titleSv, + specialisation.titleEn, + )} + + ))} +
+
+ )} +
+ ))} +
+ ); + } + + return ( +
+
+ + + {visiblePrograms.map((program) => ( + + + {getLocalizedTitle( + isSwedish, + program.titleSv, + program.titleEn, + )} + + +
+
+ + {t("plugg:navbar.open_program")} + +
+ {program.years.length > 0 && ( +
+ {program.years.map((year) => ( +
+ + {getLocalizedTitle( + isSwedish, + year.titleSv, + year.titleEn, + )} + +
    + {year.courses.length > 0 ? ( + year.courses.map((course) => ( +
  • + + {getCourseLabel(course)} + +
  • + )) + ) : ( +
  • + {t("plugg:navbar.no_courses")} +
  • + )} +
+
+ ))} +
+ )} + + {program.specialisations.length > 0 && ( +
+

+ {t("plugg:navbar.specialisations")} +

+
+ {program.specialisations.map((specialisation) => ( + + ))} +
+
+ )} +
+
+
+ ))} + {overflowPrograms.length > 0 && ( + + + + + + + {overflowPrograms.map((program) => ( + + + {getLocalizedTitle( + isSwedish, + program.titleSv, + program.titleEn, + )} + + + ))} + + + + )} +
+
+
+ {searchField} +
+ ); +} diff --git a/src/components/QueryClientProvider.tsx b/src/components/QueryClientProvider.tsx index 03b884d4..c39966ec 100644 --- a/src/components/QueryClientProvider.tsx +++ b/src/components/QueryClientProvider.tsx @@ -8,6 +8,7 @@ import type { PropsWithChildren } from "react"; import { client } from "@/api"; import { useAuthState } from "@/lib/auth"; import { API_BASE_URL } from "@/constants"; +import { normalizeApiError } from "@/types/api-error"; client.setConfig({ baseUrl: API_BASE_URL }); @@ -36,6 +37,11 @@ client.interceptors.request.use(async (request, _options) => { return request; }); +// Ensure all thrown API errors have a stable shape with detail + status_code. +client.interceptors.error.use((error, response) => { + return normalizeApiError(error, response.status); +}); + export default function QueryClientProvider({ children }: PropsWithChildren) { return ( diff --git a/src/components/ui/navigation-menu.tsx b/src/components/ui/navigation-menu.tsx index 9c1f4dee..64c77540 100644 --- a/src/components/ui/navigation-menu.tsx +++ b/src/components/ui/navigation-menu.tsx @@ -37,7 +37,7 @@ function NavigationMenuList({ string, ): string { if (typeof error === "string") { @@ -13,11 +15,22 @@ export default function getErrorMessage( return error.message; } - if (typeof error === "object" && "detail" in error) { - if (error.detail === "Unauthorized") { + if (isApiError(error)) { + if (error.status_code === 401 || error.detail === "Unauthorized") { + return t("main:loading.unauthorized"); + } + return error.detail; + } + + if (typeof error === "object" && error !== null) { + const normalized = normalizeApiError(error); + if ( + normalized.status_code === 401 || + normalized.detail === "Unauthorized" + ) { return t("main:loading.unauthorized"); } - return (error as { detail: string }).detail; + return normalized.detail; } console.debug("Unexpected error type:", error); diff --git a/src/locales/en/admin.json b/src/locales/en/admin.json index 9b7d8800..9177a61d 100644 --- a/src/locales/en/admin.json +++ b/src/locales/en/admin.json @@ -8,6 +8,22 @@ "save": "Save", "cancel": "Cancel", "remove": "Remove", + "associated_image": { + "add": "Add associated image", + "add_title": "Upload associated image", + "no_target": "No target to attach image to. Something is wrong.", + "upload": "Upload", + "upload_success": "Associated image uploaded successfully.", + "delete_success": "Associated image removed.", + "sync_error": "Failed to refresh associated image state.", + "view": "View image", + "view_action": "View image", + "delete_action": "Delete image", + "delete_title": "Delete associated image?", + "delete_description": "This removes the image from this object. This action cannot be undone.", + "preview_title": "Associated image preview", + "preview_alt": "Associated image" + }, "choose_priorities": "Choose prioritized groups", "yes": "Yes", "no": "No", @@ -17,6 +33,8 @@ "milk_allergy": "Milk Protein Allergy", "title_sv": "Title (Swedish)", "title_en": "Title (English)", + "description_sv": "Description (Swedish)", + "description_en": "Description (English)", "ends_at": "End Time", "starts_at": "Start Time", "signup_start": "Signup Start", @@ -37,6 +55,7 @@ "songs": "Songs", "spider": "Webmaster only", "nollning": "Nollning", + "plugg": "Plugg", "board": "Board and others", "elections": "Elections" }, @@ -745,6 +764,7 @@ "remove_confirm_title": "Confirm removal", "remove_confirm_text": "Are you sure you want to remove this item?", "remove_confirm_description": "A popup dialog confirming the deletion of an item.", + "remove_confirm_by_typing_default": "Type \"{{key}}\" below to confirm deletion.", "week_selector": { "placeholder": "Select week", "show_all": "Show all", @@ -941,6 +961,169 @@ "afternoon": "Afternoon" } }, + "programs": { + "self": "Programs", + "title": "Manage Programs", + "description_subtitle": "Create and manage study programs. Click on a program to edit it. There is an hour long cache period for most information on the public plugg.fsektionen.se site, so changes you make might take a bit to appear. If you want to see the changes immediately, you can clear the site data in your browser settings.", + "create_program": "Create program", + "edit_program": "Edit program", + "num_years": "Program years", + "num_specialisations": "Specialisations", + "title_en": "Title (English)", + "title_sv": "Title (Swedish)", + "description_en": "Description (English)", + "description_sv": "Description (Swedish)", + "specialisations": "Specialisations", + "select_specialisations": "Select specialisations", + "specialisations_page": { + "title": "Manage Specialisations", + "title_with_program": "Manage Specialisations for {{program}}", + "description_subtitle": "View specialisations connected to this program. Click View courses to manage a specialisation.", + "title_sv": "Title (Swedish)", + "title_en": "Title (English)", + "courses": "Courses", + "edit_specialisations": "Edit", + "back_to_programs": "Back to programs", + "view_courses": "View courses", + "view_specialisations": "View specialisations", + "add_specialisation": "Add specialisation" + }, + "create_success": "Program created!", + "create_error": "Failed to create program.", + "edit_success": "Program updated!", + "edit_error": "Failed to update program.", + "remove_success": "Program removed!", + "remove_error": "Failed to remove program.", + "confirm_remove": "Confirm removal of program", + "confirm_remove_text": "Are you sure you want to remove this program?" + }, + "specialisations": { + "self": "Specialisations", + "title": "Manage Specialisations", + "description_subtitle": "Create and manage specialisations. Click on a specialisation to edit it.", + "create_specialisation": "Create specialisation", + "edit_specialisation": "Edit specialisation", + "title_sv": "Title (Swedish)", + "title_en": "Title (English)", + "description_sv": "Description (Swedish)", + "description_en": "Description (English)", + "courses": "Courses", + "select_courses": "Select courses", + "create_success": "Specialisation created!", + "create_error": "Failed to create specialisation.", + "edit_success": "Specialisation updated!", + "edit_error": "Failed to update specialisation.", + "remove_success": "Specialisation removed!", + "remove_error": "Failed to remove specialisation.", + "confirm_remove": "Confirm removal of specialisation", + "confirm_remove_text": "Are you sure you want to remove this specialisation?", + "view_courses": "View courses", + "view_specialisations": "View specialisations", + "courses_page": { + "title": "Specialisation Courses", + "title_with_specialisation": "Courses in {{specialisation}}", + "description_subtitle": "Here you can see all courses connected to this specialisation.", + "title_column": "Title", + "course_code": "Course code", + "updated_at": "Updated", + "back_to_specialisations": "Back to specialisations", + "view_course_documents": "View course documents" + } + }, + "courses": { + "self": "Courses", + "title_page": "Manage Courses", + "description_subtitle": "Create and manage courses. Click on a course to edit it.", + "create_course": "Create course", + "edit_course": "Edit course", + "title": "Title", + "course_code": "Course code", + "description": "Description", + "updated_at": "Updated", + "create_success": "Course created!", + "create_error": "Failed to create course.", + "edit_success": "Course updated!", + "edit_error": "Failed to update course.", + "remove_success": "Course removed!", + "remove_error": "Failed to remove course.", + "confirm_remove": "Confirm removal of course", + "confirm_remove_text": "Are you sure you want to remove this course? This will also remove any documents attached to the course.", + "edit_course_details": "Edit course details", + "edit_course_documents": "Edit course documents", + "view_course_documents": "View course documents", + "view_courses": "View courses", + "course_documents": { + "title_page": "Manage Course Documents: {{course}}", + "description_subtitle": "Upload and manage course-specific documents. Click a document to edit it. After deleting a document, you may need a reload to see the changes.", + "back_to_courses": "Back to courses", + "invalid_course_id": "Invalid course id.", + "create_document": "Create course document", + "edit_document": "Edit course document", + "title": "Title", + "file": "File", + "file_name": "File name", + "author": "Author", + "category": "Category", + "select_category": "Select category", + "sub_category": "Sub category", + "updated_at": "Updated", + "create_success": "Course document created!", + "create_error": "Failed to create course document.", + "edit_success": "Course document updated!", + "edit_error": "Failed to update course document.", + "remove_success": "Course document removed!", + "remove_error": "Failed to remove course document.", + "open_document": "Open document", + "confirm_remove": "Confirm removal of course document", + "confirm_remove_text": "Are you sure you want to remove this course document?", + "file_size_error": "File size must be at most {{size}} MB.", + "file_type_error": "Only PDF files are allowed.", + "categories": { + "notes": "Notes", + "summary": "Summary", + "solutions": "Solutions", + "other": "Other" + } + }, + "short_identifier": "Short identifier", + "short_identifier_placeholder": "Shorter names separated with commas." + }, + "program_years": { + "self": "Program years", + "title": "Manage Program Years", + "title_with_program": "Manage Program Years for {{program}}", + "description_subtitle": "Create and manage program years for this program. Click a program year to edit it.", + "create_program": "Create program year", + "edit_program": "Edit program year", + "title_en": "Title (English)", + "title_sv": "Title (Swedish)", + "courses": "Courses", + "select_courses": "Select courses", + "description_en": "Description (English)", + "description_sv": "Description (Swedish)", + "create_success": "Program year created!", + "create_error": "Failed to create program year.", + "edit_success": "Program year updated!", + "edit_error": "Failed to update program year.", + "remove_success": "Program year removed!", + "remove_error": "Failed to remove program year.", + "confirm_remove": "Confirm removal of program year", + "confirm_remove_text": "Are you sure you want to remove this program year?", + "back_to_programs": "Back", + "courses_page": { + "title": "Program Year Courses", + "title_with_program_year": "Courses in {{program_year}}", + "description_subtitle": "Here you can see all courses connected to this program year.", + "title_column": "Title", + "course_code": "Course code", + "updated_at": "Updated", + "back_to_program_years": "Back to program years", + "edit_course": "Edit Course", + "edit_course_details": "Edit course details", + "edit_course_documents": "Edit course documents", + "view_course_documents": "View course documents" + } + }, "visible_election": { "self": "Visible Election" }, @@ -1143,5 +1326,10 @@ "is_active_label": "Is Active (is visible to users)" }, "save_changes": "Save Changes", - "create": "Create" + "create": "Create", + "plugg": { + "program_page": { + "course_description_fallback": "No course description added yet." + } + } } diff --git a/src/locales/en/plugg.json b/src/locales/en/plugg.json new file mode 100644 index 00000000..be55c94d --- /dev/null +++ b/src/locales/en/plugg.json @@ -0,0 +1,101 @@ +{ + "navbar": { + "back": "To homepage", + "toggle_menu": "Toggle menu", + "mobile_navigation": "Mobile navigation", + "loading": "Loading programs...", + "load_error": "Could not load navigation.", + "empty": "No programs available.", + "specialisations": "Specialisations", + "no_courses": "No courses yet", + "open_program": "Open program page", + "more": "More", + "search_placeholder": "Search for whatever...", + "search_no_results": "No results" + }, + "page": { + "title": "The study pages of the F-Guild", + "intro": "Welcome! This is where we gather course material, past exams, and helpful resources so it is easier to find your way through every year at LTH. Whether you are just getting started or close to graduation, we hope this page feels like a friendly shortcut when studies get intense.", + "search_title": "Search across all the study pages", + "search_placeholder": "Search programmes, years, courses, and specialisations...", + "search_hint": "Start typing to find matches across all studying content, not only the programme list.", + "search_empty": "No matches for your search.", + "search_kind_program": "Programme", + "search_kind_program_year": "Year", + "search_kind_specialisation": "Specialisation", + "search_kind_course": "Course", + "program_list_title": "All programmes", + "program_list_empty": "No programmes match your search.", + "open_program": "Open programme", + "program_description_fallback": "Description coming soon.", + "contact_card_eyebrow": "Help us improve these pages", + "contact_card_title": "Found something missing or outdated?", + "contact_card_text": "We want this to stay a living, reliable hub for everyone at the section. If you notice errors, broken links, outdated documents, or material that should be here, we would really appreciate hearing from you.", + "contact_card_note": "If possible, include the course code, programme or year, and a short note about what should be updated so we can fix it faster.", + "contact_card_cta": "Email plugg@fsektionen.se", + "section_programs_title": "Programmes", + "section_programs_text": "Use the top row in the navbar to switch between programmes and reveal each programme year.", + "section_courses_title": "Courses", + "section_courses_text": "Each programme year has its own course dropdown. Pick a course to jump directly to relevant material.", + "section_specialisations_title": "Specialisations", + "section_specialisations_text": "Specialisations are grouped per programme in separate dropdowns for fast access.", + "advertisement_title": "Do you like design or code?", + "advertisement_text": "Do you think you could improve this page or help with new features for the web, server or app? Apply to become a webmaster next fall semester!" + }, + "contact_reminder": { + "title": "Found something missing or outdated?", + "text": "Help us keep this page accurate. Share corrections, upload new documents, or give us tips by emailing plugg@fsektionen.se (or click this box).", + "email": "plugg@fsektionen.se" + }, + "program": { + "back": "Back", + "specialisations": "Specialisations", + "program_page": { + "years_label": "standard years added", + "program_years_title": "Standard years", + "program_years_empty": "No years added yet.", + "specialisations_empty": "No specialisations available yet.", + "program_description_fallback": "No description available for this program yet.", + "year_description_fallback": "No description added for this year.", + "specialisation_description_fallback": "No description added for this specialisation." + }, + "program_year_page": { + "courses_label": "courses", + "courses_title": "Courses", + "no_courses": "No courses yet", + "year_description_fallback": "No description added for this year.", + "course_description_fallback": "No course description added yet." + } + }, + "courses": { + "back": "Back", + "course_code_badge": "Course code: {{code}}", + "course_code_missing": "Course code unavailable", + "updated_badge": "Page last updated: {{date}}", + "documents_badge": "{{count}} documents", + "description_fallback": "No course description has been added yet.", + "documents": { + "title": "Course material", + "empty": "No documents have been added to this course yet.", + "open": "Open file", + "author": "Author", + "updated_at": "Updated", + "legacy_course_code_warning": "May be from an older course version. Course code:", + "general_subcategory": "General", + "categories": { + "notes": "Notes", + "summary": "Summary", + "solutions": "Solutions", + "other": "Other" + } + } + }, + "specialisations": { + "back": "Back", + "courses_label": "courses", + "courses_title": "Courses", + "no_courses": "No courses yet", + "description_fallback": "No description added for this specialisation.", + "course_description_fallback": "No course description added yet." + } +} diff --git a/src/locales/sv/admin.json b/src/locales/sv/admin.json index a8f9bbab..857f44f2 100644 --- a/src/locales/sv/admin.json +++ b/src/locales/sv/admin.json @@ -6,6 +6,22 @@ "save": "Spara", "cancel": "Avbryt", "remove": "Ta bort", + "associated_image": { + "add": "Lägg till associerad bild", + "add_title": "Ladda upp associerad bild", + "no_target": "Inget målobjekt för bilden. Något har gått fel.", + "upload": "Ladda upp", + "upload_success": "Associerad bild uppladdad.", + "delete_success": "Associerad bild borttagen.", + "sync_error": "Misslyckades att uppdatera bildstatus.", + "view": "Visa bild", + "view_action": "Visa bild", + "delete_action": "Ta bort bild", + "delete_title": "Ta bort associerad bild?", + "delete_description": "Detta tar bort bilden från objektet. Åtgärden kan inte ångras.", + "preview_title": "Förhandsvisning av associerad bild", + "preview_alt": "Associerad bild" + }, "loading": "Hämtar...", "error": "Något gick fel :/", "choose_priorities": "Välj prioriterade grupper", @@ -17,6 +33,8 @@ "milk_allergy": "Mjölkproteinallergi", "title_sv": "Titel (svenska)", "title_en": "Titel (engelska)", + "description_sv": "Beskrivning (svenska)", + "description_en": "Beskrivning (engelska)", "ends_at": "Sluttid", "starts_at": "Starttid", "signup_start": "Anmälningsstart", @@ -37,6 +55,7 @@ "songs": "Sånger", "spider": "Spindel", "nollning": "Nollning", + "plugg": "Plugg", "board": "Styrelse och liknande", "elections": "Val" }, @@ -745,6 +764,7 @@ "remove_confirm_title": "Bekräfta borttagning", "remove_confirm_text": "Är du säker på att du vill ta bort detta objekt?", "remove_confirm_description": "En popup-dialog som bekräftar borttagning av ett objekt.", + "remove_confirm_by_typing_default": "Skriv \"{{key}}\" nedan för att bekräfta borttagningen.", "week_selector": { "placeholder": "Välj vecka", "show_all": "Visa alla", @@ -941,6 +961,169 @@ "afternoon": "Eftermiddag" } }, + "programs": { + "self": "Program", + "title": "Hantera program", + "description_subtitle": "Skapa och hantera studieprogram. Klicka på ett program i listan nedan för att redigera det. Det finns en timmes cache på det mesta av informationen som nås genom plugg.fsektionen.se, så ändringar du gör här kanske inte syns direkt på den offentliga sidan. Om du vill testa dina ändringar direkt kan du rensa datan för fsektionen.se genom din webbläsare.", + "create_program": "Skapa program", + "edit_program": "Redigera program", + "num_years": "Årskurser", + "num_specialisations": "Specialiseringar", + "title_en": "Titel (Engelska)", + "title_sv": "Titel (Svenska)", + "description_en": "Beskrivning (Engelska)", + "description_sv": "Beskrivning (Svenska)", + "specialisations": "Specialiseringar", + "select_specialisations": "Välj specialiseringar", + "specialisations_page": { + "title": "Hantera specialiseringar", + "title_with_program": "Hantera specialiseringar för {{program}}", + "description_subtitle": "Visa specialiseringar kopplade till detta program. Klicka på Se kurser för att hantera en specialisering.", + "title_sv": "Titel (Svenska)", + "title_en": "Titel (Engelska)", + "courses": "Kurser", + "edit_specialisations": "Redigera", + "back_to_programs": "Tillbaka till program", + "view_courses": "Se kurser", + "view_specialisations": "Visa specialiseringar", + "add_specialisation": "Skapa specialisering" + }, + "create_success": "Program skapat!", + "create_error": "Misslyckades att skapa program.", + "edit_success": "Program uppdaterat!", + "edit_error": "Misslyckades att uppdatera program.", + "remove_success": "Program borttaget!", + "remove_error": "Misslyckades att ta bort program.", + "confirm_remove": "Bekräfta borttagning av program", + "confirm_remove_text": "Är du säker på att du vill ta bort detta program?" + }, + "specialisations": { + "self": "Specialiseringar", + "title": "Hantera specialiseringar", + "description_subtitle": "Skapa och hantera specialiseringar. Klicka på en specialisering för att redigera den.", + "create_specialisation": "Skapa specialisering", + "edit_specialisation": "Redigera specialisering", + "title_sv": "Titel (Svenska)", + "title_en": "Titel (Engelska)", + "description_sv": "Beskrivning (Svenska)", + "description_en": "Beskrivning (Engelska)", + "courses": "Kurser", + "select_courses": "Välj kurser", + "create_success": "Specialisering skapad!", + "create_error": "Misslyckades att skapa specialisering.", + "edit_success": "Specialisering uppdaterad!", + "edit_error": "Misslyckades att uppdatera specialisering.", + "remove_success": "Specialisering borttagen!", + "remove_error": "Misslyckades att ta bort specialisering.", + "confirm_remove": "Bekräfta borttagning av specialisering", + "confirm_remove_text": "Är du säker på att du vill ta bort denna specialisering?", + "view_courses": "Se kurser", + "view_specialisations": "Visa specialiseringar", + "courses_page": { + "title": "Kurser i specialisering", + "title_with_specialisation": "Kurser i {{specialisation}}", + "description_subtitle": "Här kan du se alla kurser kopplade till denna specialisering.", + "title_column": "Titel", + "course_code": "Kurskod", + "updated_at": "Uppdaterad", + "back_to_specialisations": "Tillbaka till specialiseringar", + "view_course_documents": "Visa kursdokument" + } + }, + "courses": { + "self": "Kurser", + "title_page": "Hantera kurser", + "description_subtitle": "Skapa och hantera kurser. Klicka på en kurs för att redigera den.", + "create_course": "Skapa kurs", + "edit_course": "Redigera kurs", + "title": "Titel", + "course_code": "Kurskod", + "description": "Beskrivning", + "updated_at": "Uppdaterad", + "create_success": "Kurs skapad!", + "create_error": "Misslyckades att skapa kurs.", + "edit_success": "Kurs uppdaterad!", + "edit_error": "Misslyckades att uppdatera kurs.", + "remove_success": "Kurs borttagen!", + "remove_error": "Misslyckades att ta bort kurs.", + "confirm_remove": "Bekräfta borttagning av kurs", + "confirm_remove_text": "Är du säker på att du vill ta bort denna kurs? Detta kommer också att ta bort alla dokument som är kopplade till kursen.", + "edit_course_details": "Redigera kursdetaljer", + "edit_course_documents": "Redigera kursdokument", + "view_course_documents": "Visa kursdokument", + "view_courses": "Visa kurser", + "course_documents": { + "title_page": "Hantera kursdokument: {{course}}", + "description_subtitle": "Ladda upp och hantera kursspecifika dokument. Klicka på ett dokument för att redigera det. Efter att du har tagit bort ett dokument kan du behöva ladda om sidan för att se ändringarna.", + "back_to_courses": "Tillbaka till kurser", + "invalid_course_id": "Ogiltigt kurs-id.", + "create_document": "Skapa kursdokument", + "edit_document": "Redigera kursdokument", + "title": "Titel", + "file": "Fil", + "file_name": "Filnamn", + "author": "Författare", + "category": "Kategori", + "select_category": "Välj kategori", + "sub_category": "Underkategori", + "updated_at": "Uppdaterad", + "create_success": "Kursdokument skapat!", + "create_error": "Misslyckades att skapa kursdokument.", + "edit_success": "Kursdokument uppdaterat!", + "edit_error": "Misslyckades att uppdatera kursdokument.", + "remove_success": "Kursdokument borttaget!", + "remove_error": "Misslyckades att ta bort kursdokument.", + "open_document": "Öppna dokument", + "confirm_remove": "Bekräfta borttagning av kursdokument", + "confirm_remove_text": "Är du säker på att du vill ta bort detta kursdokument?", + "file_size_error": "Filstorleken får vara högst {{size}} MB.", + "file_type_error": "Endast PDF-filer är tillåtna.", + "categories": { + "notes": "Anteckningar", + "summary": "Sammanfattning", + "solutions": "Lösningar", + "other": "Övrigt" + } + }, + "short_identifier": "Kort identifierare", + "short_identifier_placeholder": "Kortare namn separerade med kommatecken" + }, + "program_years": { + "self": "Årskurser", + "title": "Hantera årskurser", + "title_with_program": "Hantera årskurser för {{program}}", + "description_subtitle": "Skapa och hantera årskurser för detta program. Klicka på en årskurs för att redigera den.", + "create_program": "Skapa årskurs", + "edit_program": "Redigera årskurs", + "title_en": "Titel (Engelska)", + "title_sv": "Titel (Svenska)", + "courses": "Kurser", + "select_courses": "Välj kurser", + "description_en": "Beskrivning (Engelska)", + "description_sv": "Beskrivning (Svenska)", + "create_success": "Årskurs skapad!", + "create_error": "Misslyckades att skapa årskurs.", + "edit_success": "Årskurs uppdaterad!", + "edit_error": "Misslyckades att uppdatera årskurs.", + "remove_success": "Årskurs borttagen!", + "remove_error": "Misslyckades att ta bort årskurs.", + "confirm_remove": "Bekräfta borttagning av årskurs", + "confirm_remove_text": "Är du säker på att du vill ta bort denna årskurs?", + "back_to_programs": "Tillbaka", + "courses_page": { + "title": "Kurser i årskurs", + "title_with_program_year": "Kurser i {{program_year}}", + "description_subtitle": "Här kan du se alla kurser kopplade till denna årskurs.", + "title_column": "Titel", + "course_code": "Kurskod", + "updated_at": "Uppdaterad", + "back_to_program_years": "Tillbaka till årskurser", + "edit_course": "Redigera kurs", + "edit_course_details": "Redigera kursdetaljer", + "edit_course_documents": "Redigera kursdokument", + "view_course_documents": "Visa kursdokument" + } + }, "visible_election": { "self": "Synligt val" }, diff --git a/src/locales/sv/plugg.json b/src/locales/sv/plugg.json new file mode 100644 index 00000000..bcf952ed --- /dev/null +++ b/src/locales/sv/plugg.json @@ -0,0 +1,100 @@ +{ + "navbar": { + "back": "Till startsidan", + "toggle_menu": "Visa meny", + "mobile_navigation": "Mobil navigering", + "loading": "Laddar program...", + "load_error": "Kunde inte ladda navigeringen.", + "empty": "Inga program tillgängliga.", + "specialisations": "Inriktningar", + "no_courses": "Inga kurser ännu", + "open_program": "Öppna programsida", + "more": "Mer", + "search_placeholder": "Sök allt möjligt...", + "search_no_results": "Inga resultat" + }, + "page": { + "title": "F-sektionens pluggsidor", + "intro": "Välkommen till pluggsidorna! Här har vi samlat kursmaterial, gamla tentor och andra hjälpresurser så att du lättare hittar rätt genom hela din tid på LTH. Oavsett om du är ny på programmet eller snart klar hoppas vi att sidan känns som en trygg genväg när plugget kör ihop sig.", + "search_title": "Sök genom alla pluggsidor", + "search_placeholder": "Sök bland program, årskurser, kurser och specialiseringar...", + "search_empty": "Inga träffar på din sökning.", + "search_kind_program": "Program", + "search_kind_program_year": "Årskurs", + "search_kind_specialisation": "Inriktning", + "search_kind_course": "Kurs", + "program_list_title": "Alla program", + "program_list_empty": "Inga program finns.", + "open_program": "Öppna program", + "program_description_fallback": "Beskrivning kommer snart.", + "contact_card_eyebrow": "Hjälp oss förbättra pluggsidorna", + "contact_card_title": "Saknas något eller är något inaktuellt?", + "contact_card_text": "Vi vill att pluggsidorna ska vara levande, och en pålitlig källa till Sanning™ för alla på sektionen. Om du hittar fel, trasiga länkar, gamla dokument eller material som borde finnas här får du jättegärna höra av dig.", + "contact_card_note": "Skicka gärna med en länk till sidan där felet hittades och en kort beskrivning av vad som behöver ändras så kan vi fixa det snabbare.", + "contact_card_cta": "Mejla plugg@fsektionen.se", + "section_programs_title": "Program", + "section_programs_text": "Använd den översta raden i navbaren för att byta program och visa respektive årskurs.", + "section_courses_title": "Kurser", + "section_courses_text": "Varje årskurs har en egen kursmeny. Välj en kurs för att hoppa direkt till relevant material.", + "section_specialisations_title": "Inriktningar", + "section_specialisations_text": "Inriktningar är grupperade per program i separata menyer för snabb åtkomst.", + "advertisement_title": "Gillar du design eller kod?", + "advertisement_text": "Tror du att du hade kunnat förbättra denna sida eller hjälpa till med nya funktioner till webben, servern eller appen? Sök till spindelman under nästa hösttermin!" + }, + "contact_reminder": { + "title": "Saknas något eller är något inaktuellt?", + "text": "Hjälp oss hålla denna sida uppdaterad. Skicka rättelser, nya dokument eller tips till plugg@fsektionen.se (eller klicka här).", + "email": "plugg@fsektionen.se" + }, + "program": { + "back": "Tillbaka", + "specialisations": "Inriktningar", + "program_page": { + "years_label": "tillagda årskurser", + "program_years_title": "Årskurser", + "program_years_empty": "Inga årskurser tillagda ännu.", + "specialisations_empty": "Inga inriktningar tillagda ännu.", + "program_description_fallback": "Ingen beskrivning tillgänglig för detta program ännu.", + "year_description_fallback": "Ingen beskrivning tillagd för denna årskurs.", + "specialisation_description_fallback": "Ingen beskrivning tillagd för denna inriktning." + }, + "program_year_page": { + "courses_label": "kurser", + "courses_title": "Kurser", + "no_courses": "Inga kurser ännu", + "year_description_fallback": "Ingen beskrivning tillagd för denna årskurs.", + "course_description_fallback": "Ingen kursbeskrivning tillagd ännu." + } + }, + "courses": { + "back": "Tillbaka", + "course_code_badge": "Kurskod: {{code}}", + "course_code_missing": "Kurskod saknas", + "updated_badge": "Sidan uppdaterades senast: {{date}}", + "documents_badge": "{{count}} dokument", + "description_fallback": "Ingen kursbeskrivning har lagts till ännu.", + "documents": { + "title": "Kursmaterial", + "empty": "Inga dokument har lagts till för denna kurs ännu.", + "open": "Öppna fil", + "author": "Författare", + "updated_at": "Uppdaterad", + "legacy_course_code_warning": "Kan vara från en äldre kursomgång. Kurskod:", + "general_subcategory": "Allmänt", + "categories": { + "notes": "Anteckningar", + "summary": "Sammanfattningar", + "solutions": "Lösningar", + "other": "Övrigt" + } + } + }, + "specialisations": { + "back": "Tillbaka", + "courses_label": "kurser", + "courses_title": "Kurser", + "no_courses": "Inga kurser ännu", + "description_fallback": "Ingen beskrivning tillagd för denna specialisering.", + "course_description_fallback": "Ingen kursbeskrivning tillagd ännu." + } +} diff --git a/src/proxy.ts b/src/proxy.ts index 8e94dd8d..8ee1b3a6 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -31,8 +31,27 @@ function handleAuthRedirects(request: NextRequest): NextResponse | null { return null; } +function handleSubdomain(request: NextRequest): NextResponse | null { + const hostname = request.nextUrl.hostname || ""; + if (hostname !== "plugg.fsektionen.se" && hostname !== "plugg.stage.frontend.fsektionen.se") { + return null; + } + + const pathname = request.nextUrl.pathname; + const rewrittenUrl = new URL(request.nextUrl); // Clone and replace pathname so we keep the query parameters intact + rewrittenUrl.pathname = `/plugg${pathname}`; + return NextResponse.rewrite(rewrittenUrl); +} + export function proxy(request: NextRequest) { - // Check for auth redirects first + // 1. Subdomain rewrite for plugg + const subdomainResponse = handleSubdomain(request); + if (subdomainResponse) { + handleLanguageHeader(subdomainResponse, request); + return subdomainResponse; + } + + // 2. Auth redirects const authRedirect = handleAuthRedirects(request); if (authRedirect) { @@ -40,7 +59,7 @@ export function proxy(request: NextRequest) { return authRedirect; } - // Default: continue with language header + // 3. Default: continue with language header const response = NextResponse.next(); handleLanguageHeader(response, request); return response; diff --git a/src/types/api-error.ts b/src/types/api-error.ts new file mode 100644 index 00000000..60265b5e --- /dev/null +++ b/src/types/api-error.ts @@ -0,0 +1,70 @@ +export interface ApiError { + detail: string; + status_code: number; + code?: string; + [key: string]: unknown; +} + +const getStringDetail = (detail: unknown): string | null => { + if (typeof detail === "string") { + return detail; + } + + if (detail && typeof detail === "object") { + const values = Object.values(detail as Record); + const firstString = values.find((value) => typeof value === "string"); + if (typeof firstString === "string") { + return firstString; + } + } + + return null; +}; + +export const normalizeApiError = ( + error: unknown, + statusCode?: number, +): ApiError => { + if (error && typeof error === "object") { + const obj = error as Record; + const normalizedStatus = + typeof obj.status_code === "number" ? obj.status_code : statusCode; + const normalizedDetail = + getStringDetail(obj.detail) ?? + (typeof obj.message === "string" ? obj.message : "Unknown error"); + + return { + ...obj, + detail: normalizedDetail, + status_code: normalizedStatus ?? 0, + }; + } + + if (typeof error === "string") { + return { + detail: error, + status_code: statusCode ?? 0, + }; + } + + if (error instanceof Error) { + return { + detail: error.message, + status_code: statusCode ?? 0, + }; + } + + return { + detail: "Unknown error", + status_code: statusCode ?? 0, + }; +}; + +export const isApiError = (error: unknown): error is ApiError => { + return ( + typeof error === "object" && + error !== null && + typeof (error as { detail?: unknown }).detail === "string" && + typeof (error as { status_code?: unknown }).status_code === "number" + ); +}; diff --git a/src/types/react-query.d.ts b/src/types/react-query.d.ts new file mode 100644 index 00000000..09a2ac4d --- /dev/null +++ b/src/types/react-query.d.ts @@ -0,0 +1,8 @@ +import "@tanstack/react-query"; +import type { ApiError } from "@/types/api-error"; + +declare module "@tanstack/react-query" { + interface Register { + defaultError: ApiError; + } +} diff --git a/src/utils/pluggHrefBuilders.ts b/src/utils/pluggHrefBuilders.ts new file mode 100644 index 00000000..12bc98ac --- /dev/null +++ b/src/utils/pluggHrefBuilders.ts @@ -0,0 +1,24 @@ +import urlFormatter from "@/utils/urlFormatter"; + +export function buildProgramHref(programTitle: string) { + return `/plugg/program/${urlFormatter(programTitle)}`; +} + +export function buildProgramYearHref( + programTitle: string, + programYearTitle: string, +) { + return `/plugg/program/${urlFormatter(programTitle)}/arskurser/${urlFormatter(programYearTitle)}`; +} + +export function buildCourseHref(courseTitle: string) { + return `/plugg/kurser/${urlFormatter(courseTitle)}`; +} + +export function buildSpecialisationHref(specialisationTitle: string) { + return `/plugg/specialiseringar/${urlFormatter(specialisationTitle)}`; +} + +export function buildCourseDocumentFileHref(courseDocumentId: number) { + return `/plugg/kursdokument/${courseDocumentId}`; +} diff --git a/src/utils/urlFormatter.ts b/src/utils/urlFormatter.ts new file mode 100644 index 00000000..d31f6c49 --- /dev/null +++ b/src/utils/urlFormatter.ts @@ -0,0 +1,10 @@ +export default function urlFormatter(value: string | number) { + return String(value) + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[åä]/g, "a") + .replace(/ö/g, "o") + .replace(/[^a-z0-9\-]/g, "") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} diff --git a/src/utils/viewingUserGotPerms.ts b/src/utils/viewingUserGotPerms.ts index 09793f6b..b0d6b515 100644 --- a/src/utils/viewingUserGotPerms.ts +++ b/src/utils/viewingUserGotPerms.ts @@ -1,8 +1,9 @@ import type { AdminUserRead } from "@/api"; +import { ApiError } from "@/types/api-error"; export default function viewingUserGotPerms( userData: AdminUserRead | undefined, - userError: Error | null, + userError: ApiError | null, userIsFetching: boolean, permissionTarget = "user", ): boolean { diff --git a/src/widgets/AdminForm.tsx b/src/widgets/AdminForm.tsx index 172af630..114dcffa 100644 --- a/src/widgets/AdminForm.tsx +++ b/src/widgets/AdminForm.tsx @@ -30,6 +30,7 @@ import { useTranslation } from "react-i18next"; import { Textarea } from "@/components/ui/textarea"; import { SelectFromOptions } from "./SelectFromOptions"; import { ConfirmDeleteDialog } from "@/components/ConfirmDeleteDialog"; +import StyledMultiSelect from "@/components/StyledMultiSelect"; type BaseAdminFormInputField = { name: Path; @@ -57,12 +58,27 @@ type SelectFromOptionsAdminFormInputField = placeholder?: string; }; +type StyledMultiSelectAdminFormInputField = + BaseAdminFormInputField & { + variant: "styledMultiSelect"; + options: { value: string | number; label: string }[]; + placeholder?: string; + }; + +type FileAdminFormInputField = + BaseAdminFormInputField & { + variant: "file"; + accept?: string; + }; + // Implement further cases here export type AdminFormInputField = | TextAdminFormInputField | TextareaAdminFormInputField - | SelectFromOptionsAdminFormInputField; + | SelectFromOptionsAdminFormInputField + | StyledMultiSelectAdminFormInputField + | FileAdminFormInputField; export interface AdminFormProps { title: string; @@ -85,6 +101,7 @@ export interface AdminFormProps { confirmDeleteDialogTitle?: string; confirmDeleteDialogDescription?: string; confirmDeleteDialogConfirmByTyping?: boolean; + confirmDeleteDialogConfirmByTypingText?: string; confirmDeleteDialogConfirmByTypingKey?: string; } @@ -111,6 +128,7 @@ export default function AdminForm({ confirmDeleteDialogDescription, confirmDeleteDialogConfirmByTyping = false, confirmDeleteDialogConfirmByTypingKey, + confirmDeleteDialogConfirmByTypingText, }: AdminFormProps) { const [internalOpen, setInternalOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); @@ -278,6 +296,69 @@ export default function AdminForm({ )} /> ); + case "styledMultiSelect": + return ( + { + const selectedValues = Array.isArray(field.value) + ? (field.value as Array) + : []; + return ( + + {inputField.label} + + + selectedValues.includes(option.value), + )} + onChange={(options) => { + const vals = Array.isArray(options) + ? options.map((o) => o.value) + : []; + field.onChange(vals); + }} + /> + + + + ); + }} + /> + ); + case "file": + return ( + ( + + {inputField.label} + + { + field.onChange(event.target.files?.[0]); + }} + /> + + + + )} + /> + ); // Implement further cases here default: return null; @@ -300,6 +381,9 @@ export default function AdminForm({ title={confirmDeleteDialogTitle} description={confirmDeleteDialogDescription} confirmByTyping={confirmDeleteDialogConfirmByTyping} + confirmByTypingText={ + confirmDeleteDialogConfirmByTypingText + } confirmByTypingKey={ confirmDeleteDialogConfirmByTypingKey } diff --git a/src/widgets/AdminPage.tsx b/src/widgets/AdminPage.tsx index cb003f97..31351702 100644 --- a/src/widgets/AdminPage.tsx +++ b/src/widgets/AdminPage.tsx @@ -86,7 +86,7 @@ export default function AdminPage({ /** * See the docs for {@link EditComponent}. */ - editComponent: EditComponent; + editComponent: EditComponent | null; headerButtons?: ReactNode; columnFilters?: ColumnFiltersState; onColumnFiltersChange?: OnChangeFn; @@ -303,7 +303,7 @@ export default function AdminPage({
- setEditing(null)} /> + {EditComponent && setEditing(null)} />} ); }