34 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
e9c570a821 Add comprehensive GitHub Copilot instructions with validated build processes
Co-authored-by: snow-jallen <42281341+snow-jallen@users.noreply.github.com>
2025-09-10 18:09:06 +00:00
copilot-swe-agent[bot]
3f44e54c9b Initial plan 2025-09-10 17:54:50 +00:00
ecb5f6d70f error checking update 2025-09-01 08:51:32 -06:00
523a05d45e when creating assignments, verify the classroom url can be swapped 2025-08-29 11:05:50 -06:00
5f408749e4 updating matching logic 2025-08-25 11:13:11 -06:00
994d6e9a03 add graded answers for short answer questions to help text 2025-08-25 10:43:16 -06:00
d1a768393c improving replace url features 2025-08-21 08:55:29 -06:00
224cc9cd2a replacing text can work 2025-08-21 08:38:48 -06:00
e07a12f622 fix test 2025-08-21 08:28:52 -06:00
54e4d7b4a1 adding some prefetches, not sure if makes difference 2025-08-13 11:24:55 -06:00
e8de00a2b1 small refactors 2025-08-13 11:12:15 -06:00
762a51d6da sort module button 2025-08-11 14:06:59 -06:00
5715b081a9 adding readme instructions 2025-07-30 10:07:17 -06:00
c5759c0bec settings 2025-07-29 15:06:27 -06:00
f7357e4c08 better titles 2025-07-29 11:18:08 -06:00
60b2ad7959 sidebar collapsing is better 2025-07-23 11:56:56 -06:00
a94087dd98 one more folder change 2025-07-23 11:41:03 -06:00
99f491f16e refactoring canvas files 2025-07-23 11:40:18 -06:00
815f929c2d more code refactor to colocate feature code 2025-07-23 11:25:12 -06:00
c37ad0708e more refactor 2025-07-23 09:57:00 -06:00
aa15b2b335 more refactor 2025-07-23 09:55:30 -06:00
1885431574 more refactor 2025-07-23 09:54:11 -06:00
3e371247d6 more refactoring by feature 2025-07-23 09:46:35 -06:00
d5a40e52d9 fixing lint config 2025-07-23 09:29:19 -06:00
c95c40f9e7 refactoring files to be located by feature 2025-07-23 09:23:44 -06:00
46e0c36916 can add new courses, kinda janky 2025-07-22 15:09:10 -06:00
704a5ae404 can add existing courses 2025-07-22 14:23:40 -06:00
67b67100c1 path selecting element 2025-07-22 13:55:15 -06:00
01d137efcf moving to a global config 2025-07-22 10:05:55 -06:00
cea6aef453 adding github examples to help string 2025-07-21 14:22:29 -06:00
746253b6c2 importing course scrubs classroom links 2025-07-21 14:20:31 -06:00
5ab371334e can get classroom links based on settings 2025-07-21 14:18:22 -06:00
42ce579eee adding github classroom links to settings 2025-07-21 14:11:46 -06:00
9aec082467 working on mcp 2025-07-21 11:42:27 -06:00
234 changed files with 3096 additions and 1761 deletions

152
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,152 @@
# Canvas Management Application
Canvas Management is a Next.js web application that provides a user interface for managing Canvas LMS courses, modules, assignments, quizzes, and pages. It features real-time file synchronization through WebSocket connections and supports Docker containerization.
Always reference these instructions first and fallback to search or bash commands only when you encounter unexpected information that does not match the info here.
## Working Effectively
### Bootstrap, Build, and Test the Repository
- Install pnpm globally: `npm install -g pnpm`
- Install dependencies: `pnpm install --config.confirmModulesPurge=false` -- takes 25 seconds. NEVER CANCEL. Set timeout to 60+ seconds.
- Build the application: `pnpm run build` -- takes 47 seconds. NEVER CANCEL. Set timeout to 90+ minutes for CI environments.
- Run tests: `pnpm run test` -- takes 8 seconds. NEVER CANCEL. Set timeout to 30+ seconds.
- Run linting: `pnpm run lint` -- takes 13 seconds. NEVER CANCEL. Set timeout to 30+ seconds.
### Environment Setup
- Copy `.env.test` to `.env.local` for local development
- Set required environment variables:
- `STORAGE_DIRECTORY="./temp/storage"` - Directory for course data storage
- `NEXT_PUBLIC_ENABLE_FILE_SYNC=true` - Enable file synchronization features
- `CANVAS_TOKEN="your_canvas_api_token"` - Canvas API token (optional for local development)
- Create storage directory: `mkdir -p temp/storage`
- Create simple globalSettings.yml: `echo "courses: []" > globalSettings.yml`
### Run the Development Server
- **Development server without WebSocket**: `pnpm run devNoSocket` -- starts in 1.5 seconds
- **Development server with WebSocket**: `pnpm run dev` -- runs `pnpm install` then starts WebSocket server with Next.js
- Access the application at: `http://localhost:3000`
- WebSocket server enables real-time file watching and synchronization
### Run Production Build
- **Production server**: `pnpm run start` -- starts production WebSocket server with Next.js
- Requires completing the build step first
### Docker Development
- **Local Docker build**: `./build.sh` -- takes 5+ minutes. NEVER CANCEL. Set timeout to 120+ minutes.
- **Development with Docker**: Use `run.sh` or `docker-compose.dev.yml`
- **Production with Docker**: Use `docker-compose.yml`
- Note: Docker builds may fail in sandboxed environments due to certificate issues
## Validation
### Manual Testing Scenarios
- ALWAYS run through complete end-to-end scenarios after making changes
- Start the development server and verify the main page loads with "Add New Course" and "Add Existing Course" buttons
- Test course creation workflow (will fail without valid Canvas API token, which is expected)
- Verify WebSocket connectivity shows "Socket connected successfully" in browser console
- Check that file system watching works when NEXT_PUBLIC_ENABLE_FILE_SYNC=true
### Required Validation Steps
- Always run `pnpm run lint` before committing changes
- Always run `pnpm run test` to ensure tests pass
- Always run `pnpm run build` to verify production build works
- Test both `devNoSocket` and `dev` modes to ensure WebSocket functionality works
## Common Tasks
### Build System Requirements
- **Node.js**: Version 20+ (validated with v20.19.5)
- **Package Manager**: pnpm v10+ (install with `npm install -g pnpm`)
- **Build Tool**: Next.js 15.3.5 with React 19
### Technology Stack
- **Frontend**: Next.js 15.3.5, React 19, TypeScript 5.8.3
- **Styling**: Tailwind CSS 4.1.11
- **Testing**: Vitest 3.2.4 with @testing-library/react
- **Linting**: ESLint 9.31.0 with Next.js and TypeScript configs
- **Real-time**: WebSocket with Socket.IO for file watching
- **API**: tRPC for type-safe API calls
- **Canvas Integration**: Axios for Canvas LMS API communication
### Key Project Structure
```
src/
├── app/ # Next.js app router pages
├── components/ # Reusable React components
├── features/ # Feature-specific code (local file handling, Canvas API)
├── services/ # Utility services and API helpers
└── websocket.js # WebSocket server for file watching
```
### Development Workflow Tips
- Use `pnpm dev` for full development with file watching
- Use `pnpm devNoSocket` for faster startup when WebSocket features not needed
- Monitor console for WebSocket connection status and Canvas API errors
- Canvas API errors are expected without valid CANVAS_TOKEN
- File sync requires NEXT_PUBLIC_ENABLE_FILE_SYNC=true
### Storage Configuration
- Course data stored in markdown files within storage directory
- `globalSettings.yml` controls which courses appear in UI
- Each course requires settings.yml file in its directory
- Images supported via volume mounts to `/app/public/images/`
### Frequent Command Outputs
#### Repository Root Structure
```
.
├── README.md
├── package.json
├── pnpm-lock.yaml
├── Dockerfile
├── docker-compose.yml
├── docker-compose.dev.yml
├── build.sh
├── run.sh
├── globalSettings.yml
├── eslint.config.mjs
├── vitest.config.ts
├── tsconfig.json
├── tailwind.config.ts
├── next.config.mjs
├── postcss.config.mjs
└── src/
```
#### Key Package Scripts
```json
{
"dev": "pnpm install --config.confirmModulesPurge=false && node src/websocket.js",
"devNoSocket": "next dev",
"build": "next build",
"start": "NODE_ENV=production node src/websocket.js",
"lint": "eslint . --config eslint.config.mjs && tsc && next lint",
"test": "vitest"
}
```
## Critical Timing Information
- **NEVER CANCEL** any build or test commands - wait for completion
- **Dependency Installation**: ~25 seconds (set 60+ second timeout)
- **Production Build**: ~47 seconds (set 90+ minute timeout for CI)
- **Test Suite**: ~8 seconds (set 30+ second timeout)
- **Linting**: ~13 seconds (set 30+ second timeout)
- **Development Server Startup**: ~1.5 seconds
- **Docker Build**: 5+ minutes locally (set 120+ minute timeout)
## Error Handling
### Expected Errors During Development
- Canvas API connection errors without valid CANVAS_TOKEN
- Socket.IO 404 errors when running devNoSocket mode
- Docker build certificate issues in sandboxed environments
- Course settings file not found errors with empty storage directory
### Troubleshooting
- If WebSocket connection fails, check NEXT_PUBLIC_ENABLE_FILE_SYNC environment variable
- If build fails, ensure pnpm is installed globally
- If tests fail with storage errors, check STORAGE_DIRECTORY environment variable
- For Canvas integration, set valid CANVAS_TOKEN environment variable

View File

@@ -4,12 +4,40 @@
<https://nowucca.com/2020/07/04/working-around-canvas-limitations.html> <https://nowucca.com/2020/07/04/working-around-canvas-limitations.html>
## Getting Started and Usage ## Getting Started and Usage (v3)
<!-- draft --> All class data files are stored in markdown files in a folder. I recommend making this folder a git repo. Here's an example docker compose:
```yml
services:
canvas_manager:
image: alexmickelson/canvas_management:3
user: "1000:1000"
container_name: canvas-manager
ports:
- 3000:3000
env_file:
- .env
environment:
- storageDirectory=/app/storage
- TZ=America/Denver
- NEXT_PUBLIC_ENABLE_FILE_SYNC=true
volumes:
- ./globalSettings.yml:/app/globalSettings.yml
- ~/projects/faculty:/app/storage
- ~/projects/facultyFiles:/app/public/images/facultyFiles
```
The `globalSettings.yml` file specifies which folders in your storage directory you want to display in the UI. This way you can have old classes files stored, but not bring them into the UI unless you need to (like when you are planning a new semester, you might want to see the old semester).
`globalSettings.yml` can start like this, this file will be edited as you add classes to manage.
```yml
courses: []
```
### Enable Image Support ## Enable Image Support
You must set the `NEXT_PUBLIC_ENABLE_FILE_SYNC` environment variable to true. Images need to be available in the `/app/public/` directory in the container so that nextjs will serve them as static files. Images can also be set to public URL's on the web. You must set the `NEXT_PUBLIC_ENABLE_FILE_SYNC` environment variable to true. Images need to be available in the `/app/public/` directory in the container so that nextjs will serve them as static files. Images can also be set to public URL's on the web.

View File

@@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
MAJOR_VERSION="2" MAJOR_VERSION="3"
MINOR_VERSION="8" MINOR_VERSION="0"
VERSION="$MAJOR_VERSION.$MINOR_VERSION" VERSION="$MAJOR_VERSION.$MINOR_VERSION"
TAG_FLAG=false TAG_FLAG=false

View File

@@ -15,17 +15,20 @@ services:
- NEXT_PUBLIC_ENABLE_FILE_SYNC=true - NEXT_PUBLIC_ENABLE_FILE_SYNC=true
- REDIS_URL=redis://redis:6379 - REDIS_URL=redis://redis:6379
volumes: volumes:
# - ./globalSettings.dev.yml:/app/globalSettings.yml
- ./globalSettings.yml:/app/globalSettings.yml
- .:/app - .:/app
- ~/projects/faculty/1810/2025-spring-alex/in-person:/app/storage/intro_to_web_old - ~/projects/faculty:/app/storage
- ~/projects/faculty/1810/2025-fall-alex/modules:/app/storage/intro_to_web # - ~/projects/faculty/1810/2025-spring-alex/in-person:/app/storage/intro_to_web_old
- ~/projects/faculty/4850_AdvancedFE/2025-fall-alex/modules:/app/storage/advanced_frontend # - ~/projects/faculty/1810/2025-fall-alex/modules:/app/storage/intro_to_web
- ~/projects/faculty/4850_AdvancedFE/2024-fall-alex/modules:/app/storage/advanced_frontend_old # - ~/projects/faculty/4850_AdvancedFE/2025-fall-alex/modules:/app/storage/advanced_frontend
- ~/projects/faculty/1430/2024-fall-alex/modules:/app/storage/ux_old # - ~/projects/faculty/4850_AdvancedFE/2024-fall-alex/modules:/app/storage/advanced_frontend_old
- ~/projects/faculty/1430/2025-fall-alex/modules:/app/storage/ux # - ~/projects/faculty/1430/2024-fall-alex/modules:/app/storage/ux_old
- ~/projects/faculty/1420/2024-fall/Modules:/app/storage/1420_old # - ~/projects/faculty/1430/2025-fall-alex/modules:/app/storage/ux
- ~/projects/faculty/1420/2025-fall-alex/modules:/app/storage/1420 # - ~/projects/faculty/1420/2024-fall/Modules:/app/storage/1420_old
- ~/projects/faculty/1425/2024-fall/Modules:/app/storage/1425_old # - ~/projects/faculty/1420/2025-fall-alex/modules:/app/storage/1420
- ~/projects/faculty/1425/2025-fall-alex/modules:/app/storage/1425 # - ~/projects/faculty/1425/2024-fall/Modules:/app/storage/1425_old
# - ~/projects/faculty/1425/2025-fall-alex/modules:/app/storage/1425
- ~/projects/facultyFiles:/app/public/images/facultyFiles - ~/projects/facultyFiles:/app/public/images/facultyFiles
redis: redis:
@@ -47,7 +50,7 @@ services:
--api-key "$MCP_TOKEN" \ --api-key "$MCP_TOKEN" \
--server-type "streamable_http" \ --server-type "streamable_http" \
--cors-allow-origins "*" \ --cors-allow-origins "*" \
-- http://canvas-dev:3000/api/mcp -- http://canvas-dev:3000/api/mcp/mcp/
' '
working_dir: /app working_dir: /app
ports: ports:

View File

@@ -1,8 +1,8 @@
services: services:
canvas_manager: canvas_manager:
image: alexmickelson/canvas_management:2.7 image: alexmickelson/canvas_management:3
user: "1000:1000" user: "1000:1000"
container_name: canvas-manager-2 container_name: canvas-manager
ports: ports:
- 3000:3000 - 3000:3000
env_file: env_file:
@@ -14,30 +14,8 @@ services:
- REDIS_URL=redis://redis:6379 - REDIS_URL=redis://redis:6379
# - FILE_POLLING=true # - FILE_POLLING=true
volumes: volumes:
# - ~/projects/faculty/3840_Telemetry/2024Spring_alex/modules:/app/storage/spring_2024_telemetry - ./globalSettings.yml:/app/globalSettings.yml
- ~/projects/faculty:/app/storage
# - ~/projects/faculty/1400/2025_spring_alex/modules:/app/storage/1400
# - ~/projects/faculty/1405/2025_spring_alex:/app/storage/1405
# - ~/projects/faculty/3840_Telemetry/2025_spring_alex/modules:/app/storage/telemetry
# - ~/projects/faculty/4620_Distributed/2025Spring/modules:/app/storage/distributed
# - ~/projects/faculty/4620_Distributed/2024Spring/modules:/app/storage/distributed_old
- ~/projects/faculty/1810/2025-spring-alex/in-person:/app/storage/intro_to_web_old
- ~/projects/faculty/1810/2025-fall-alex/modules:/app/storage/intro_to_web
- ~/projects/faculty/4850_AdvancedFE/2025-fall-alex/modules:/app/storage/advanced_frontend
- ~/projects/faculty/4850_AdvancedFE/2024-fall-alex/modules:/app/storage/advanced_frontend_old
- ~/projects/faculty/1430/2024-fall-alex/modules:/app/storage/ux_old
- ~/projects/faculty/1430/2025-fall-alex/modules:/app/storage/ux
- ~/projects/faculty/1420/2024-fall/Modules:/app/storage/1420_old
- ~/projects/faculty/1420/2025-fall-alex/modules:/app/storage/1420
- ~/projects/faculty/1425/2024-fall/Modules:/app/storage/1425_old
- ~/projects/faculty/1425/2025-fall-alex/modules:/app/storage/1425
- ~/projects/facultyFiles:/app/public/images/facultyFiles - ~/projects/facultyFiles:/app/public/images/facultyFiles

View File

@@ -10,13 +10,11 @@ const compat = new FlatCompat({
}); });
const eslintConfig = [ const eslintConfig = [
{
ignores: ["**/node_modules/**", "**/.next/**", "storage/**"],
},
...compat.config({ ...compat.config({
extends: ["next/core-web-vitals", "next/typescript", "prettier"], extends: ["next/core-web-vitals", "next/typescript", "prettier"],
ignores: [
"**/node_modules/**",
"**/.next/**",
"storage/**"
],
rules: { rules: {
"react-refresh/only-export-components": "off", // Disabled the rule "react-refresh/only-export-components": "off", // Disabled the rule
"@typescript-eslint/no-unused-vars": [ "@typescript-eslint/no-unused-vars": [

5
globalSettings.dev.yml Normal file
View File

@@ -0,0 +1,5 @@
courses:
- path: ./3820_BackEnd/2025-fall/Modules/
name: Back-End
- path: ./3820_BackEnd/2024-fall/Modules/
name: Back-End

21
globalSettings.yml Normal file
View File

@@ -0,0 +1,21 @@
courses:
- path: ./4850_AdvancedFE/2025-fall-alex/modules/
name: Adv Frontend
- path: ./1420/2025-fall-alex/modules/
name: "1420"
- path: ./1810/2025-fall-alex/modules/
name: Web Intro
- path: ./1430/2025-fall-alex/modules/
name: UX
- path: ./1425/2025-fall-alex/modules/
name: "1425"
- path: ./1405/2025_spring_alex/
name: 1405_old
- path: ./3840_Telemetry/2025_spring_alex/modules/
name: Telem and Ops
- path: ./4850_AdvancedFE/2024-fall-alex/modules/
name: Old Adv Frontend
- path: ./1430/2025-spring-jonathan/Modules/
name: Jonathan UX
- path: ./1400/2025_spring_alex/modules/
name: 1400-spring

12
requests/module.http Normal file
View File

@@ -0,0 +1,12 @@
### doesn't work, there is a form request that does thi son the site, but this isn't it
POST https://snow.instructure.com/api/v1/courses/1154759/modules/3965250/reorder
Authorization: Bearer {{$dotenv CANVAS_TOKEN}}
Content-Type: application/json
{
"order": [
29273428, 29274455, 29272498, 29274867, 29272743, 29273425
]
}

View File

@@ -1,6 +1,10 @@
"use client"; "use client";
import { useLocalCoursesSettingsQuery } from "@/hooks/localCourse/localCoursesHooks"; import { useLocalCoursesSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { getDateKey, getTermName, groupByStartDate } from "@/models/local/utils/timeUtils"; import {
getDateKey,
getTermName,
groupByStartDate,
} from "@/features/local/utils/timeUtils";
import { getCourseUrl } from "@/services/urlUtils"; import { getCourseUrl } from "@/services/urlUtils";
import Link from "next/link"; import Link from "next/link";
@@ -11,6 +15,8 @@ export default function CourseList() {
const sortedDates = Object.keys(coursesByStartDate).sort(); const sortedDates = Object.keys(coursesByStartDate).sort();
console.log(allSettings, coursesByStartDate);
return ( return (
<div className="flex flex-row "> <div className="flex flex-row ">
{sortedDates.map((startDate) => ( {sortedDates.map((startDate) => (
@@ -24,6 +30,7 @@ export default function CourseList() {
<Link <Link
href={getCourseUrl(settings.name)} href={getCourseUrl(settings.name)}
shallow={true} shallow={true}
prefetch={true}
className=" className="
font-bold text-xl block font-bold text-xl block
transition-all hover:scale-105 hover:underline hover:text-slate-200 transition-all hover:scale-105 hover:underline hover:text-slate-200

80
src/app/DataHydration.tsx Normal file
View File

@@ -0,0 +1,80 @@
import { fileStorageService } from "@/features/local/utils/fileStorageService";
import { trpcAppRouter } from "@/services/serverFunctions/appRouter";
import { createTrpcContext } from "@/services/serverFunctions/context";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { createServerSideHelpers } from "@trpc/react-query/server";
import superjson from "superjson";
export default async function DataHydration({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
console.log("starting hydration");
const trpcHelper = createServerSideHelpers({
router: trpcAppRouter,
ctx: createTrpcContext(),
transformer: superjson,
queryClientConfig: {
defaultOptions: {
queries: {
staleTime: Infinity,
},
},
},
});
const allSettings = await fileStorageService.settings.getAllCoursesSettings();
await Promise.all(
allSettings.map(async (settings) => {
const courseName = settings.name;
const moduleNames = await trpcHelper.module.getModuleNames.fetch({
courseName,
});
await Promise.all([
// assignments
...moduleNames.map(
async (moduleName) =>
await trpcHelper.assignment.getAllAssignments.prefetch({
courseName,
moduleName,
})
),
// quizzes
...moduleNames.map(
async (moduleName) =>
await trpcHelper.quiz.getAllQuizzes.prefetch({
courseName,
moduleName,
})
),
// pages
...moduleNames.map(
async (moduleName) =>
await trpcHelper.page.getAllPages.prefetch({
courseName,
moduleName,
})
),
]);
})
);
// lectures
await Promise.all(
allSettings.map(
async (settings) =>
await trpcHelper.lectures.getLectures.fetch({
courseName: settings.name,
})
)
);
const dehydratedState = dehydrate(trpcHelper.queryClient);
console.log("ran hydration");
return (
<HydrationBoundary state={dehydratedState}>{children}</HydrationBoundary>
);
}

View File

@@ -1,25 +1,27 @@
"use client"; "use client";
import ButtonSelect from "@/components/ButtonSelect";
import { DayOfWeekInput } from "@/components/form/DayOfWeekInput"; import { DayOfWeekInput } from "@/components/form/DayOfWeekInput";
import SelectInput from "@/components/form/SelectInput"; import SelectInput from "@/components/form/SelectInput";
import { StoragePathSelector } from "@/components/form/StoragePathSelector";
import TextInput from "@/components/form/TextInput";
import { Spinner } from "@/components/Spinner"; import { Spinner } from "@/components/Spinner";
import { SuspenseAndErrorHandling } from "@/components/SuspenseAndErrorHandling"; import { SuspenseAndErrorHandling } from "@/components/SuspenseAndErrorHandling";
import { useCourseListInTermQuery } from "@/hooks/canvas/canvasCourseHooks";
import { useCanvasTermsQuery } from "@/hooks/canvas/canvasHooks";
import { import {
useCreateLocalCourseMutation, useCreateLocalCourseMutation,
useLocalCoursesSettingsQuery, useLocalCoursesSettingsQuery,
} from "@/hooks/localCourse/localCoursesHooks"; } from "@/features/local/course/localCoursesHooks";
import { useEmptyDirectoriesQuery } from "@/hooks/localCourse/storageDirectoryHooks"; import { CanvasCourseModel } from "@/features/canvas/models/courses/canvasCourseModel";
import { CanvasCourseModel } from "@/models/canvas/courses/canvasCourseModel"; import { CanvasEnrollmentTermModel } from "@/features/canvas/models/enrollmentTerms/canvasEnrollmentTermModel";
import { CanvasEnrollmentTermModel } from "@/models/canvas/enrollmentTerms/canvasEnrollmentTermModel"; import { AssignmentSubmissionType } from "@/features/local/assignments/models/assignmentSubmissionType";
import { AssignmentSubmissionType } from "@/models/local/assignment/assignmentSubmissionType"; import { getCourseUrl } from "@/services/urlUtils";
import { useRouter } from "next/navigation";
import React, { Dispatch, SetStateAction, useMemo, useState } from "react";
import { import {
DayOfWeek, DayOfWeek,
LocalCourseSettings, LocalCourseSettings,
} from "@/models/local/localCourseSettings"; } from "@/features/local/course/localCourseSettings";
import { getCourseUrl } from "@/services/urlUtils"; import { useCourseListInTermQuery } from "@/features/canvas/hooks/canvasCourseHooks";
import { useRouter } from "next/navigation"; import { useCanvasTermsQuery } from "@/features/canvas/hooks/canvasHooks";
import React, { useMemo, useState } from "react";
const sampleCompose = `services: const sampleCompose = `services:
canvas_manager: canvas_manager:
@@ -37,7 +39,7 @@ const sampleCompose = `services:
- ~/projects/faculty/4850_AdvancedFE/2024-fall-alex/modules:/app/storage/advanced_frontend - ~/projects/faculty/4850_AdvancedFE/2024-fall-alex/modules:/app/storage/advanced_frontend
`; `;
export default function NewCourseForm() { export default function AddNewCourseToGlobalSettingsForm() {
const router = useRouter(); const router = useRouter();
const today = useMemo(() => new Date(), []); const today = useMemo(() => new Date(), []);
const { data: canvasTerms } = useCanvasTermsQuery(today); const { data: canvasTerms } = useCanvasTermsQuery(today);
@@ -54,6 +56,7 @@ export default function NewCourseForm() {
const [courseToImport, setCourseToImport] = useState< const [courseToImport, setCourseToImport] = useState<
LocalCourseSettings | undefined LocalCourseSettings | undefined
>(); >();
const [name, setName] = useState("");
const createCourse = useCreateLocalCourseMutation(); const createCourse = useCreateLocalCourseMutation();
const formIsComplete = const formIsComplete =
@@ -61,12 +64,13 @@ export default function NewCourseForm() {
return ( return (
<div> <div>
<SelectInput <ButtonSelect
value={selectedTerm}
setValue={setSelectedTerm}
label={"Canvas Term"}
options={canvasTerms} options={canvasTerms}
getOptionName={(t) => t.name} getOptionName={(t) => t?.name ?? ""}
setValue={setSelectedTerm}
value={selectedTerm}
label={"Canvas Term"}
center={true}
/> />
<SuspenseAndErrorHandling> <SuspenseAndErrorHandling>
{selectedTerm && ( {selectedTerm && (
@@ -80,6 +84,8 @@ export default function NewCourseForm() {
setSelectedDaysOfWeek={setSelectedDaysOfWeek} setSelectedDaysOfWeek={setSelectedDaysOfWeek}
courseToImport={courseToImport} courseToImport={courseToImport}
setCourseToImport={setCourseToImport} setCourseToImport={setCourseToImport}
name={name}
setName={setName}
/> />
)} )}
</SuspenseAndErrorHandling> </SuspenseAndErrorHandling>
@@ -88,6 +94,7 @@ export default function NewCourseForm() {
disabled={!formIsComplete || createCourse.isPending} disabled={!formIsComplete || createCourse.isPending}
onClick={async () => { onClick={async () => {
if (formIsComplete) { if (formIsComplete) {
console.log("Creating course with settings:", selectedDirectory);
const newSettings: LocalCourseSettings = courseToImport const newSettings: LocalCourseSettings = courseToImport
? { ? {
...courseToImport, ...courseToImport,
@@ -126,8 +133,10 @@ export default function NewCourseForm() {
await createCourse.mutateAsync({ await createCourse.mutateAsync({
settings: newSettings, settings: newSettings,
settingsFromCourseToImport: courseToImport, settingsFromCourseToImport: courseToImport,
name,
directory: selectedDirectory,
}); });
router.push(getCourseUrl(selectedDirectory)); router.push(getCourseUrl(name));
} }
}} }}
> >
@@ -148,32 +157,35 @@ function OtherSettings({
selectedTerm, selectedTerm,
selectedCanvasCourse, selectedCanvasCourse,
setSelectedCanvasCourse, setSelectedCanvasCourse,
selectedDirectory, selectedDirectory: _,
setSelectedDirectory, setSelectedDirectory,
selectedDaysOfWeek, selectedDaysOfWeek,
setSelectedDaysOfWeek, setSelectedDaysOfWeek,
courseToImport, courseToImport,
setCourseToImport, setCourseToImport,
name,
setName,
}: { }: {
selectedTerm: CanvasEnrollmentTermModel; selectedTerm: CanvasEnrollmentTermModel;
selectedCanvasCourse: CanvasCourseModel | undefined; selectedCanvasCourse: CanvasCourseModel | undefined;
setSelectedCanvasCourse: React.Dispatch< setSelectedCanvasCourse: Dispatch<
React.SetStateAction<CanvasCourseModel | undefined> SetStateAction<CanvasCourseModel | undefined>
>; >;
selectedDirectory: string | undefined; selectedDirectory: string | undefined;
setSelectedDirectory: React.Dispatch< setSelectedDirectory: Dispatch<SetStateAction<string | undefined>>;
React.SetStateAction<string | undefined>
>;
selectedDaysOfWeek: DayOfWeek[]; selectedDaysOfWeek: DayOfWeek[];
setSelectedDaysOfWeek: React.Dispatch<React.SetStateAction<DayOfWeek[]>>; setSelectedDaysOfWeek: Dispatch<SetStateAction<DayOfWeek[]>>;
courseToImport: LocalCourseSettings | undefined; courseToImport: LocalCourseSettings | undefined;
setCourseToImport: React.Dispatch< setCourseToImport: Dispatch<SetStateAction<LocalCourseSettings | undefined>>;
React.SetStateAction<LocalCourseSettings | undefined> name: string;
>; setName: Dispatch<SetStateAction<string>>;
}) { }) {
const { data: canvasCourses } = useCourseListInTermQuery(selectedTerm.id); const { data: canvasCourses } = useCourseListInTermQuery(selectedTerm.id);
const { data: allSettings } = useLocalCoursesSettingsQuery(); const { data: allSettings } = useLocalCoursesSettingsQuery();
const { data: emptyDirectories } = useEmptyDirectoriesQuery(); const [directory, setDirectory] = useState("./");
// const directoryIsCourseQuery = useDirectoryIsCourseQuery(
// selectedDirectory ?? "./"
// );
const populatedCanvasCourseIds = allSettings?.map((s) => s.canvasId) ?? []; const populatedCanvasCourseIds = allSettings?.map((s) => s.canvasId) ?? [];
const availableCourses = const availableCourses =
@@ -184,25 +196,21 @@ function OtherSettings({
return ( return (
<> <>
<SelectInput <ButtonSelect
value={selectedCanvasCourse} value={selectedCanvasCourse}
setValue={setSelectedCanvasCourse} setValue={setSelectedCanvasCourse}
label={"Course"} label={"Course"}
options={availableCourses} options={availableCourses}
getOptionName={(c) => c.name} getOptionName={(c) => c?.name ?? ""}
center={true}
/> />
<SelectInput
value={selectedDirectory} <StoragePathSelector
setValue={setSelectedDirectory} value={directory}
setValue={setDirectory}
setLastTypedValue={setSelectedDirectory}
label={"Storage Folder"} label={"Storage Folder"}
options={emptyDirectories ?? []}
getOptionName={(d) => d}
emptyOptionText="--- add a new folder to your docker compose to add more folders ---"
/> />
<div className="px-5">
New folders will not be created automatically, you are expected to mount
a docker volume for each courses.
</div>
<br /> <br />
<div className="flex justify-center"> <div className="flex justify-center">
<DayOfWeekInput <DayOfWeekInput
@@ -225,6 +233,7 @@ function OtherSettings({
options={allSettings} options={allSettings}
getOptionName={(c) => c.name} getOptionName={(c) => c.name}
/> />
<TextInput value={name} setValue={setName} label={"Display Name"} />
<div className="px-5"> <div className="px-5">
Assignments, Quizzes, Pages, and Lectures will have their due dates Assignments, Quizzes, Pages, and Lectures will have their due dates
moved based on how far they are from the start of the semester. moved based on how far they are from the start of the semester.

View File

@@ -0,0 +1,114 @@
"use client";
import ClientOnly from "@/components/ClientOnly";
import { StoragePathSelector } from "@/components/form/StoragePathSelector";
import TextInput from "@/components/form/TextInput";
import { SuspenseAndErrorHandling } from "@/components/SuspenseAndErrorHandling";
import {
useGlobalSettingsQuery,
useUpdateGlobalSettingsMutation,
} from "@/features/local/globalSettings/globalSettingsHooks";
import { useDirectoryIsCourseQuery } from "@/features/local/utils/storageDirectoryHooks";
import { FC, useEffect, useRef, useState } from "react";
export const AddExistingCourseToGlobalSettings = () => {
const [showForm, setShowForm] = useState(false);
return (
<div>
<div className="flex justify-center">
<button className="" onClick={() => setShowForm((i) => !i)}>
Add Existing Course
</button>
</div>
<div className={" collapsible " + (showForm && "expand")}>
<div className="border rounded-md p-3 m-3">
<SuspenseAndErrorHandling>
<ClientOnly>{showForm && <ExistingCourseForm />}</ClientOnly>
</SuspenseAndErrorHandling>
</div>
</div>
</div>
);
};
const ExistingCourseForm: FC<object> = () => {
const [path, setPath] = useState("./");
const [name, setName] = useState("");
const nameInputRef = useRef<HTMLInputElement>(null);
const directoryIsCourseQuery = useDirectoryIsCourseQuery(path);
const { data: globalSettings } = useGlobalSettingsQuery();
const updateSettingsMutation = useUpdateGlobalSettingsMutation();
// Focus name input when directory becomes a valid course
useEffect(() => {
console.log("Checking directory:", directoryIsCourseQuery.data);
if (directoryIsCourseQuery.data) {
console.log("Focusing name input");
nameInputRef.current?.focus();
}
}, [directoryIsCourseQuery.data]);
return (
<form
onSubmit={async (e) => {
e.preventDefault();
console.log(path);
await updateSettingsMutation.mutateAsync({
globalSettings: {
...globalSettings,
courses: [
...globalSettings.courses,
{
name,
path,
},
],
},
});
setName("");
setPath("./");
}}
className="min-w-3xl"
>
<h2>Add Existing Course</h2>
<div className="flex items-center mt-2 text-slate-500">
{directoryIsCourseQuery.isLoading ? (
<>
<span className="animate-spin mr-2"></span>
<span>Checking directory...</span>
</>
) : directoryIsCourseQuery.data ? (
<>
<span className="text-green-600 mr-2"></span>
<span>This is a valid course directory.</span>
</>
) : (
<>
<span className="text-red-600 mr-2"></span>
<span>Not a course directory.</span>
</>
)}
</div>
<StoragePathSelector
value={path}
setValue={setPath}
label={"Course Directory Path"}
/>
{directoryIsCourseQuery.data && (
<>
<TextInput
value={name}
setValue={setName}
label={"Display Name"}
inputRef={nameInputRef}
/>
<div className="text-center">
<button className="text-center mt-3">Save</button>
</div>
</>
)}
</form>
);
};

View File

@@ -1,16 +1,16 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useState } from "react";
import { SuspenseAndErrorHandling } from "@/components/SuspenseAndErrorHandling"; import { SuspenseAndErrorHandling } from "@/components/SuspenseAndErrorHandling";
import NewCourseForm from "./NewCourseForm"; import AddNewCourseToGlobalSettingsForm from "./AddCourseToGlobalSettingsForm";
import ClientOnly from "@/components/ClientOnly"; import ClientOnly from "@/components/ClientOnly";
export default function AddNewCourse() { export default function AddCourseToGlobalSettings() {
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
return ( return (
<div> <div>
<div className="flex justify-center"> <div className="flex justify-center">
<button className="" onClick={() => setShowForm(true)}> <button className="" onClick={() => setShowForm((i) => !i)}>
Add New Course Add New Course
</button> </button>
</div> </div>
@@ -18,7 +18,9 @@ export default function AddNewCourse() {
<div className={" collapsible " + (showForm && "expand")}> <div className={" collapsible " + (showForm && "expand")}>
<div className="border rounded-md p-3 m-3"> <div className="border rounded-md p-3 m-3">
<SuspenseAndErrorHandling> <SuspenseAndErrorHandling>
<ClientOnly>{showForm && <NewCourseForm />}</ClientOnly> <ClientOnly>
{showForm && <AddNewCourseToGlobalSettingsForm />}
</ClientOnly>
</SuspenseAndErrorHandling> </SuspenseAndErrorHandling>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,79 @@
export const githubClassroomUrlPrompt = `
## getting github classroom link
## Preparation
- Always ask for key information if not provided:
- "Which classroom would you like to create an assignment in?"
- "What would you like to name the assignment?"
## Step-by-Step Process
1. **Navigate to GitHub Classroom**
\`\`\`javascript
// Navigate to GitHub Classroom
await page.goto('https://classroom.github.com');
\`\`\`
2. **Select the Target Classroom**
\`\`\`javascript
// Click on the desired classroom
await page.getByRole('link', { name: 'your-classroom-name' }).click();
\`\`\`
3. **Click the "New assignment" button**
\`\`\`javascript
// Click New assignment
await page.getByRole('link', { name: 'New assignment' }).click();
\`\`\`
4. **Enter the assignment title**
\`\`\`javascript
// Fill in the assignment title
await page.getByRole('textbox', { name: 'Assignment title' }).fill('your-assignment-name');
\`\`\`
5. **Click "Continue" to move to the next step**
\`\`\`javascript
// Click Continue
await page.getByRole('button', { name: 'Continue' }).click();
\`\`\`
6. **Configure starter code (optional) and click "Continue" to proceed**
\`\`\`javascript
// Optional: Select a starter code repository if needed
// await page.getByRole('combobox', { name: 'Find a GitHub repository' }).fill('your-repo');
// Click Continue
await page.getByRole('button', { name: 'Continue' }).click();
\`\`\`
7. **Set up autograding (optional) and click "Create assignment"**
\`\`\`javascript
// Optional: Set up autograding tests if needed
// await page.getByRole('button', { name: 'Add test' }).click();
// Click Create assignment
await page.getByRole('button', { name: 'Create assignment' }).click();
\`\`\`
8. **Copy the assignment invitation link to share with students**
\`\`\`javascript
// Click Copy assignment invitation link
await page.getByRole('button', { name: 'Copy assignment invitation' }).first().click();
// The link is now in clipboard and ready to share with students
\`\`\`
## Tips and Best Practices
- Always verify with the user if specific settings are needed (like deadlines, visibility, etc.)
- Test the invitation link by opening it in another browser to ensure it works correctly
- Consider optional features like starter code and autograding tests for more complex assignments
- Remember to communicate the invitation link to students via email, LMS, or other channels
## Common Issues and Solutions
- If authentication is needed, prompt the user to authenticate in their browser
- If a classroom isn't visible, ensure the user has proper permissions
- For starter code issues, verify that the repository is properly set as a template
- If GitHub Classroom is slow, advise patience during high-traffic periods
`

View File

@@ -1,10 +1,11 @@
import { assignmentMarkdownSerializer } from "@/models/local/assignment/utils/assignmentMarkdownSerializer"; import { assignmentMarkdownSerializer } from "@/features/local/assignments/models/utils/assignmentMarkdownSerializer";
import { LocalCourseSettings } from "@/models/local/localCourseSettings"; import { groupByStartDate } from "@/features/local/utils/timeUtils";
import { groupByStartDate } from "@/models/local/utils/timeUtils";
import { fileStorageService } from "@/services/fileStorage/fileStorageService";
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { createMcpHandler } from "mcp-handler"; import { createMcpHandler } from "mcp-handler";
import { z } from "zod"; import { z } from "zod";
import { githubClassroomUrlPrompt } from "./github-classroom-prompt";
import { courseItemFileStorageService } from "@/features/local/course/courseItemFileStorageService";
import { fileStorageService } from "@/features/local/utils/fileStorageService";
import { getModuleNamesFromFiles } from "@/features/local/modules/moduleRouter";
const handler = createMcpHandler( const handler = createMcpHandler(
(server) => { (server) => {
@@ -42,17 +43,17 @@ const handler = createMcpHandler(
courseName: z.string(), courseName: z.string(),
}, },
async ({ courseName }) => { async ({ courseName }) => {
const modules = await fileStorageService.modules.getModuleNames( const modules = await getModuleNamesFromFiles(
courseName courseName
); );
const assignments = ( const assignments = (
await Promise.all( await Promise.all(
modules.map(async (moduleName) => { modules.map(async (moduleName) => {
const assignments = const assignments = await courseItemFileStorageService.getItems({
await fileStorageService.assignments.getAssignments(
courseName, courseName,
moduleName moduleName,
); type: "Assignment",
});
return assignments.map((assignment) => ({ return assignments.map((assignment) => ({
assignmentName: assignment.name, assignmentName: assignment.name,
moduleName, moduleName,
@@ -101,11 +102,12 @@ const handler = createMcpHandler(
"courseName, moduleName, and assignmentName must be strings" "courseName, moduleName, and assignmentName must be strings"
); );
} }
const assignment = await fileStorageService.assignments.getAssignment( const assignment = await courseItemFileStorageService.getItem({
courseName, courseName,
moduleName, moduleName,
assignmentName name: assignmentName,
); type: "Assignment",
});
console.log("mcp assignment", assignment); console.log("mcp assignment", assignment);
return { return {
@@ -118,42 +120,58 @@ const handler = createMcpHandler(
}; };
} }
); );
server.tool(
server.registerResource( "get_github_classroom_url_instructions",
"course_assignment", "gets instructions for creating a GitHub Classroom assignment, call this to get a prompt showing how to create a GitHub Classroom assignment",
new ResourceTemplate( {},
"canvas:///courses/{courseName}/module/{moduleName}/assignments/{assignmentName}", async () => {
{ list: undefined }
),
{
title: "Course Assignment",
description: "Markdown representation of a course assignment",
},
async (uri, { courseName, moduleName, assignmentName }) => {
if (
typeof courseName !== "string" ||
typeof moduleName !== "string" ||
typeof assignmentName !== "string"
) {
throw new Error(
"courseName, moduleName, and assignmentName must be strings"
);
}
const assignment = await fileStorageService.assignments.getAssignment(
courseName,
moduleName,
assignmentName
);
return { return {
contents: [ content: [
{ {
uri: uri.href, type: "text",
text: assignment.description, text: githubClassroomUrlPrompt,
}, },
], ],
}; };
} }
); );
// resources dont integrate well right now
// server.registerResource(
// "course_assignment",
// new ResourceTemplate(
// "canvas:///courses/{courseName}/module/{moduleName}/assignments/{assignmentName}",
// { list: undefined }
// ),
// {
// title: "Course Assignment",
// description: "Markdown representation of a course assignment",
// },
// async (uri, { courseName, moduleName, assignmentName }) => {
// if (
// typeof courseName !== "string" ||
// typeof moduleName !== "string" ||
// typeof assignmentName !== "string"
// ) {
// throw new Error(
// "courseName, moduleName, and assignmentName must be strings"
// );
// }
// const assignment = await fileStorageService.assignments.getAssignment(
// courseName,
// moduleName,
// assignmentName
// );
// return {
// contents: [
// {
// uri: uri.href,
// text: assignment.description,
// },
// ],
// };
// }
// );
}, },
{ {
serverInfo: { serverInfo: {

View File

@@ -1,10 +1,8 @@
import { createTrpcContext } from "@/services/serverFunctions/context"; import { createTrpcContext } from "@/services/serverFunctions/context";
import { trpcAppRouter } from "@/services/serverFunctions/router/app"; import { trpcAppRouter } from "@/services/serverFunctions/appRouter";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
const handler = async (request: Request) => { const handler = async (request: Request) => {
// await new Promise(r => setTimeout(r, 1000)); // delay for testing
return fetchRequestHandler({ return fetchRequestHandler({
endpoint: "/api/trpc", endpoint: "/api/trpc",
req: request, req: request,

View File

@@ -1,25 +1,60 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect } from "react";
import CourseSettingsLink from "./CourseSettingsLink"; import CourseSettingsLink from "./CourseSettingsLink";
import ModuleList from "./modules/ModuleList"; import ModuleList from "./modules/ModuleList";
import LeftChevron from "@/components/icons/LeftChevron"; import LeftChevron from "@/components/icons/LeftChevron";
import RightChevron from "@/components/icons/RightChevron"; import RightChevron from "@/components/icons/RightChevron";
export default function CollapsableSidebar() { const collapseThreshold = 1400;
const [isCollapsed, setIsCollapsed] = useState(false);
export default function CollapsableSidebar() {
const [windowCollapseRecommended, setWindowCollapseRecommended] =
useState(window.innerWidth <= collapseThreshold);
const [userCollapsed, setUserCollapsed] = useState<
"unset" | "collapsed" | "uncollapsed"
>("unset");
useEffect(() => {
function handleResize() {
if (window.innerWidth <= collapseThreshold) {
setWindowCollapseRecommended(true);
} else {
setWindowCollapseRecommended(false);
}
}
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
let collapsed;
if (userCollapsed === "unset") {
collapsed = windowCollapseRecommended;
} else {
collapsed = userCollapsed === "collapsed";
}
const widthClass = collapsed ? "w-0" : "w-96";
const visibilityClass = collapsed ? "invisible " : "visible";
const widthClass = isCollapsed ? "w-0" : "w-96";
const visibilityClass = isCollapsed ? "invisible " : "visible";
return ( return (
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
<div className="flex flex-row justify-between mb-2"> <div className="flex flex-row justify-between mb-2">
<div className="visible mx-3 mt-2"> <div className="visible mx-3 mt-2">
<button onClick={() => setIsCollapsed((i) => !i)}> <button
{isCollapsed ? <LeftChevron /> : <RightChevron />} onClick={() => {
setUserCollapsed((prev) => {
if (prev === "unset") {
return collapsed ? "uncollapsed" : "collapsed";
}
return prev === "collapsed" ? "uncollapsed" : "collapsed";
});
}}
>
{collapsed ? <LeftChevron /> : <RightChevron />}
</button> </button>
</div> </div>
<div className={" " + (isCollapsed ? "w-0 invisible hidden" : "")}> <div className={" " + (collapsed ? "w-0 invisible hidden" : "")}>
<CourseSettingsLink /> <CourseSettingsLink />
</div> </div>
</div> </div>

View File

@@ -1,23 +1,23 @@
"use client"; "use client";
import { Spinner } from "@/components/Spinner"; import { Spinner } from "@/components/Spinner";
import { import {
canvasAssignmentKeys,
useCanvasAssignmentsQuery, useCanvasAssignmentsQuery,
} from "@/hooks/canvas/canvasAssignmentHooks"; canvasAssignmentKeys,
import { canvasCourseKeys } from "@/hooks/canvas/canvasCourseHooks"; } from "@/features/canvas/hooks/canvasAssignmentHooks";
import { canvasCourseKeys } from "@/features/canvas/hooks/canvasCourseHooks";
import { import {
canvasCourseModuleKeys,
useCanvasModulesQuery, useCanvasModulesQuery,
} from "@/hooks/canvas/canvasModuleHooks"; canvasCourseModuleKeys,
} from "@/features/canvas/hooks/canvasModuleHooks";
import { import {
canvasPageKeys,
useCanvasPagesQuery, useCanvasPagesQuery,
} from "@/hooks/canvas/canvasPageHooks"; canvasPageKeys,
} from "@/features/canvas/hooks/canvasPageHooks";
import { import {
canvasQuizKeys,
useCanvasQuizzesQuery, useCanvasQuizzesQuery,
} from "@/hooks/canvas/canvasQuizHooks"; canvasQuizKeys,
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks"; } from "@/features/canvas/hooks/canvasQuizHooks";
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import Link from "next/link"; import Link from "next/link";

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks"; import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import Link from "next/link"; import Link from "next/link";
import { useCourseContext } from "./context/courseContext"; import { useCourseContext } from "./context/courseContext";
import { getCourseSettingsUrl } from "@/services/urlUtils"; import { getCourseSettingsUrl } from "@/services/urlUtils";

View File

@@ -1,12 +1,12 @@
"use client"; "use client";
import { CalendarMonthModel, getWeekNumber } from "./calendarMonthUtils"; import { CalendarMonthModel, getWeekNumber } from "./calendarMonthUtils";
import { DayOfWeek } from "@/models/local/localCourseSettings";
import { Expandable } from "@/components/Expandable"; import { Expandable } from "@/components/Expandable";
import { CalendarWeek } from "./CalendarWeek"; import { CalendarWeek } from "./CalendarWeek";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks"; import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { getDateFromStringOrThrow } from "@/models/local/utils/timeUtils"; import { getDateFromStringOrThrow } from "@/features/local/utils/timeUtils";
import UpChevron from "@/components/icons/UpChevron"; import UpChevron from "@/components/icons/UpChevron";
import DownChevron from "@/components/icons/DownChevron"; import DownChevron from "@/components/icons/DownChevron";
import { DayOfWeek } from "@/features/local/course/localCourseSettings";
export const CalendarMonth = ({ month }: { month: CalendarMonthModel }) => { export const CalendarMonth = ({ month }: { month: CalendarMonthModel }) => {
// const weekInMilliseconds = 604_800_000; // const weekInMilliseconds = 604_800_000;
@@ -29,7 +29,8 @@ export const CalendarMonth = ({ month }: { month: CalendarMonthModel }) => {
new Date(month.year, month.month, 1) new Date(month.year, month.month, 1)
); );
const shouldCollapse = (pastWeekNumber >= startOfMonthWeekNumber) && !isPastSemester; const shouldCollapse =
pastWeekNumber >= startOfMonthWeekNumber && !isPastSemester;
const monthName = new Date(month.year, month.month - 1, 1).toLocaleString( const monthName = new Date(month.year, month.month - 1, 1).toLocaleString(
"default", "default",

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks"; import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { getDateFromStringOrThrow } from "@/models/local/utils/timeUtils"; import { getDateFromStringOrThrow } from "@/features/local/utils/timeUtils";
import { getWeekNumber } from "./calendarMonthUtils"; import { getWeekNumber } from "./calendarMonthUtils";
import Day from "./day/Day"; import Day from "./day/Day";

View File

@@ -1,8 +1,8 @@
"use client"; "use client";
import { getDateFromStringOrThrow } from "@/models/local/utils/timeUtils"; import { getDateFromStringOrThrow } from "@/features/local/utils/timeUtils";
import { getMonthsBetweenDates } from "./calendarMonthUtils"; import { getMonthsBetweenDates } from "./calendarMonthUtils";
import { CalendarMonth } from "./CalendarMonth"; import { CalendarMonth } from "./CalendarMonth";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks"; import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import CalendarItemsContextProvider from "../context/CalendarItemsContextProvider"; import CalendarItemsContextProvider from "../context/CalendarItemsContextProvider";

View File

@@ -1,7 +1,7 @@
import { import {
dateToMarkdownString, dateToMarkdownString,
getDateFromStringOrThrow, getDateFromStringOrThrow,
} from "@/models/local/utils/timeUtils"; } from "@/features/local/utils/timeUtils";
export interface CalendarMonthModel { export interface CalendarMonthModel {
year: number; year: number;

View File

@@ -2,13 +2,13 @@
import { import {
getDateFromStringOrThrow, getDateFromStringOrThrow,
getDateOnlyMarkdownString, getDateOnlyMarkdownString,
} from "@/models/local/utils/timeUtils"; } from "@/features/local/utils/timeUtils";
import { useDraggingContext } from "../../context/drag/draggingContext"; import { useDraggingContext } from "../../context/drag/draggingContext";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks"; import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { getDayOfWeek } from "@/models/local/localCourseSettings";
import { ItemInDay } from "./ItemInDay"; import { ItemInDay } from "./ItemInDay";
import { useTodaysItems } from "./useTodaysItems"; import { useTodaysItems } from "./useTodaysItems";
import { DayTitle } from "./DayTitle"; import { DayTitle } from "./DayTitle";
import { getDayOfWeek } from "@/features/local/course/localCourseSettings";
export default function Day({ day, month }: { day: string; month: number }) { export default function Day({ day, month }: { day: string; month: number }) {
const dayAsDate = getDateFromStringOrThrow( const dayAsDate = getDateFromStringOrThrow(

View File

@@ -5,8 +5,8 @@ import { useCourseContext } from "../../context/courseContext";
import NewItemForm from "../../modules/NewItemForm"; import NewItemForm from "../../modules/NewItemForm";
import { DraggableItem } from "../../context/drag/draggingContext"; import { DraggableItem } from "../../context/drag/draggingContext";
import { useDragStyleContext } from "../../context/drag/dragStyleContext"; import { useDragStyleContext } from "../../context/drag/dragStyleContext";
import { getLectureForDay } from "@/models/local/utils/lectureUtils"; import { getLectureForDay } from "@/features/local/utils/lectureUtils";
import { useLecturesSuspenseQuery } from "@/hooks/localCourse/lectureHooks"; import { useLecturesSuspenseQuery } from "@/features/local/lectures/lectureHooks";
import ClientOnly from "@/components/ClientOnly"; import ClientOnly from "@/components/ClientOnly";
import { Tooltip } from "@/components/Tooltip"; import { Tooltip } from "@/components/Tooltip";
import { useRef, useState } from "react"; import { useRef, useState } from "react";

View File

@@ -1,4 +1,4 @@
import { IModuleItem } from "@/models/local/IModuleItem"; import { IModuleItem } from "@/features/local/modules/IModuleItem";
import { getModuleItemUrl } from "@/services/urlUtils"; import { getModuleItemUrl } from "@/services/urlUtils";
import Link from "next/link"; import Link from "next/link";
import { ReactNode, useRef, useState } from "react"; import { ReactNode, useRef, useState } from "react";

View File

@@ -1,18 +1,18 @@
"use client"; "use client";
import { CanvasAssignment } from "@/models/canvas/assignments/canvasAssignment"; import { CanvasAssignment } from "@/features/canvas/models/assignments/canvasAssignment";
import { CanvasPage } from "@/models/canvas/pages/canvasPageModel"; import { CanvasPage } from "@/features/canvas/models/pages/canvasPageModel";
import { CanvasQuiz } from "@/models/canvas/quizzes/canvasQuizModel"; import { CanvasQuiz } from "@/features/canvas/models/quizzes/canvasQuizModel";
import { LocalAssignment } from "@/models/local/assignment/localAssignment"; import { LocalAssignment } from "@/features/local/assignments/models/localAssignment";
import { LocalCourseSettings } from "@/models/local/localCourseSettings";
import { LocalCoursePage } from "@/models/local/page/localCoursePage";
import { LocalQuiz } from "@/models/local/quiz/localQuiz";
import { import {
dateToMarkdownString, dateToMarkdownString,
getDateFromStringOrThrow, getDateFromStringOrThrow,
} from "@/models/local/utils/timeUtils"; } from "@/features/local/utils/timeUtils";
import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils"; import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils";
import { htmlIsCloseEnough } from "@/services/utils/htmlIsCloseEnough"; import { htmlIsCloseEnough } from "@/services/utils/htmlIsCloseEnough";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { LocalCoursePage } from "@/features/local/pages/localCoursePageModels";
import { LocalQuiz } from "@/features/local/quizzes/models/localQuiz";
import { LocalCourseSettings } from "@/features/local/course/localCourseSettings";
export const getStatus = ({ export const getStatus = ({
item, item,
@@ -105,7 +105,16 @@ export const getStatus = ({
try { try {
const htmlIsSame = htmlIsCloseEnough( const htmlIsSame = htmlIsCloseEnough(
markdownToHTMLSafe(assignment.description, settings), markdownToHTMLSafe({
markdownString: assignment.description,
settings,
replaceText: [
{
source: "insert_github_classroom_url",
destination: assignment.githubClassroomAssignmentShareLink || "",
},
],
}),
canvasAssignment.description canvasAssignment.description
); );
if (!htmlIsSame) if (!htmlIsSame)

View File

@@ -1,18 +1,19 @@
"use client"; "use client";
import { useCanvasAssignmentsQuery } from "@/hooks/canvas/canvasAssignmentHooks";
import { useCanvasPagesQuery } from "@/hooks/canvas/canvasPageHooks"; import { LocalAssignment } from "@/features/local/assignments/models/localAssignment";
import { useCanvasQuizzesQuery } from "@/hooks/canvas/canvasQuizHooks";
import { LocalAssignment } from "@/models/local/assignment/localAssignment";
import { LocalCoursePage } from "@/models/local/page/localCoursePage";
import { LocalQuiz } from "@/models/local/quiz/localQuiz";
import { import {
getDateFromStringOrThrow, getDateFromStringOrThrow,
getDateOnlyMarkdownString, getDateOnlyMarkdownString,
} from "@/models/local/utils/timeUtils"; } from "@/features/local/utils/timeUtils";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { useCalendarItemsContext } from "../../context/calendarItemsContext"; import { useCalendarItemsContext } from "../../context/calendarItemsContext";
import { getStatus } from "./getStatus"; import { getStatus } from "./getStatus";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks"; import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { LocalCoursePage } from "@/features/local/pages/localCoursePageModels";
import { LocalQuiz } from "@/features/local/quizzes/models/localQuiz";
import { useCanvasAssignmentsQuery } from "@/features/canvas/hooks/canvasAssignmentHooks";
import { useCanvasPagesQuery } from "@/features/canvas/hooks/canvasPageHooks";
import { useCanvasQuizzesQuery } from "@/features/canvas/hooks/canvasQuizHooks";
export function useTodaysItems(day: string) { export function useTodaysItems(day: string) {
const { data: settings } = useLocalCourseSettingsQuery(); const { data: settings } = useLocalCourseSettingsQuery();

View File

@@ -1,4 +1,4 @@
"use client" "use client";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { import {
CalendarItemsContext, CalendarItemsContext,
@@ -8,7 +8,7 @@ import {
useCourseQuizzesByModuleByDateQuery, useCourseQuizzesByModuleByDateQuery,
useCourseAssignmentsByModuleByDateQuery, useCourseAssignmentsByModuleByDateQuery,
useCoursePagesByModuleByDateQuery, useCoursePagesByModuleByDateQuery,
} from "@/hooks/localCourse/localCourseModuleHooks"; } from "@/features/local/modules/localCourseModuleHooks";
export default function CalendarItemsContextProvider({ export default function CalendarItemsContextProvider({
children, children,

View File

@@ -1,6 +1,6 @@
import { LocalAssignment } from "@/models/local/assignment/localAssignment"; import { LocalAssignment } from "@/features/local/assignments/models/localAssignment";
import { LocalCoursePage } from "@/models/local/page/localCoursePage"; import { LocalCoursePage } from "@/features/local/pages/localCoursePageModels";
import { LocalQuiz } from "@/models/local/quiz/localQuiz"; import { LocalQuiz } from "@/features/local/quizzes/models/localQuiz";
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
export interface CalendarItemsInterface { export interface CalendarItemsInterface {

View File

@@ -1,5 +1,5 @@
"use client"; "use client";
import { IModuleItem } from "@/models/local/IModuleItem"; import { IModuleItem } from "@/features/local/modules/IModuleItem";
import { createContext, useContext, DragEvent } from "react"; import { createContext, useContext, DragEvent } from "react";
export interface DraggableItem { export interface DraggableItem {

View File

@@ -1,6 +1,8 @@
"use client"; "use client";
import { getDateFromStringOrThrow, dateToMarkdownString } from "@/models/local/utils/timeUtils"; import {
getDateFromStringOrThrow,
dateToMarkdownString,
} from "@/features/local/utils/timeUtils";
export function getNewLockDate( export function getNewLockDate(
originalDueDate: string, originalDueDate: string,
@@ -9,13 +11,16 @@ export function getNewLockDate(
): string | undefined { ): string | undefined {
// todo: preserve previous due date / lock date offset // todo: preserve previous due date / lock date offset
const dueDate = getDateFromStringOrThrow(originalDueDate, "dueAt date"); const dueDate = getDateFromStringOrThrow(originalDueDate, "dueAt date");
const lockDate = originalLockDate === undefined const lockDate =
originalLockDate === undefined
? undefined ? undefined
: getDateFromStringOrThrow(originalLockDate, "lockAt date"); : getDateFromStringOrThrow(originalLockDate, "lockAt date");
const originalOffset = lockDate === undefined ? undefined : lockDate.getTime() - dueDate.getTime(); const originalOffset =
lockDate === undefined ? undefined : lockDate.getTime() - dueDate.getTime();
const newLockDate = originalOffset === undefined const newLockDate =
originalOffset === undefined
? undefined ? undefined
: new Date(dayAsDate.getTime() + originalOffset); : new Date(dayAsDate.getTime() + originalOffset);

View File

@@ -1,26 +1,26 @@
"use client"; "use client";
import { useUpdateAssignmentMutation } from "@/hooks/localCourse/assignmentHooks";
import { import {
useLecturesSuspenseQuery, useLecturesSuspenseQuery,
useLectureUpdateMutation, useLectureUpdateMutation,
} from "@/hooks/localCourse/lectureHooks"; } from "@/features/local/lectures/lectureHooks";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks"; import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { useUpdatePageMutation } from "@/hooks/localCourse/pageHooks"; import { useUpdatePageMutation } from "@/features/local/pages/pageHooks";
import { LocalAssignment } from "@/models/local/assignment/localAssignment"; import { LocalAssignment } from "@/features/local/assignments/models/localAssignment";
import { Lecture } from "@/models/local/lecture"; import { Lecture } from "@/features/local/lectures/lectureModel";
import { getLectureForDay } from "@/models/local/utils/lectureUtils"; import { getLectureForDay } from "@/features/local/utils/lectureUtils";
import { LocalCoursePage } from "@/models/local/page/localCoursePage";
import { LocalQuiz } from "@/models/local/quiz/localQuiz";
import { import {
getDateFromStringOrThrow, getDateFromStringOrThrow,
getDateOnlyMarkdownString, getDateOnlyMarkdownString,
dateToMarkdownString, dateToMarkdownString,
} from "@/models/local/utils/timeUtils"; } from "@/features/local/utils/timeUtils";
import { Dispatch, SetStateAction, useCallback, DragEvent } from "react"; import { Dispatch, SetStateAction, useCallback, DragEvent } from "react";
import { DraggableItem } from "./draggingContext"; import { DraggableItem } from "./draggingContext";
import { getNewLockDate } from "./getNewLockDate"; import { getNewLockDate } from "./getNewLockDate";
import { useUpdateQuizMutation } from "@/hooks/localCourse/quizHooks"; import { useUpdateQuizMutation } from "@/features/local/quizzes/quizHooks";
import { useCourseContext } from "../courseContext"; import { useCourseContext } from "../courseContext";
import { useUpdateAssignmentMutation } from "@/features/local/assignments/assignmentHooks";
import { LocalCoursePage } from "@/features/local/pages/localCoursePageModels";
import { LocalQuiz } from "@/features/local/quizzes/models/localQuiz";
export function useItemDropOnDay({ export function useItemDropOnDay({
setIsDragging, setIsDragging,

View File

@@ -1,13 +1,13 @@
"use client"; "use client";
import { useUpdateAssignmentMutation } from "@/hooks/localCourse/assignmentHooks"; import { useUpdatePageMutation } from "@/features/local/pages/pageHooks";
import { useUpdatePageMutation } from "@/hooks/localCourse/pageHooks"; import { LocalAssignment } from "@/features/local/assignments/models/localAssignment";
import { LocalAssignment } from "@/models/local/assignment/localAssignment";
import { LocalCoursePage } from "@/models/local/page/localCoursePage";
import { LocalQuiz } from "@/models/local/quiz/localQuiz";
import { Dispatch, SetStateAction, useCallback, DragEvent } from "react"; import { Dispatch, SetStateAction, useCallback, DragEvent } from "react";
import { DraggableItem } from "./draggingContext"; import { DraggableItem } from "./draggingContext";
import { useCourseContext } from "../courseContext"; import { useCourseContext } from "../courseContext";
import { useUpdateQuizMutation } from "@/hooks/localCourse/quizHooks"; import { useUpdateQuizMutation } from "@/features/local/quizzes/quizHooks";
import { useUpdateAssignmentMutation } from "@/features/local/assignments/assignmentHooks";
import { LocalCoursePage } from "@/features/local/pages/localCoursePageModels";
import { LocalQuiz } from "@/features/local/quizzes/models/localQuiz";
export function useItemDropOnModule({ export function useItemDropOnModule({
setIsDragging, setIsDragging,

View File

@@ -9,8 +9,9 @@ export async function generateMetadata({
params: Promise<{ courseName: string }>; params: Promise<{ courseName: string }>;
}): Promise<Metadata> { }): Promise<Metadata> {
const { courseName } = await params; const { courseName } = await params;
const decodedCourseName = decodeURIComponent(getTitle(courseName));
return { return {
title: getTitle(courseName), title: decodedCourseName,
}; };
} }

View File

@@ -3,18 +3,18 @@ import { MonacoEditor } from "@/components/editor/MonacoEditor";
import { import {
useLecturesSuspenseQuery, useLecturesSuspenseQuery,
useLectureUpdateMutation, useLectureUpdateMutation,
} from "@/hooks/localCourse/lectureHooks"; } from "@/features/local/lectures/lectureHooks";
import { import {
lectureToString, lectureToString,
parseLecture, parseLecture,
} from "@/services/fileStorage/utils/lectureUtils"; } from "@/features/local/lectures/lectureUtils";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import LecturePreview from "./LecturePreview"; import LecturePreview from "./LecturePreview";
import EditLectureTitle from "./EditLectureTitle"; import EditLectureTitle from "./EditLectureTitle";
import LectureButtons from "./LectureButtons"; import LectureButtons from "./LectureButtons";
import { useCourseContext } from "../../context/courseContext"; import { useCourseContext } from "../../context/courseContext";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks"; import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { Lecture } from "@/models/local/lecture"; import { Lecture } from "@/features/local/lectures/lectureModel";
import { useAuthoritativeUpdates } from "../../utils/useAuthoritativeUpdates"; import { useAuthoritativeUpdates } from "../../utils/useAuthoritativeUpdates";
import { EditLayout } from "@/components/EditLayout"; import { EditLayout } from "@/components/EditLayout";

View File

@@ -1,10 +1,10 @@
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks"; import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { getDayOfWeek } from "@/models/local/localCourseSettings"; import { getDateFromString } from "@/features/local/utils/timeUtils";
import { getDateFromString } from "@/models/local/utils/timeUtils"; import { getLectureWeekName } from "@/features/local/lectures/lectureUtils";
import { getLectureWeekName } from "@/services/fileStorage/utils/lectureUtils";
import { getCourseUrl, getLecturePreviewUrl } from "@/services/urlUtils"; import { getCourseUrl, getLecturePreviewUrl } from "@/services/urlUtils";
import { useCourseContext } from "../../context/courseContext"; import { useCourseContext } from "../../context/courseContext";
import Link from "next/link"; import Link from "next/link";
import { getDayOfWeek } from "@/features/local/course/localCourseSettings";
export default function EditLectureTitle({ export default function EditLectureTitle({
lectureDay, lectureDay,
@@ -22,6 +22,7 @@ export default function EditLectureTitle({
className="btn hidden sm:inline" className="btn hidden sm:inline"
href={getCourseUrl(courseName)} href={getCourseUrl(courseName)}
shallow={true} shallow={true}
prefetch={true}
> >
{courseName} {courseName}
</Link> </Link>

View File

@@ -6,8 +6,8 @@ import { getCourseUrl } from "@/services/urlUtils";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { useCourseContext } from "../../context/courseContext"; import { useCourseContext } from "../../context/courseContext";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks"; import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { useDeleteLectureMutation } from "@/hooks/localCourse/lectureHooks"; import { useDeleteLectureMutation } from "@/features/local/lectures/lectureHooks";
import Link from "next/link"; import Link from "next/link";
export default function LectureButtons({ lectureDay }: { lectureDay: string }) { export default function LectureButtons({ lectureDay }: { lectureDay: string }) {

View File

@@ -1,5 +1,5 @@
import MarkdownDisplay from "@/components/MarkdownDisplay"; import MarkdownDisplay from "@/components/MarkdownDisplay";
import { Lecture } from "@/models/local/lecture"; import { Lecture } from "@/features/local/lectures/lectureModel";
export default function LecturePreview({ lecture }: { lecture: Lecture }) { export default function LecturePreview({ lecture }: { lecture: Lecture }) {
return ( return (

View File

@@ -11,8 +11,9 @@ export async function generateMetadata({
const { courseName, lectureDay } = await params; const { courseName, lectureDay } = await params;
const decodedDay = decodeURIComponent(lectureDay); const decodedDay = decodeURIComponent(lectureDay);
const dayOnly = decodedDay.split(" ")[0]; const dayOnly = decodedDay.split(" ")[0];
const decodedCourseName = decodeURIComponent(getTitle(courseName));
return { return {
title: getTitle(`${courseName} lecture ${dayOnly}`), title: getTitle(`${decodedCourseName} lecture ${dayOnly}`),
}; };
} }

View File

@@ -2,7 +2,7 @@ import EditLecture from "./EditLecture";
import { import {
getDateFromStringOrThrow, getDateFromStringOrThrow,
getDateOnlyMarkdownString, getDateOnlyMarkdownString,
} from "@/models/local/utils/timeUtils"; } from "@/features/local/utils/timeUtils";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function page({ export default async function page({

View File

@@ -4,7 +4,7 @@ import LecturePreview from "../LecturePreview";
import { getCourseUrl, getLectureUrl } from "@/services/urlUtils"; import { getCourseUrl, getLectureUrl } from "@/services/urlUtils";
import { useCourseContext } from "../../../context/courseContext"; import { useCourseContext } from "../../../context/courseContext";
import Link from "next/link"; import Link from "next/link";
import { useLecturesSuspenseQuery } from "@/hooks/localCourse/lectureHooks"; import { useLecturesSuspenseQuery } from "@/features/local/lectures/lectureHooks";
export default function LecturePreviewPage({ export default function LecturePreviewPage({
lectureDay, lectureDay,

View File

@@ -1,7 +1,7 @@
import { import {
getDateFromStringOrThrow, getDateFromStringOrThrow,
getDateOnlyMarkdownString, getDateOnlyMarkdownString,
} from "@/models/local/utils/timeUtils"; } from "@/features/local/utils/timeUtils";
import LecturePreviewPage from "./LecturePreviewPage"; import LecturePreviewPage from "./LecturePreviewPage";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";

View File

@@ -1,6 +1,6 @@
import { Expandable } from "@/components/Expandable"; import { Expandable } from "@/components/Expandable";
import TextInput from "@/components/form/TextInput"; import TextInput from "@/components/form/TextInput";
import { useCreateModuleMutation } from "@/hooks/localCourse/localCourseModuleHooks"; import { useCreateModuleMutation } from "@/features/local/modules/localCourseModuleHooks";
import React, { useState } from "react"; import React, { useState } from "react";
import { useCourseContext } from "../context/courseContext"; import { useCourseContext } from "../context/courseContext";

View File

@@ -1,11 +1,11 @@
"use client"; "use client";
import { usePagesQueries } from "@/hooks/localCourse/pageHooks"; import { usePagesQueries } from "@/features/local/pages/pageHooks";
import { IModuleItem } from "@/models/local/IModuleItem"; import { IModuleItem } from "@/features/local/modules/IModuleItem";
import { import {
getDateFromString, getDateFromString,
getDateFromStringOrThrow, getDateFromStringOrThrow,
getDateOnlyMarkdownString, getDateOnlyMarkdownString,
} from "@/models/local/utils/timeUtils"; } from "@/features/local/utils/timeUtils";
import { Fragment } from "react"; import { Fragment } from "react";
import Modal, { useModal } from "../../../../components/Modal"; import Modal, { useModal } from "../../../../components/Modal";
import NewItemForm from "./NewItemForm"; import NewItemForm from "./NewItemForm";
@@ -21,10 +21,13 @@ import { getModuleItemUrl } from "@/services/urlUtils";
import { useCourseContext } from "../context/courseContext"; import { useCourseContext } from "../context/courseContext";
import { Expandable } from "../../../../components/Expandable"; import { Expandable } from "../../../../components/Expandable";
import { useDragStyleContext } from "../context/drag/dragStyleContext"; import { useDragStyleContext } from "../context/drag/dragStyleContext";
import { useQuizzesQueries } from "@/hooks/localCourse/quizHooks"; import { useQuizzesQueries } from "@/features/local/quizzes/quizHooks";
import { useAssignmentNamesQuery } from "@/hooks/localCourse/assignmentHooks";
import { useTRPC } from "@/services/serverFunctions/trpcClient"; import { useTRPC } from "@/services/serverFunctions/trpcClient";
import { useSuspenseQueries } from "@tanstack/react-query"; import { useSuspenseQueries } from "@tanstack/react-query";
import { useAssignmentNamesQuery } from "@/features/local/assignments/assignmentHooks";
import { useReorderCanvasModuleItemsMutation } from "@/features/canvas/hooks/canvasModuleHooks";
import { useCanvasModulesQuery } from "@/features/canvas/hooks/canvasModuleHooks";
import { Spinner } from "@/components/Spinner";
export default function ExpandableModule({ export default function ExpandableModule({
moduleName, moduleName,
@@ -50,6 +53,8 @@ export default function ExpandableModule({
const { data: quizzes } = useQuizzesQueries(moduleName); const { data: quizzes } = useQuizzesQueries(moduleName);
const { data: pages } = usePagesQueries(moduleName); const { data: pages } = usePagesQueries(moduleName);
const modal = useModal(); const modal = useModal();
const reorderMutation = useReorderCanvasModuleItemsMutation();
const { data: canvasModules } = useCanvasModulesQuery();
const moduleItems: { const moduleItems: {
type: "assignment" | "quiz" | "page"; type: "assignment" | "quiz" | "page";
@@ -110,6 +115,30 @@ export default function ExpandableModule({
)} )}
> >
<> <>
{!reorderMutation.isPending && (
<button
className=" me-3"
onClick={() => {
const canvasModuleId = canvasModules?.find(
(m) => m.name === moduleName
)?.id;
if (!canvasModuleId) {
console.error(
"Canvas module ID not found for",
moduleName
);
return;
}
reorderMutation.mutate({
moduleId: canvasModuleId,
items: moduleItems.map((item) => item.item),
});
}}
>
Sort by Due Date
</button>
)}
{reorderMutation.isPending && <Spinner />}
<Modal <Modal
modalControl={modal} modalControl={modal}
buttonText="New Item" buttonText="New Item"

View File

@@ -2,9 +2,9 @@
import CheckIcon from "@/components/icons/CheckIcon"; import CheckIcon from "@/components/icons/CheckIcon";
import { Spinner } from "@/components/Spinner"; import { Spinner } from "@/components/Spinner";
import { import {
useAddCanvasModuleMutation,
useCanvasModulesQuery, useCanvasModulesQuery,
} from "@/hooks/canvas/canvasModuleHooks"; useAddCanvasModuleMutation,
} from "@/features/canvas/hooks/canvasModuleHooks";
export function ModuleCanvasStatus({ moduleName }: { moduleName: string }) { export function ModuleCanvasStatus({ moduleName }: { moduleName: string }) {
const { data: canvasModules } = useCanvasModulesQuery(); const { data: canvasModules } = useCanvasModulesQuery();

View File

@@ -1,5 +1,5 @@
"use client"; "use client";
import { useModuleNamesQuery } from "@/hooks/localCourse/localCourseModuleHooks"; import { useModuleNamesQuery } from "@/features/local/modules/localCourseModuleHooks";
import ExpandableModule from "./ExpandableModule"; import ExpandableModule from "./ExpandableModule";
import CreateModule from "./CreateModule"; import CreateModule from "./CreateModule";

View File

@@ -3,20 +3,20 @@ import ButtonSelect from "@/components/ButtonSelect";
import SelectInput from "@/components/form/SelectInput"; import SelectInput from "@/components/form/SelectInput";
import TextInput from "@/components/form/TextInput"; import TextInput from "@/components/form/TextInput";
import { Spinner } from "@/components/Spinner"; import { Spinner } from "@/components/Spinner";
import { useCreateAssignmentMutation } from "@/hooks/localCourse/assignmentHooks"; import { useModuleNamesQuery } from "@/features/local/modules/localCourseModuleHooks";
import { useModuleNamesQuery } from "@/hooks/localCourse/localCourseModuleHooks"; import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks"; import { useCreatePageMutation } from "@/features/local/pages/pageHooks";
import { useCreatePageMutation } from "@/hooks/localCourse/pageHooks"; import { LocalAssignmentGroup } from "@/features/local/assignments/models/localAssignmentGroup";
import { LocalAssignmentGroup } from "@/models/local/assignment/localAssignmentGroup";
import React, { useState } from "react"; import React, { useState } from "react";
import { useCourseContext } from "../context/courseContext"; import { useCourseContext } from "../context/courseContext";
import { useCreateQuizMutation } from "@/hooks/localCourse/quizHooks"; import { useCreateQuizMutation } from "@/features/local/quizzes/quizHooks";
import { import {
getDateFromString, getDateFromString,
dateToMarkdownString, dateToMarkdownString,
getDateFromStringOrThrow, getDateFromStringOrThrow,
} from "@/models/local/utils/timeUtils"; } from "@/features/local/utils/timeUtils";
import { useCreateAssignmentMutation } from "@/features/local/assignments/assignmentHooks";
export default function NewItemForm({ export default function NewItemForm({
moduleName: defaultModuleName, moduleName: defaultModuleName,
@@ -153,9 +153,9 @@ export default function NewItemForm({
<div> <div>
<ButtonSelect<"Assignment" | "Quiz" | "Page"> <ButtonSelect<"Assignment" | "Quiz" | "Page">
options={["Assignment", "Quiz", "Page"]} options={["Assignment", "Quiz", "Page"]}
getName={(o) => o?.toString() ?? ""} getOptionName={(o) => o?.toString() ?? ""}
setSelectedOption={(t) => setType(t ?? "Assignment")} setValue={(t) => setType(t ?? "Assignment")}
selectedOption={type} value={type}
label="Type" label="Type"
/> />
</div> </div>
@@ -166,9 +166,9 @@ export default function NewItemForm({
{type !== "Page" && ( {type !== "Page" && (
<ButtonSelect <ButtonSelect
options={settings.assignmentGroups} options={settings.assignmentGroups}
getName={(g) => g?.name ?? ""} getOptionName={(g) => g?.name ?? ""}
setSelectedOption={setAssignmentGroup} setValue={setAssignmentGroup}
selectedOption={assignmentGroup} value={assignmentGroup}
label="Assignment Group" label="Assignment Group"
/> />
)} )}

View File

@@ -6,13 +6,13 @@ import {
useAddAssignmentToCanvasMutation, useAddAssignmentToCanvasMutation,
useDeleteAssignmentFromCanvasMutation, useDeleteAssignmentFromCanvasMutation,
useUpdateAssignmentInCanvasMutation, useUpdateAssignmentInCanvasMutation,
} from "@/hooks/canvas/canvasAssignmentHooks"; } from "@/features/canvas/hooks/canvasAssignmentHooks";
import { baseCanvasUrl } from "@/features/canvas/services/canvasServiceUtils";
import { import {
useAssignmentQuery, useAssignmentQuery,
useDeleteAssignmentMutation, useDeleteAssignmentMutation,
} from "@/hooks/localCourse/assignmentHooks"; } from "@/features/local/assignments/assignmentHooks";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks"; import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { baseCanvasUrl } from "@/services/canvas/canvasServiceUtils";
import { getCourseUrl } from "@/services/urlUtils"; import { getCourseUrl } from "@/services/urlUtils";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";

View File

@@ -1,7 +1,7 @@
import MarkdownDisplay from "@/components/MarkdownDisplay"; import MarkdownDisplay from "@/components/MarkdownDisplay";
import { LocalAssignment } from "@/models/local/assignment/localAssignment"; import { LocalAssignment } from "@/features/local/assignments/models/localAssignment";
import { rubricItemIsExtraCredit } from "@/models/local/assignment/rubricItem"; import { rubricItemIsExtraCredit } from "@/features/local/assignments/models/rubricItem";
import { assignmentPoints } from "@/models/local/assignment/utils/assignmentPointsUtils"; import { assignmentPoints } from "@/features/local/assignments/models/utils/assignmentPointsUtils";
import { formatHumanReadableDate } from "@/services/utils/dateFormat"; import { formatHumanReadableDate } from "@/services/utils/dateFormat";
import React, { Fragment } from "react"; import React, { Fragment } from "react";
@@ -59,13 +59,15 @@ export default function AssignmentPreview({
<hr /> <hr />
<br /> <br />
<section> <section>
<MarkdownDisplay markdown={assignment.description} /> <MarkdownDisplay
{/* <div markdown={assignment.description}
className="markdownPreview" replaceText={[
dangerouslySetInnerHTML={{ {
__html: htmlPreview, source: "insert_github_classroom_url",
}} destination: assignment.githubClassroomAssignmentShareLink || "",
></div> */} },
]}
/>
</section> </section>
<hr /> <hr />
<section> <section>

View File

@@ -1,18 +1,13 @@
"use client"; "use client";
import { MonacoEditor } from "@/components/editor/MonacoEditor"; import { MonacoEditor } from "@/components/editor/MonacoEditor";
import {
useAssignmentQuery,
useUpdateAssignmentMutation,
useUpdateImageSettingsForAssignment,
} from "@/hooks/localCourse/assignmentHooks";
import { import {
LocalAssignment, LocalAssignment,
localAssignmentMarkdown, localAssignmentMarkdown,
} from "@/models/local/assignment/localAssignment"; } from "@/features/local/assignments/models/localAssignment";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import AssignmentPreview from "./AssignmentPreview"; import AssignmentPreview from "./AssignmentPreview";
import { useCourseContext } from "@/app/course/[courseName]/context/courseContext"; import { useCourseContext } from "@/app/course/[courseName]/context/courseContext";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks"; import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import ClientOnly from "@/components/ClientOnly"; import ClientOnly from "@/components/ClientOnly";
import { SuspenseAndErrorHandling } from "@/components/SuspenseAndErrorHandling"; import { SuspenseAndErrorHandling } from "@/components/SuspenseAndErrorHandling";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -22,6 +17,11 @@ import EditAssignmentHeader from "./EditAssignmentHeader";
import { Spinner } from "@/components/Spinner"; import { Spinner } from "@/components/Spinner";
import { getAssignmentHelpString } from "./getAssignmentHelpString"; import { getAssignmentHelpString } from "./getAssignmentHelpString";
import { EditLayout } from "@/components/EditLayout"; import { EditLayout } from "@/components/EditLayout";
import {
useAssignmentQuery,
useUpdateAssignmentMutation,
useUpdateImageSettingsForAssignment,
} from "@/features/local/assignments/assignmentHooks";
export default function EditAssignment({ export default function EditAssignment({
moduleName, moduleName,
@@ -131,7 +131,7 @@ export default function EditAssignment({
Body={ Body={
<> <>
{showHelp && ( {showHelp && (
<div className=" max-w-96"> <div className=" max-w-96 flex-1 h-full overflow-y-auto">
<pre> <pre>
<code>{getAssignmentHelpString(settings)}</code> <code>{getAssignmentHelpString(settings)}</code>
</pre> </pre>

View File

@@ -13,7 +13,12 @@ export default function EditAssignmentHeader({
const { courseName } = useCourseContext(); const { courseName } = useCourseContext();
return ( return (
<div className="py-1 flex flex-row justify-start gap-3"> <div className="py-1 flex flex-row justify-start gap-3">
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}> <Link
className="btn"
href={getCourseUrl(courseName)}
shallow={true}
prefetch={true}
>
{courseName} {courseName}
</Link> </Link>
<UpdateAssignmentName <UpdateAssignmentName

View File

@@ -5,7 +5,7 @@ import { Spinner } from "@/components/Spinner";
import { import {
useAssignmentQuery, useAssignmentQuery,
useUpdateAssignmentMutation, useUpdateAssignmentMutation,
} from "@/hooks/localCourse/assignmentHooks"; } from "@/features/local/assignments/assignmentHooks";
import { getModuleItemUrl } from "@/services/urlUtils"; import { getModuleItemUrl } from "@/services/urlUtils";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { AssignmentSubmissionType } from "@/models/local/assignment/assignmentSubmissionType"; import { AssignmentSubmissionType } from "@/features/local/assignments/models/assignmentSubmissionType";
import { LocalCourseSettings } from "@/models/local/localCourseSettings"; import { LocalCourseSettings } from "@/features/local/course/localCourseSettings";
export function getAssignmentHelpString(settings: LocalCourseSettings) { export function getAssignmentHelpString(settings: LocalCourseSettings) {
const groupNames = settings.assignmentGroups.map((g) => g.name).join("\n- "); const groupNames = settings.assignmentGroups.map((g) => g.name).join("\n- ");
@@ -33,6 +33,7 @@ You can use markdown to format your assignment description. For example, you can
[Link to Canvas](https://canvas.instructure.com) [Link to Canvas](https://canvas.instructure.com)
\`Inline code\` \`Inline code\`
> Blockquote > Blockquote
@@ -54,6 +55,16 @@ flowchart TD
C -->|Three| F[fa:fa-car Car] C -->|Three| F[fa:fa-car Car]
\`\`\` \`\`\`
## github classroom links will be replaced by the GithubClassroomAssignmentShareLink setting
[Github Classroom](insert_github_classroom_url)
## Files
If you have mounted a folder in the /app/public/images directory, you can link to files like this:
![formulas](/images/facultyFiles/1405/lab-04-simple-math-formulas.png)
## Rubric ## Rubric
- 1pt: singular point - 1pt: singular point

View File

@@ -14,8 +14,9 @@ export async function generateMetadata({
}): Promise<Metadata> { }): Promise<Metadata> {
const { courseName, assignmentName } = await params; const { courseName, assignmentName } = await params;
const decodedAssignmentName = decodeURIComponent(assignmentName); const decodedAssignmentName = decodeURIComponent(assignmentName);
const decodedCourseName = decodeURIComponent(courseName);
return { return {
title: getTitle(`${decodedAssignmentName}, ${courseName}`), title: getTitle(`${decodedAssignmentName}, ${decodedCourseName}`),
}; };
} }

View File

@@ -1,13 +1,8 @@
"use client"; "use client";
import { MonacoEditor } from "@/components/editor/MonacoEditor"; import { MonacoEditor } from "@/components/editor/MonacoEditor";
import {
usePageQuery,
useUpdatePageMutation,
} from "@/hooks/localCourse/pageHooks";
import { localPageMarkdownUtils } from "@/models/local/page/localCoursePage";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import PagePreview from "./PagePreview"; import PagePreview from "./PagePreview";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks"; import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import EditPageButtons from "./EditPageButtons"; import EditPageButtons from "./EditPageButtons";
import ClientOnly from "@/components/ClientOnly"; import ClientOnly from "@/components/ClientOnly";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -15,6 +10,11 @@ import { useCourseContext } from "@/app/course/[courseName]/context/courseContex
import { useAuthoritativeUpdates } from "@/app/course/[courseName]/utils/useAuthoritativeUpdates"; import { useAuthoritativeUpdates } from "@/app/course/[courseName]/utils/useAuthoritativeUpdates";
import EditPageHeader from "./EditPageHeader"; import EditPageHeader from "./EditPageHeader";
import { EditLayout } from "@/components/EditLayout"; import { EditLayout } from "@/components/EditLayout";
import { localPageMarkdownUtils } from "@/features/local/pages/localCoursePageModels";
import {
usePageQuery,
useUpdatePageMutation,
} from "@/features/local/pages/pageHooks";
export default function EditPage({ export default function EditPage({
moduleName, moduleName,
@@ -125,5 +125,5 @@ export default function EditPage({
</> </>
} }
/> />
) );
} }

View File

@@ -4,15 +4,15 @@ import { Spinner } from "@/components/Spinner";
import { import {
useCanvasPagesQuery, useCanvasPagesQuery,
useCreateCanvasPageMutation, useCreateCanvasPageMutation,
useDeleteCanvasPageMutation,
useUpdateCanvasPageMutation, useUpdateCanvasPageMutation,
} from "@/hooks/canvas/canvasPageHooks"; useDeleteCanvasPageMutation,
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks"; } from "@/features/canvas/hooks/canvasPageHooks";
import { baseCanvasUrl } from "@/features/canvas/services/canvasServiceUtils";
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { import {
useDeletePageMutation, useDeletePageMutation,
usePageQuery, usePageQuery,
} from "@/hooks/localCourse/pageHooks"; } from "@/features/local/pages/pageHooks";
import { baseCanvasUrl } from "@/services/canvas/canvasServiceUtils";
import { getCourseUrl } from "@/services/urlUtils"; import { getCourseUrl } from "@/services/urlUtils";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";

View File

@@ -17,6 +17,7 @@ export default function EditPageHeader({
className="btn" className="btn"
href={getCourseUrl(courseName)} href={getCourseUrl(courseName)}
shallow={true} shallow={true}
prefetch={true}
> >
{courseName} {courseName}
</Link> </Link>

View File

@@ -1,9 +1,7 @@
import MarkdownDisplay from "@/components/MarkdownDisplay"; import MarkdownDisplay from "@/components/MarkdownDisplay";
import { LocalCoursePage } from "@/models/local/page/localCoursePage"; import { LocalCoursePage } from "@/features/local/pages/localCoursePageModels";
import React from "react"; import React from "react";
export default function PagePreview({ page }: { page: LocalCoursePage }) { export default function PagePreview({ page }: { page: LocalCoursePage }) {
return ( return <MarkdownDisplay markdown={page.text} />;
<MarkdownDisplay markdown={page.text} />
);
} }

View File

@@ -5,7 +5,7 @@ import { Spinner } from "@/components/Spinner";
import { import {
usePageQuery, usePageQuery,
useUpdatePageMutation, useUpdatePageMutation,
} from "@/hooks/localCourse/pageHooks"; } from "@/features/local/pages/pageHooks";
import { getModuleItemUrl } from "@/services/urlUtils"; import { getModuleItemUrl } from "@/services/urlUtils";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";

View File

@@ -14,8 +14,9 @@ export async function generateMetadata({
}): Promise<Metadata> { }): Promise<Metadata> {
const { courseName, pageName } = await params; const { courseName, pageName } = await params;
const decodedPageName = decodeURIComponent(pageName); const decodedPageName = decodeURIComponent(pageName);
const decodedCourseName = decodeURIComponent(courseName);
return { return {
title: getTitle(`${decodedPageName}, ${courseName}`), title: getTitle(`${decodedPageName}, ${decodedCourseName}`),
}; };
} }

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import { MonacoEditor } from "@/components/editor/MonacoEditor"; import { MonacoEditor } from "@/components/editor/MonacoEditor";
import { quizMarkdownUtils } from "@/models/local/quiz/utils/quizMarkdownUtils";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import QuizPreview from "./QuizPreview"; import QuizPreview from "./QuizPreview";
import { QuizButtons } from "./QuizButton"; import { QuizButtons } from "./QuizButton";
@@ -11,13 +10,14 @@ import { useCourseContext } from "@/app/course/[courseName]/context/courseContex
import { import {
useQuizQuery, useQuizQuery,
useUpdateQuizMutation, useUpdateQuizMutation,
} from "@/hooks/localCourse/quizHooks"; } from "@/features/local/quizzes/quizHooks";
import { useAuthoritativeUpdates } from "../../../../utils/useAuthoritativeUpdates"; import { useAuthoritativeUpdates } from "../../../../utils/useAuthoritativeUpdates";
import { extractLabelValue } from "@/models/local/assignment/utils/markdownUtils"; import { extractLabelValue } from "@/features/local/assignments/models/utils/markdownUtils";
import EditQuizHeader from "./EditQuizHeader"; import EditQuizHeader from "./EditQuizHeader";
import { LocalCourseSettings } from "@/models/local/localCourseSettings"; import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
import { EditLayout } from "@/components/EditLayout"; import { EditLayout } from "@/components/EditLayout";
import { quizMarkdownUtils } from "@/features/local/quizzes/models/utils/quizMarkdownUtils";
import { LocalCourseSettings } from "@/features/local/course/localCourseSettings";
const helpString = (settings: LocalCourseSettings) => { const helpString = (settings: LocalCourseSettings) => {
const groupNames = settings.assignmentGroups.map((g) => g.name).join("\n- "); const groupNames = settings.assignmentGroups.map((g) => g.name).join("\n- ");
@@ -61,6 +61,11 @@ points: 4
the underscore is optional the underscore is optional
short answer short answer
--- ---
short answer with auto-graded responses
*a) answer 1
*b) other valid answer
short_answer=
---
this is a matching question this is a matching question
^ left answer - right dropdown ^ left answer - right dropdown
^ other thing - another option ^ other thing - another option

View File

@@ -17,6 +17,7 @@ export default function EditQuizHeader({
className="btn" className="btn"
href={getCourseUrl(courseName)} href={getCourseUrl(courseName)}
shallow={true} shallow={true}
prefetch={true}
> >
{courseName} {courseName}
</Link> </Link>

View File

@@ -5,13 +5,13 @@ import {
useCanvasQuizzesQuery, useCanvasQuizzesQuery,
useAddQuizToCanvasMutation, useAddQuizToCanvasMutation,
useDeleteQuizFromCanvasMutation, useDeleteQuizFromCanvasMutation,
} from "@/hooks/canvas/canvasQuizHooks"; } from "@/features/canvas/hooks/canvasQuizHooks";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks"; import { baseCanvasUrl } from "@/features/canvas/services/canvasServiceUtils";
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { import {
useDeleteQuizMutation, useDeleteQuizMutation,
useQuizQuery, useQuizQuery,
} from "@/hooks/localCourse/quizHooks"; } from "@/features/local/quizzes/quizHooks";
import { baseCanvasUrl } from "@/services/canvas/canvasServiceUtils";
import { getCourseUrl } from "@/services/urlUtils"; import { getCourseUrl } from "@/services/urlUtils";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";

View File

@@ -1,10 +1,10 @@
import CheckIcon from "@/components/icons/CheckIcon"; import CheckIcon from "@/components/icons/CheckIcon";
import MarkdownDisplay from "@/components/MarkdownDisplay"; import MarkdownDisplay from "@/components/MarkdownDisplay";
import { useQuizQuery } from "@/hooks/localCourse/quizHooks";
import { import {
LocalQuizQuestion, LocalQuizQuestion,
QuestionType, QuestionType,
} from "@/models/local/quiz/localQuizQuestion"; } from "@/features/local/quizzes/models/localQuizQuestion";
import { useQuizQuery } from "@/features/local/quizzes/quizHooks";
import { escapeMatchingText } from "@/services/utils/questionHtmlUtils"; import { escapeMatchingText } from "@/services/utils/questionHtmlUtils";
export default function QuizPreview({ export default function QuizPreview({

View File

@@ -5,7 +5,7 @@ import { Spinner } from "@/components/Spinner";
import { import {
useQuizQuery, useQuizQuery,
useUpdateQuizMutation, useUpdateQuizMutation,
} from "@/hooks/localCourse/quizHooks"; } from "@/features/local/quizzes/quizHooks";
import { getModuleItemUrl } from "@/services/urlUtils"; import { getModuleItemUrl } from "@/services/urlUtils";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";

View File

@@ -14,8 +14,9 @@ export async function generateMetadata({
}): Promise<Metadata> { }): Promise<Metadata> {
const { courseName, quizName } = await params; const { courseName, quizName } = await params;
const decodedQuizName = decodeURIComponent(quizName); const decodedQuizName = decodeURIComponent(quizName);
const decodedCourseName = decodeURIComponent(courseName);
return { return {
title: getTitle(`${decodedQuizName}, ${courseName}`), title: getTitle(`${decodedQuizName}, ${decodedCourseName}`),
}; };
} }

View File

@@ -4,14 +4,13 @@ import { CourseNavigation } from "./CourseNavigation";
import { DragStyleContextProvider } from "./context/drag/dragStyleContext"; import { DragStyleContextProvider } from "./context/drag/dragStyleContext";
import CollapsableSidebar from "./CollapsableSidebar"; import CollapsableSidebar from "./CollapsableSidebar";
export default async function CoursePage() { export default async function CoursePage() {
return ( return (
<> <>
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
<DragStyleContextProvider> <DragStyleContextProvider>
<DraggingContextProvider> <DraggingContextProvider>
<div className="flex sm:flex-row h-full flex-col max-w-[2400px] mx-auto"> <div className="flex sm:flex-row h-full flex-col max-w-[2400px] w-full mx-auto">
<div className="flex-1 h-full flex flex-col"> <div className="flex-1 h-full flex flex-col">
<CourseNavigation /> <CourseNavigation />
<CourseCalendar /> <CourseCalendar />

View File

@@ -3,15 +3,15 @@
import { import {
useLocalCourseSettingsQuery, useLocalCourseSettingsQuery,
useUpdateLocalCourseSettingsMutation, useUpdateLocalCourseSettingsMutation,
} from "@/hooks/localCourse/localCoursesHooks"; } from "@/features/local/course/localCoursesHooks";
import { LocalAssignmentGroup } from "@/models/local/assignment/localAssignmentGroup"; import { LocalAssignmentGroup } from "@/features/local/assignments/models/localAssignmentGroup";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import TextInput from "../../../../components/form/TextInput"; import TextInput from "../../../../components/form/TextInput";
import { useSetAssignmentGroupsMutation } from "@/hooks/canvas/canvasCourseHooks";
import { settingsBox } from "./sharedSettings"; import { settingsBox } from "./sharedSettings";
import { Spinner } from "@/components/Spinner"; import { Spinner } from "@/components/Spinner";
import { baseCanvasUrl } from "@/services/canvas/canvasServiceUtils";
import MeatballIcon from "./MeatballIcon"; import MeatballIcon from "./MeatballIcon";
import { useSetAssignmentGroupsMutation } from "@/features/canvas/hooks/canvasCourseHooks";
import { baseCanvasUrl } from "@/features/canvas/services/canvasServiceUtils";
export default function AssignmentGroupManagement() { export default function AssignmentGroupManagement() {
const { data: settings, isPending } = useLocalCourseSettingsQuery(); const { data: settings, isPending } = useLocalCourseSettingsQuery();

View File

@@ -4,7 +4,7 @@ import { Spinner } from "@/components/Spinner";
import { import {
useLocalCourseSettingsQuery, useLocalCourseSettingsQuery,
useUpdateLocalCourseSettingsMutation, useUpdateLocalCourseSettingsMutation,
} from "@/hooks/localCourse/localCoursesHooks"; } from "@/features/local/course/localCoursesHooks";
import React from "react"; import React from "react";
export default function DaysOfWeekSettings() { export default function DaysOfWeekSettings() {

View File

@@ -3,7 +3,7 @@
import { import {
useLocalCourseSettingsQuery, useLocalCourseSettingsQuery,
useUpdateLocalCourseSettingsMutation, useUpdateLocalCourseSettingsMutation,
} from "@/hooks/localCourse/localCoursesHooks"; } from "@/features/local/course/localCoursesHooks";
import { TimePicker } from "../../../../components/TimePicker"; import { TimePicker } from "../../../../components/TimePicker";
import { useState } from "react"; import { useState } from "react";
import DefaultLockOffset from "./DefaultLockOffset"; import DefaultLockOffset from "./DefaultLockOffset";

View File

@@ -3,7 +3,7 @@ import TextInput from "@/components/form/TextInput";
import { import {
useLocalCourseSettingsQuery, useLocalCourseSettingsQuery,
useUpdateLocalCourseSettingsMutation, useUpdateLocalCourseSettingsMutation,
} from "@/hooks/localCourse/localCoursesHooks"; } from "@/features/local/course/localCoursesHooks";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { settingsBox } from "./sharedSettings"; import { settingsBox } from "./sharedSettings";

View File

@@ -4,7 +4,7 @@ import TextInput from "@/components/form/TextInput";
import { import {
useLocalCourseSettingsQuery, useLocalCourseSettingsQuery,
useUpdateLocalCourseSettingsMutation, useUpdateLocalCourseSettingsMutation,
} from "@/hooks/localCourse/localCoursesHooks"; } from "@/features/local/course/localCoursesHooks";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export default function DefaultLockOffset() { export default function DefaultLockOffset() {

View File

@@ -1,8 +1,8 @@
"use client"; "use client";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks"; import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { settingsBox } from "./sharedSettings"; import { settingsBox } from "./sharedSettings";
import { useCourseStudentsQuery } from "@/hooks/canvas/canvasCourseHooks";
import { Spinner } from "@/components/Spinner"; import { Spinner } from "@/components/Spinner";
import { useCourseStudentsQuery } from "@/features/canvas/hooks/canvasCourseHooks";
export default function GithubClassroomList() { export default function GithubClassroomList() {
const { data: settings } = useLocalCourseSettingsQuery(); const { data: settings } = useLocalCourseSettingsQuery();

View File

@@ -5,13 +5,13 @@ import { SuspenseAndErrorHandling } from "@/components/SuspenseAndErrorHandling"
import { import {
useLocalCourseSettingsQuery, useLocalCourseSettingsQuery,
useUpdateLocalCourseSettingsMutation, useUpdateLocalCourseSettingsMutation,
} from "@/hooks/localCourse/localCoursesHooks"; } from "@/features/local/course/localCoursesHooks";
import { getDateFromString } from "@/models/local/utils/timeUtils"; import { getDateFromString } from "@/features/local/utils/timeUtils";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
holidaysToString, holidaysToString,
parseHolidays, parseHolidays,
} from "../../../../models/local/utils/settingsUtils"; } from "../../../../features/local/utils/settingsUtils";
import { settingsBox } from "./sharedSettings"; import { settingsBox } from "./sharedSettings";
const exampleString = `springBreak: const exampleString = `springBreak:

View File

@@ -1,5 +1,5 @@
"use client"; "use client";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks"; import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { getCourseUrl } from "@/services/urlUtils"; import { getCourseUrl } from "@/services/urlUtils";
import Link from "next/link"; import Link from "next/link";
import React from "react"; import React from "react";

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks"; import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { getDateOnlyMarkdownString } from "@/models/local/utils/timeUtils"; import { getDateOnlyMarkdownString } from "@/features/local/utils/timeUtils";
import React from "react"; import React from "react";
import { settingsBox } from "./sharedSettings"; import { settingsBox } from "./sharedSettings";

View File

@@ -3,11 +3,11 @@ import SelectInput from "@/components/form/SelectInput";
import { import {
useLocalCourseSettingsQuery, useLocalCourseSettingsQuery,
useUpdateLocalCourseSettingsMutation, useUpdateLocalCourseSettingsMutation,
} from "@/hooks/localCourse/localCoursesHooks"; } from "@/features/local/course/localCoursesHooks";
import { import {
AssignmentSubmissionType, AssignmentSubmissionType,
AssignmentSubmissionTypeList, AssignmentSubmissionTypeList,
} from "@/models/local/assignment/assignmentSubmissionType"; } from "@/features/local/assignments/models/assignmentSubmissionType";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { settingsBox } from "./sharedSettings"; import { settingsBox } from "./sharedSettings";

View File

@@ -1,8 +1,10 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useCanvasTabsQuery } from "@/hooks/canvas/canvasNavigationHooks";
import { useUpdateCanvasTabMutation } from "@/hooks/canvas/canvasNavigationHooks";
import { Spinner } from "@/components/Spinner"; import { Spinner } from "@/components/Spinner";
import { NavTabListItem } from "./NavTabListItem"; import { NavTabListItem } from "./NavTabListItem";
import {
useCanvasTabsQuery,
useUpdateCanvasTabMutation,
} from "@/features/canvas/hooks/canvasNavigationHooks";
export const CanvasNavigationManagement = () => { export const CanvasNavigationManagement = () => {
const { data: tabs, isLoading, isError } = useCanvasTabsQuery(); const { data: tabs, isLoading, isError } = useCanvasTabsQuery();

View File

@@ -1,6 +1,6 @@
import { Spinner } from "@/components/Spinner"; import { Spinner } from "@/components/Spinner";
import { useUpdateCanvasTabMutation } from "@/hooks/canvas/canvasNavigationHooks"; import { useUpdateCanvasTabMutation } from "@/features/canvas/hooks/canvasNavigationHooks";
import { CanvasCourseTab } from "@/services/canvas/canvasNavigationService"; import { CanvasCourseTab } from "@/features/canvas/services/canvasNavigationService";
import React, { FC } from "react"; import React, { FC } from "react";
export const NavTabListItem: FC<{ export const NavTabListItem: FC<{

View File

@@ -8,8 +8,9 @@ export async function generateMetadata({
params: Promise<{ courseName: string }>; params: Promise<{ courseName: string }>;
}): Promise<Metadata> { }): Promise<Metadata> {
const { courseName } = await params; const { courseName } = await params;
const decodedCourseName = decodeURIComponent(courseName);
return { return {
title: getTitle(courseName) + " Settings", title: getTitle(decodedCourseName) + " Settings",
}; };
} }

View File

@@ -2,15 +2,10 @@ import type { Metadata } from "next";
import "./globals.css"; import "./globals.css";
import Providers from "./providers"; import Providers from "./providers";
import { Suspense } from "react"; import { Suspense } from "react";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { MyToaster } from "./MyToaster"; import { MyToaster } from "./MyToaster";
import { createServerSideHelpers } from "@trpc/react-query/server";
import { trpcAppRouter } from "@/services/serverFunctions/router/app";
import { createTrpcContext } from "@/services/serverFunctions/context";
import superjson from "superjson";
import { fileStorageService } from "@/services/fileStorage/fileStorageService";
import { ClientCacheInvalidation } from "../components/realtime/ClientCacheInvalidation"; import { ClientCacheInvalidation } from "../components/realtime/ClientCacheInvalidation";
import { getTitle } from "@/services/titleUtils"; import { getTitle } from "@/services/titleUtils";
import DataHydration from "./DataHydration";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -41,77 +36,3 @@ export default async function RootLayout({
</html> </html>
); );
} }
async function DataHydration({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
console.log("starting hydration");
const trpcHelper = createServerSideHelpers({
router: trpcAppRouter,
ctx: createTrpcContext(),
transformer: superjson,
queryClientConfig: {
defaultOptions: {
queries: {
staleTime: Infinity,
},
},
},
});
const allSettings = await fileStorageService.settings.getAllCoursesSettings();
await Promise.all(
allSettings.map(async (settings) => {
const courseName = settings.name;
const moduleNames = await trpcHelper.module.getModuleNames.fetch({
courseName,
});
await Promise.all([
// assignments
...moduleNames.map(
async (moduleName) =>
await trpcHelper.assignment.getAllAssignments.prefetch({
courseName,
moduleName,
})
),
// quizzes
...moduleNames.map(
async (moduleName) =>
await trpcHelper.quiz.getAllQuizzes.prefetch({
courseName,
moduleName,
})
),
// pages
...moduleNames.map(
async (moduleName) =>
await trpcHelper.page.getAllPages.prefetch({
courseName,
moduleName,
})
),
]);
})
);
// lectures
await Promise.all(
allSettings.map(
async (settings) =>
await trpcHelper.lectures.getLectures.fetch({
courseName: settings.name,
})
)
);
const dehydratedState = dehydrate(trpcHelper.queryClient);
console.log("ran hydration");
return (
<HydrationBoundary state={dehydratedState}>{children}</HydrationBoundary>
);
}

View File

@@ -1,5 +1,6 @@
import CourseList from "./CourseList"; import CourseList from "./CourseList";
import AddNewCourse from "./newCourse/AddNewCourse"; import { AddExistingCourseToGlobalSettings } from "./addCourse/AddExistingCourseToGlobalSettings";
import AddCourseToGlobalSettings from "./addCourse/AddNewCourse";
import TodaysLectures from "./todaysLectures/TodaysLectures"; import TodaysLectures from "./todaysLectures/TodaysLectures";
export default async function Home() { export default async function Home() {
@@ -18,7 +19,36 @@ export default async function Home() {
<TodaysLectures /> <TodaysLectures />
<br /> <br />
<br /> <br />
<AddNewCourse /> <AddCourseToGlobalSettings />
<AddExistingCourseToGlobalSettings />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
</div> </div>
</main> </main>
); );

View File

@@ -3,13 +3,13 @@
import { getLecturePreviewUrl } from "@/services/urlUtils"; import { getLecturePreviewUrl } from "@/services/urlUtils";
import Link from "next/link"; import Link from "next/link";
import { useCourseContext } from "../course/[courseName]/context/courseContext"; import { useCourseContext } from "../course/[courseName]/context/courseContext";
import { useLecturesSuspenseQuery as useLecturesQuery } from "@/hooks/localCourse/lectureHooks"; import { useLecturesSuspenseQuery as useLecturesQuery } from "@/features/local/lectures/lectureHooks";
import { getLectureForDay } from "@/models/local/utils/lectureUtils"; import { getLectureForDay } from "@/features/local/utils/lectureUtils";
import { getDateOnlyMarkdownString } from "@/models/local/utils/timeUtils"; import { getDateOnlyMarkdownString } from "@/features/local/utils/timeUtils";
export default function OneCourseLectures() { export default function OneCourseLectures() {
const { courseName } = useCourseContext(); const { courseName } = useCourseContext();
const {data: weeks} = useLecturesQuery(); const { data: weeks } = useLecturesQuery();
const dayAsDate = new Date(); const dayAsDate = new Date();
const dayAsString = getDateOnlyMarkdownString(dayAsDate); const dayAsString = getDateOnlyMarkdownString(dayAsDate);

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useLocalCoursesSettingsQuery } from "@/hooks/localCourse/localCoursesHooks"; import { useLocalCoursesSettingsQuery } from "@/features/local/course/localCoursesHooks";
import OneCourseLectures from "./OneCourseLectures"; import OneCourseLectures from "./OneCourseLectures";
import { SuspenseAndErrorHandling } from "@/components/SuspenseAndErrorHandling"; import { SuspenseAndErrorHandling } from "@/components/SuspenseAndErrorHandling";
import CourseContextProvider from "../course/[courseName]/context/CourseContextProvider"; import CourseContextProvider from "../course/[courseName]/context/CourseContextProvider";

View File

@@ -2,32 +2,39 @@ import React from "react";
export default function ButtonSelect<T>({ export default function ButtonSelect<T>({
options, options,
getName, getOptionName,
setSelectedOption, setValue,
selectedOption, value,
label label,
center = false,
}: { }: {
options: T[]; options: T[];
getName: (value: T | undefined) => string; getOptionName: (value: T | undefined) => string;
setSelectedOption: (value: T | undefined) => void; setValue: (value: T | undefined) => void;
selectedOption: T | undefined; value: T | undefined;
label: string; label: string;
center?: boolean;
}) { }) {
return ( return (
<div> <div className={center ? "text-center" : ""}>
<label>{label}</label> <label>{label}</label>
<div className="flex flex-row gap-3 flex-wrap"> <div
className={
"flex flex-row gap-3 flex-wrap " + (center ? "justify-center" : "")
}
>
{options.map((o) => ( {options.map((o) => (
<button <button
type="button" type="button"
key={getName(o)} key={getOptionName(o)}
className={ className={
getName(o) === getName(selectedOption) ? " " : "unstyled btn-outline" getOptionName(o) === getOptionName(value)
? " "
: "unstyled btn-outline"
} }
onClick={() => setSelectedOption(o)} onClick={() => setValue(o)}
> >
{getName(o)} {getOptionName(o)}
</button> </button>
))} ))}
</div> </div>

View File

@@ -1,14 +1,19 @@
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks"; import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { SuspenseAndErrorHandling } from "./SuspenseAndErrorHandling"; import { SuspenseAndErrorHandling } from "./SuspenseAndErrorHandling";
import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils"; import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils";
import { LocalCourseSettings } from "@/models/local/localCourseSettings"; import { LocalCourseSettings } from "@/features/local/course/localCourseSettings";
export default function MarkdownDisplay({ export default function MarkdownDisplay({
markdown, markdown,
className = "", className = "",
replaceText = [],
}: { }: {
markdown: string; markdown: string;
className?: string; className?: string;
replaceText?: {
source: string;
destination: string;
}[];
}) { }) {
const { data: settings } = useLocalCourseSettingsQuery(); const { data: settings } = useLocalCourseSettingsQuery();
return ( return (
@@ -17,6 +22,7 @@ export default function MarkdownDisplay({
markdown={markdown} markdown={markdown}
settings={settings} settings={settings}
className={className} className={className}
replaceText={replaceText}
/> />
</SuspenseAndErrorHandling> </SuspenseAndErrorHandling>
); );
@@ -26,16 +32,25 @@ function DangerousInnerMarkdown({
markdown, markdown,
settings, settings,
className, className,
replaceText,
}: { }: {
markdown: string; markdown: string;
settings: LocalCourseSettings; settings: LocalCourseSettings;
className: string; className: string;
replaceText: {
source: string;
destination: string;
}[];
}) { }) {
return ( return (
<div <div
className={"markdownPreview " + className} className={"markdownPreview " + className}
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: markdownToHTMLSafe(markdown, settings), __html: markdownToHTMLSafe({
markdownString: markdown,
settings,
replaceText,
}),
}} }}
></div> ></div>
); );

View File

@@ -1,3 +1,4 @@
"use client"
import { getErrorMessage } from "@/services/utils/queryClient"; import { getErrorMessage } from "@/services/utils/queryClient";
import { QueryErrorResetBoundary } from "@tanstack/react-query"; import { QueryErrorResetBoundary } from "@tanstack/react-query";
import { FC, ReactNode, Suspense } from "react"; import { FC, ReactNode, Suspense } from "react";

View File

@@ -1,5 +1,5 @@
"use client"; "use client";
import { SimpleTimeOnly } from "@/models/local/localCourseSettings"; import { SimpleTimeOnly } from "@/features/local/course/localCourseSettings";
import { FC } from "react"; import { FC } from "react";
export const TimePicker: FC<{ export const TimePicker: FC<{

View File

@@ -1,4 +1,4 @@
import { DayOfWeek } from "@/models/local/localCourseSettings"; import { DayOfWeek } from "@/features/local/course/localCourseSettings";
export function DayOfWeekInput({ export function DayOfWeekInput({
selectedDays, selectedDays,

View File

@@ -0,0 +1,169 @@
import { useDirectoryContentsQuery } from "@/features/local/utils/storageDirectoryHooks";
import { useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
export function StoragePathSelector({
value,
setValue,
label,
className,
setLastTypedValue,
}: {
value: string;
setValue: (newValue: string) => void;
label: string;
className?: string;
setLastTypedValue?: (value: string) => void;
}) {
const [path, setPath] = useState(value);
const { data: directoryContents } = useDirectoryContentsQuery(value);
const [isFocused, setIsFocused] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
const [arrowUsed, setArrowUsed] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setPath(value);
}, [value]);
useEffect(() => {
if (setLastTypedValue) setLastTypedValue(path);
}, [path, setLastTypedValue]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!isFocused || filteredFolders.length === 0) return;
if (e.key === "ArrowDown") {
setHighlightedIndex((prev) => (prev + 1) % filteredFolders.length);
setArrowUsed(true);
e.preventDefault();
} else if (e.key === "ArrowUp") {
setHighlightedIndex(
(prev) => (prev - 1 + filteredFolders.length) % filteredFolders.length
);
setArrowUsed(true);
e.preventDefault();
} else if (e.key === "Tab") {
if (highlightedIndex >= 0) {
handleSelectFolder(filteredFolders[highlightedIndex], arrowUsed);
e.preventDefault();
} else {
handleSelectFolder(filteredFolders[1], arrowUsed);
e.preventDefault();
}
} else if (e.key === "Enter") {
if (highlightedIndex >= 0) {
handleSelectFolder(filteredFolders[highlightedIndex], arrowUsed);
e.preventDefault();
} else {
setIsFocused(false);
inputRef.current?.blur();
e.preventDefault();
}
} else if (e.key === "Escape") {
setIsFocused(false);
inputRef.current?.blur();
e.preventDefault();
}
};
// Calculate dropdown position style
const dropdownPositionStyle = (() => {
if (inputRef.current) {
const rect = inputRef.current.getBoundingClientRect();
return {
top: rect.bottom + window.scrollY,
left: rect.left + window.scrollX,
width: rect.width,
};
}
return {};
})();
// Get last part of the path
const lastPart = path.split("/")[path.split("/").length - 1] || "";
// Filter options to those whose name matches the last part of the path
const filteredFolders = (directoryContents?.folders ?? []).filter((option) =>
option.toLowerCase().includes(lastPart.toLowerCase())
);
// Handle folder selection
const handleSelectFolder = (option: string, shouldFocus: boolean = false) => {
let newPath = path.endsWith("/")
? path + option
: path.replace(/[^/]*$/, option);
if (!newPath.endsWith("/")) {
newPath += "/";
}
setPath(newPath);
setValue(newPath);
setArrowUsed(false);
setHighlightedIndex(-1);
if (shouldFocus) {
setTimeout(() => inputRef.current?.focus(), 0);
}
};
// Scroll highlighted option into view when it changes
useEffect(() => {
if (dropdownRef.current && highlightedIndex >= 0) {
const optionElements =
dropdownRef.current.querySelectorAll(".dropdown-option");
if (optionElements[highlightedIndex]) {
(optionElements[highlightedIndex] as HTMLElement).scrollIntoView({
block: "nearest",
});
}
}
}, [highlightedIndex]);
return (
<label className={"flex flex-col relative " + className}>
{label}
<br />
<input
ref={inputRef}
className="bg-slate-800 w-full px-1"
value={path}
onChange={(e) => {
setPath(e.target.value);
if (e.target.value.endsWith("/")) {
setValue(e.target.value);
setTimeout(() => inputRef.current?.focus(), 0);
}
}}
onFocus={() => setIsFocused(true)}
onBlur={() => setTimeout(() => setIsFocused(false), 100)}
onKeyDown={handleKeyDown}
autoComplete="off"
/>
{isFocused &&
createPortal(
<div className=" ">
<div
ref={dropdownRef}
className={
" text-slate-300 border border-slate-500 " +
"absolute bg-slate-900 rounded-md mt-1 w-full max-h-96 overflow-y-auto pointer-events-auto"
}
style={dropdownPositionStyle}
>
{filteredFolders.map((option, idx) => (
<div
key={option}
className={`dropdown-option w-full px-2 py-1 cursor-pointer ${
highlightedIndex === idx ? "bg-blue-700 text-white" : ""
}`}
onMouseDown={() => handleSelectFolder(option)}
onMouseEnter={() => setHighlightedIndex(idx)}
>
{option}
</div>
))}
</div>
</div>,
document.body
)}
</label>
);
}

View File

@@ -6,12 +6,14 @@ export default function TextInput({
label, label,
className, className,
isTextArea = false, isTextArea = false,
inputRef = undefined,
}: { }: {
value: string; value: string;
setValue: (newValue: string) => void; setValue: (newValue: string) => void;
label: string; label: string;
className?: string; className?: string;
isTextArea?: boolean; isTextArea?: boolean;
inputRef?: React.RefObject<HTMLInputElement | null>;
}) { }) {
return ( return (
<label className={"flex flex-col " + className}> <label className={"flex flex-col " + className}>
@@ -22,6 +24,7 @@ export default function TextInput({
className="bg-slate-800 border border-slate-500 rounded-md w-full px-1" className="bg-slate-800 border border-slate-500 rounded-md w-full px-1"
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
ref={inputRef}
/> />
)} )}
{isTextArea && ( {isTextArea && (

View File

@@ -1,12 +1,12 @@
import { canvasAssignmentService } from "@/services/canvas/canvasAssignmentService";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useLocalCourseSettingsQuery } from "../localCourse/localCoursesHooks"; import { LocalAssignment } from "@/features/local/assignments/models/localAssignment";
import { LocalAssignment } from "@/models/local/assignment/localAssignment";
import { canvasModuleService } from "@/services/canvas/canvasModuleService";
import { import {
useAddCanvasModuleMutation, useAddCanvasModuleMutation,
useCanvasModulesQuery, useCanvasModulesQuery,
} from "./canvasModuleHooks"; } from "./canvasModuleHooks";
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { canvasModuleService } from "../services/canvasModuleService";
import { canvasAssignmentService } from "../services/canvasAssignmentService";
export const canvasAssignmentKeys = { export const canvasAssignmentKeys = {
assignments: (canvasCourseId: number) => assignments: (canvasCourseId: number) =>

View File

@@ -1,11 +1,11 @@
import { CanvasAssignmentGroup } from "@/models/canvas/assignments/canvasAssignmentGroup"; import { CanvasAssignmentGroup } from "@/features/canvas/models/assignments/canvasAssignmentGroup";
import { CanvasCourseModel } from "@/models/canvas/courses/canvasCourseModel"; import { CanvasCourseModel } from "@/features/canvas/models/courses/canvasCourseModel";
import { LocalAssignmentGroup } from "@/models/local/assignment/localAssignmentGroup"; import { LocalAssignmentGroup } from "@/features/local/assignments/models/localAssignmentGroup";
import { LocalCourseSettings } from "@/models/local/localCourseSettings";
import { canvasAssignmentGroupService } from "@/services/canvas/canvasAssignmentGroupService";
import { canvasService } from "@/services/canvas/canvasService";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { useUpdateLocalCourseSettingsMutation } from "../localCourse/localCoursesHooks"; import { LocalCourseSettings } from "@/features/local/course/localCourseSettings";
import { useUpdateLocalCourseSettingsMutation } from "@/features/local/course/localCoursesHooks";
import { canvasAssignmentGroupService } from "../services/canvasAssignmentGroupService";
import { canvasService } from "../services/canvasService";
export const canvasCourseKeys = { export const canvasCourseKeys = {
courseDetails: (canavasId: number) => courseDetails: (canavasId: number) =>

View File

@@ -1,5 +1,5 @@
import { canvasService } from "@/services/canvas/canvasService";
import { useSuspenseQuery } from "@tanstack/react-query"; import { useSuspenseQuery } from "@tanstack/react-query";
import { canvasService } from "../services/canvasService";
export const canvasKeys = { export const canvasKeys = {
allTerms: ["all canvas terms"] as const, allTerms: ["all canvas terms"] as const,

View File

@@ -0,0 +1,82 @@
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { canvasModuleService } from "../services/canvasModuleService";
import { IModuleItem } from "@/features/local/modules/IModuleItem";
export const canvasCourseModuleKeys = {
modules: (canvasId: number) => ["canvas", canvasId, "module list"] as const,
};
export const useCanvasModulesQuery = () => {
const { data: settings } = useLocalCourseSettingsQuery();
return useQuery({
queryKey: canvasCourseModuleKeys.modules(settings.canvasId),
queryFn: async () =>
await canvasModuleService.getCourseModules(settings.canvasId),
});
};
export const useAddCanvasModuleMutation = () => {
const { data: settings } = useLocalCourseSettingsQuery();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (moduleName: string) =>
await canvasModuleService.createModule(settings.canvasId, moduleName),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: canvasCourseModuleKeys.modules(settings.canvasId),
});
},
});
};
export const useReorderCanvasModuleItemsMutation = () => {
const { data: settings } = useLocalCourseSettingsQuery();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
moduleId,
items,
}: {
moduleId: number;
items: IModuleItem[];
}) => {
if (!settings?.canvasId) throw new Error("No canvasId in settings");
const canvasModule = await canvasModuleService.getModuleWithItems(
settings.canvasId,
moduleId
);
if (!canvasModule.items) {
throw new Error(
"cannot sort canvas module items, no items found in module"
);
}
const canvasItems = canvasModule.items;
// Sort IModuleItems by dueAt
const sorted = [...items].sort((a, b) => {
const aDate = a.dueAt ? new Date(a.dueAt).getTime() : 0;
const bDate = b.dueAt ? new Date(b.dueAt).getTime() : 0;
return aDate - bDate;
});
// Map sorted IModuleItems to CanvasModuleItem ids by matching name/title
const orderedIds = sorted
.map((localItem) => canvasItems.find((canvasItem) => canvasItem.title === localItem.name)?.id)
.filter((id): id is number => typeof id === "number");
return await canvasModuleService.reorderModuleItems(
settings.canvasId,
moduleId,
orderedIds
);
},
onSuccess: (_data) => {
if (!settings?.canvasId) return;
queryClient.invalidateQueries({
queryKey: canvasCourseModuleKeys.modules(settings.canvasId),
});
},
});
};

View File

@@ -1,6 +1,6 @@
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"; import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
import { useLocalCourseSettingsQuery } from "../localCourse/localCoursesHooks"; import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { canvasNavigationService } from "@/services/canvas/canvasNavigationService"; import { canvasNavigationService } from "../services/canvasNavigationService";
export const canvasCourseTabKeys = { export const canvasCourseTabKeys = {
tabs: (canvasId: number) => ["canvas", canvasId, "tabs list"] as const, tabs: (canvasId: number) => ["canvas", canvasId, "tabs list"] as const,

View File

@@ -1,12 +1,12 @@
import { LocalCoursePage } from "@/models/local/page/localCoursePage"; import { LocalCoursePage } from "@/features/local/pages/localCoursePageModels";
import { canvasPageService } from "@/services/canvas/canvasPageService";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useLocalCourseSettingsQuery } from "../localCourse/localCoursesHooks";
import { canvasModuleService } from "@/services/canvas/canvasModuleService";
import { import {
useCanvasModulesQuery, useCanvasModulesQuery,
useAddCanvasModuleMutation, useAddCanvasModuleMutation,
} from "./canvasModuleHooks"; } from "./canvasModuleHooks";
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { canvasModuleService } from "../services/canvasModuleService";
import { canvasPageService } from "../services/canvasPageService";
export const canvasPageKeys = { export const canvasPageKeys = {
pagesInCourse: (courseCanvasId: number) => [ pagesInCourse: (courseCanvasId: number) => [

View File

@@ -1,12 +1,12 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useLocalCourseSettingsQuery } from "../localCourse/localCoursesHooks";
import { canvasQuizService } from "@/services/canvas/canvasQuizService";
import { LocalQuiz } from "@/models/local/quiz/localQuiz";
import { import {
useAddCanvasModuleMutation, useAddCanvasModuleMutation,
useCanvasModulesQuery, useCanvasModulesQuery,
} from "./canvasModuleHooks"; } from "./canvasModuleHooks";
import { canvasModuleService } from "@/services/canvas/canvasModuleService"; import { LocalQuiz } from "@/features/local/quizzes/models/localQuiz";
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { canvasModuleService } from "../services/canvasModuleService";
import { canvasQuizService } from "../services/canvasQuizService";
export const canvasQuizKeys = { export const canvasQuizKeys = {
quizzes: (canvasCourseId: number) => quizzes: (canvasCourseId: number) =>

Some files were not shown because too many files have changed in this diff Show More