mirror of
https://github.com/alexmickelson/canvasManagement.git
synced 2026-03-25 23:28:33 -06:00
Compare commits
108 Commits
copilot/fi
...
4d934f27f3
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d934f27f3 | |||
| e960e2fa42 | |||
|
|
e3079cbc5a | ||
|
|
f5a50fdc02 | ||
| aa191fe90b | |||
| ca240811f2 | |||
| c224a6a9e2 | |||
| 865e86d11f | |||
| b62dcb9c62 | |||
|
|
77fde8198e | ||
|
|
8172724a4f | ||
|
|
07155991aa | ||
| 9ce42c21f9 | |||
|
|
55a9fffd54 | ||
| a4a8e3cbb6 | |||
| dda46a3c49 | |||
| 5b202f25e6 | |||
| 558eb74fbc | |||
| b6a84f2fbc | |||
| fb5ee94b55 | |||
| 678727c650 | |||
| b5899c02e4 | |||
| 767528560c | |||
| 8c01cb2422 | |||
| 076c0b1025 | |||
| 20b14da180 | |||
|
|
52b8967949 | ||
|
|
eb661a3e59 | ||
|
|
712a3e5155 | ||
|
|
7bb276d52a | ||
|
|
44c42d1abc | ||
|
|
1e3ff085f8 | ||
|
|
3c6ba35bce | ||
|
|
cef2323886 | ||
|
|
859bdf01f2 | ||
|
|
e9f33e0174 | ||
|
|
e07d0a6e47 | ||
| 51f2be1988 | |||
|
|
a203dc6e46 | ||
| f6b2427749 | |||
| cab8b881f2 | |||
| ae19b5a075 | |||
| 8aec682974 | |||
| 17bd460407 | |||
| 53c8422a5b | |||
| 890e08d1b2 | |||
| 7fec0424d7 | |||
| 11c2366f93 | |||
| 5988639378 | |||
| 15b184ddc0 | |||
| e35a5ffab6 | |||
| b53948db72 | |||
| d9f7e7b3e9 | |||
| 47c69251c8 | |||
| 4c978f392d | |||
| d6584fd338 | |||
| 6a56036782 | |||
| bd32599469 | |||
| 9638d7308e | |||
| bf835caa37 | |||
| e7e244222e | |||
| 2e474cb43a | |||
| 33120c40a5 | |||
| 2ec3d9349e | |||
| 5e088fb4eb | |||
|
|
aae9e7bba4 | ||
| 58175c1426 | |||
| 03529f875a | |||
|
|
95c9d07592 | ||
|
|
9918b63a1e | ||
|
|
f808a517d3 | ||
|
|
b47fa4cff5 | ||
|
|
efe2060fcd | ||
|
|
c60ba92f28 | ||
|
|
b65cfa73d7 | ||
|
|
dbc7887d82 | ||
| ecb5f6d70f | |||
| 523a05d45e | |||
| 5f408749e4 | |||
| 994d6e9a03 | |||
| d1a768393c | |||
| 224cc9cd2a | |||
| e07a12f622 | |||
| 54e4d7b4a1 | |||
| e8de00a2b1 | |||
| 762a51d6da | |||
| 5715b081a9 | |||
| c5759c0bec | |||
| f7357e4c08 | |||
| 60b2ad7959 | |||
| a94087dd98 | |||
| 99f491f16e | |||
| 815f929c2d | |||
| c37ad0708e | |||
| aa15b2b335 | |||
| 1885431574 | |||
| 3e371247d6 | |||
| d5a40e52d9 | |||
| c95c40f9e7 | |||
| 46e0c36916 | |||
| 704a5ae404 | |||
| 67b67100c1 | |||
| 01d137efcf | |||
| cea6aef453 | |||
| 746253b6c2 | |||
| 5ab371334e | |||
| 42ce579eee | |||
| 9aec082467 |
27
.github/workflows/docker-deploy.yml
vendored
Normal file
27
.github/workflows/docker-deploy.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: Deploy to Docker Hub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
run: |
|
||||
chmod +x ./build.sh
|
||||
./build.sh -t -p
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -44,3 +44,4 @@ next-env.d.ts
|
||||
|
||||
storage/
|
||||
temp/
|
||||
.claude/
|
||||
34
README.md
34
README.md
@@ -4,12 +4,40 @@
|
||||
|
||||
<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.
|
||||
|
||||
16
build.sh
16
build.sh
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
MAJOR_VERSION="2"
|
||||
MINOR_VERSION="8"
|
||||
MAJOR_VERSION="3"
|
||||
MINOR_VERSION="0"
|
||||
VERSION="$MAJOR_VERSION.$MINOR_VERSION"
|
||||
|
||||
TAG_FLAG=false
|
||||
@@ -44,9 +44,9 @@ if [ "$PUSH_FLAG" = true ]; then
|
||||
echo "alexmickelson/canvas_management:$MAJOR_VERSION"
|
||||
echo "alexmickelson/canvas_management:latest"
|
||||
|
||||
docker push alexmickelson/canvas_management:"$VERSION"
|
||||
docker push alexmickelson/canvas_management:"$MAJOR_VERSION"
|
||||
docker push alexmickelson/canvas_management:latest
|
||||
docker push -q alexmickelson/canvas_management:"$VERSION"
|
||||
docker push -q alexmickelson/canvas_management:"$MAJOR_VERSION"
|
||||
docker push -q alexmickelson/canvas_management:latest
|
||||
fi
|
||||
|
||||
if [ "$TAG_FLAG" = false ] && [ "$PUSH_FLAG" = false ]; then
|
||||
@@ -59,7 +59,7 @@ if [ "$TAG_FLAG" = false ] && [ "$PUSH_FLAG" = false ]; then
|
||||
echo "docker image tag canvas_management:$VERSION alexmickelson/canvas_management:$VERSION"
|
||||
echo "docker image tag canvas_management:$VERSION alexmickelson/canvas_management:$MAJOR_VERSION"
|
||||
echo "docker image tag canvas_management:latest alexmickelson/canvas_management:latest"
|
||||
echo "docker push alexmickelson/canvas_management:$VERSION"
|
||||
echo "docker push alexmickelson/canvas_management:$MAJOR_VERSION"
|
||||
echo "docker push alexmickelson/canvas_management:latest"
|
||||
echo "docker push -q alexmickelson/canvas_management:$VERSION"
|
||||
echo "docker push -q alexmickelson/canvas_management:$MAJOR_VERSION"
|
||||
echo "docker push -q alexmickelson/canvas_management:latest"
|
||||
fi
|
||||
|
||||
@@ -15,17 +15,20 @@ services:
|
||||
- NEXT_PUBLIC_ENABLE_FILE_SYNC=true
|
||||
- REDIS_URL=redis://redis:6379
|
||||
volumes:
|
||||
# - ./globalSettings.dev.yml:/app/globalSettings.yml
|
||||
- ./globalSettings.yml:/app/globalSettings.yml
|
||||
- .:/app
|
||||
- ~/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/faculty:/app/storage
|
||||
# - ~/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
|
||||
|
||||
redis:
|
||||
@@ -47,7 +50,7 @@ services:
|
||||
--api-key "$MCP_TOKEN" \
|
||||
--server-type "streamable_http" \
|
||||
--cors-allow-origins "*" \
|
||||
-- http://canvas-dev:3000/api/mcp
|
||||
-- http://canvas-dev:3000/api/mcp/mcp/
|
||||
'
|
||||
working_dir: /app
|
||||
ports:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
services:
|
||||
canvas_manager:
|
||||
image: alexmickelson/canvas_management:2.7
|
||||
image: alexmickelson/canvas_management:3
|
||||
user: "1000:1000"
|
||||
container_name: canvas-manager-2
|
||||
container_name: canvas-manager
|
||||
ports:
|
||||
- 3000:3000
|
||||
env_file:
|
||||
@@ -14,42 +14,20 @@ services:
|
||||
- REDIS_URL=redis://redis:6379
|
||||
# - FILE_POLLING=true
|
||||
volumes:
|
||||
# - ~/projects/faculty/3840_Telemetry/2024Spring_alex/modules:/app/storage/spring_2024_telemetry
|
||||
|
||||
# - ~/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
|
||||
|
||||
- ./globalSettings.yml:/app/globalSettings.yml
|
||||
- ~/projects/faculty:/app/storage
|
||||
- ~/projects/facultyFiles:/app/public/images/facultyFiles
|
||||
|
||||
|
||||
redis:
|
||||
image: redis
|
||||
container_name: redis
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
restart: unless-stopped
|
||||
# redis:
|
||||
# image: redis
|
||||
# container_name: redis
|
||||
# volumes:
|
||||
# - redis-data:/data
|
||||
# restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
# volumes:
|
||||
# redis-data:
|
||||
|
||||
# https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/
|
||||
# https://github.com/jonas-merkle/container-cloudflare-tunnel
|
||||
|
||||
@@ -10,13 +10,11 @@ const compat = new FlatCompat({
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
{
|
||||
ignores: ["**/node_modules/**", "**/.next/**", "storage/**"],
|
||||
},
|
||||
...compat.config({
|
||||
extends: ["next/core-web-vitals", "next/typescript", "prettier"],
|
||||
ignores: [
|
||||
"**/node_modules/**",
|
||||
"**/.next/**",
|
||||
"storage/**"
|
||||
],
|
||||
rules: {
|
||||
"react-refresh/only-export-components": "off", // Disabled the rule
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
|
||||
5
globalSettings.dev.yml
Normal file
5
globalSettings.dev.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
courses:
|
||||
- path: ./3820_BackEnd/2025-fall/Modules/
|
||||
name: Back-End
|
||||
- path: ./3820_BackEnd/2024-fall/Modules/
|
||||
name: Back-End
|
||||
23
globalSettings.yml
Normal file
23
globalSettings.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
courses:
|
||||
- path: ./1420/2025-fall-alex/modules/
|
||||
name: "1420"
|
||||
- path: ./1425/2025-fall-alex/modules/
|
||||
name: "1425"
|
||||
- path: ./4850_AdvancedFE/2026-spring-alex/modules
|
||||
name: Adv Frontend
|
||||
- path: ./1400/2026_spring_alex/modules
|
||||
name: "1400"
|
||||
- path: ./1405/2026_spring_alex
|
||||
name: "1405"
|
||||
- path: ./3840_Telemetry/2026_spring_alex
|
||||
name: Telem and Ops
|
||||
- path: ./4620_Distributed/2026-spring-alex/modules
|
||||
name: Distributed
|
||||
- path: ./4620_Distributed/2025Spring/modules/
|
||||
name: distributed-old
|
||||
- path: ./3840_Telemetry/2025_spring_alex/modules/
|
||||
name: telemetry-old
|
||||
- path: ./4850_AdvancedFE/2025-fall-alex/modules/
|
||||
name: adv-frontend-old
|
||||
- path: ./1810/2026-spring-alex/modules/
|
||||
name: Web Intro
|
||||
@@ -32,6 +32,7 @@
|
||||
"marked-katex-extension": "^5.1.5",
|
||||
"mcp-handler": "^1.0.0",
|
||||
"next": "^15.3.5",
|
||||
"pako": "^2.1.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"socket.io": "^4.8.1",
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -65,6 +65,9 @@ importers:
|
||||
next:
|
||||
specifier: ^15.3.5
|
||||
version: 15.3.5(@babel/core@7.28.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
pako:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
react:
|
||||
specifier: ^19.1.0
|
||||
version: 19.1.0
|
||||
@@ -2628,6 +2631,9 @@ packages:
|
||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
pako@2.1.0:
|
||||
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
|
||||
|
||||
parent-module@1.0.1:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -5853,6 +5859,8 @@ snapshots:
|
||||
dependencies:
|
||||
p-limit: 3.1.0
|
||||
|
||||
pako@2.1.0: {}
|
||||
|
||||
parent-module@1.0.1:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
|
||||
12
requests/module.http
Normal file
12
requests/module.http
Normal 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
|
||||
]
|
||||
}
|
||||
|
||||
@@ -30,6 +30,11 @@ Content-Type: application/json
|
||||
GET https://snow.instructure.com/api/v1/courses/958185/assignments
|
||||
Authorization: Bearer {{$dotenv CANVAS_TOKEN}}
|
||||
|
||||
|
||||
###
|
||||
GET https://snow.instructure.com/api/v1/courses/1155293/quizzes/4366122/questions
|
||||
Authorization: Bearer {{$dotenv CANVAS_TOKEN}}
|
||||
|
||||
###
|
||||
POST https://snow.instructure.com/api/v1/courses/958185/quizzes/3358912/questions
|
||||
Authorization: Bearer {{$dotenv CANVAS_TOKEN}}
|
||||
|
||||
@@ -1,41 +1,132 @@
|
||||
"use client";
|
||||
import { useLocalCoursesSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
|
||||
import { getDateKey, getTermName, groupByStartDate } from "@/models/local/utils/timeUtils";
|
||||
import { Toggle } from "@/components/form/Toggle";
|
||||
import { useLocalCoursesSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||
import {
|
||||
useGlobalSettingsQuery,
|
||||
useUpdateGlobalSettingsMutation,
|
||||
} from "@/features/local/globalSettings/globalSettingsHooks";
|
||||
import {
|
||||
getDateKey,
|
||||
getTermName,
|
||||
groupByStartDate,
|
||||
} from "@/features/local/utils/timeUtils";
|
||||
import { getCourseUrl } from "@/services/urlUtils";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import Modal, { useModal } from "@/components/Modal";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
|
||||
export default function CourseList() {
|
||||
const { data: allSettings } = useLocalCoursesSettingsQuery();
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const coursesByStartDate = groupByStartDate(allSettings);
|
||||
|
||||
const sortedDates = Object.keys(coursesByStartDate).sort();
|
||||
|
||||
return (
|
||||
<div className="flex flex-row ">
|
||||
{sortedDates.map((startDate) => (
|
||||
<div
|
||||
key={startDate}
|
||||
className=" border-4 border-slate-800 rounded p-3 m-3"
|
||||
>
|
||||
<div className="text-center">{getTermName(startDate)}</div>
|
||||
{coursesByStartDate[getDateKey(startDate)].map((settings) => (
|
||||
<div key={settings.name}>
|
||||
<Link
|
||||
href={getCourseUrl(settings.name)}
|
||||
shallow={true}
|
||||
className="
|
||||
font-bold text-xl block
|
||||
transition-all hover:scale-105 hover:underline hover:text-slate-200
|
||||
mb-3
|
||||
"
|
||||
>
|
||||
{settings.name}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<Toggle
|
||||
label={"Delete Mode"}
|
||||
value={isDeleting}
|
||||
onChange={(set) => setIsDeleting(set)}
|
||||
/>
|
||||
<div className="flex flex-row ">
|
||||
{sortedDates.map((startDate) => (
|
||||
<div
|
||||
key={startDate}
|
||||
className=" border-4 border-slate-800 rounded p-3 m-3"
|
||||
>
|
||||
<div className="text-center">{getTermName(startDate)}</div>
|
||||
{coursesByStartDate[getDateKey(startDate)].map((settings) => (
|
||||
<CourseItem
|
||||
key={settings.name}
|
||||
courseName={settings.name}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CourseItem({
|
||||
courseName,
|
||||
isDeleting,
|
||||
}: {
|
||||
courseName: string;
|
||||
isDeleting: boolean;
|
||||
}) {
|
||||
const { data: globalSettings } = useGlobalSettingsQuery();
|
||||
const updateSettingsMutation = useUpdateGlobalSettingsMutation();
|
||||
const modal = useModal();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
{isDeleting && (
|
||||
<Modal
|
||||
modalControl={modal}
|
||||
buttonText="X"
|
||||
buttonClass="
|
||||
unstyled
|
||||
text-red-200 hover:text-red-400
|
||||
bg-red-950/50 hover:bg-red-950/70
|
||||
transition-all hover:scale-110
|
||||
mb-3
|
||||
"
|
||||
modalWidth="w-1/3"
|
||||
>
|
||||
{({ closeModal }) => (
|
||||
<div>
|
||||
<div className="text-center">
|
||||
Are you sure you want to remove {courseName} from global
|
||||
settings?
|
||||
</div>
|
||||
<br />
|
||||
<div className="flex justify-around gap-3">
|
||||
<button
|
||||
onClick={async () => {
|
||||
await updateSettingsMutation.mutateAsync({
|
||||
globalSettings: {
|
||||
...globalSettings,
|
||||
courses: globalSettings.courses.filter(
|
||||
(course) => course.name !== courseName
|
||||
),
|
||||
},
|
||||
});
|
||||
closeModal();
|
||||
}}
|
||||
disabled={updateSettingsMutation.isPending}
|
||||
className="btn-danger"
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
disabled={updateSettingsMutation.isPending}
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
{updateSettingsMutation.isPending && <Spinner />}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)}
|
||||
<Link
|
||||
href={getCourseUrl(courseName)}
|
||||
shallow={true}
|
||||
prefetch={true}
|
||||
className="
|
||||
font-bold text-xl block
|
||||
transition-all hover:scale-105 hover:underline hover:text-slate-200
|
||||
mb-3
|
||||
"
|
||||
>
|
||||
{courseName}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
src/app/DataHydration.tsx
Normal file
80
src/app/DataHydration.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,30 @@
|
||||
"use client";
|
||||
import ButtonSelect from "@/components/ButtonSelect";
|
||||
import { DayOfWeekInput } from "@/components/form/DayOfWeekInput";
|
||||
import SelectInput from "@/components/form/SelectInput";
|
||||
import { StoragePathSelector } from "@/components/form/StoragePathSelector";
|
||||
import TextInput from "@/components/form/TextInput";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
import { SuspenseAndErrorHandling } from "@/components/SuspenseAndErrorHandling";
|
||||
import { useCourseListInTermQuery } from "@/hooks/canvas/canvasCourseHooks";
|
||||
import { useCanvasTermsQuery } from "@/hooks/canvas/canvasHooks";
|
||||
import {
|
||||
useCreateLocalCourseMutation,
|
||||
useLocalCoursesSettingsQuery,
|
||||
} from "@/hooks/localCourse/localCoursesHooks";
|
||||
import { useEmptyDirectoriesQuery } from "@/hooks/localCourse/storageDirectoryHooks";
|
||||
import { CanvasCourseModel } from "@/models/canvas/courses/canvasCourseModel";
|
||||
import { CanvasEnrollmentTermModel } from "@/models/canvas/enrollmentTerms/canvasEnrollmentTermModel";
|
||||
import { AssignmentSubmissionType } from "@/models/local/assignment/assignmentSubmissionType";
|
||||
} from "@/features/local/course/localCoursesHooks";
|
||||
import { CanvasCourseModel } from "@/features/canvas/models/courses/canvasCourseModel";
|
||||
import { CanvasEnrollmentTermModel } from "@/features/canvas/models/enrollmentTerms/canvasEnrollmentTermModel";
|
||||
import { AssignmentSubmissionType } from "@/features/local/assignments/models/assignmentSubmissionType";
|
||||
import { getCourseUrl } from "@/services/urlUtils";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { Dispatch, SetStateAction, useMemo, useState } from "react";
|
||||
import {
|
||||
DayOfWeek,
|
||||
LocalCourseSettings,
|
||||
} from "@/models/local/localCourseSettings";
|
||||
import { getCourseUrl } from "@/services/urlUtils";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useMemo, useState } from "react";
|
||||
} from "@/features/local/course/localCourseSettings";
|
||||
import { useCourseListInTermQuery } from "@/features/canvas/hooks/canvasCourseHooks";
|
||||
import { useCanvasTermsQuery } from "@/features/canvas/hooks/canvasHooks";
|
||||
import { useDirectoryExistsQuery } from "@/features/local/utils/storageDirectoryHooks";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const sampleCompose = `services:
|
||||
canvas_manager:
|
||||
image: alexmickelson/canvas_management:2 # pull this image regularly
|
||||
@@ -37,7 +41,7 @@ const sampleCompose = `services:
|
||||
- ~/projects/faculty/4850_AdvancedFE/2024-fall-alex/modules:/app/storage/advanced_frontend
|
||||
`;
|
||||
|
||||
export default function NewCourseForm() {
|
||||
export default function AddNewCourseToGlobalSettingsForm() {
|
||||
const router = useRouter();
|
||||
const today = useMemo(() => new Date(), []);
|
||||
const { data: canvasTerms } = useCanvasTermsQuery(today);
|
||||
@@ -54,6 +58,7 @@ export default function NewCourseForm() {
|
||||
const [courseToImport, setCourseToImport] = useState<
|
||||
LocalCourseSettings | undefined
|
||||
>();
|
||||
const [name, setName] = useState("");
|
||||
const createCourse = useCreateLocalCourseMutation();
|
||||
|
||||
const formIsComplete =
|
||||
@@ -61,12 +66,13 @@ export default function NewCourseForm() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SelectInput
|
||||
value={selectedTerm}
|
||||
setValue={setSelectedTerm}
|
||||
label={"Canvas Term"}
|
||||
<ButtonSelect
|
||||
options={canvasTerms}
|
||||
getOptionName={(t) => t.name}
|
||||
getOptionName={(t) => t?.name ?? ""}
|
||||
setValue={setSelectedTerm}
|
||||
value={selectedTerm}
|
||||
label={"Canvas Term"}
|
||||
center={true}
|
||||
/>
|
||||
<SuspenseAndErrorHandling>
|
||||
{selectedTerm && (
|
||||
@@ -80,6 +86,8 @@ export default function NewCourseForm() {
|
||||
setSelectedDaysOfWeek={setSelectedDaysOfWeek}
|
||||
courseToImport={courseToImport}
|
||||
setCourseToImport={setCourseToImport}
|
||||
name={name}
|
||||
setName={setName}
|
||||
/>
|
||||
)}
|
||||
</SuspenseAndErrorHandling>
|
||||
@@ -88,10 +96,16 @@ export default function NewCourseForm() {
|
||||
disabled={!formIsComplete || createCourse.isPending}
|
||||
onClick={async () => {
|
||||
if (formIsComplete) {
|
||||
console.log(
|
||||
"Creating course with settings:",
|
||||
selectedDirectory,
|
||||
"old course",
|
||||
courseToImport
|
||||
);
|
||||
const newSettings: LocalCourseSettings = courseToImport
|
||||
? {
|
||||
...courseToImport,
|
||||
name: selectedDirectory,
|
||||
name: name,
|
||||
daysOfWeek: selectedDaysOfWeek,
|
||||
canvasId: selectedCanvasCourse.id,
|
||||
startDate: selectedTerm.start_at ?? "",
|
||||
@@ -107,7 +121,7 @@ export default function NewCourseForm() {
|
||||
assets: [],
|
||||
}
|
||||
: {
|
||||
name: selectedDirectory,
|
||||
name: name,
|
||||
assignmentGroups: [],
|
||||
daysOfWeek: selectedDaysOfWeek,
|
||||
canvasId: selectedCanvasCourse.id,
|
||||
@@ -126,8 +140,10 @@ export default function NewCourseForm() {
|
||||
await createCourse.mutateAsync({
|
||||
settings: newSettings,
|
||||
settingsFromCourseToImport: courseToImport,
|
||||
name,
|
||||
directory: selectedDirectory,
|
||||
});
|
||||
router.push(getCourseUrl(selectedDirectory));
|
||||
router.push(getCourseUrl(name));
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -135,11 +151,6 @@ export default function NewCourseForm() {
|
||||
</button>
|
||||
</div>
|
||||
{createCourse.isPending && <Spinner />}
|
||||
|
||||
<pre>
|
||||
<div>Example docker compose</div>
|
||||
<code className="language-yml">{sampleCompose}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -148,32 +159,35 @@ function OtherSettings({
|
||||
selectedTerm,
|
||||
selectedCanvasCourse,
|
||||
setSelectedCanvasCourse,
|
||||
selectedDirectory,
|
||||
selectedDirectory: _,
|
||||
setSelectedDirectory,
|
||||
selectedDaysOfWeek,
|
||||
setSelectedDaysOfWeek,
|
||||
courseToImport,
|
||||
setCourseToImport,
|
||||
name,
|
||||
setName,
|
||||
}: {
|
||||
selectedTerm: CanvasEnrollmentTermModel;
|
||||
selectedCanvasCourse: CanvasCourseModel | undefined;
|
||||
setSelectedCanvasCourse: React.Dispatch<
|
||||
React.SetStateAction<CanvasCourseModel | undefined>
|
||||
setSelectedCanvasCourse: Dispatch<
|
||||
SetStateAction<CanvasCourseModel | undefined>
|
||||
>;
|
||||
selectedDirectory: string | undefined;
|
||||
setSelectedDirectory: React.Dispatch<
|
||||
React.SetStateAction<string | undefined>
|
||||
>;
|
||||
setSelectedDirectory: Dispatch<SetStateAction<string | undefined>>;
|
||||
selectedDaysOfWeek: DayOfWeek[];
|
||||
setSelectedDaysOfWeek: React.Dispatch<React.SetStateAction<DayOfWeek[]>>;
|
||||
setSelectedDaysOfWeek: Dispatch<SetStateAction<DayOfWeek[]>>;
|
||||
courseToImport: LocalCourseSettings | undefined;
|
||||
setCourseToImport: React.Dispatch<
|
||||
React.SetStateAction<LocalCourseSettings | undefined>
|
||||
>;
|
||||
setCourseToImport: Dispatch<SetStateAction<LocalCourseSettings | undefined>>;
|
||||
name: string;
|
||||
setName: Dispatch<SetStateAction<string>>;
|
||||
}) {
|
||||
const { data: canvasCourses } = useCourseListInTermQuery(selectedTerm.id);
|
||||
const { data: canvasCourses, isLoading: canvasCoursesLoading } =
|
||||
useCourseListInTermQuery(selectedTerm.id);
|
||||
const { data: allSettings } = useLocalCoursesSettingsQuery();
|
||||
const { data: emptyDirectories } = useEmptyDirectoriesQuery();
|
||||
const [directory, setDirectory] = useState("./");
|
||||
const { data: directoryExists, isLoading: directoryExistsLoading } =
|
||||
useDirectoryExistsQuery(directory);
|
||||
|
||||
const populatedCanvasCourseIds = allSettings?.map((s) => s.canvasId) ?? [];
|
||||
const availableCourses =
|
||||
@@ -184,24 +198,43 @@ function OtherSettings({
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectInput
|
||||
<ButtonSelect
|
||||
value={selectedCanvasCourse}
|
||||
setValue={setSelectedCanvasCourse}
|
||||
label={"Course"}
|
||||
options={availableCourses}
|
||||
getOptionName={(c) => c.name}
|
||||
getOptionName={(c) => c?.name ?? ""}
|
||||
center={true}
|
||||
/>
|
||||
<SelectInput
|
||||
value={selectedDirectory}
|
||||
setValue={setSelectedDirectory}
|
||||
{canvasCoursesLoading && <Spinner />}
|
||||
{!canvasCoursesLoading && availableCourses.length === 0 && (
|
||||
<div className="text-center text-red-300">
|
||||
<div className="flex justify-center ">
|
||||
<div className="text-left">
|
||||
No available courses in this term to add. Either
|
||||
<ol>
|
||||
<li>all courses have already been added, or</li>
|
||||
<li>there are no courses in this term</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StoragePathSelector
|
||||
value={directory}
|
||||
setValue={setDirectory}
|
||||
setLastTypedValue={setSelectedDirectory}
|
||||
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 className="text-center mt-2 min-h-6">
|
||||
{directoryExistsLoading && <Spinner />}
|
||||
{!directoryExistsLoading && directoryExists && (
|
||||
<div className="text-red-300">Directory must be a new folder</div>
|
||||
)}
|
||||
{!directoryExistsLoading && directoryExists === false && (
|
||||
<div className="text-green-300">✓ New folder</div>
|
||||
)}
|
||||
</div>
|
||||
<br />
|
||||
<div className="flex justify-center">
|
||||
@@ -225,6 +258,7 @@ function OtherSettings({
|
||||
options={allSettings}
|
||||
getOptionName={(c) => c.name}
|
||||
/>
|
||||
<TextInput value={name} setValue={setName} label={"Display Name"} />
|
||||
<div className="px-5">
|
||||
Assignments, Quizzes, Pages, and Lectures will have their due dates
|
||||
moved based on how far they are from the start of the semester.
|
||||
114
src/app/addCourse/AddExistingCourseToGlobalSettings.tsx
Normal file
114
src/app/addCourse/AddExistingCourseToGlobalSettings.tsx
Normal 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)}>
|
||||
{showForm ? "Hide Form" : "Import 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>
|
||||
);
|
||||
};
|
||||
@@ -1,24 +1,27 @@
|
||||
"use client";
|
||||
import React, { useState } from "react";
|
||||
import { SuspenseAndErrorHandling } from "@/components/SuspenseAndErrorHandling";
|
||||
import NewCourseForm from "./NewCourseForm";
|
||||
import AddNewCourseToGlobalSettingsForm from "./AddCourseToGlobalSettingsForm";
|
||||
import ClientOnly from "@/components/ClientOnly";
|
||||
|
||||
export default function AddNewCourse() {
|
||||
export default function AddCourseToGlobalSettings() {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-center">
|
||||
<button className="" onClick={() => setShowForm(true)}>
|
||||
Add New Course
|
||||
<button className="" onClick={() => setShowForm((i) => !i)}>
|
||||
{showForm ? "Hide Form" : "Add New Course"}
|
||||
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={" collapsible " + (showForm && "expand")}>
|
||||
<div className="border rounded-md p-3 m-3">
|
||||
<SuspenseAndErrorHandling>
|
||||
<ClientOnly>{showForm && <NewCourseForm />}</ClientOnly>
|
||||
<ClientOnly>
|
||||
{showForm && <AddNewCourseToGlobalSettingsForm />}
|
||||
</ClientOnly>
|
||||
</SuspenseAndErrorHandling>
|
||||
</div>
|
||||
</div>
|
||||
79
src/app/api/mcp/[transport]/github-classroom-prompt.ts
Normal file
79
src/app/api/mcp/[transport]/github-classroom-prompt.ts
Normal 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
|
||||
`
|
||||
@@ -1,10 +1,11 @@
|
||||
import { assignmentMarkdownSerializer } from "@/models/local/assignment/utils/assignmentMarkdownSerializer";
|
||||
import { LocalCourseSettings } from "@/models/local/localCourseSettings";
|
||||
import { groupByStartDate } from "@/models/local/utils/timeUtils";
|
||||
import { fileStorageService } from "@/services/fileStorage/fileStorageService";
|
||||
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { assignmentMarkdownSerializer } from "@/features/local/assignments/models/utils/assignmentMarkdownSerializer";
|
||||
import { groupByStartDate } from "@/features/local/utils/timeUtils";
|
||||
import { createMcpHandler } from "mcp-handler";
|
||||
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(
|
||||
(server) => {
|
||||
@@ -42,17 +43,17 @@ const handler = createMcpHandler(
|
||||
courseName: z.string(),
|
||||
},
|
||||
async ({ courseName }) => {
|
||||
const modules = await fileStorageService.modules.getModuleNames(
|
||||
const modules = await getModuleNamesFromFiles(
|
||||
courseName
|
||||
);
|
||||
const assignments = (
|
||||
await Promise.all(
|
||||
modules.map(async (moduleName) => {
|
||||
const assignments =
|
||||
await fileStorageService.assignments.getAssignments(
|
||||
courseName,
|
||||
moduleName
|
||||
);
|
||||
const assignments = await courseItemFileStorageService.getItems({
|
||||
courseName,
|
||||
moduleName,
|
||||
type: "Assignment",
|
||||
});
|
||||
return assignments.map((assignment) => ({
|
||||
assignmentName: assignment.name,
|
||||
moduleName,
|
||||
@@ -101,11 +102,12 @@ const handler = createMcpHandler(
|
||||
"courseName, moduleName, and assignmentName must be strings"
|
||||
);
|
||||
}
|
||||
const assignment = await fileStorageService.assignments.getAssignment(
|
||||
const assignment = await courseItemFileStorageService.getItem({
|
||||
courseName,
|
||||
moduleName,
|
||||
assignmentName
|
||||
);
|
||||
name: assignmentName,
|
||||
type: "Assignment",
|
||||
});
|
||||
|
||||
console.log("mcp assignment", assignment);
|
||||
return {
|
||||
@@ -118,42 +120,58 @@ const handler = createMcpHandler(
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
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
|
||||
);
|
||||
server.tool(
|
||||
"get_github_classroom_url_instructions",
|
||||
"gets instructions for creating a GitHub Classroom assignment, call this to get a prompt showing how to create a GitHub Classroom assignment",
|
||||
{},
|
||||
async () => {
|
||||
return {
|
||||
contents: [
|
||||
content: [
|
||||
{
|
||||
uri: uri.href,
|
||||
text: assignment.description,
|
||||
type: "text",
|
||||
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: {
|
||||
@@ -1,10 +1,8 @@
|
||||
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";
|
||||
|
||||
const handler = async (request: Request) => {
|
||||
|
||||
// await new Promise(r => setTimeout(r, 1000)); // delay for testing
|
||||
return fetchRequestHandler({
|
||||
endpoint: "/api/trpc",
|
||||
req: request,
|
||||
|
||||
@@ -1,25 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import CourseSettingsLink from "./CourseSettingsLink";
|
||||
import ModuleList from "./modules/ModuleList";
|
||||
import LeftChevron from "@/components/icons/LeftChevron";
|
||||
import RightChevron from "@/components/icons/RightChevron";
|
||||
|
||||
export default function CollapsableSidebar() {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const collapseThreshold = 1400;
|
||||
|
||||
export default function CollapsableSidebar() {
|
||||
const [windowCollapseRecommended, setWindowCollapseRecommended] =
|
||||
useState(false);
|
||||
const [userCollapsed, setUserCollapsed] = useState<
|
||||
"unset" | "collapsed" | "uncollapsed"
|
||||
>("unset");
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize on mount
|
||||
setWindowCollapseRecommended(window.innerWidth <= collapseThreshold);
|
||||
|
||||
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 (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex flex-row justify-between mb-2">
|
||||
<div className="visible mx-3 mt-2">
|
||||
<button onClick={() => setIsCollapsed((i) => !i)}>
|
||||
{isCollapsed ? <LeftChevron /> : <RightChevron />}
|
||||
<button
|
||||
onClick={() => {
|
||||
setUserCollapsed((prev) => {
|
||||
if (prev === "unset") {
|
||||
return collapsed ? "uncollapsed" : "collapsed";
|
||||
}
|
||||
return prev === "collapsed" ? "uncollapsed" : "collapsed";
|
||||
});
|
||||
}}
|
||||
>
|
||||
{collapsed ? <LeftChevron /> : <RightChevron />}
|
||||
</button>
|
||||
</div>
|
||||
<div className={" " + (isCollapsed ? "w-0 invisible hidden" : "")}>
|
||||
<div className={" " + (collapsed ? "w-0 invisible hidden" : "")}>
|
||||
<CourseSettingsLink />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
"use client";
|
||||
import { BreadCrumbs } from "@/components/BreadCrumbs";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
import {
|
||||
canvasAssignmentKeys,
|
||||
useCanvasAssignmentsQuery,
|
||||
} from "@/hooks/canvas/canvasAssignmentHooks";
|
||||
import { canvasCourseKeys } from "@/hooks/canvas/canvasCourseHooks";
|
||||
canvasAssignmentKeys,
|
||||
} from "@/features/canvas/hooks/canvasAssignmentHooks";
|
||||
import { canvasCourseKeys } from "@/features/canvas/hooks/canvasCourseHooks";
|
||||
import {
|
||||
canvasCourseModuleKeys,
|
||||
useCanvasModulesQuery,
|
||||
} from "@/hooks/canvas/canvasModuleHooks";
|
||||
canvasCourseModuleKeys,
|
||||
} from "@/features/canvas/hooks/canvasModuleHooks";
|
||||
import {
|
||||
canvasPageKeys,
|
||||
useCanvasPagesQuery,
|
||||
} from "@/hooks/canvas/canvasPageHooks";
|
||||
canvasPageKeys,
|
||||
} from "@/features/canvas/hooks/canvasPageHooks";
|
||||
import {
|
||||
canvasQuizKeys,
|
||||
useCanvasQuizzesQuery,
|
||||
} from "@/hooks/canvas/canvasQuizHooks";
|
||||
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
|
||||
canvasQuizKeys,
|
||||
} from "@/features/canvas/hooks/canvasQuizHooks";
|
||||
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
|
||||
export function CourseNavigation() {
|
||||
const { data: settings } = useLocalCourseSettingsQuery();
|
||||
@@ -33,9 +33,8 @@ export function CourseNavigation() {
|
||||
|
||||
return (
|
||||
<div className="pb-1 flex flex-row gap-3">
|
||||
<Link href={"/"} className="btn" shallow={true}>
|
||||
Back to Course List
|
||||
</Link>
|
||||
<BreadCrumbs />
|
||||
|
||||
<a
|
||||
href={`https://snow.instructure.com/courses/${settings.canvasId}`}
|
||||
className="btn"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
|
||||
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||
import Link from "next/link";
|
||||
import { useCourseContext } from "./context/courseContext";
|
||||
import { getCourseSettingsUrl } from "@/services/urlUtils";
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
import { CalendarMonthModel, getWeekNumber } from "./calendarMonthUtils";
|
||||
import { DayOfWeek } from "@/models/local/localCourseSettings";
|
||||
import { Expandable } from "@/components/Expandable";
|
||||
import { CalendarWeek } from "./CalendarWeek";
|
||||
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
|
||||
import { getDateFromStringOrThrow } from "@/models/local/utils/timeUtils";
|
||||
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||
import { getDateFromStringOrThrow } from "@/features/local/utils/timeUtils";
|
||||
import UpChevron from "@/components/icons/UpChevron";
|
||||
import DownChevron from "@/components/icons/DownChevron";
|
||||
import { DayOfWeek } from "@/features/local/course/localCourseSettings";
|
||||
|
||||
export const CalendarMonth = ({ month }: { month: CalendarMonthModel }) => {
|
||||
// const weekInMilliseconds = 604_800_000;
|
||||
@@ -29,7 +29,8 @@ export const CalendarMonth = ({ month }: { month: CalendarMonthModel }) => {
|
||||
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(
|
||||
"default",
|
||||
@@ -46,7 +47,7 @@ export const CalendarMonth = ({ month }: { month: CalendarMonthModel }) => {
|
||||
className={
|
||||
"text-2xl transition-all duration-500 " +
|
||||
"hover:text-slate-50 underline hover:scale-105 " +
|
||||
"flex "
|
||||
"flex cursor-pointer"
|
||||
}
|
||||
onClick={() => setIsExpanded((e) => !e)}
|
||||
role="button"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
|
||||
import { getDateFromStringOrThrow } from "@/models/local/utils/timeUtils";
|
||||
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||
import { getDateFromStringOrThrow } from "@/features/local/utils/timeUtils";
|
||||
import { getWeekNumber } from "./calendarMonthUtils";
|
||||
import Day from "./day/Day";
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
import { getDateFromStringOrThrow } from "@/models/local/utils/timeUtils";
|
||||
import { getDateFromStringOrThrow } from "@/features/local/utils/timeUtils";
|
||||
import { getMonthsBetweenDates } from "./calendarMonthUtils";
|
||||
import { CalendarMonth } from "./CalendarMonth";
|
||||
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
|
||||
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import CalendarItemsContextProvider from "../context/CalendarItemsContextProvider";
|
||||
|
||||
@@ -13,10 +13,11 @@ export default function CourseCalendar() {
|
||||
() => getDateFromStringOrThrow(settings.startDate, "course start date"),
|
||||
[settings.startDate]
|
||||
);
|
||||
const endDateTime = useMemo(
|
||||
() => getDateFromStringOrThrow(settings.endDate, "course end date"),
|
||||
[settings.endDate]
|
||||
);
|
||||
const endDateTime = useMemo(() => {
|
||||
const date = getDateFromStringOrThrow(settings.endDate, "course end date");
|
||||
date.setDate(date.getDate() + 14); // buffer to make sure calendar shows week of finals and grades due
|
||||
return date;
|
||||
}, [settings.endDate]);
|
||||
const months = useMemo(
|
||||
() => getMonthsBetweenDates(startDateTime, endDateTime),
|
||||
[endDateTime, startDateTime]
|
||||
@@ -43,15 +44,15 @@ export default function CourseCalendar() {
|
||||
return (
|
||||
<div
|
||||
className="
|
||||
min-h-0
|
||||
flex-grow
|
||||
border-2
|
||||
border-gray-900
|
||||
rounded-lg
|
||||
bg-linear-to-br
|
||||
from-blue-950/30
|
||||
to-fuchsia-950/10 to-60%
|
||||
sm:p-1
|
||||
min-h-0
|
||||
flex-grow
|
||||
border-2
|
||||
border-gray-900
|
||||
rounded-lg
|
||||
bg-linear-to-br
|
||||
from-blue-950/30
|
||||
to-fuchsia-950/10 to-60%
|
||||
sm:p-1
|
||||
"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
dateToMarkdownString,
|
||||
getDateFromStringOrThrow,
|
||||
} from "@/models/local/utils/timeUtils";
|
||||
} from "@/features/local/utils/timeUtils";
|
||||
|
||||
export interface CalendarMonthModel {
|
||||
year: number;
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
import {
|
||||
getDateFromStringOrThrow,
|
||||
getDateOnlyMarkdownString,
|
||||
} from "@/models/local/utils/timeUtils";
|
||||
} from "@/features/local/utils/timeUtils";
|
||||
import { useDraggingContext } from "../../context/drag/draggingContext";
|
||||
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
|
||||
import { getDayOfWeek } from "@/models/local/localCourseSettings";
|
||||
import { ItemInDay } from "./ItemInDay";
|
||||
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||
import { ItemInDay } from "./itemInDay/ItemInDay";
|
||||
import { useTodaysItems } from "./useTodaysItems";
|
||||
import { DayTitle } from "./DayTitle";
|
||||
import { getDayOfWeek } from "@/features/local/course/localCourseSettings";
|
||||
|
||||
export default function Day({ day, month }: { day: string; month: number }) {
|
||||
const dayAsDate = getDateFromStringOrThrow(
|
||||
day,
|
||||
"calculating same month in day"
|
||||
"calculating same month in day",
|
||||
);
|
||||
const isToday =
|
||||
getDateOnlyMarkdownString(new Date()) ===
|
||||
@@ -31,8 +31,8 @@ export default function Day({ day, month }: { day: string; month: number }) {
|
||||
(holidaysHappeningToday, holiday) => {
|
||||
const holidayDates = holiday.days.map((d) =>
|
||||
getDateOnlyMarkdownString(
|
||||
getDateFromStringOrThrow(d, "holiday date in day component")
|
||||
)
|
||||
getDateFromStringOrThrow(d, "holiday date in day component"),
|
||||
),
|
||||
);
|
||||
const today = getDateOnlyMarkdownString(dayAsDate);
|
||||
|
||||
@@ -40,16 +40,16 @@ export default function Day({ day, month }: { day: string; month: number }) {
|
||||
return [...holidaysHappeningToday, holiday.name];
|
||||
return holidaysHappeningToday;
|
||||
},
|
||||
[] as string[]
|
||||
[] as string[],
|
||||
);
|
||||
|
||||
const semesterStart = getDateFromStringOrThrow(
|
||||
settings.startDate,
|
||||
"comparing start date in day"
|
||||
"comparing start date in day",
|
||||
);
|
||||
const semesterEnd = getDateFromStringOrThrow(
|
||||
settings.endDate,
|
||||
"comparing end date in day"
|
||||
"comparing end date in day",
|
||||
);
|
||||
|
||||
const isInSemester = semesterStart < dayAsDate && semesterEnd > dayAsDate;
|
||||
@@ -90,7 +90,7 @@ export default function Day({ day, month }: { day: string; month: number }) {
|
||||
status={status}
|
||||
message={message}
|
||||
/>
|
||||
)
|
||||
),
|
||||
)}
|
||||
{todaysQuizzes.map(({ quiz, moduleName, status, message }) => (
|
||||
<ItemInDay
|
||||
|
||||
@@ -5,11 +5,11 @@ import { useCourseContext } from "../../context/courseContext";
|
||||
import NewItemForm from "../../modules/NewItemForm";
|
||||
import { DraggableItem } from "../../context/drag/draggingContext";
|
||||
import { useDragStyleContext } from "../../context/drag/dragStyleContext";
|
||||
import { getLectureForDay } from "@/models/local/utils/lectureUtils";
|
||||
import { useLecturesSuspenseQuery } from "@/hooks/localCourse/lectureHooks";
|
||||
import { getLectureForDay } from "@/features/local/utils/lectureUtils";
|
||||
import { useLecturesSuspenseQuery } from "@/features/local/lectures/lectureHooks";
|
||||
import ClientOnly from "@/components/ClientOnly";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTooltip } from "@/components/useTooltip";
|
||||
|
||||
export function DayTitle({ day, dayAsDate }: { day: string; dayAsDate: Date }) {
|
||||
const { courseName } = useCourseContext();
|
||||
@@ -17,8 +17,7 @@ export function DayTitle({ day, dayAsDate }: { day: string; dayAsDate: Date }) {
|
||||
const { setIsDragging } = useDragStyleContext();
|
||||
const todaysLecture = getLectureForDay(weeks, dayAsDate);
|
||||
const modal = useModal();
|
||||
const linkRef = useRef<HTMLAnchorElement>(null);
|
||||
const [tooltipVisible, setTooltipVisible] = useState(false);
|
||||
const { visible, targetRef, showTooltip, hideTooltip } = useTooltip();
|
||||
|
||||
const lectureName = todaysLecture && (todaysLecture.name || "lecture");
|
||||
|
||||
@@ -44,9 +43,9 @@ export function DayTitle({ day, dayAsDate }: { day: string; dayAsDate: Date }) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
}}
|
||||
ref={linkRef}
|
||||
onMouseEnter={() => setTooltipVisible(true)}
|
||||
onMouseLeave={() => setTooltipVisible(false)}
|
||||
ref={targetRef}
|
||||
onMouseEnter={showTooltip}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
{dayAsDate.getDate()} {lectureName}
|
||||
</Link>
|
||||
@@ -65,15 +64,40 @@ export function DayTitle({ day, dayAsDate }: { day: string; dayAsDate: Date }) {
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
targetRef={linkRef}
|
||||
visible={tooltipVisible}
|
||||
targetRef={targetRef}
|
||||
visible={visible}
|
||||
/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
<Modal
|
||||
buttonComponent={({ openModal }) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width={22}
|
||||
height={22}
|
||||
className="cursor-pointer hover:scale-125 hover:stroke-slate-400 stroke-slate-500 transition-all m-0.5"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
onClick={openModal}
|
||||
>
|
||||
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
|
||||
<g
|
||||
id="SVGRepo_tracerCarrier"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
></g>
|
||||
<g id="SVGRepo_iconCarrier">
|
||||
<path
|
||||
d="M6 12H18M12 6V18"
|
||||
className=" "
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
></path>
|
||||
</g>
|
||||
</svg>
|
||||
)}
|
||||
modalControl={modal}
|
||||
buttonText="+"
|
||||
buttonClass="unstyled hover:font-bold hover:scale-125 px-1 mb-auto mt-0 pt-0"
|
||||
modalWidth="w-135"
|
||||
>
|
||||
{({ closeModal }) => (
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { IModuleItem } from "@/models/local/IModuleItem";
|
||||
import { getModuleItemUrl } from "@/services/urlUtils";
|
||||
import Link from "next/link";
|
||||
import { ReactNode, useRef, useState } from "react";
|
||||
import { useCourseContext } from "../../context/courseContext";
|
||||
import { DraggableItem } from "../../context/drag/draggingContext";
|
||||
import ClientOnly from "@/components/ClientOnly";
|
||||
import { useDragStyleContext } from "../../context/drag/dragStyleContext";
|
||||
import { Tooltip } from "../../../../../components/Tooltip";
|
||||
|
||||
export function ItemInDay({
|
||||
type,
|
||||
moduleName,
|
||||
status,
|
||||
item,
|
||||
message,
|
||||
}: {
|
||||
type: "assignment" | "page" | "quiz";
|
||||
status: "localOnly" | "incomplete" | "published";
|
||||
moduleName: string;
|
||||
item: IModuleItem;
|
||||
message: ReactNode;
|
||||
}) {
|
||||
const { courseName } = useCourseContext();
|
||||
const { setIsDragging } = useDragStyleContext();
|
||||
const linkRef = useRef<HTMLAnchorElement>(null);
|
||||
const [tooltipVisible, setTooltipVisible] = useState(false);
|
||||
return (
|
||||
<div className={" relative group "}>
|
||||
<Link
|
||||
href={getModuleItemUrl(courseName, moduleName, type, item.name)}
|
||||
shallow={true}
|
||||
className={
|
||||
" border rounded-sm sm:px-1 sm:mx-1 break-words mb-1 truncate sm:text-wrap text-nowrap " +
|
||||
" bg-slate-800 " +
|
||||
" block " +
|
||||
(status === "localOnly" && " text-slate-500 border-slate-600 ") +
|
||||
(status === "incomplete" && " border-rose-900 ") +
|
||||
(status === "published" && " border-green-800 ")
|
||||
}
|
||||
role="button"
|
||||
draggable="true"
|
||||
onDragStart={(e) => {
|
||||
const draggableItem: DraggableItem = {
|
||||
type,
|
||||
item,
|
||||
sourceModuleName: moduleName,
|
||||
};
|
||||
e.dataTransfer.setData(
|
||||
"draggableItem",
|
||||
JSON.stringify(draggableItem)
|
||||
);
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onMouseEnter={() => setTooltipVisible(true)}
|
||||
onMouseLeave={() => setTooltipVisible(false)}
|
||||
ref={linkRef}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
<ClientOnly>
|
||||
<Tooltip
|
||||
message={message}
|
||||
targetRef={linkRef}
|
||||
visible={tooltipVisible && status === "incomplete"}
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
"use client";
|
||||
import { CanvasAssignment } from "@/models/canvas/assignments/canvasAssignment";
|
||||
import { CanvasPage } from "@/models/canvas/pages/canvasPageModel";
|
||||
import { CanvasQuiz } from "@/models/canvas/quizzes/canvasQuizModel";
|
||||
import { LocalAssignment } from "@/models/local/assignment/localAssignment";
|
||||
import { LocalCourseSettings } from "@/models/local/localCourseSettings";
|
||||
import { LocalCoursePage } from "@/models/local/page/localCoursePage";
|
||||
import { LocalQuiz } from "@/models/local/quiz/localQuiz";
|
||||
import { CanvasAssignment } from "@/features/canvas/models/assignments/canvasAssignment";
|
||||
import { CanvasPage } from "@/features/canvas/models/pages/canvasPageModel";
|
||||
import { CanvasQuiz } from "@/features/canvas/models/quizzes/canvasQuizModel";
|
||||
import { LocalAssignment } from "@/features/local/assignments/models/localAssignment";
|
||||
import {
|
||||
dateToMarkdownString,
|
||||
getDateFromStringOrThrow,
|
||||
} from "@/models/local/utils/timeUtils";
|
||||
} from "@/features/local/utils/timeUtils";
|
||||
import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils";
|
||||
import { htmlIsCloseEnough } from "@/services/utils/htmlIsCloseEnough";
|
||||
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 = ({
|
||||
item,
|
||||
@@ -105,7 +105,16 @@ export const getStatus = ({
|
||||
|
||||
try {
|
||||
const htmlIsSame = htmlIsCloseEnough(
|
||||
markdownToHTMLSafe(assignment.description, settings),
|
||||
markdownToHTMLSafe({
|
||||
markdownString: assignment.description,
|
||||
settings,
|
||||
replaceText: [
|
||||
{
|
||||
source: "insert_github_classroom_url",
|
||||
destination: assignment.githubClassroomAssignmentShareLink || "",
|
||||
},
|
||||
],
|
||||
}),
|
||||
canvasAssignment.description
|
||||
);
|
||||
if (!htmlIsSame)
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
import { useEffect, FC, useState } from "react";
|
||||
import { IModuleItem } from "@/features/local/modules/IModuleItem";
|
||||
import { LocalAssignment } from "@/features/local/assignments/models/localAssignment";
|
||||
import { useCalendarItemsContext } from "../../../context/calendarItemsContext";
|
||||
import {
|
||||
useCreateAssignmentMutation,
|
||||
useDeleteAssignmentMutation,
|
||||
} from "@/features/local/assignments/assignmentHooks";
|
||||
import {
|
||||
useCanvasAssignmentsQuery,
|
||||
useUpdateAssignmentInCanvasMutation,
|
||||
useDeleteAssignmentFromCanvasMutation,
|
||||
useAddAssignmentToCanvasMutation,
|
||||
canvasAssignmentKeys,
|
||||
} from "@/features/canvas/hooks/canvasAssignmentHooks";
|
||||
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||
import { baseCanvasUrl } from "@/features/canvas/services/canvasServiceUtils";
|
||||
import { useCourseContext } from "../../../context/courseContext";
|
||||
import Modal, { ModalControl } from "@/components/Modal";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
function getDuplicateName(name: string, existingNames: string[]): string {
|
||||
const match = name.match(/^(.*)\s+(\d+)$/);
|
||||
const baseName = match ? match[1] : name;
|
||||
const startNum = match ? parseInt(match[2]) + 1 : 2;
|
||||
let num = startNum;
|
||||
while (existingNames.includes(`${baseName} ${num}`)) {
|
||||
num++;
|
||||
}
|
||||
return `${baseName} ${num}`;
|
||||
}
|
||||
|
||||
export const AssignmentDayItemContextMenu: FC<{
|
||||
modalControl: ModalControl;
|
||||
item: IModuleItem;
|
||||
moduleName: string;
|
||||
}> = ({ modalControl, item, moduleName }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { courseName } = useCourseContext();
|
||||
const calendarItems = useCalendarItemsContext();
|
||||
const createAssignmentMutation = useCreateAssignmentMutation();
|
||||
const deleteLocalMutation = useDeleteAssignmentMutation();
|
||||
const updateInCanvasMutation = useUpdateAssignmentInCanvasMutation();
|
||||
const deleteFromCanvasMutation = useDeleteAssignmentFromCanvasMutation();
|
||||
const addToCanvasMutation = useAddAssignmentToCanvasMutation();
|
||||
const { data: canvasAssignments } = useCanvasAssignmentsQuery();
|
||||
const { data: settings } = useLocalCourseSettingsQuery();
|
||||
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
|
||||
const assignmentInCanvas = canvasAssignments?.find(
|
||||
(a) => a.name === item.name,
|
||||
);
|
||||
|
||||
const canvasUrl = assignmentInCanvas
|
||||
? `${baseCanvasUrl}/courses/${settings.canvasId}/assignments/${assignmentInCanvas.id}`
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setConfirmingDelete(false);
|
||||
modalControl.closeModal();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [modalControl]);
|
||||
|
||||
const handleClose = () => {
|
||||
for (let i = 1; i <= 8; i += 2) {
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: canvasAssignmentKeys.assignments(settings.canvasId),
|
||||
});
|
||||
}, i * 1000);
|
||||
}
|
||||
setConfirmingDelete(false);
|
||||
modalControl.closeModal();
|
||||
};
|
||||
|
||||
const handleDuplicate = () => {
|
||||
const assignment = item as LocalAssignment;
|
||||
const existingNames = Object.values(calendarItems).flatMap((modules) =>
|
||||
(modules[moduleName]?.assignments ?? []).map((a) => a.name),
|
||||
);
|
||||
const newName = getDuplicateName(item.name, existingNames);
|
||||
createAssignmentMutation.mutate({
|
||||
courseName,
|
||||
moduleName,
|
||||
assignmentName: newName,
|
||||
assignment: { ...assignment, name: newName },
|
||||
});
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteLocalMutation.mutate({
|
||||
courseName,
|
||||
moduleName,
|
||||
assignmentName: item.name,
|
||||
});
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleUpdateCanvas = () => {
|
||||
if (assignmentInCanvas) {
|
||||
updateInCanvasMutation.mutate({
|
||||
canvasAssignmentId: assignmentInCanvas.id,
|
||||
assignment: item as LocalAssignment,
|
||||
});
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFromCanvas = () => {
|
||||
if (assignmentInCanvas) {
|
||||
deleteFromCanvasMutation.mutate({
|
||||
canvasAssignmentId: assignmentInCanvas.id,
|
||||
assignmentName: item.name,
|
||||
});
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToCanvas = () => {
|
||||
addToCanvasMutation.mutate({
|
||||
assignment: item as LocalAssignment,
|
||||
moduleName,
|
||||
});
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const baseButtonClasses = " font-bold text-left py-1";
|
||||
const normalButtonClass =
|
||||
"hover:bg-blue-900 disabled:opacity-50 bg-blue-900/50 text-blue-50 border border-blue-800/70 rounded ";
|
||||
const dangerClasses =
|
||||
"bg-rose-900/30 hover:bg-rose-950 disabled:opacity-50 text-rose-50 border border-rose-900/40 rounded";
|
||||
return (
|
||||
<Modal modalControl={modalControl} backgroundCoverColor="bg-black/30">
|
||||
{() => (
|
||||
<div className="p-2">
|
||||
<div className="text-center p-1 text-slate-200 ">{item.name}</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{confirmingDelete ? (
|
||||
<>
|
||||
<div className={``}>Delete from disk?</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className={`unstyled ${baseButtonClasses} ${normalButtonClass}`}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className={`unstyled ${baseButtonClasses} ${dangerClasses}`}
|
||||
>
|
||||
Yes, delete
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{canvasUrl && (
|
||||
<>
|
||||
<a
|
||||
href={canvasUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={` block px-3 ${baseButtonClasses} ${normalButtonClass}`}
|
||||
onClick={handleClose}
|
||||
>
|
||||
View in Canvas
|
||||
</a>
|
||||
<button
|
||||
onClick={handleUpdateCanvas}
|
||||
disabled={updateInCanvasMutation.isPending}
|
||||
className={`unstyled ${baseButtonClasses} ${normalButtonClass}`}
|
||||
>
|
||||
Update in Canvas
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteFromCanvas}
|
||||
disabled={deleteFromCanvasMutation.isPending}
|
||||
className={`unstyled ${baseButtonClasses} ${dangerClasses}`}
|
||||
>
|
||||
Delete from Canvas
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{!canvasUrl && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleAddToCanvas}
|
||||
disabled={addToCanvasMutation.isPending}
|
||||
className={`unstyled ${baseButtonClasses} ${normalButtonClass}`}
|
||||
>
|
||||
Add to Canvas
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
className={`unstyled ${baseButtonClasses} ${dangerClasses}`}
|
||||
>
|
||||
Delete from Disk
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={handleDuplicate}
|
||||
className={`unstyled ${baseButtonClasses} ${normalButtonClass}`}
|
||||
>
|
||||
Duplicate
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
import MarkdownDisplay from "@/components/MarkdownDisplay";
|
||||
import { IModuleItem } from "@/features/local/modules/IModuleItem";
|
||||
import { FC } from "react";
|
||||
|
||||
export const GetPreviewContent: FC<{
|
||||
type: "assignment" | "page" | "quiz";
|
||||
item: IModuleItem;
|
||||
}> = ({ type, item }) => {
|
||||
if (type === "assignment" && "description" in item) {
|
||||
const assignment = item as {
|
||||
description: string;
|
||||
githubClassroomAssignmentShareLink?: string;
|
||||
};
|
||||
return (
|
||||
<MarkdownDisplay
|
||||
markdown={assignment.description}
|
||||
replaceText={[
|
||||
{
|
||||
source: "insert_github_classroom_url",
|
||||
destination: assignment.githubClassroomAssignmentShareLink || "",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
} else if (type === "page" && "text" in item) {
|
||||
return <MarkdownDisplay markdown={item.text as string} />;
|
||||
} else if (type === "quiz" && "questions" in item) {
|
||||
const quiz = item as { questions: { text: string }[] };
|
||||
return quiz.questions.map((q, i: number) => (
|
||||
<div key={i} className="">
|
||||
<MarkdownDisplay markdown={q.text as string} />
|
||||
</div>
|
||||
));
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
import { IModuleItem } from "@/features/local/modules/IModuleItem";
|
||||
import { getModuleItemUrl } from "@/services/urlUtils";
|
||||
import Link from "next/link";
|
||||
import { FC, ReactNode } from "react";
|
||||
import { useCourseContext } from "../../../context/courseContext";
|
||||
import { useTooltip } from "@/components/useTooltip";
|
||||
import { DraggableItem } from "../../../context/drag/draggingContext";
|
||||
import ClientOnly from "@/components/ClientOnly";
|
||||
import { useDragStyleContext } from "../../../context/drag/dragStyleContext";
|
||||
import { Tooltip } from "../../../../../../components/Tooltip";
|
||||
import { AssignmentDayItemContextMenu } from "./DayItemContextMenu";
|
||||
import { GetPreviewContent } from "./GetPreviewContent";
|
||||
import { useModal } from "@/components/Modal";
|
||||
|
||||
export const ItemInDay: FC<{
|
||||
type: "assignment" | "page" | "quiz";
|
||||
status: "localOnly" | "incomplete" | "published";
|
||||
moduleName: string;
|
||||
item: IModuleItem;
|
||||
message: ReactNode;
|
||||
}> = ({ type, moduleName, status, item, message }) => {
|
||||
const { courseName } = useCourseContext();
|
||||
const { setIsDragging } = useDragStyleContext();
|
||||
const { visible, targetRef, showTooltip, hideTooltip } = useTooltip(500);
|
||||
const modalControl = useModal();
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
if (type !== "assignment") return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
modalControl.openModal({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={" relative group "}>
|
||||
<Link
|
||||
href={getModuleItemUrl(courseName, moduleName, type, item.name)}
|
||||
shallow={true}
|
||||
className={
|
||||
" border rounded-sm sm:px-1 sm:mx-1 break-words mb-1 truncate sm:text-wrap text-nowrap " +
|
||||
" bg-slate-800 " +
|
||||
" block " +
|
||||
(status === "localOnly" && " text-slate-500 border-slate-600 ") +
|
||||
(status === "incomplete" && " border-rose-900 ") +
|
||||
(status === "published" && " border-green-800 ")
|
||||
}
|
||||
role="button"
|
||||
draggable="true"
|
||||
onDragStart={(e) => {
|
||||
const draggableItem: DraggableItem = {
|
||||
type,
|
||||
item,
|
||||
sourceModuleName: moduleName,
|
||||
};
|
||||
e.dataTransfer.setData(
|
||||
"draggableItem",
|
||||
JSON.stringify(draggableItem),
|
||||
);
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onMouseEnter={showTooltip}
|
||||
onMouseLeave={hideTooltip}
|
||||
onContextMenu={handleContextMenu}
|
||||
ref={targetRef}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
<ClientOnly>
|
||||
{status === "published" ? (
|
||||
<Tooltip
|
||||
message={
|
||||
<div className="max-w-md">
|
||||
<GetPreviewContent type={type} item={item} />
|
||||
</div>
|
||||
}
|
||||
targetRef={targetRef}
|
||||
visible={visible}
|
||||
/>
|
||||
) : (
|
||||
<Tooltip message={message} targetRef={targetRef} visible={visible} />
|
||||
)}
|
||||
{type === "assignment" && (
|
||||
<AssignmentDayItemContextMenu
|
||||
modalControl={modalControl}
|
||||
item={item}
|
||||
moduleName={moduleName}
|
||||
/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,18 +1,19 @@
|
||||
"use client";
|
||||
import { useCanvasAssignmentsQuery } from "@/hooks/canvas/canvasAssignmentHooks";
|
||||
import { useCanvasPagesQuery } from "@/hooks/canvas/canvasPageHooks";
|
||||
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 { LocalAssignment } from "@/features/local/assignments/models/localAssignment";
|
||||
import {
|
||||
getDateFromStringOrThrow,
|
||||
getDateOnlyMarkdownString,
|
||||
} from "@/models/local/utils/timeUtils";
|
||||
} from "@/features/local/utils/timeUtils";
|
||||
import { ReactNode } from "react";
|
||||
import { useCalendarItemsContext } from "../../context/calendarItemsContext";
|
||||
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) {
|
||||
const { data: settings } = useLocalCourseSettingsQuery();
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ItemNavigationButtons({
|
||||
previousUrl,
|
||||
nextUrl,
|
||||
}: {
|
||||
previousUrl: string | null;
|
||||
nextUrl: string | null;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{previousUrl && (
|
||||
<Link className="btn" href={previousUrl} shallow={true}>
|
||||
Previous
|
||||
</Link>
|
||||
)}
|
||||
{nextUrl && (
|
||||
<Link className="btn" href={nextUrl} shallow={true}>
|
||||
Next
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client"
|
||||
"use client";
|
||||
import { ReactNode } from "react";
|
||||
import {
|
||||
CalendarItemsContext,
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
useCourseQuizzesByModuleByDateQuery,
|
||||
useCourseAssignmentsByModuleByDateQuery,
|
||||
useCoursePagesByModuleByDateQuery,
|
||||
} from "@/hooks/localCourse/localCourseModuleHooks";
|
||||
} from "@/features/local/modules/localCourseModuleHooks";
|
||||
|
||||
export default function CalendarItemsContextProvider({
|
||||
children,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LocalAssignment } from "@/models/local/assignment/localAssignment";
|
||||
import { LocalCoursePage } from "@/models/local/page/localCoursePage";
|
||||
import { LocalQuiz } from "@/models/local/quiz/localQuiz";
|
||||
import { LocalAssignment } from "@/features/local/assignments/models/localAssignment";
|
||||
import { LocalCoursePage } from "@/features/local/pages/localCoursePageModels";
|
||||
import { LocalQuiz } from "@/features/local/quizzes/models/localQuiz";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
export interface CalendarItemsInterface {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import { IModuleItem } from "@/models/local/IModuleItem";
|
||||
import { IModuleItem } from "@/features/local/modules/IModuleItem";
|
||||
import { createContext, useContext, DragEvent } from "react";
|
||||
|
||||
export interface DraggableItem {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
import { getDateFromStringOrThrow, dateToMarkdownString } from "@/models/local/utils/timeUtils";
|
||||
|
||||
import {
|
||||
getDateFromStringOrThrow,
|
||||
dateToMarkdownString,
|
||||
} from "@/features/local/utils/timeUtils";
|
||||
|
||||
export function getNewLockDate(
|
||||
originalDueDate: string,
|
||||
@@ -9,15 +11,18 @@ export function getNewLockDate(
|
||||
): string | undefined {
|
||||
// todo: preserve previous due date / lock date offset
|
||||
const dueDate = getDateFromStringOrThrow(originalDueDate, "dueAt date");
|
||||
const lockDate = originalLockDate === undefined
|
||||
? undefined
|
||||
: getDateFromStringOrThrow(originalLockDate, "lockAt date");
|
||||
const lockDate =
|
||||
originalLockDate === undefined
|
||||
? undefined
|
||||
: 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
|
||||
? undefined
|
||||
: new Date(dayAsDate.getTime() + originalOffset);
|
||||
const newLockDate =
|
||||
originalOffset === undefined
|
||||
? undefined
|
||||
: new Date(dayAsDate.getTime() + originalOffset);
|
||||
|
||||
return newLockDate === undefined
|
||||
? undefined
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
"use client";
|
||||
import { useUpdateAssignmentMutation } from "@/hooks/localCourse/assignmentHooks";
|
||||
import {
|
||||
useLecturesSuspenseQuery,
|
||||
useLectureUpdateMutation,
|
||||
} from "@/hooks/localCourse/lectureHooks";
|
||||
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
|
||||
import { useUpdatePageMutation } from "@/hooks/localCourse/pageHooks";
|
||||
import { LocalAssignment } from "@/models/local/assignment/localAssignment";
|
||||
import { Lecture } from "@/models/local/lecture";
|
||||
import { getLectureForDay } from "@/models/local/utils/lectureUtils";
|
||||
import { LocalCoursePage } from "@/models/local/page/localCoursePage";
|
||||
import { LocalQuiz } from "@/models/local/quiz/localQuiz";
|
||||
} from "@/features/local/lectures/lectureHooks";
|
||||
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||
import { useUpdatePageMutation } from "@/features/local/pages/pageHooks";
|
||||
import { LocalAssignment } from "@/features/local/assignments/models/localAssignment";
|
||||
import { Lecture } from "@/features/local/lectures/lectureModel";
|
||||
import { getLectureForDay } from "@/features/local/utils/lectureUtils";
|
||||
import {
|
||||
getDateFromStringOrThrow,
|
||||
getDateOnlyMarkdownString,
|
||||
dateToMarkdownString,
|
||||
} from "@/models/local/utils/timeUtils";
|
||||
} from "@/features/local/utils/timeUtils";
|
||||
import { Dispatch, SetStateAction, useCallback, DragEvent } from "react";
|
||||
import { DraggableItem } from "./draggingContext";
|
||||
import { getNewLockDate } from "./getNewLockDate";
|
||||
import { useUpdateQuizMutation } from "@/hooks/localCourse/quizHooks";
|
||||
import { useUpdateQuizMutation } from "@/features/local/quizzes/quizHooks";
|
||||
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({
|
||||
setIsDragging,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
import { useUpdateAssignmentMutation } from "@/hooks/localCourse/assignmentHooks";
|
||||
import { useUpdatePageMutation } from "@/hooks/localCourse/pageHooks";
|
||||
import { LocalAssignment } from "@/models/local/assignment/localAssignment";
|
||||
import { LocalCoursePage } from "@/models/local/page/localCoursePage";
|
||||
import { LocalQuiz } from "@/models/local/quiz/localQuiz";
|
||||
import { useUpdatePageMutation } from "@/features/local/pages/pageHooks";
|
||||
import { LocalAssignment } from "@/features/local/assignments/models/localAssignment";
|
||||
import { Dispatch, SetStateAction, useCallback, DragEvent } from "react";
|
||||
import { DraggableItem } from "./draggingContext";
|
||||
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({
|
||||
setIsDragging,
|
||||
|
||||
70
src/app/course/[courseName]/hooks/navigationLogic.test.ts
Normal file
70
src/app/course/[courseName]/hooks/navigationLogic.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
getOrderedItems,
|
||||
getOrderedLectures,
|
||||
getNavigationLinks,
|
||||
OrderedCourseItem,
|
||||
} from "./navigationLogic";
|
||||
import { CalendarItemsInterface } from "../context/calendarItemsContext";
|
||||
|
||||
describe("navigationLogic", () => {
|
||||
const courseName = "testCourse";
|
||||
|
||||
it("getOrderedItems should order items by date, then alphabetically by name", () => {
|
||||
const createMock = (
|
||||
date: string,
|
||||
name: string,
|
||||
key: "assignments" | "quizzes" | "pages"
|
||||
) =>
|
||||
({
|
||||
[date]: { "Module 1": { [key]: [{ name }] } },
|
||||
} as unknown as CalendarItemsInterface);
|
||||
|
||||
const orderedItems = getOrderedItems(
|
||||
courseName,
|
||||
createMock("2023-01-01", "Z Assignment", "assignments"),
|
||||
createMock("2023-01-01", "A Quiz", "quizzes"),
|
||||
createMock("2023-01-02", "B Assignment", "assignments"),
|
||||
createMock("2023-01-02", "A Page", "pages")
|
||||
);
|
||||
|
||||
expect(orderedItems.map((i) => `${i.date} ${i.name}`)).toEqual([
|
||||
"2023-01-01 A Quiz",
|
||||
"2023-01-01 Z Assignment",
|
||||
"2023-01-02 A Page",
|
||||
"2023-01-02 B Assignment",
|
||||
]);
|
||||
});
|
||||
|
||||
it("getNavigationLinks should handle wrapping and normal navigation", () => {
|
||||
const items: OrderedCourseItem[] = [
|
||||
{ type: "assignment", name: "1", moduleName: "M", date: "D", url: "u1" },
|
||||
{ type: "quiz", name: "2", moduleName: "M", date: "D", url: "u2" },
|
||||
{ type: "page", name: "3", moduleName: "M", date: "D", url: "u3" },
|
||||
];
|
||||
|
||||
// Forward wrap (last -> first)
|
||||
expect(getNavigationLinks(items, "page", "3", "M").nextUrl).toBe("u1");
|
||||
|
||||
// Backward wrap (first -> last)
|
||||
expect(getNavigationLinks(items, "assignment", "1", "M").previousUrl).toBe(
|
||||
"u3"
|
||||
);
|
||||
|
||||
// Normal navigation (middle)
|
||||
const middle = getNavigationLinks(items, "quiz", "2", "M");
|
||||
expect(middle.previousUrl).toBe("u1");
|
||||
expect(middle.nextUrl).toBe("u3");
|
||||
});
|
||||
|
||||
it("getOrderedLectures should flatten weeks and generate correct URLs", () => {
|
||||
const weeks = [
|
||||
{ lectures: [{ date: "01/01/2023" }] },
|
||||
{ lectures: [{ date: "01/02/2023" }, { date: "01/03/2023" }] },
|
||||
];
|
||||
const lectures = getOrderedLectures(weeks, courseName);
|
||||
expect(lectures).toHaveLength(3);
|
||||
expect(lectures[0].url).toContain(encodeURIComponent("01/01/2023"));
|
||||
expect(lectures[0].type).toBe("lecture");
|
||||
});
|
||||
});
|
||||
83
src/app/course/[courseName]/hooks/navigationLogic.ts
Normal file
83
src/app/course/[courseName]/hooks/navigationLogic.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { CalendarItemsInterface } from "../context/calendarItemsContext";
|
||||
import { getLectureUrl, getModuleItemUrl } from "@/services/urlUtils";
|
||||
|
||||
export type CourseItemType = "assignment" | "quiz" | "page" | "lecture";
|
||||
|
||||
export interface OrderedCourseItem {
|
||||
type: CourseItemType;
|
||||
name: string;
|
||||
moduleName?: string;
|
||||
date: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export function getOrderedItems(
|
||||
courseName: string,
|
||||
...calendars: CalendarItemsInterface[]
|
||||
): OrderedCourseItem[] {
|
||||
const itemTypes = [
|
||||
{ key: "assignments" as const, type: "assignment" as const },
|
||||
{ key: "quizzes" as const, type: "quiz" as const },
|
||||
{ key: "pages" as const, type: "page" as const },
|
||||
];
|
||||
|
||||
return calendars
|
||||
.flatMap((calendar) =>
|
||||
Object.entries(calendar).flatMap(([date, modules]) =>
|
||||
Object.entries(modules).flatMap(([moduleName, moduleData]) =>
|
||||
itemTypes.flatMap(({ key, type }) =>
|
||||
(moduleData[key] || []).map((item) => ({
|
||||
type,
|
||||
name: item.name,
|
||||
moduleName,
|
||||
date,
|
||||
url: getModuleItemUrl(courseName, moduleName, type, item.name),
|
||||
}))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const dateCompare = a.date.localeCompare(b.date);
|
||||
if (dateCompare !== 0) return dateCompare;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
export function getOrderedLectures(
|
||||
weeks: { lectures: { date: string }[] }[],
|
||||
courseName: string
|
||||
): OrderedCourseItem[] {
|
||||
return weeks
|
||||
.flatMap((week) => week.lectures)
|
||||
.map((lecture) => ({
|
||||
type: "lecture",
|
||||
name: lecture.date,
|
||||
date: lecture.date,
|
||||
url: getLectureUrl(courseName, lecture.date),
|
||||
}));
|
||||
}
|
||||
|
||||
export function getNavigationLinks(
|
||||
list: OrderedCourseItem[],
|
||||
type: CourseItemType,
|
||||
name: string,
|
||||
moduleName?: string
|
||||
) {
|
||||
const index = list.findIndex((item) => {
|
||||
if (type === "lecture") return item.date === name;
|
||||
return (
|
||||
item.name === name && item.type === type && item.moduleName === moduleName
|
||||
);
|
||||
});
|
||||
|
||||
if (index === -1) return { previousUrl: null, nextUrl: null };
|
||||
|
||||
const previousIndex = (index - 1 + list.length) % list.length;
|
||||
const nextIndex = (index + 1) % list.length;
|
||||
|
||||
return {
|
||||
previousUrl: list[previousIndex].url,
|
||||
nextUrl: list[nextIndex].url,
|
||||
};
|
||||
}
|
||||
14
src/app/course/[courseName]/hooks/useItemNavigation.ts
Normal file
14
src/app/course/[courseName]/hooks/useItemNavigation.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useOrderedCourseItems } from "./useOrderedCourseItems";
|
||||
import { getNavigationLinks, CourseItemType } from "./navigationLogic";
|
||||
|
||||
export function useItemNavigation(
|
||||
type: CourseItemType,
|
||||
name: string,
|
||||
moduleName?: string
|
||||
) {
|
||||
const { orderedItems, orderedLectures } = useOrderedCourseItems();
|
||||
|
||||
const list = type === "lecture" ? orderedLectures : orderedItems;
|
||||
|
||||
return getNavigationLinks(list, type, name, moduleName);
|
||||
}
|
||||
24
src/app/course/[courseName]/hooks/useOrderedCourseItems.ts
Normal file
24
src/app/course/[courseName]/hooks/useOrderedCourseItems.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
useCourseAssignmentsByModuleByDateQuery,
|
||||
useCoursePagesByModuleByDateQuery,
|
||||
useCourseQuizzesByModuleByDateQuery,
|
||||
} from "@/features/local/modules/localCourseModuleHooks";
|
||||
import { useLecturesSuspenseQuery } from "@/features/local/lectures/lectureHooks";
|
||||
import { useCourseContext } from "../context/courseContext";
|
||||
import { getOrderedItems, getOrderedLectures } from "./navigationLogic";
|
||||
|
||||
export function useOrderedCourseItems() {
|
||||
const { courseName } = useCourseContext();
|
||||
const { data: weeks } = useLecturesSuspenseQuery();
|
||||
|
||||
const orderedItems = getOrderedItems(
|
||||
courseName,
|
||||
useCourseAssignmentsByModuleByDateQuery(),
|
||||
useCourseQuizzesByModuleByDateQuery(),
|
||||
useCoursePagesByModuleByDateQuery()
|
||||
);
|
||||
|
||||
const orderedLectures = getOrderedLectures(weeks, courseName);
|
||||
|
||||
return { orderedItems, orderedLectures };
|
||||
}
|
||||
@@ -9,8 +9,9 @@ export async function generateMetadata({
|
||||
params: Promise<{ courseName: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { courseName } = await params;
|
||||
const decodedCourseName = decodeURIComponent(getTitle(courseName));
|
||||
return {
|
||||
title: getTitle(courseName),
|
||||
title: decodedCourseName,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,18 +3,18 @@ import { MonacoEditor } from "@/components/editor/MonacoEditor";
|
||||
import {
|
||||
useLecturesSuspenseQuery,
|
||||
useLectureUpdateMutation,
|
||||
} from "@/hooks/localCourse/lectureHooks";
|
||||
} from "@/features/local/lectures/lectureHooks";
|
||||
import {
|
||||
lectureToString,
|
||||
parseLecture,
|
||||
} from "@/services/fileStorage/utils/lectureUtils";
|
||||
} from "@/features/local/lectures/lectureUtils";
|
||||
import { useEffect, useState } from "react";
|
||||
import LecturePreview from "./LecturePreview";
|
||||
import EditLectureTitle from "./EditLectureTitle";
|
||||
import LectureButtons from "./LectureButtons";
|
||||
import { useCourseContext } from "../../context/courseContext";
|
||||
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
|
||||
import { Lecture } from "@/models/local/lecture";
|
||||
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||
import { Lecture } from "@/features/local/lectures/lectureModel";
|
||||
import { useAuthoritativeUpdates } from "../../utils/useAuthoritativeUpdates";
|
||||
import { EditLayout } from "@/components/EditLayout";
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
|
||||
import { getDayOfWeek } from "@/models/local/localCourseSettings";
|
||||
import { getDateFromString } from "@/models/local/utils/timeUtils";
|
||||
import { getLectureWeekName } from "@/services/fileStorage/utils/lectureUtils";
|
||||
import { getCourseUrl, getLecturePreviewUrl } from "@/services/urlUtils";
|
||||
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||
import { getDateFromString } from "@/features/local/utils/timeUtils";
|
||||
import { getLectureWeekName } from "@/features/local/lectures/lectureUtils";
|
||||
import { getLecturePreviewUrl } from "@/services/urlUtils";
|
||||
import { useCourseContext } from "../../context/courseContext";
|
||||
import Link from "next/link";
|
||||
import { getDayOfWeek } from "@/features/local/course/localCourseSettings";
|
||||
import { BreadCrumbs } from "@/components/BreadCrumbs";
|
||||
|
||||
export default function EditLectureTitle({
|
||||
lectureDay,
|
||||
@@ -17,15 +18,7 @@ export default function EditLectureTitle({
|
||||
const lectureWeekName = getLectureWeekName(settings.startDate, lectureDay);
|
||||
return (
|
||||
<div className="flex justify-between sm:flex-row flex-col">
|
||||
<div className="my-auto">
|
||||
<Link
|
||||
className="btn hidden sm:inline"
|
||||
href={getCourseUrl(courseName)}
|
||||
shallow={true}
|
||||
>
|
||||
{courseName}
|
||||
</Link>
|
||||
</div>
|
||||
<BreadCrumbs />
|
||||
<div className="flex justify-center ">
|
||||
<h3 className="mt-auto me-3 text-slate-500 ">Lecture</h3>
|
||||
<h1 className="">
|
||||
|
||||
@@ -6,9 +6,11 @@ import { getCourseUrl } from "@/services/urlUtils";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useCourseContext } from "../../context/courseContext";
|
||||
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
|
||||
import { useDeleteLectureMutation } from "@/hooks/localCourse/lectureHooks";
|
||||
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||
import { useDeleteLectureMutation } from "@/features/local/lectures/lectureHooks";
|
||||
import Link from "next/link";
|
||||
import { useItemNavigation } from "../../hooks/useItemNavigation";
|
||||
import ItemNavigationButtons from "../../components/ItemNavigationButtons";
|
||||
|
||||
export default function LectureButtons({ lectureDay }: { lectureDay: string }) {
|
||||
const { courseName } = useCourseContext();
|
||||
@@ -17,6 +19,7 @@ export default function LectureButtons({ lectureDay }: { lectureDay: string }) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const modal = useModal();
|
||||
const deleteLecture = useDeleteLectureMutation();
|
||||
const { previousUrl, nextUrl } = useItemNavigation("lecture", lectureDay);
|
||||
|
||||
return (
|
||||
<div className="p-5 flex flex-row justify-end gap-3">
|
||||
@@ -61,6 +64,7 @@ export default function LectureButtons({ lectureDay }: { lectureDay: string }) {
|
||||
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
|
||||
Go Back
|
||||
</Link>
|
||||
<ItemNavigationButtons previousUrl={previousUrl} nextUrl={nextUrl} />
|
||||
{isLoading && <Spinner />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 }) {
|
||||
return (
|
||||
@@ -11,7 +11,7 @@ export default function LecturePreview({ lecture }: { lecture: Lecture }) {
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<MarkdownDisplay markdown={lecture.content} />
|
||||
<MarkdownDisplay markdown={lecture.content} convertImages={false} />
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -11,8 +11,9 @@ export async function generateMetadata({
|
||||
const { courseName, lectureDay } = await params;
|
||||
const decodedDay = decodeURIComponent(lectureDay);
|
||||
const dayOnly = decodedDay.split(" ")[0];
|
||||
const decodedCourseName = decodeURIComponent(getTitle(courseName));
|
||||
return {
|
||||
title: getTitle(`${courseName} lecture ${dayOnly}`),
|
||||
title: getTitle(`${decodedCourseName} lecture ${dayOnly}`),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import EditLecture from "./EditLecture";
|
||||
import {
|
||||
getDateFromStringOrThrow,
|
||||
getDateOnlyMarkdownString,
|
||||
} from "@/models/local/utils/timeUtils";
|
||||
} from "@/features/local/utils/timeUtils";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function page({
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import LecturePreview from "../LecturePreview";
|
||||
import { getCourseUrl, getLectureUrl } from "@/services/urlUtils";
|
||||
import { useCourseContext } from "../../../context/courseContext";
|
||||
import Link from "next/link";
|
||||
import { useLecturesSuspenseQuery } from "@/hooks/localCourse/lectureHooks";
|
||||
import { useLecturesSuspenseQuery } from "@/features/local/lectures/lectureHooks";
|
||||
import { BreadCrumbs } from "@/components/BreadCrumbs";
|
||||
|
||||
export default function LecturePreviewPage({
|
||||
lectureDay,
|
||||
}: {
|
||||
lectureDay: string;
|
||||
}) {
|
||||
const { courseName } = useCourseContext();
|
||||
const { data: weeks } = useLecturesSuspenseQuery();
|
||||
const lecture = weeks
|
||||
.flatMap(({ lectures }) => lectures.map((lecture) => lecture))
|
||||
@@ -23,20 +20,7 @@ export default function LecturePreviewPage({
|
||||
return (
|
||||
<div className="flex h-full xl:flex-row flex-col ">
|
||||
<div className="flex-shrink flex-1 pb-1 ms-3 xl:ms-0 flex flex-row flex-wrap gap-3 content-start ">
|
||||
<div className="">
|
||||
<Link
|
||||
className="btn"
|
||||
href={getLectureUrl(courseName, lectureDay)}
|
||||
shallow={true}
|
||||
>
|
||||
Edit Lecture
|
||||
</Link>
|
||||
</div>
|
||||
<div className="">
|
||||
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
|
||||
Course Calendar
|
||||
</Link>
|
||||
</div>
|
||||
<BreadCrumbs />
|
||||
</div>
|
||||
<div className="flex justify-center min-h-0 px-2">
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
getDateFromStringOrThrow,
|
||||
getDateOnlyMarkdownString,
|
||||
} from "@/models/local/utils/timeUtils";
|
||||
} from "@/features/local/utils/timeUtils";
|
||||
import LecturePreviewPage from "./LecturePreviewPage";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Expandable } from "@/components/Expandable";
|
||||
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 { useCourseContext } from "../context/courseContext";
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
import { usePagesQueries } from "@/hooks/localCourse/pageHooks";
|
||||
import { IModuleItem } from "@/models/local/IModuleItem";
|
||||
import { usePagesQueries } from "@/features/local/pages/pageHooks";
|
||||
import { IModuleItem } from "@/features/local/modules/IModuleItem";
|
||||
import {
|
||||
getDateFromString,
|
||||
getDateFromStringOrThrow,
|
||||
getDateOnlyMarkdownString,
|
||||
} from "@/models/local/utils/timeUtils";
|
||||
} from "@/features/local/utils/timeUtils";
|
||||
import { Fragment } from "react";
|
||||
import Modal, { useModal } from "../../../../components/Modal";
|
||||
import NewItemForm from "./NewItemForm";
|
||||
@@ -21,10 +21,13 @@ import { getModuleItemUrl } from "@/services/urlUtils";
|
||||
import { useCourseContext } from "../context/courseContext";
|
||||
import { Expandable } from "../../../../components/Expandable";
|
||||
import { useDragStyleContext } from "../context/drag/dragStyleContext";
|
||||
import { useQuizzesQueries } from "@/hooks/localCourse/quizHooks";
|
||||
import { useAssignmentNamesQuery } from "@/hooks/localCourse/assignmentHooks";
|
||||
import { useQuizzesQueries } from "@/features/local/quizzes/quizHooks";
|
||||
import { useTRPC } from "@/services/serverFunctions/trpcClient";
|
||||
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({
|
||||
moduleName,
|
||||
@@ -50,6 +53,8 @@ export default function ExpandableModule({
|
||||
const { data: quizzes } = useQuizzesQueries(moduleName);
|
||||
const { data: pages } = usePagesQueries(moduleName);
|
||||
const modal = useModal();
|
||||
const reorderMutation = useReorderCanvasModuleItemsMutation();
|
||||
const { data: canvasModules } = useCanvasModulesQuery();
|
||||
|
||||
const moduleItems: {
|
||||
type: "assignment" | "quiz" | "page";
|
||||
@@ -102,7 +107,7 @@ export default function ExpandableModule({
|
||||
</ClientOnly>
|
||||
<ExpandIcon
|
||||
style={{
|
||||
...(isExpanded ? { rotate: "-90deg" } : {}),
|
||||
...(isExpanded ? { rotate: "90deg" } : {rotate: "180deg"}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -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
|
||||
modalControl={modal}
|
||||
buttonText="New Item"
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
import CheckIcon from "@/components/icons/CheckIcon";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
import {
|
||||
useAddCanvasModuleMutation,
|
||||
useCanvasModulesQuery,
|
||||
} from "@/hooks/canvas/canvasModuleHooks";
|
||||
useAddCanvasModuleMutation,
|
||||
} from "@/features/canvas/hooks/canvasModuleHooks";
|
||||
|
||||
export function ModuleCanvasStatus({ moduleName }: { moduleName: string }) {
|
||||
const { data: canvasModules } = useCanvasModulesQuery();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import { useModuleNamesQuery } from "@/hooks/localCourse/localCourseModuleHooks";
|
||||
import { useModuleNamesQuery } from "@/features/local/modules/localCourseModuleHooks";
|
||||
import ExpandableModule from "./ExpandableModule";
|
||||
import CreateModule from "./CreateModule";
|
||||
|
||||
|
||||
@@ -3,20 +3,21 @@ import ButtonSelect from "@/components/ButtonSelect";
|
||||
import SelectInput from "@/components/form/SelectInput";
|
||||
import TextInput from "@/components/form/TextInput";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
import { useCreateAssignmentMutation } from "@/hooks/localCourse/assignmentHooks";
|
||||
import { useModuleNamesQuery } from "@/hooks/localCourse/localCourseModuleHooks";
|
||||
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
|
||||
import { useCreatePageMutation } from "@/hooks/localCourse/pageHooks";
|
||||
import { LocalAssignmentGroup } from "@/models/local/assignment/localAssignmentGroup";
|
||||
import { useModuleNamesQuery } from "@/features/local/modules/localCourseModuleHooks";
|
||||
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||
import { useCreatePageMutation } from "@/features/local/pages/pageHooks";
|
||||
import { LocalAssignmentGroup } from "@/features/local/assignments/models/localAssignmentGroup";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useCourseContext } from "../context/courseContext";
|
||||
import { useCreateQuizMutation } from "@/hooks/localCourse/quizHooks";
|
||||
import { useCreateQuizMutation } from "@/features/local/quizzes/quizHooks";
|
||||
import {
|
||||
getDateFromString,
|
||||
dateToMarkdownString,
|
||||
getDateFromStringOrThrow,
|
||||
} from "@/models/local/utils/timeUtils";
|
||||
} from "@/features/local/utils/timeUtils";
|
||||
import { useCreateAssignmentMutation } from "@/features/local/assignments/assignmentHooks";
|
||||
import { validateFileName } from "@/services/fileNameValidation";
|
||||
|
||||
export default function NewItemForm({
|
||||
moduleName: defaultModuleName,
|
||||
@@ -39,6 +40,13 @@ export default function NewItemForm({
|
||||
);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [nameError, setNameError] = useState("");
|
||||
|
||||
const handleNameChange = (newName: string) => {
|
||||
setName(newName);
|
||||
const error = validateFileName(newName);
|
||||
setNameError(error);
|
||||
};
|
||||
|
||||
const defaultDate = getDateFromString(
|
||||
creationDate ? creationDate : dateToMarkdownString(new Date())
|
||||
@@ -65,6 +73,12 @@ export default function NewItemForm({
|
||||
className="flex flex-col gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate name before submission
|
||||
if (nameError) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dueAt =
|
||||
dueDate === ""
|
||||
? dueDate
|
||||
@@ -153,22 +167,31 @@ export default function NewItemForm({
|
||||
<div>
|
||||
<ButtonSelect<"Assignment" | "Quiz" | "Page">
|
||||
options={["Assignment", "Quiz", "Page"]}
|
||||
getName={(o) => o?.toString() ?? ""}
|
||||
setSelectedOption={(t) => setType(t ?? "Assignment")}
|
||||
selectedOption={type}
|
||||
getOptionName={(o) => o?.toString() ?? ""}
|
||||
setValue={(t) => setType(t ?? "Assignment")}
|
||||
value={type}
|
||||
label="Type"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<TextInput label={type + " Name"} value={name} setValue={setName} />
|
||||
<TextInput
|
||||
label={type + " Name"}
|
||||
value={name}
|
||||
setValue={handleNameChange}
|
||||
/>
|
||||
{nameError && (
|
||||
<div className="text-red-300 bg-red-950/50 border p-1 rounded border-red-900/50 text-sm mt-1">
|
||||
{nameError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{type !== "Page" && (
|
||||
<ButtonSelect
|
||||
options={settings.assignmentGroups}
|
||||
getName={(g) => g?.name ?? ""}
|
||||
setSelectedOption={setAssignmentGroup}
|
||||
selectedOption={assignmentGroup}
|
||||
getOptionName={(g) => g?.name ?? ""}
|
||||
setValue={setAssignmentGroup}
|
||||
value={assignmentGroup}
|
||||
label="Assignment Group"
|
||||
/>
|
||||
)}
|
||||
@@ -178,7 +201,9 @@ export default function NewItemForm({
|
||||
No assignment groups created, create them in the course settings page
|
||||
</div>
|
||||
)}
|
||||
<button type="submit">Create</button>
|
||||
<button disabled={!!nameError} type="submit">
|
||||
Create
|
||||
</button>
|
||||
{isPending && <Spinner />}
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -6,17 +6,21 @@ import {
|
||||
useAddAssignmentToCanvasMutation,
|
||||
useDeleteAssignmentFromCanvasMutation,
|
||||
useUpdateAssignmentInCanvasMutation,
|
||||
} from "@/hooks/canvas/canvasAssignmentHooks";
|
||||
canvasAssignmentKeys,
|
||||
} from "@/features/canvas/hooks/canvasAssignmentHooks";
|
||||
import { baseCanvasUrl } from "@/features/canvas/services/canvasServiceUtils";
|
||||
import {
|
||||
useAssignmentQuery,
|
||||
useDeleteAssignmentMutation,
|
||||
} from "@/hooks/localCourse/assignmentHooks";
|
||||
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
|
||||
import { baseCanvasUrl } from "@/services/canvas/canvasServiceUtils";
|
||||
} from "@/features/local/assignments/assignmentHooks";
|
||||
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||
import { getCourseUrl } from "@/services/urlUtils";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useItemNavigation } from "../../../../hooks/useItemNavigation";
|
||||
import ItemNavigationButtons from "../../../../components/ItemNavigationButtons";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export function AssignmentFooterButtons({
|
||||
moduleName,
|
||||
@@ -32,9 +36,10 @@ export function AssignmentFooterButtons({
|
||||
const { data: settings } = useLocalCourseSettingsQuery();
|
||||
const { data: canvasAssignments, isFetching: canvasIsFetching } =
|
||||
useCanvasAssignmentsQuery();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: assignment, isFetching } = useAssignmentQuery(
|
||||
moduleName,
|
||||
assignmentName
|
||||
assignmentName,
|
||||
);
|
||||
const addToCanvas = useAddAssignmentToCanvasMutation();
|
||||
const deleteFromCanvas = useDeleteAssignmentFromCanvasMutation();
|
||||
@@ -42,9 +47,14 @@ export function AssignmentFooterButtons({
|
||||
const deleteLocal = useDeleteAssignmentMutation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const modal = useModal();
|
||||
const { previousUrl, nextUrl } = useItemNavigation(
|
||||
"assignment",
|
||||
assignmentName,
|
||||
moduleName,
|
||||
);
|
||||
|
||||
const assignmentInCanvas = canvasAssignments?.find(
|
||||
(a) => a.name === assignmentName
|
||||
(a) => a.name === assignmentName,
|
||||
);
|
||||
|
||||
const anythingIsLoading =
|
||||
@@ -77,6 +87,17 @@ export function AssignmentFooterButtons({
|
||||
className="btn"
|
||||
target="_blank"
|
||||
href={`${baseCanvasUrl}/courses/${settings.canvasId}/assignments/${assignmentInCanvas.id}`}
|
||||
onClick={() => {
|
||||
for (let i = 1; i <= 8; i += 2) {
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: canvasAssignmentKeys.assignments(
|
||||
settings.canvasId,
|
||||
),
|
||||
});
|
||||
}, i * 1000);
|
||||
}
|
||||
}}
|
||||
>
|
||||
View in Canvas
|
||||
</a>
|
||||
@@ -155,6 +176,7 @@ export function AssignmentFooterButtons({
|
||||
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
|
||||
Go Back
|
||||
</Link>
|
||||
<ItemNavigationButtons previousUrl={previousUrl} nextUrl={nextUrl} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import MarkdownDisplay from "@/components/MarkdownDisplay";
|
||||
import { LocalAssignment } from "@/models/local/assignment/localAssignment";
|
||||
import { rubricItemIsExtraCredit } from "@/models/local/assignment/rubricItem";
|
||||
import { assignmentPoints } from "@/models/local/assignment/utils/assignmentPointsUtils";
|
||||
import { LocalAssignment } from "@/features/local/assignments/models/localAssignment";
|
||||
import { rubricItemIsExtraCredit } from "@/features/local/assignments/models/rubricItem";
|
||||
import { assignmentPoints } from "@/features/local/assignments/models/utils/assignmentPointsUtils";
|
||||
import { formatHumanReadableDate } from "@/services/utils/dateFormat";
|
||||
import React, { Fragment } from "react";
|
||||
|
||||
@@ -59,13 +59,15 @@ export default function AssignmentPreview({
|
||||
<hr />
|
||||
<br />
|
||||
<section>
|
||||
<MarkdownDisplay markdown={assignment.description} />
|
||||
{/* <div
|
||||
className="markdownPreview"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: htmlPreview,
|
||||
}}
|
||||
></div> */}
|
||||
<MarkdownDisplay
|
||||
markdown={assignment.description}
|
||||
replaceText={[
|
||||
{
|
||||
source: "insert_github_classroom_url",
|
||||
destination: assignment.githubClassroomAssignmentShareLink || "",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
<hr />
|
||||
<section>
|
||||
@@ -73,15 +75,14 @@ export default function AssignmentPreview({
|
||||
{extraPoints !== 0 && (
|
||||
<h5 className="text-center">{extraPoints} Extra Credit Points</h5>
|
||||
)}
|
||||
<div className="grid grid-cols-3">
|
||||
<div className="grid grid-cols-[auto_auto_1fr]">
|
||||
{assignment.rubric.map((rubricItem, i) => (
|
||||
<Fragment key={rubricItem.label + i}>
|
||||
<div className="text-end pe-3 col-span-2">{rubricItem.label}</div>
|
||||
<div>
|
||||
{rubricItem.points}
|
||||
|
||||
{rubricItemIsExtraCredit(rubricItem) ? " - Extra Credit" : ""}
|
||||
<div className="text-end pe-1">
|
||||
{rubricItemIsExtraCredit(rubricItem) ? "Extra Credit" : ""}
|
||||
</div>
|
||||
<div className="text-end pe-3">{rubricItem.points}</div>
|
||||
<div>{rubricItem.label}</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
"use client";
|
||||
import { MonacoEditor } from "@/components/editor/MonacoEditor";
|
||||
import {
|
||||
useAssignmentQuery,
|
||||
useUpdateAssignmentMutation,
|
||||
useUpdateImageSettingsForAssignment,
|
||||
} from "@/hooks/localCourse/assignmentHooks";
|
||||
import {
|
||||
LocalAssignment,
|
||||
localAssignmentMarkdown,
|
||||
} from "@/models/local/assignment/localAssignment";
|
||||
} from "@/features/local/assignments/models/localAssignment";
|
||||
import { useEffect, useState } from "react";
|
||||
import AssignmentPreview from "./AssignmentPreview";
|
||||
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 { SuspenseAndErrorHandling } from "@/components/SuspenseAndErrorHandling";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -22,6 +17,11 @@ import EditAssignmentHeader from "./EditAssignmentHeader";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
import { getAssignmentHelpString } from "./getAssignmentHelpString";
|
||||
import { EditLayout } from "@/components/EditLayout";
|
||||
import {
|
||||
useAssignmentQuery,
|
||||
useUpdateAssignmentMutation,
|
||||
useUpdateImageSettingsForAssignment,
|
||||
} from "@/features/local/assignments/assignmentHooks";
|
||||
|
||||
export default function EditAssignment({
|
||||
moduleName,
|
||||
@@ -131,7 +131,7 @@ export default function EditAssignment({
|
||||
Body={
|
||||
<>
|
||||
{showHelp && (
|
||||
<div className=" max-w-96">
|
||||
<div className=" max-w-96 flex-1 h-full overflow-y-auto">
|
||||
<pre>
|
||||
<code>{getAssignmentHelpString(settings)}</code>
|
||||
</pre>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCourseContext } from "@/app/course/[courseName]/context/courseContext";
|
||||
import { BreadCrumbs } from "@/components/BreadCrumbs";
|
||||
import { UpdateAssignmentName } from "./UpdateAssignmentName";
|
||||
import { getCourseUrl } from "@/services/urlUtils";
|
||||
import Link from "next/link";
|
||||
import { RightSingleChevron } from "@/components/icons/RightSingleChevron";
|
||||
|
||||
export default function EditAssignmentHeader({
|
||||
moduleName,
|
||||
@@ -10,17 +9,21 @@ export default function EditAssignmentHeader({
|
||||
assignmentName: string;
|
||||
moduleName: string;
|
||||
}) {
|
||||
const { courseName } = useCourseContext();
|
||||
return (
|
||||
<div className="py-1 flex flex-row justify-start gap-3">
|
||||
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
|
||||
{courseName}
|
||||
</Link>
|
||||
<UpdateAssignmentName
|
||||
assignmentName={assignmentName}
|
||||
moduleName={moduleName}
|
||||
/>
|
||||
<div className="my-auto">{assignmentName}</div>
|
||||
<div className="py-1 flex flex-row justify-between">
|
||||
<div className="flex flex-row">
|
||||
<BreadCrumbs />
|
||||
<span className="text-slate-500 cursor-default select-none my-auto">
|
||||
<RightSingleChevron />
|
||||
</span>
|
||||
<div className="my-auto px-3">{assignmentName}</div>
|
||||
</div>
|
||||
<div className="px-1">
|
||||
<UpdateAssignmentName
|
||||
assignmentName={assignmentName}
|
||||
moduleName={moduleName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Spinner } from "@/components/Spinner";
|
||||
import {
|
||||
useAssignmentQuery,
|
||||
useUpdateAssignmentMutation,
|
||||
} from "@/hooks/localCourse/assignmentHooks";
|
||||
} from "@/features/local/assignments/assignmentHooks";
|
||||
import { getModuleItemUrl } from "@/services/urlUtils";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
@@ -40,22 +40,37 @@ export function UpdateAssignmentName({
|
||||
if (name === assignmentName) closeModal();
|
||||
|
||||
setIsLoading(true); // page refresh resets flag
|
||||
await updateAssignment.mutateAsync({
|
||||
assignment: assignment,
|
||||
moduleName,
|
||||
assignmentName: name,
|
||||
previousModuleName: moduleName,
|
||||
previousAssignmentName: assignmentName,
|
||||
courseName,
|
||||
});
|
||||
try {
|
||||
await updateAssignment.mutateAsync({
|
||||
assignment: assignment,
|
||||
moduleName,
|
||||
assignmentName: name,
|
||||
previousModuleName: moduleName,
|
||||
previousAssignmentName: assignmentName,
|
||||
courseName,
|
||||
});
|
||||
|
||||
// update url (will trigger reload...)
|
||||
router.replace(
|
||||
getModuleItemUrl(courseName, moduleName, "assignment", name),
|
||||
{}
|
||||
);
|
||||
// update url (will trigger reload...)
|
||||
router.replace(
|
||||
getModuleItemUrl(courseName, moduleName, "assignment", name),
|
||||
{}
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="
|
||||
text-yellow-300
|
||||
bg-yellow-950/30
|
||||
border-2
|
||||
rounded-lg
|
||||
border-yellow-800
|
||||
p-1 text-sm mb-2"
|
||||
>
|
||||
Warning: does not rename in Canvas
|
||||
</div>
|
||||
<TextInput
|
||||
value={name}
|
||||
setValue={setName}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
import { AssignmentSubmissionType } from "@/models/local/assignment/assignmentSubmissionType";
|
||||
import { LocalCourseSettings } from "@/models/local/localCourseSettings";
|
||||
import { AssignmentSubmissionType } from "@/features/local/assignments/models/assignmentSubmissionType";
|
||||
import { LocalCourseSettings } from "@/features/local/course/localCourseSettings";
|
||||
|
||||
export function getAssignmentHelpString(settings: LocalCourseSettings) {
|
||||
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)
|
||||
|
||||
|
||||
\`Inline code\`
|
||||
|
||||
> Blockquote
|
||||
@@ -54,6 +55,32 @@ flowchart TD
|
||||
C -->|Three| F[fa:fa-car Car]
|
||||
\`\`\`
|
||||
|
||||
## LaTeX Math
|
||||
|
||||
**Inline math:** The Fibonacci sequence is defined as: \$F(n) = F(n-1) + F(n-2)\$ where \$F(0) = 0\$ and \$F(1) = 1\$.
|
||||
|
||||
**Block math:**
|
||||
\$\$F(n) = F(n-1) + F(n-2)\$\$
|
||||
|
||||
**Complex equations:**
|
||||
\$\$
|
||||
F(n) = \\begin{cases}
|
||||
0 & \\text{if } n = 0 \\\\
|
||||
1 & \\text{if } n = 1 \\\\
|
||||
F(n-1) + F(n-2) & \\text{if } n > 1
|
||||
\\end{cases}
|
||||
\$\$
|
||||
|
||||
## 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:
|
||||
|
||||

|
||||
|
||||
## Rubric
|
||||
|
||||
- 1pt: singular point
|
||||
|
||||
@@ -14,8 +14,9 @@ export async function generateMetadata({
|
||||
}): Promise<Metadata> {
|
||||
const { courseName, assignmentName } = await params;
|
||||
const decodedAssignmentName = decodeURIComponent(assignmentName);
|
||||
const decodedCourseName = decodeURIComponent(courseName);
|
||||
return {
|
||||
title: getTitle(`${decodedAssignmentName}, ${courseName}`),
|
||||
title: getTitle(`${decodedAssignmentName}, ${decodedCourseName}`),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
"use client";
|
||||
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 PagePreview from "./PagePreview";
|
||||
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
|
||||
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||
import EditPageButtons from "./EditPageButtons";
|
||||
import ClientOnly from "@/components/ClientOnly";
|
||||
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 EditPageHeader from "./EditPageHeader";
|
||||
import { EditLayout } from "@/components/EditLayout";
|
||||
import { localPageMarkdownUtils } from "@/features/local/pages/localCoursePageModels";
|
||||
import {
|
||||
usePageQuery,
|
||||
useUpdatePageMutation,
|
||||
} from "@/features/local/pages/pageHooks";
|
||||
|
||||
export default function EditPage({
|
||||
moduleName,
|
||||
@@ -102,13 +102,13 @@ export default function EditPage({
|
||||
<EditLayout
|
||||
Header={<EditPageHeader pageName={pageName} moduleName={moduleName} />}
|
||||
Body={
|
||||
<div className="columns-2 min-h-0 flex-1">
|
||||
<div className="flex-1 h-full">
|
||||
<div className="flex min-h-0 flex-1 gap-4 overflow-hidden">
|
||||
<div className="flex-1 h-full min-w-0 overflow-hidden">
|
||||
<MonacoEditor key={monacoKey} value={text} onChange={textUpdate} />
|
||||
</div>
|
||||
<div className="h-full">
|
||||
<div className="flex-1 h-full min-w-0 flex flex-col overflow-hidden">
|
||||
<div className="text-red-300">{error && error}</div>
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<br />
|
||||
<PagePreview page={page} />
|
||||
</div>
|
||||
@@ -125,5 +125,5 @@ export default function EditPage({
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,19 +4,21 @@ import { Spinner } from "@/components/Spinner";
|
||||
import {
|
||||
useCanvasPagesQuery,
|
||||
useCreateCanvasPageMutation,
|
||||
useDeleteCanvasPageMutation,
|
||||
useUpdateCanvasPageMutation,
|
||||
} from "@/hooks/canvas/canvasPageHooks";
|
||||
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
|
||||
useDeleteCanvasPageMutation,
|
||||
} from "@/features/canvas/hooks/canvasPageHooks";
|
||||
import { baseCanvasUrl } from "@/features/canvas/services/canvasServiceUtils";
|
||||
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||
import {
|
||||
useDeletePageMutation,
|
||||
usePageQuery,
|
||||
} from "@/hooks/localCourse/pageHooks";
|
||||
import { baseCanvasUrl } from "@/services/canvas/canvasServiceUtils";
|
||||
} from "@/features/local/pages/pageHooks";
|
||||
import { getCourseUrl } from "@/services/urlUtils";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import { useItemNavigation } from "../../../../hooks/useItemNavigation";
|
||||
import ItemNavigationButtons from "../../../../components/ItemNavigationButtons";
|
||||
|
||||
export default function EditPageButtons({
|
||||
moduleName,
|
||||
@@ -36,6 +38,11 @@ export default function EditPageButtons({
|
||||
const deletePageLocal = useDeletePageMutation();
|
||||
const modal = useModal();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { previousUrl, nextUrl } = useItemNavigation(
|
||||
"page",
|
||||
pageName,
|
||||
moduleName
|
||||
);
|
||||
|
||||
const pageInCanvas = canvasPages?.find((p) => p.title === pageName);
|
||||
|
||||
@@ -125,6 +132,7 @@ export default function EditPageButtons({
|
||||
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
|
||||
Go Back
|
||||
</Link>
|
||||
<ItemNavigationButtons previousUrl={previousUrl} nextUrl={nextUrl} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCourseContext } from "@/app/course/[courseName]/context/courseContext";
|
||||
import { getCourseUrl } from "@/services/urlUtils";
|
||||
import Link from "next/link";
|
||||
import { UpdatePageName } from "./UpdatePageName";
|
||||
import { BreadCrumbs } from "@/components/BreadCrumbs";
|
||||
import { RightSingleChevron } from "@/components/icons/RightSingleChevron";
|
||||
|
||||
export default function EditPageHeader({
|
||||
moduleName,
|
||||
@@ -10,18 +9,18 @@ export default function EditPageHeader({
|
||||
pageName: string;
|
||||
moduleName: string;
|
||||
}) {
|
||||
const { courseName } = useCourseContext();
|
||||
return (
|
||||
<div className="py-1 flex flex-row justify-start gap-3">
|
||||
<Link
|
||||
className="btn"
|
||||
href={getCourseUrl(courseName)}
|
||||
shallow={true}
|
||||
>
|
||||
{courseName}
|
||||
</Link>
|
||||
<UpdatePageName pageName={pageName} moduleName={moduleName} />
|
||||
<div className="my-auto">{pageName}</div>
|
||||
<div className="py-1 flex flex-row justify-between">
|
||||
<div className="flex flex-row">
|
||||
<BreadCrumbs />
|
||||
<span className="text-slate-500 cursor-default select-none my-auto">
|
||||
<RightSingleChevron />
|
||||
</span>
|
||||
<div className="my-auto px-3">{pageName}</div>
|
||||
</div>
|
||||
<div className="px-1">
|
||||
<UpdatePageName pageName={pageName} moduleName={moduleName} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import MarkdownDisplay from "@/components/MarkdownDisplay";
|
||||
import { LocalCoursePage } from "@/models/local/page/localCoursePage";
|
||||
import { LocalCoursePage } from "@/features/local/pages/localCoursePageModels";
|
||||
import React from "react";
|
||||
|
||||
export default function PagePreview({ page }: { page: LocalCoursePage }) {
|
||||
return (
|
||||
<MarkdownDisplay markdown={page.text} />
|
||||
);
|
||||
return <MarkdownDisplay markdown={page.text} />;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Spinner } from "@/components/Spinner";
|
||||
import {
|
||||
usePageQuery,
|
||||
useUpdatePageMutation,
|
||||
} from "@/hooks/localCourse/pageHooks";
|
||||
} from "@/features/local/pages/pageHooks";
|
||||
import { getModuleItemUrl } from "@/services/urlUtils";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
@@ -56,6 +56,17 @@ export function UpdatePageName({
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="
|
||||
text-yellow-300
|
||||
bg-yellow-950/30
|
||||
border-2
|
||||
rounded-lg
|
||||
border-yellow-800
|
||||
p-1 text-sm mb-2"
|
||||
>
|
||||
Warning: does not rename in Canvas
|
||||
</div>
|
||||
<TextInput value={name} setValue={setName} label={"Rename Page"} />
|
||||
<button className="w-full my-3">Save New Name</button>
|
||||
{isLoading && <Spinner />}
|
||||
|
||||
@@ -14,8 +14,9 @@ export async function generateMetadata({
|
||||
}): Promise<Metadata> {
|
||||
const { courseName, pageName } = await params;
|
||||
const decodedPageName = decodeURIComponent(pageName);
|
||||
const decodedCourseName = decodeURIComponent(courseName);
|
||||
return {
|
||||
title: getTitle(`${decodedPageName}, ${courseName}`),
|
||||
title: getTitle(`${decodedPageName}, ${decodedCourseName}`),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
import { MonacoEditor } from "@/components/editor/MonacoEditor";
|
||||
import { quizMarkdownUtils } from "@/models/local/quiz/utils/quizMarkdownUtils";
|
||||
import { useEffect, useState } from "react";
|
||||
import QuizPreview from "./QuizPreview";
|
||||
import { QuizButtons } from "./QuizButton";
|
||||
@@ -11,13 +10,17 @@ import { useCourseContext } from "@/app/course/[courseName]/context/courseContex
|
||||
import {
|
||||
useQuizQuery,
|
||||
useUpdateQuizMutation,
|
||||
} from "@/hooks/localCourse/quizHooks";
|
||||
} from "@/features/local/quizzes/quizHooks";
|
||||
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 { LocalCourseSettings } from "@/models/local/localCourseSettings";
|
||||
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
|
||||
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||
import { useGlobalSettingsQuery } from "@/features/local/globalSettings/globalSettingsHooks";
|
||||
import { getFeedbackDelimitersFromSettings } from "@/features/local/globalSettings/globalSettingsUtils";
|
||||
import type { GlobalSettings } from "@/features/local/globalSettings/globalSettingsModels";
|
||||
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 groupNames = settings.assignmentGroups.map((g) => g.name).join("\n- ");
|
||||
@@ -61,11 +64,38 @@ points: 4
|
||||
the underscore is optional
|
||||
short answer
|
||||
---
|
||||
short answer with auto-graded responses
|
||||
*a) answer 1
|
||||
*b) other valid answer
|
||||
short_answer=
|
||||
---
|
||||
this is a matching question
|
||||
^ left answer - right dropdown
|
||||
^ other thing - another option
|
||||
^ - distractor
|
||||
^ - other distractor`;
|
||||
^ - other distractor
|
||||
---
|
||||
Points: 3
|
||||
FEEDBACK EXAMPLE
|
||||
What is 2+3?
|
||||
+ Correct! Good job
|
||||
- Incorrect, try again
|
||||
... This is general feedback shown regardless
|
||||
*a) 4
|
||||
*b) 5
|
||||
c) 6
|
||||
---
|
||||
Points: 2
|
||||
FEEDBACK EXAMPLE
|
||||
Multiline feedback example
|
||||
+
|
||||
Great work!
|
||||
You understand the concept.
|
||||
-
|
||||
Not quite right.
|
||||
Review the material and try again.
|
||||
*a) correct answer
|
||||
b) wrong answer`;
|
||||
};
|
||||
|
||||
export default function EditQuiz({
|
||||
@@ -84,10 +114,15 @@ export default function EditQuiz({
|
||||
isFetching,
|
||||
} = useQuizQuery(moduleName, quizName);
|
||||
const updateQuizMutation = useUpdateQuizMutation();
|
||||
const { data: globalSettings } = useGlobalSettingsQuery();
|
||||
const feedbackDelimiters = getFeedbackDelimitersFromSettings(
|
||||
(globalSettings ?? ({} as GlobalSettings)) as GlobalSettings,
|
||||
);
|
||||
|
||||
const { clientIsAuthoritative, text, textUpdate, monacoKey } =
|
||||
useAuthoritativeUpdates({
|
||||
serverUpdatedAt: serverDataUpdatedAt,
|
||||
startingText: quizMarkdownUtils.toMarkdown(quiz),
|
||||
startingText: quizMarkdownUtils.toMarkdown(quiz, feedbackDelimiters),
|
||||
});
|
||||
|
||||
const [error, setError] = useState("");
|
||||
@@ -103,13 +138,18 @@ export default function EditQuiz({
|
||||
try {
|
||||
const name = extractLabelValue(text, "Name");
|
||||
if (
|
||||
quizMarkdownUtils.toMarkdown(quiz) !==
|
||||
quizMarkdownUtils.toMarkdown(quiz, feedbackDelimiters) !==
|
||||
quizMarkdownUtils.toMarkdown(
|
||||
quizMarkdownUtils.parseMarkdown(text, name)
|
||||
quizMarkdownUtils.parseMarkdown(text, name, feedbackDelimiters),
|
||||
feedbackDelimiters,
|
||||
)
|
||||
) {
|
||||
if (clientIsAuthoritative) {
|
||||
const updatedQuiz = quizMarkdownUtils.parseMarkdown(text, quizName);
|
||||
const updatedQuiz = quizMarkdownUtils.parseMarkdown(
|
||||
text,
|
||||
quizName,
|
||||
feedbackDelimiters,
|
||||
);
|
||||
await updateQuizMutation.mutateAsync({
|
||||
quiz: updatedQuiz,
|
||||
moduleName,
|
||||
@@ -120,7 +160,7 @@ export default function EditQuiz({
|
||||
});
|
||||
} else {
|
||||
console.log(
|
||||
"client not authoritative, updating client with server quiz"
|
||||
"client not authoritative, updating client with server quiz",
|
||||
);
|
||||
textUpdate(quizMarkdownUtils.toMarkdown(quiz), true);
|
||||
}
|
||||
@@ -138,6 +178,7 @@ export default function EditQuiz({
|
||||
}, [
|
||||
clientIsAuthoritative,
|
||||
courseName,
|
||||
feedbackDelimiters,
|
||||
isFetching,
|
||||
moduleName,
|
||||
quiz,
|
||||
@@ -154,7 +195,7 @@ export default function EditQuiz({
|
||||
Body={
|
||||
<>
|
||||
{showHelp && (
|
||||
<pre className=" max-w-96">
|
||||
<pre className=" max-w-96 h-full overflow-y-auto">
|
||||
<code>{helpString(settings)}</code>
|
||||
</pre>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCourseContext } from "@/app/course/[courseName]/context/courseContext";
|
||||
import { getCourseUrl } from "@/services/urlUtils";
|
||||
import Link from "next/link";
|
||||
import { RightSingleChevron } from "@/components/icons/RightSingleChevron";
|
||||
import { UpdateQuizName } from "./UpdateQuizName";
|
||||
import { BreadCrumbs } from "@/components/BreadCrumbs";
|
||||
|
||||
export default function EditQuizHeader({
|
||||
moduleName,
|
||||
@@ -10,18 +9,18 @@ export default function EditQuizHeader({
|
||||
quizName: string;
|
||||
moduleName: string;
|
||||
}) {
|
||||
const { courseName } = useCourseContext();
|
||||
return (
|
||||
<div className="py-1 flex flex-row justify-start gap-3">
|
||||
<Link
|
||||
className="btn"
|
||||
href={getCourseUrl(courseName)}
|
||||
shallow={true}
|
||||
>
|
||||
{courseName}
|
||||
</Link>
|
||||
<UpdateQuizName quizName={quizName} moduleName={moduleName} />
|
||||
<div>{quizName}</div>
|
||||
<div className="py-1 flex flex-row justify-between">
|
||||
<div className="flex flex-row">
|
||||
<BreadCrumbs />
|
||||
<span className="text-slate-500 cursor-default select-none my-auto">
|
||||
<RightSingleChevron />
|
||||
</span>
|
||||
<div className="my-auto px-3">{quizName}</div>
|
||||
</div>
|
||||
<div className="px-1">
|
||||
<UpdateQuizName quizName={quizName} moduleName={moduleName} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,16 +5,18 @@ import {
|
||||
useCanvasQuizzesQuery,
|
||||
useAddQuizToCanvasMutation,
|
||||
useDeleteQuizFromCanvasMutation,
|
||||
} from "@/hooks/canvas/canvasQuizHooks";
|
||||
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
|
||||
} from "@/features/canvas/hooks/canvasQuizHooks";
|
||||
import { baseCanvasUrl } from "@/features/canvas/services/canvasServiceUtils";
|
||||
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||
import {
|
||||
useDeleteQuizMutation,
|
||||
useQuizQuery,
|
||||
} from "@/hooks/localCourse/quizHooks";
|
||||
import { baseCanvasUrl } from "@/services/canvas/canvasServiceUtils";
|
||||
} from "@/features/local/quizzes/quizHooks";
|
||||
import { getCourseUrl } from "@/services/urlUtils";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useItemNavigation } from "../../../../hooks/useItemNavigation";
|
||||
import ItemNavigationButtons from "../../../../components/ItemNavigationButtons";
|
||||
|
||||
export function QuizButtons({
|
||||
moduleName,
|
||||
@@ -35,6 +37,11 @@ export function QuizButtons({
|
||||
const deleteFromCanvas = useDeleteQuizFromCanvasMutation();
|
||||
const deleteLocal = useDeleteQuizMutation();
|
||||
const modal = useModal();
|
||||
const { previousUrl, nextUrl } = useItemNavigation(
|
||||
"quiz",
|
||||
quizName,
|
||||
moduleName
|
||||
);
|
||||
|
||||
const quizInCanvas = canvasQuizzes?.find((c) => c.title === quizName);
|
||||
|
||||
@@ -111,6 +118,7 @@ export function QuizButtons({
|
||||
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
|
||||
Go Back
|
||||
</Link>
|
||||
<ItemNavigationButtons previousUrl={previousUrl} nextUrl={nextUrl} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import CheckIcon from "@/components/icons/CheckIcon";
|
||||
import MarkdownDisplay from "@/components/MarkdownDisplay";
|
||||
import { useQuizQuery } from "@/hooks/localCourse/quizHooks";
|
||||
import {
|
||||
LocalQuizQuestion,
|
||||
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";
|
||||
|
||||
export default function QuizPreview({
|
||||
@@ -80,6 +80,45 @@ function QuizQuestionPreview({ question }: { question: LocalQuizQuestion }) {
|
||||
</div>
|
||||
</div>
|
||||
<MarkdownDisplay markdown={question.text} className="ms-4 mb-2" />
|
||||
|
||||
{/* Feedback Section */}
|
||||
{(question.correctComments ||
|
||||
question.incorrectComments ||
|
||||
question.neutralComments) && (
|
||||
<div className=" m-2 ps-2 py-1 rounded flex bg-slate-950/50">
|
||||
<div>Feedback</div>
|
||||
<div className="mx-4 space-y-1">
|
||||
{question.correctComments && (
|
||||
<div className="border-l-2 border-green-700 pl-2 py-1 flex">
|
||||
<span className="text-green-500">+ </span>
|
||||
<MarkdownDisplay
|
||||
markdown={question.correctComments}
|
||||
className="ms-4 mb-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{question.incorrectComments && (
|
||||
<div className="border-l-2 border-red-700 pl-2 py-1 flex">
|
||||
<span className="text-red-500">- </span>
|
||||
<MarkdownDisplay
|
||||
markdown={question.incorrectComments}
|
||||
className="ms-4 mb-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{question.neutralComments && (
|
||||
<div className="border-l-2 border-blue-800 pl-2 py-1 flex">
|
||||
<span className="text-blue-500">... </span>
|
||||
<MarkdownDisplay
|
||||
markdown={question.neutralComments}
|
||||
className="ms-4 mb-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{question.questionType === QuestionType.MATCHING && (
|
||||
<div>
|
||||
{question.answers.map((answer) => (
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Spinner } from "@/components/Spinner";
|
||||
import {
|
||||
useQuizQuery,
|
||||
useUpdateQuizMutation,
|
||||
} from "@/hooks/localCourse/quizHooks";
|
||||
} from "@/features/local/quizzes/quizHooks";
|
||||
import { getModuleItemUrl } from "@/services/urlUtils";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
@@ -56,6 +56,17 @@ export function UpdateQuizName({
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="
|
||||
text-yellow-300
|
||||
bg-yellow-950/30
|
||||
border-2
|
||||
rounded-lg
|
||||
border-yellow-800
|
||||
p-1 text-sm mb-2"
|
||||
>
|
||||
Warning: does not rename in Canvas
|
||||
</div>
|
||||
<TextInput value={name} setValue={setName} label={"Rename Quiz"} />
|
||||
<button className="w-full my-3">Save New Name</button>
|
||||
{isLoading && <Spinner />}
|
||||
|
||||
@@ -14,8 +14,9 @@ export async function generateMetadata({
|
||||
}): Promise<Metadata> {
|
||||
const { courseName, quizName } = await params;
|
||||
const decodedQuizName = decodeURIComponent(quizName);
|
||||
const decodedCourseName = decodeURIComponent(courseName);
|
||||
return {
|
||||
title: getTitle(`${decodedQuizName}, ${courseName}`),
|
||||
title: getTitle(`${decodedQuizName}, ${decodedCourseName}`),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,13 @@ import { CourseNavigation } from "./CourseNavigation";
|
||||
import { DragStyleContextProvider } from "./context/drag/dragStyleContext";
|
||||
import CollapsableSidebar from "./CollapsableSidebar";
|
||||
|
||||
|
||||
export default async function CoursePage() {
|
||||
return (
|
||||
<>
|
||||
<div className="h-full flex flex-col">
|
||||
<DragStyleContextProvider>
|
||||
<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">
|
||||
<CourseNavigation />
|
||||
<CourseCalendar />
|
||||
|
||||
@@ -3,20 +3,22 @@
|
||||
import {
|
||||
useLocalCourseSettingsQuery,
|
||||
useUpdateLocalCourseSettingsMutation,
|
||||
} from "@/hooks/localCourse/localCoursesHooks";
|
||||
import { LocalAssignmentGroup } from "@/models/local/assignment/localAssignmentGroup";
|
||||
} from "@/features/local/course/localCoursesHooks";
|
||||
import { LocalAssignmentGroup } from "@/features/local/assignments/models/localAssignmentGroup";
|
||||
import { useEffect, useState } from "react";
|
||||
import TextInput from "../../../../components/form/TextInput";
|
||||
import { useSetAssignmentGroupsMutation } from "@/hooks/canvas/canvasCourseHooks";
|
||||
import { settingsBox } from "./sharedSettings";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
import { baseCanvasUrl } from "@/services/canvas/canvasServiceUtils";
|
||||
import MeatballIcon from "./MeatballIcon";
|
||||
import { useSetAssignmentGroupsMutation } from "@/features/canvas/hooks/canvasCourseHooks";
|
||||
import { baseCanvasUrl } from "@/features/canvas/services/canvasServiceUtils";
|
||||
import Modal, { useModal } from "@/components/Modal";
|
||||
|
||||
export default function AssignmentGroupManagement() {
|
||||
const { data: settings, isPending } = useLocalCourseSettingsQuery();
|
||||
const updateSettings = useUpdateLocalCourseSettingsMutation();
|
||||
const applyInCanvas = useSetAssignmentGroupsMutation(settings.canvasId);
|
||||
const modal = useModal();
|
||||
|
||||
const [assignmentGroups, setAssignmentGroups] = useState<
|
||||
LocalAssignmentGroup[]
|
||||
@@ -104,17 +106,46 @@ export default function AssignmentGroupManagement() {
|
||||
</div>
|
||||
<br />
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={async () => {
|
||||
const newSettings = await applyInCanvas.mutateAsync(settings);
|
||||
|
||||
// prevent debounce from resetting
|
||||
if (newSettings) setAssignmentGroups(newSettings.assignmentGroups);
|
||||
}}
|
||||
disabled={applyInCanvas.isPending}
|
||||
<Modal
|
||||
modalControl={modal}
|
||||
buttonText="Update Assignment Groups In Canvas"
|
||||
buttonClass="btn-"
|
||||
modalWidth="w-1/5"
|
||||
>
|
||||
Update Assignment Groups In Canvas
|
||||
</button>
|
||||
{({ closeModal }) => (
|
||||
<div>
|
||||
<div className="text-center font-bold">
|
||||
DANGER: updating assignment groups can delete assignments and grades from canvas.
|
||||
</div>
|
||||
<div className="text-center">
|
||||
This is only recommended to do at the beginning of a semester. Are you sure you want to continue?
|
||||
</div>
|
||||
<br />
|
||||
<div className="flex justify-around gap-3">
|
||||
<button
|
||||
onClick={async () => {
|
||||
const newSettings = await applyInCanvas.mutateAsync(
|
||||
settings
|
||||
);
|
||||
|
||||
// prevent debounce from resetting
|
||||
if (newSettings)
|
||||
setAssignmentGroups(newSettings.assignmentGroups);
|
||||
}}
|
||||
disabled={applyInCanvas.isPending}
|
||||
className="btn-danger"
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button onClick={closeModal} disabled={applyInCanvas.isPending}>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
{applyInCanvas.isPending && <Spinner />}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
</div>
|
||||
{applyInCanvas.isPending && <Spinner />}
|
||||
{applyInCanvas.isSuccess && (
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Spinner } from "@/components/Spinner";
|
||||
import {
|
||||
useLocalCourseSettingsQuery,
|
||||
useUpdateLocalCourseSettingsMutation,
|
||||
} from "@/hooks/localCourse/localCoursesHooks";
|
||||
} from "@/features/local/course/localCoursesHooks";
|
||||
import React from "react";
|
||||
|
||||
export default function DaysOfWeekSettings() {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import {
|
||||
useLocalCourseSettingsQuery,
|
||||
useUpdateLocalCourseSettingsMutation,
|
||||
} from "@/hooks/localCourse/localCoursesHooks";
|
||||
} from "@/features/local/course/localCoursesHooks";
|
||||
import { TimePicker } from "../../../../components/TimePicker";
|
||||
import { useState } from "react";
|
||||
import DefaultLockOffset from "./DefaultLockOffset";
|
||||
|
||||
@@ -3,7 +3,7 @@ import TextInput from "@/components/form/TextInput";
|
||||
import {
|
||||
useLocalCourseSettingsQuery,
|
||||
useUpdateLocalCourseSettingsMutation,
|
||||
} from "@/hooks/localCourse/localCoursesHooks";
|
||||
} from "@/features/local/course/localCoursesHooks";
|
||||
import { useState, useEffect } from "react";
|
||||
import { settingsBox } from "./sharedSettings";
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import TextInput from "@/components/form/TextInput";
|
||||
import {
|
||||
useLocalCourseSettingsQuery,
|
||||
useUpdateLocalCourseSettingsMutation,
|
||||
} from "@/hooks/localCourse/localCoursesHooks";
|
||||
} from "@/features/local/course/localCoursesHooks";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function DefaultLockOffset() {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
|
||||
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||
import { settingsBox } from "./sharedSettings";
|
||||
import { useCourseStudentsQuery } from "@/hooks/canvas/canvasCourseHooks";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
import { useCourseStudentsQuery } from "@/features/canvas/hooks/canvasCourseHooks";
|
||||
|
||||
export default function GithubClassroomList() {
|
||||
const { data: settings } = useLocalCourseSettingsQuery();
|
||||
|
||||
@@ -5,13 +5,13 @@ import { SuspenseAndErrorHandling } from "@/components/SuspenseAndErrorHandling"
|
||||
import {
|
||||
useLocalCourseSettingsQuery,
|
||||
useUpdateLocalCourseSettingsMutation,
|
||||
} from "@/hooks/localCourse/localCoursesHooks";
|
||||
import { getDateFromString } from "@/models/local/utils/timeUtils";
|
||||
} from "@/features/local/course/localCoursesHooks";
|
||||
import { getDateFromString } from "@/features/local/utils/timeUtils";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
holidaysToString,
|
||||
parseHolidays,
|
||||
} from "../../../../models/local/utils/settingsUtils";
|
||||
} from "../../../../features/local/utils/settingsUtils";
|
||||
import { settingsBox } from "./sharedSettings";
|
||||
|
||||
const exampleString = `springBreak:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
|
||||
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||
import { getCourseUrl } from "@/services/urlUtils";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
|
||||
import { getDateOnlyMarkdownString } from "@/models/local/utils/timeUtils";
|
||||
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||
import { getDateOnlyMarkdownString } from "@/features/local/utils/timeUtils";
|
||||
import React from "react";
|
||||
import { settingsBox } from "./sharedSettings";
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@ import SelectInput from "@/components/form/SelectInput";
|
||||
import {
|
||||
useLocalCourseSettingsQuery,
|
||||
useUpdateLocalCourseSettingsMutation,
|
||||
} from "@/hooks/localCourse/localCoursesHooks";
|
||||
} from "@/features/local/course/localCoursesHooks";
|
||||
import {
|
||||
AssignmentSubmissionType,
|
||||
AssignmentSubmissionTypeList,
|
||||
} from "@/models/local/assignment/assignmentSubmissionType";
|
||||
} from "@/features/local/assignments/models/assignmentSubmissionType";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { settingsBox } from "./sharedSettings";
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useState } from "react";
|
||||
import { useCanvasTabsQuery } from "@/hooks/canvas/canvasNavigationHooks";
|
||||
import { useUpdateCanvasTabMutation } from "@/hooks/canvas/canvasNavigationHooks";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
import { NavTabListItem } from "./NavTabListItem";
|
||||
import {
|
||||
useCanvasTabsQuery,
|
||||
useUpdateCanvasTabMutation,
|
||||
} from "@/features/canvas/hooks/canvasNavigationHooks";
|
||||
|
||||
export const CanvasNavigationManagement = () => {
|
||||
const { data: tabs, isLoading, isError } = useCanvasTabsQuery();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
import { useUpdateCanvasTabMutation } from "@/hooks/canvas/canvasNavigationHooks";
|
||||
import { CanvasCourseTab } from "@/services/canvas/canvasNavigationService";
|
||||
import { useUpdateCanvasTabMutation } from "@/features/canvas/hooks/canvasNavigationHooks";
|
||||
import { CanvasCourseTab } from "@/features/canvas/services/canvasNavigationService";
|
||||
import React, { FC } from "react";
|
||||
|
||||
export const NavTabListItem: FC<{
|
||||
|
||||
@@ -8,8 +8,9 @@ export async function generateMetadata({
|
||||
params: Promise<{ courseName: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { courseName } = await params;
|
||||
const decodedCourseName = decodeURIComponent(courseName);
|
||||
return {
|
||||
title: getTitle(courseName) + " Settings",
|
||||
title: getTitle(decodedCourseName) + " Settings",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,15 +2,10 @@ import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import Providers from "./providers";
|
||||
import { Suspense } from "react";
|
||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||
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 { getTitle } from "@/services/titleUtils";
|
||||
import DataHydration from "./DataHydration";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -25,7 +20,7 @@ export default async function RootLayout({
|
||||
return (
|
||||
<html lang="en">
|
||||
<head></head>
|
||||
<body className="flex justify-center">
|
||||
<body className="flex justify-center h-screen" suppressHydrationWarning>
|
||||
<div className="bg-gray-950 h-screen text-slate-300 w-screen sm:p-1">
|
||||
<MyToaster />
|
||||
<Suspense>
|
||||
@@ -41,77 +36,3 @@ export default async function RootLayout({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import CourseList from "./CourseList";
|
||||
import AddNewCourse from "./newCourse/AddNewCourse";
|
||||
import { AddExistingCourseToGlobalSettings } from "./addCourse/AddExistingCourseToGlobalSettings";
|
||||
import AddCourseToGlobalSettings from "./addCourse/AddNewCourse";
|
||||
import TodaysLectures from "./todaysLectures/TodaysLectures";
|
||||
|
||||
export default async function Home() {
|
||||
@@ -18,7 +19,31 @@ export default async function Home() {
|
||||
<TodaysLectures />
|
||||
<br />
|
||||
<br />
|
||||
<AddNewCourse />
|
||||
<AddCourseToGlobalSettings />
|
||||
<br />
|
||||
<div className="mb-96">
|
||||
<AddExistingCourseToGlobalSettings />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ export function makeQueryClient() {
|
||||
// refetchOnMount: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: 0,
|
||||
onError: (error) => {
|
||||
const message = getAxiosErrorMessage(error as AxiosError);
|
||||
console.error("Mutation error:", message);
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
import { getLecturePreviewUrl } from "@/services/urlUtils";
|
||||
import Link from "next/link";
|
||||
import { useCourseContext } from "../course/[courseName]/context/courseContext";
|
||||
import { useLecturesSuspenseQuery as useLecturesQuery } from "@/hooks/localCourse/lectureHooks";
|
||||
import { getLectureForDay } from "@/models/local/utils/lectureUtils";
|
||||
import { getDateOnlyMarkdownString } from "@/models/local/utils/timeUtils";
|
||||
import { useLecturesSuspenseQuery as useLecturesQuery } from "@/features/local/lectures/lectureHooks";
|
||||
import { getLectureForDay } from "@/features/local/utils/lectureUtils";
|
||||
import { getDateOnlyMarkdownString } from "@/features/local/utils/timeUtils";
|
||||
|
||||
export default function OneCourseLectures() {
|
||||
const { courseName } = useCourseContext();
|
||||
const {data: weeks} = useLecturesQuery();
|
||||
const { data: weeks } = useLecturesQuery();
|
||||
|
||||
const dayAsDate = new Date();
|
||||
const dayAsString = getDateOnlyMarkdownString(dayAsDate);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useLocalCoursesSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
|
||||
import { useLocalCoursesSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||
import OneCourseLectures from "./OneCourseLectures";
|
||||
import { SuspenseAndErrorHandling } from "@/components/SuspenseAndErrorHandling";
|
||||
import CourseContextProvider from "../course/[courseName]/context/CourseContextProvider";
|
||||
|
||||
106
src/components/BreadCrumbs.tsx
Normal file
106
src/components/BreadCrumbs.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import HomeIcon from "./icons/HomeIcon";
|
||||
import { RightSingleChevron } from "./icons/RightSingleChevron";
|
||||
|
||||
export const BreadCrumbs = () => {
|
||||
const pathname = usePathname();
|
||||
|
||||
const pathSegments = pathname?.split("/").filter(Boolean) || [];
|
||||
const isCourseRoute = pathSegments[0] === "course";
|
||||
|
||||
const courseName =
|
||||
isCourseRoute && pathSegments[1]
|
||||
? decodeURIComponent(pathSegments[1])
|
||||
: null;
|
||||
|
||||
const isLectureRoute = isCourseRoute && pathSegments[2] === "lecture";
|
||||
const lectureDate =
|
||||
isLectureRoute && pathSegments[3]
|
||||
? decodeURIComponent(pathSegments[3])
|
||||
: null;
|
||||
|
||||
const lectureDateOnly = lectureDate
|
||||
? (() => {
|
||||
const dateStr = lectureDate.split(" ")[0];
|
||||
const date = new Date(dateStr);
|
||||
const month = date.toLocaleDateString("en-US", { month: "short" });
|
||||
const day = date.getDate();
|
||||
return `${month} ${day}`;
|
||||
})()
|
||||
: null;
|
||||
|
||||
const sharedBackgroundClassNames = `
|
||||
group
|
||||
hover:bg-blue-900/30
|
||||
rounded-lg
|
||||
h-full
|
||||
flex
|
||||
items-center
|
||||
transition
|
||||
`;
|
||||
const sharedLinkClassNames = `
|
||||
text-slate-300
|
||||
transition
|
||||
group-hover:text-slate-100
|
||||
rounded-lg
|
||||
h-full
|
||||
flex
|
||||
items-center
|
||||
px-3
|
||||
`;
|
||||
|
||||
return (
|
||||
<nav className="flex flex-row font-bold text-sm items-center">
|
||||
<span className={sharedBackgroundClassNames}>
|
||||
<Link
|
||||
href="/"
|
||||
shallow={true}
|
||||
className="flex items-center gap-1 rounded-lg h-full "
|
||||
>
|
||||
<span className={sharedLinkClassNames}>
|
||||
<HomeIcon />
|
||||
</span>
|
||||
</Link>
|
||||
</span>
|
||||
|
||||
{courseName && (
|
||||
<>
|
||||
<span className="text-slate-500 cursor-default select-none">
|
||||
<RightSingleChevron />
|
||||
</span>
|
||||
<span className={sharedBackgroundClassNames}>
|
||||
<Link
|
||||
href={`/course/${encodeURIComponent(courseName)}`}
|
||||
shallow={true}
|
||||
className={sharedLinkClassNames}
|
||||
>
|
||||
{courseName}
|
||||
</Link>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isLectureRoute && lectureDate && courseName && (
|
||||
<>
|
||||
<span className="text-slate-500 cursor-default select-none">
|
||||
<RightSingleChevron />
|
||||
</span>
|
||||
<span className={sharedBackgroundClassNames}>
|
||||
<Link
|
||||
href={`/course/${encodeURIComponent(
|
||||
courseName
|
||||
)}/lecture/${encodeURIComponent(lectureDate)}`}
|
||||
shallow={true}
|
||||
className={sharedLinkClassNames}
|
||||
>
|
||||
{lectureDateOnly}
|
||||
</Link>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user