diff --git a/next.config.js b/next.config.js index bb15531..9015b00 100644 --- a/next.config.js +++ b/next.config.js @@ -1,6 +1,7 @@ /** @type {import('next').NextConfig} */ const nextConfig = { output: "export", + images: { unoptimized: true } }; module.exports = nextConfig; diff --git a/src/@types/activities.ts b/src/@types/activities.ts new file mode 100644 index 0000000..60509e8 --- /dev/null +++ b/src/@types/activities.ts @@ -0,0 +1,211 @@ +export interface ActivityResult { + permissions: unknown; + paging: { + count: number; + limit: number; + nextPage: string; + offset: number; + previousPage: string; + }; + results: Activity[]; +} + +export interface Activity { + iconUrl: string + launchInNewWindow: boolean + contentDetail: ContentDetail + modifiedDate: number + courseId: string + title: string + isReviewable: boolean + isGroupContent: boolean + inSequence: boolean + dueDateExceptionType: string + renderType: string + thumbnailAltText: string + aiState: string + inLesson: boolean + titleColor: string + position: number + contentHandler: ContentHandler + description: string + body: Body + permissions: Permissions + id: string + state: string + visibility?: string + parentId?: string + adaptiveReleaseRules?: AdaptiveReleaseRules +} + +type ContentHandler = "resource/x-bb-folder" + | "resource/x-bb-file" + | "resource/x-bb-assignment" + | "resource/x-bb-externallink"; + +export interface ContentDetail { + "resource/x-bb-folder"?: ResourceXBbFolder + "resource/x-bb-file"?: ResourceXBbFile + "resource/x-bb-assignment"?: ResourceXBbAssignment + "resource/x-bb-externallink"?: ResourceXBbExternallink +} + +export interface ResourceXBbFolder { + isBbPage: boolean + isFolder: boolean +} + +export interface ResourceXBbFile { + file: File + fileAssociationMode: string +} + +export interface File { + existingFileReference: string + permanentUrl: string + mimeType: string + viewerUrl: string + forceDownload: boolean + isMedia: boolean + fileName: string + xid: string +} + +export interface ResourceXBbAssignment { + gradingColumn: GradingColumn + groupContent: boolean + safeAssignOptions: SafeAssignOptions +} + +export interface GradingColumn { + gradebookCategory: GradebookCategory + localizedColumnName: LocalizedColumnName + effectiveColumnName: string + isAttemptBased: boolean + gradeScoreDesignation: string + formativeIndicator: string + isFormative: boolean + courseId: string + scoreProviderHandle: string + visible: boolean + peerGrading: boolean + delegatedGrading: boolean + contentId: string + anonymousGrading: boolean + dateCreated: string + deleted: boolean + gradingSchemaId: string + columnName: string + aggregationModel: string + hideAttempt: boolean + scorable: boolean + visibleInAllTerms: boolean + visibleInBook: boolean + showStatsToStudent: boolean + limitedAttendance: boolean + userCreatedColumn: boolean + autoPostGrades: boolean + ltiDomainId: any + gradesReleased: boolean + enforceDueDate: boolean + position: number + possible: number + multipleAttempts: number + calculationType: string + gradebookCategoryId: string + id: string + "@id": string +} + +export interface GradebookCategory { + isUserDefined: boolean + courseId: string + title: string + localizableTitle: LocalizableTitle + description: any + id: string +} + +export interface LocalizableTitle { + languageKey: string + bundle: string +} + +export interface LocalizedColumnName { + languageKey: string + bundle: string +} + +export interface SafeAssignOptions { + checkAttempts: boolean + canStudentViewReports: boolean + excludeSubmissions: boolean + globalSearch: boolean +} + +export interface ResourceXBbExternallink { + url: string +} + +export interface Body { + rawText: string + displayText: string + webLocation: string + fileLocation: string +} + +export interface Permissions { + edit: boolean + contentHandlerPermissionMap: ContentHandlerPermissionMap + modifyAvailability: boolean + adaptiveRelease: AdaptiveRelease + canHaveReviewState: boolean + canViewReviewState: boolean + canUpdateReviewState: boolean + dashboardView: boolean + createLearningStandardsAlignment: boolean + viewDesigner: boolean + delete: boolean + copy: boolean +} + +export interface ContentHandlerPermissionMap { + createDiscussion: boolean +} + +export interface AdaptiveRelease { + edit: boolean + criteria: Criteria + delete: boolean + create: boolean + view: boolean +} + +export interface Criteria { + performance: Performance + acl: Acl + dates: Dates +} + +export interface Performance { + edit: boolean + delete: boolean + create: boolean +} + +export interface Acl { + edit: boolean + delete: boolean + create: boolean +} + +export interface Dates { + edit: boolean + delete: boolean + create: boolean +} + +export interface AdaptiveReleaseRules { + endDate: string + startDate: any +} diff --git a/src/@types/courses.ts b/src/@types/courses.ts new file mode 100644 index 0000000..1d24fa8 --- /dev/null +++ b/src/@types/courses.ts @@ -0,0 +1,144 @@ +export interface CourseResponse { + permissions: unknown; + paging: { + count: number + limit: number + nextPage: string + offset: number + previousPage: string; + }; + results: CourseResult[]; +} +export interface CourseResult { + role: string + course: Course + courseId: string + isAvailable: boolean + modifiedDate: string + userId: string + lastAccessDate: string + receiveEmail: boolean + includedInRoster: boolean + dueDateExceptionType: string + timeLimitExceptionType: string + enrollmentDate: string + dataSourceId: string + courseRole: CourseRole + userHasHidden: boolean + courseCardColorIndex: number + id: string +} + +export interface Course { + courseId: string + isAvailable: boolean + createdDate: string + modifiedDate: string + batchUid: string + uuid: string + navStyle: string + durationType: string + classificationId: string + dataSourceId: string + enrollmentType: string + paceType: string + bannerImageFile?: string + bannerAltText: string + isLocaleEnforced: boolean + defaultViewContent: string + termId?: string + isClosed: boolean + description: string + term?: Term + banner?: Banner + displayId: string + effectiveAvailability: boolean + isBannerVisible: boolean + isAllowObservers: boolean + isHonorTermAvailability: boolean + bannerImageThumbnail?: BannerImageThumbnail + isAllowGuests: boolean + displayName: string + permissions: Permissions2 + ultraStatus: string + courseViewOption: string + isOrganization: boolean + externalAccessUrl: string + homePageUrl: string + name: string + id: string + guestAccessUrl?: string +} + +export interface Term { + isAvailable: boolean + endDate: string + startDate: string + durationType: string + daysOfUse: number + description: Description + dataSrcId: string + permissions: Permissions + coursesPerTerm: number + name: string + id: string +} + +export interface Description { + rawText: string + displayText: string + webLocation: any + fileLocation: any +} + +export interface Permissions { + edit: boolean + delete: boolean +} + +export interface Banner { + permanentUrl: string + forceDownload: boolean +} + +export interface BannerImageThumbnail { + permanentUrl: string + forceDownload: boolean +} + +export interface Permissions2 { + edit: boolean + archive: boolean + delete: boolean + export: boolean + copy: boolean + editDuration: boolean + editTerm: boolean + chooseUltraStatus: boolean + editBanner: boolean + editBannerVisibility: boolean + editAvailability: boolean + editName: boolean + editLocale: boolean + import: boolean + viewAutoArchive: boolean + messagesEnabled: boolean + convert: boolean + editClosed: boolean + viewEnrollments: boolean +} + +export interface CourseRole { + courseName: CourseName + roleBucket: string + sortOrder: number + identifier: string + roleAvailability: string + isActAsInstructor: boolean + id: string +} + +export interface CourseName { + bundle: string + languageKey: string +} diff --git a/src/app/[locale]/(dashboard)/courses/[id]/page.tsx b/src/app/[locale]/(dashboard)/courses/[id]/page.tsx new file mode 100644 index 0000000..851e482 --- /dev/null +++ b/src/app/[locale]/(dashboard)/courses/[id]/page.tsx @@ -0,0 +1,28 @@ +"use client"; +import {List, Stack, Typography} from "@mui/material"; +import {useGetCourseContentsQuery} from "@/redux/services/course-api"; +import ActivityCard from "@/components/activity-card"; + +export default function CourseActivities({ params }: { params: { id: string }} ) { + const { data } = useGetCourseContentsQuery(params.id); + + console.log(data, params.id) + + return ( + + + Activities from course - {params.id} + + + + {data?.results?.map((activity) => { + if (activity.contentHandler !== "resource/x-bb-assignment") { + return; + } + + return + })} + + + ) +} \ No newline at end of file diff --git a/src/app/[locale]/(dashboard)/dashboard/page.tsx b/src/app/[locale]/(dashboard)/dashboard/page.tsx index 72b3e5e..0ab7e6c 100644 --- a/src/app/[locale]/(dashboard)/dashboard/page.tsx +++ b/src/app/[locale]/(dashboard)/dashboard/page.tsx @@ -1,9 +1,14 @@ "use client"; -import { Stack, Typography } from "@mui/material"; -import { useGetMeQuery } from "@/redux/services/user-api"; +import {CircularProgress, Grid, Stack, Typography} from "@mui/material"; +import {useGetMeQuery} from "@/redux/services/user-api"; +import {useGetCoursesQuery} from "@/redux/services/course-api"; +import CourseCard from "@/components/course-card"; export default function Activities() { const { isLoading, data } = useGetMeQuery(); + const { data: courses } = useGetCoursesQuery(data?.id ?? "", { skip: data === undefined }); + + console.log(courses) return ( - Activities page + + Activities - { courses?.paging?.count ?? 0 } + {isLoading ? : undefined} + + + + {courses?.results?.map((courseRoot) => ( + + ))} + ); } diff --git a/src/components/activity-card.tsx b/src/components/activity-card.tsx new file mode 100644 index 0000000..ed010d0 --- /dev/null +++ b/src/components/activity-card.tsx @@ -0,0 +1,40 @@ +import { ListItem, ListItemText, Tooltip } from "@mui/material"; +import { Done, QueryBuilder } from "@mui/icons-material"; +import { Activity } from "@/@types/activities"; +import { useGetAttemptsQuery } from "@/redux/services/activity-api"; + +export default function ActivityCard({ activity }: { activity: Activity }) { + const { data } = useGetAttemptsQuery({ + activityId: activity.contentDetail[activity.contentHandler as "resource/x-bb-assignment"]!.gradingColumn.id, + courseId: activity.courseId + }); + + return ( + + + + { + data && Object.keys(data.lookup).length !== 0 + ? ( + + + + ) + : undefined + } + + { + activity.adaptiveReleaseRules + ? ( + + + + ) + : undefined + } + + ) +} \ No newline at end of file diff --git a/src/components/course-card.tsx b/src/components/course-card.tsx new file mode 100644 index 0000000..2989b43 --- /dev/null +++ b/src/components/course-card.tsx @@ -0,0 +1,71 @@ +import {ButtonBase, Card, CardContent, CardMedia, Grid, Typography} from "@mui/material"; +import {CourseResult} from "@/@types/courses"; +import Image from "next/image"; +import {useRouter} from "next/navigation"; +import {useEffect, useState} from "react"; +import {useGetCourseBannerQuery, useGetCourseStaticBannerQuery} from "@/redux/services/banner-api"; +import {useWithLocale} from "@/hooks/useWithLocale"; + +export default function CourseCard({ courseRoot }: { courseRoot: CourseResult }) { + const router = useRouter(); + const withLocale = useWithLocale(); + const [expired, setExpired] = useState(false); + const { data: bannerUrl } = courseRoot.course.banner + ? useGetCourseBannerQuery(courseRoot.courseId) + : useGetCourseStaticBannerQuery(`nature${courseRoot.courseCardColorIndex % 20 + 1}_thumb`); + + function handleClick() { + router.push(withLocale(`/courses/${courseRoot.courseId}`)); + } + + useEffect(() => { + const endDate = courseRoot.course.term?.endDate; + + if (!endDate) { + return; + } + + const endDateParsed = Date.parse(endDate); + const now = Date.now(); + + setExpired(endDateParsed < now); + }, [courseRoot.course.term?.endDate]); + + return ( + + handleClick()} sx={{ width: "100%", height: "100%" }}> + + +
+ {courseRoot.course.bannerAltText +
+
+ + + + {courseRoot.courseId} + + + + {courseRoot.course.name} + + + + {courseRoot.course.description} + + +
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/navbar.tsx b/src/components/navbar.tsx index 2236b14..2e3aeb1 100644 --- a/src/components/navbar.tsx +++ b/src/components/navbar.tsx @@ -17,17 +17,12 @@ import { Skeleton, Stack, Toolbar, - Typography, + Typography } from "@mui/material"; import { stringAvatar } from "@/helpers/text-to-avatar"; import { useGetMeQuery } from "@/redux/services/user-api"; import React, { FC, PropsWithChildren, useState } from "react"; -import { - AccountCircle, - Logout, - Menu as MenuIcon, - Settings, -} from "@mui/icons-material"; +import { AccountCircle, Logout, Menu as MenuIcon, Settings } from "@mui/icons-material"; import { signOut } from "@/redux/actions/auth-action"; import { useAppDispatch } from "@/redux/hooks"; import Link from "next/link"; diff --git a/src/redux/services/activity-api.ts b/src/redux/services/activity-api.ts new file mode 100644 index 0000000..d0cc577 --- /dev/null +++ b/src/redux/services/activity-api.ts @@ -0,0 +1,18 @@ +import { createApi } from "@reduxjs/toolkit/query/react"; +import { authFetchBaseQuery } from "@/utils/auth-fetch-base-query"; +import { getApi } from "@/utils/get-api"; + + +export const activityApi = createApi({ + reducerPath: "activityApi", + baseQuery: authFetchBaseQuery({ baseUrl: getApi() }), + endpoints: (builder) => ({ + getAttempts: builder.query<{ lookup: { [key: string]: any } }, { courseId: string, activityId: string }>({ + query: (data) => ({ + url: `/learn/api/v1/courses/${data.courseId}/gradebook/columns/${data.activityId}/attempts` + }) + }) + }) +}); + +export const { useGetAttemptsQuery } = activityApi; \ No newline at end of file diff --git a/src/redux/services/banner-api.ts b/src/redux/services/banner-api.ts new file mode 100644 index 0000000..f677038 --- /dev/null +++ b/src/redux/services/banner-api.ts @@ -0,0 +1,44 @@ +import {createApi} from "@reduxjs/toolkit/query/react"; +import {authFetchBaseQuery} from "@/utils/auth-fetch-base-query"; +import {getApi} from "@/utils/get-api"; +import {ResponseType} from "@tauri-apps/api/http"; + +export const bannerApi = createApi({ + reducerPath: "bannerApi", + baseQuery: authFetchBaseQuery({ baseUrl: getApi() }), + endpoints: (builder) => ({ + getCourseBanner: builder.query({ + query: (courseId) => ({ + url: `/bannerthumbnail/${courseId}`, + options: { + responseType: ResponseType.Text, + method: "GET", + }, + }), + transformResponse: (response: string, meta) => { + if (meta?.response.status !== 200) return null; + + return meta?.response.url; + } + }), + getCourseStaticBanner: builder.query({ + query: (bannerName) => ({ + url: `/ultra/static/images/default-banners/${bannerName}.jpg`, + options: { + responseType: ResponseType.Binary, + method: "GET", + }, + }), + transformResponse: (response: string, meta) => { + if (meta?.response.status !== 200) return null; + if (meta?.response.data === undefined) return null; + + const buffer = Buffer.from(meta.response.data as string, 'binary'); + + return `data:image/png;base64,${buffer.toString("base64")}`; + } + }) + }), +}); + +export const { useGetCourseBannerQuery, useGetCourseStaticBannerQuery } = bannerApi; diff --git a/src/redux/services/course-api.ts b/src/redux/services/course-api.ts new file mode 100644 index 0000000..1ec549e --- /dev/null +++ b/src/redux/services/course-api.ts @@ -0,0 +1,38 @@ +import {createApi} from "@reduxjs/toolkit/query/react"; +import {authFetchBaseQuery} from "@/utils/auth-fetch-base-query"; +import {getApi} from "@/utils/get-api"; +import {CourseResponse} from "@/@types/courses"; +import {ActivityResult} from "@/@types/activities"; + +export const courseApi = createApi({ + reducerPath: "courseApi", + baseQuery: authFetchBaseQuery({ baseUrl: getApi() }), + endpoints: (builder) => ({ + getCourses: builder.query({ + query: (userid) => ({ + url: `/learn/api/v1/users/${userid}/memberships`, + options: { + query: { + expand: "course.effectiveAvailability,course.permissions,courseRole", + includeCount: "true", + limit: "10000", + }, + method: "GET", + }, + }) + }), + getCourseContents: builder.query({ + query: (courseId) => ({ + url: `/learn/api/v1/courses/${courseId}/contents`, + options: { + query: { + recursive: "true" + }, + method: "GET" + } + }) + }) + }), +}); + +export const { useGetCoursesQuery, useGetCourseContentsQuery } = courseApi; diff --git a/src/redux/services/user-api.ts b/src/redux/services/user-api.ts index 18d89e1..e1946c1 100644 --- a/src/redux/services/user-api.ts +++ b/src/redux/services/user-api.ts @@ -27,7 +27,7 @@ export const userApi = createApi({ method: "GET", responseType: ResponseType.JSON, }, - }), + }) }), }), }); diff --git a/src/redux/store.ts b/src/redux/store.ts index 305d6e8..a42efbb 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -1,15 +1,21 @@ -import { combineReducers, configureStore } from "@reduxjs/toolkit"; -import { persistReducer, persistStore } from "redux-persist"; -import { setupListeners } from "@reduxjs/toolkit/query"; -import { authReducer } from "@/redux/features/auth-slice"; -import { userApi } from "@/redux/services/user-api"; +import {combineReducers, configureStore} from "@reduxjs/toolkit"; +import {persistReducer, persistStore} from "redux-persist"; +import {setupListeners} from "@reduxjs/toolkit/query"; +import {authReducer} from "@/redux/features/auth-slice"; +import {userApi} from "@/redux/services/user-api"; import storage from "@/redux/custom-storage"; import { settingsReducer } from "@/redux/features/settings-slice"; +import {courseApi} from "@/redux/services/course-api"; +import {activityApi} from "@/redux/services/activity-api"; +import {bannerApi} from "@/redux/services/banner-api"; export const rootReducers = combineReducers({ auth: authReducer, settings: settingsReducer, [userApi.reducerPath]: userApi.reducer, + [courseApi.reducerPath]: courseApi.reducer, + [activityApi.reducerPath]: activityApi.reducer, + [bannerApi.reducerPath]: bannerApi.reducer }); const persistConfig = { @@ -26,6 +32,9 @@ export const store = configureStore({ middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: false }).concat([ userApi.middleware, + courseApi.middleware, + activityApi.middleware, + bannerApi.middleware ]), });