mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
Merge pull request #222 from learnhouse/feat/course-updates
CourseUpdates feature
This commit is contained in:
commit
f7675e430f
11 changed files with 619 additions and 65 deletions
41
apps/api/src/db/course_updates.py
Normal file
41
apps/api/src/db/course_updates.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
from typing import Optional
|
||||||
|
from sqlalchemy import Column, ForeignKey, Integer
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class CourseUpdate(SQLModel, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
courseupdate_uuid: str
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
course_id: int = Field(
|
||||||
|
sa_column=Column(Integer, ForeignKey("course.id", ondelete="CASCADE"))
|
||||||
|
)
|
||||||
|
linked_activity_uuids: Optional[str] = Field(default=None)
|
||||||
|
org_id: int = Field(default=None, foreign_key="organization.id")
|
||||||
|
creation_date: str
|
||||||
|
update_date: str
|
||||||
|
|
||||||
|
class CourseUpdateCreate(SQLModel):
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
linked_activity_uuids: Optional[str] = Field(default=None)
|
||||||
|
org_id: int
|
||||||
|
|
||||||
|
class CourseUpdateRead(SQLModel):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
course_id: int
|
||||||
|
courseupdate_uuid: str
|
||||||
|
linked_activity_uuids: Optional[str] = Field(default=None)
|
||||||
|
org_id: int
|
||||||
|
creation_date: str
|
||||||
|
update_date: str
|
||||||
|
|
||||||
|
class CourseUpdateUpdate(SQLModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
content: Optional[str] = None
|
||||||
|
linked_activity_uuids: Optional[str] = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2,6 +2,11 @@ from typing import List
|
||||||
from fastapi import APIRouter, Depends, UploadFile, Form, Request
|
from fastapi import APIRouter, Depends, UploadFile, Form, Request
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
from src.core.events.database import get_db_session
|
from src.core.events.database import get_db_session
|
||||||
|
from src.db.course_updates import (
|
||||||
|
CourseUpdateCreate,
|
||||||
|
CourseUpdateRead,
|
||||||
|
CourseUpdateUpdate,
|
||||||
|
)
|
||||||
from src.db.users import PublicUser
|
from src.db.users import PublicUser
|
||||||
from src.db.courses import (
|
from src.db.courses import (
|
||||||
CourseCreate,
|
CourseCreate,
|
||||||
|
|
@ -19,6 +24,7 @@ from src.services.courses.courses import (
|
||||||
delete_course,
|
delete_course,
|
||||||
update_course_thumbnail,
|
update_course_thumbnail,
|
||||||
)
|
)
|
||||||
|
from src.services.courses.updates import create_update, delete_update, get_updates_by_course_uuid, update_update
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -51,7 +57,9 @@ async def api_create_course(
|
||||||
learnings=learnings,
|
learnings=learnings,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
)
|
)
|
||||||
return await create_course(request, org_id, course, current_user, db_session, thumbnail)
|
return await create_course(
|
||||||
|
request, org_id, course, current_user, db_session, thumbnail
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{course_uuid}/thumbnail")
|
@router.put("/{course_uuid}/thumbnail")
|
||||||
|
|
@ -145,3 +153,64 @@ async def api_delete_course(
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return await delete_course(request, course_uuid, current_user, db_session)
|
return await delete_course(request, course_uuid, current_user, db_session)
|
||||||
|
|
||||||
|
@ router.get("/{course_uuid}/updates")
|
||||||
|
async def api_get_course_updates(
|
||||||
|
request: Request,
|
||||||
|
course_uuid: str,
|
||||||
|
db_session: Session = Depends(get_db_session),
|
||||||
|
current_user: PublicUser = Depends(get_current_user),
|
||||||
|
) -> List[CourseUpdateRead]:
|
||||||
|
"""
|
||||||
|
Get Course Updates by course_uuid
|
||||||
|
"""
|
||||||
|
|
||||||
|
return await get_updates_by_course_uuid(request, course_uuid, current_user, db_session)
|
||||||
|
|
||||||
|
@router.post("/{course_uuid}/updates")
|
||||||
|
async def api_create_course_update(
|
||||||
|
request: Request,
|
||||||
|
course_uuid: str,
|
||||||
|
update_object: CourseUpdateCreate,
|
||||||
|
db_session: Session = Depends(get_db_session),
|
||||||
|
current_user: PublicUser = Depends(get_current_user),
|
||||||
|
) -> CourseUpdateRead:
|
||||||
|
"""
|
||||||
|
Create new Course Update
|
||||||
|
"""
|
||||||
|
|
||||||
|
return await create_update(
|
||||||
|
request, course_uuid, update_object, current_user, db_session
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.put("/{course_uuid}/update/{courseupdate_uuid}")
|
||||||
|
async def api_update_course_update(
|
||||||
|
request: Request,
|
||||||
|
course_uuid: str,
|
||||||
|
courseupdate_uuid: str,
|
||||||
|
update_object: CourseUpdateUpdate,
|
||||||
|
db_session: Session = Depends(get_db_session),
|
||||||
|
current_user: PublicUser = Depends(get_current_user),
|
||||||
|
) -> CourseUpdateRead:
|
||||||
|
"""
|
||||||
|
Update Course Update by courseupdate_uuid
|
||||||
|
"""
|
||||||
|
|
||||||
|
return await update_update(
|
||||||
|
request, courseupdate_uuid, update_object, current_user, db_session
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.delete("/{course_uuid}/update/{courseupdate_uuid}")
|
||||||
|
async def api_delete_course_update(
|
||||||
|
request: Request,
|
||||||
|
course_uuid: str,
|
||||||
|
courseupdate_uuid: str,
|
||||||
|
db_session: Session = Depends(get_db_session),
|
||||||
|
current_user: PublicUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Delete Course Update by courseupdate_uuid
|
||||||
|
"""
|
||||||
|
|
||||||
|
return await delete_update(request, courseupdate_uuid, current_user, db_session)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ async def check_element_type(element_uuid):
|
||||||
"""
|
"""
|
||||||
Check if the element is a course, a user, a house or a collection, by checking its prefix
|
Check if the element is a course, a user, a house or a collection, by checking its prefix
|
||||||
"""
|
"""
|
||||||
if element_uuid.startswith("course_"):
|
if element_uuid.startswith("course_") or element_uuid.startswith("courseupdate_"):
|
||||||
return "courses"
|
return "courses"
|
||||||
elif element_uuid.startswith("user_"):
|
elif element_uuid.startswith("user_"):
|
||||||
return "users"
|
return "users"
|
||||||
|
|
|
||||||
151
apps/api/src/services/courses/updates.py
Normal file
151
apps/api/src/services/courses/updates.py
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List
|
||||||
|
from uuid import uuid4
|
||||||
|
from fastapi import HTTPException, Request, status
|
||||||
|
from sqlmodel import Session, col, select
|
||||||
|
from src.db.course_updates import (
|
||||||
|
CourseUpdate,
|
||||||
|
CourseUpdateCreate,
|
||||||
|
CourseUpdateRead,
|
||||||
|
CourseUpdateUpdate,
|
||||||
|
)
|
||||||
|
from src.db.courses import Course
|
||||||
|
from src.db.organizations import Organization
|
||||||
|
from src.db.users import AnonymousUser, PublicUser
|
||||||
|
from src.services.courses.courses import rbac_check
|
||||||
|
|
||||||
|
|
||||||
|
async def create_update(
|
||||||
|
request: Request,
|
||||||
|
course_uuid: str,
|
||||||
|
update_object: CourseUpdateCreate,
|
||||||
|
current_user: PublicUser | AnonymousUser,
|
||||||
|
db_session: Session,
|
||||||
|
) -> CourseUpdateRead:
|
||||||
|
|
||||||
|
# CHekc if org exists
|
||||||
|
statement_org = select(Organization).where(Organization.id == update_object.org_id)
|
||||||
|
org = db_session.exec(statement_org).first()
|
||||||
|
|
||||||
|
if not org or org.id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT, detail="Organization does not exist"
|
||||||
|
)
|
||||||
|
|
||||||
|
statement = select(Course).where(Course.course_uuid == course_uuid)
|
||||||
|
course = db_session.exec(statement).first()
|
||||||
|
|
||||||
|
if not course or course.id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT, detail="Course does not exist"
|
||||||
|
)
|
||||||
|
|
||||||
|
# RBAC check
|
||||||
|
await rbac_check(request, course.course_uuid, current_user, "update", db_session)
|
||||||
|
|
||||||
|
# Generate UUID
|
||||||
|
courseupdate_uuid = str(f"courseupdate_{uuid4()}")
|
||||||
|
|
||||||
|
update = CourseUpdate(
|
||||||
|
**update_object.model_dump(),
|
||||||
|
course_id=course.id,
|
||||||
|
courseupdate_uuid=courseupdate_uuid,
|
||||||
|
creation_date=str(datetime.now()),
|
||||||
|
update_date=str(datetime.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
db_session.add(update)
|
||||||
|
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(update)
|
||||||
|
|
||||||
|
return CourseUpdateRead(**update.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
# Update Course Update
|
||||||
|
async def update_update(
|
||||||
|
request: Request,
|
||||||
|
courseupdate_uuid: str,
|
||||||
|
update_object: CourseUpdateUpdate,
|
||||||
|
current_user: PublicUser | AnonymousUser,
|
||||||
|
db_session: Session,
|
||||||
|
) -> CourseUpdateRead:
|
||||||
|
statement = select(CourseUpdate).where(
|
||||||
|
CourseUpdate.courseupdate_uuid == courseupdate_uuid
|
||||||
|
)
|
||||||
|
update = db_session.exec(statement).first()
|
||||||
|
|
||||||
|
if not update or update.id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT, detail="Update does not exist"
|
||||||
|
)
|
||||||
|
|
||||||
|
# RBAC check
|
||||||
|
await rbac_check(
|
||||||
|
request, update.courseupdate_uuid, current_user, "update", db_session
|
||||||
|
)
|
||||||
|
|
||||||
|
for key, value in update_object.model_dump().items():
|
||||||
|
if value is not None:
|
||||||
|
setattr(update, key, value)
|
||||||
|
|
||||||
|
db_session.add(update)
|
||||||
|
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(update)
|
||||||
|
|
||||||
|
return CourseUpdateRead(**update.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
# Delete Course Update
|
||||||
|
async def delete_update(
|
||||||
|
request: Request,
|
||||||
|
courseupdate_uuid: str,
|
||||||
|
current_user: PublicUser | AnonymousUser,
|
||||||
|
db_session: Session,
|
||||||
|
):
|
||||||
|
statement = select(CourseUpdate).where(
|
||||||
|
CourseUpdate.courseupdate_uuid == courseupdate_uuid
|
||||||
|
)
|
||||||
|
update = db_session.exec(statement).first()
|
||||||
|
|
||||||
|
if not update or update.id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT, detail="Update does not exist"
|
||||||
|
)
|
||||||
|
|
||||||
|
# RBAC check
|
||||||
|
await rbac_check(
|
||||||
|
request, update.courseupdate_uuid, current_user, "delete", db_session
|
||||||
|
)
|
||||||
|
|
||||||
|
db_session.delete(update)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
return {"message": "Update deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
# Get Course Updates by Course ID
|
||||||
|
async def get_updates_by_course_uuid(
|
||||||
|
request: Request,
|
||||||
|
course_uuid: str,
|
||||||
|
current_user: PublicUser | AnonymousUser,
|
||||||
|
db_session: Session,
|
||||||
|
) -> List[CourseUpdateRead]:
|
||||||
|
# FInd if course exists
|
||||||
|
statement = select(Course).where(Course.course_uuid == course_uuid)
|
||||||
|
course = db_session.exec(statement).first()
|
||||||
|
|
||||||
|
if not course or course.id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT, detail="Course does not exist"
|
||||||
|
)
|
||||||
|
|
||||||
|
statement = (
|
||||||
|
select(CourseUpdate)
|
||||||
|
.where(CourseUpdate.course_id == course.id)
|
||||||
|
.order_by(col(CourseUpdate.creation_date).desc())
|
||||||
|
) # https://sqlmodel.tiangolo.com/tutorial/where/#type-annotations-and-errors
|
||||||
|
updates = db_session.exec(statement).all()
|
||||||
|
|
||||||
|
return [CourseUpdateRead(**update.model_dump()) for update in updates]
|
||||||
|
|
@ -15,6 +15,8 @@ import {
|
||||||
import { ArrowRight, Check, File, Sparkles, Video } from 'lucide-react'
|
import { ArrowRight, Check, File, Sparkles, Video } from 'lucide-react'
|
||||||
import { useOrg } from '@components/Contexts/OrgContext'
|
import { useOrg } from '@components/Contexts/OrgContext'
|
||||||
import UserAvatar from '@components/Objects/UserAvatar'
|
import UserAvatar from '@components/Objects/UserAvatar'
|
||||||
|
import CourseUpdates from '@components/Objects/CourseUpdates/CourseUpdates'
|
||||||
|
import { CourseProvider } from '@components/Contexts/CourseContext'
|
||||||
|
|
||||||
const CourseClient = (props: any) => {
|
const CourseClient = (props: any) => {
|
||||||
const [user, setUser] = useState<any>({})
|
const [user, setUser] = useState<any>({})
|
||||||
|
|
@ -68,10 +70,17 @@ const CourseClient = (props: any) => {
|
||||||
<PageLoading></PageLoading>
|
<PageLoading></PageLoading>
|
||||||
) : (
|
) : (
|
||||||
<GeneralWrapperStyled>
|
<GeneralWrapperStyled>
|
||||||
<div className="pb-3">
|
<div className="pb-3 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
<p className="text-md font-bold text-gray-400 pb-2">Course</p>
|
<p className="text-md font-bold text-gray-400 pb-2">Course</p>
|
||||||
<h1 className="text-3xl -mt-3 font-bold">{course.name}</h1>
|
<h1 className="text-3xl -mt-3 font-bold">{course.name}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<CourseProvider courseuuid={course.course_uuid}>
|
||||||
|
<CourseUpdates />
|
||||||
|
</CourseProvider>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{props.course?.thumbnail_image && org ? (
|
{props.course?.thumbnail_image && org ? (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
260
apps/web/components/Objects/CourseUpdates/CourseUpdates.tsx
Normal file
260
apps/web/components/Objects/CourseUpdates/CourseUpdates.tsx
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
import { PencilLine, Rss, TentTree } from 'lucide-react'
|
||||||
|
import React, { useEffect } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { useFormik } from 'formik'
|
||||||
|
import * as Form from '@radix-ui/react-form'
|
||||||
|
import FormLayout, {
|
||||||
|
FormField,
|
||||||
|
FormLabelAndMessage,
|
||||||
|
Input,
|
||||||
|
Textarea,
|
||||||
|
} from '@components/StyledElements/Form/Form'
|
||||||
|
import { useCourse } from '@components/Contexts/CourseContext'
|
||||||
|
import useSWR, { mutate } from 'swr'
|
||||||
|
import { getAPIUrl } from '@services/config/config'
|
||||||
|
import { swrFetcher } from '@services/utils/ts/requests'
|
||||||
|
import useAdminStatus from '@components/Hooks/useAdminStatus'
|
||||||
|
import { useOrg } from '@components/Contexts/OrgContext'
|
||||||
|
import { createCourseUpdate, deleteCourseUpdate } from '@services/courses/updates'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal'
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
|
function CourseUpdates() {
|
||||||
|
const course = useCourse() as any;
|
||||||
|
const { data: updates } = useSWR(`${getAPIUrl()}courses/${course?.courseStructure.course_uuid}/updates`, swrFetcher)
|
||||||
|
const [isModelOpen, setIsModelOpen] = React.useState(false)
|
||||||
|
|
||||||
|
function handleModelOpen() {
|
||||||
|
setIsModelOpen(!isModelOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// if user clicks outside the model, close the model
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
function handleClickOutside(event: any) {
|
||||||
|
console.log(event.target.id)
|
||||||
|
if (event.target.closest('.bg-white') || event.target.id === 'delete-update-button') return;
|
||||||
|
setIsModelOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative' }} className='bg-white hover:bg-neutral-50 transition-all ease-linear nice-shadow rounded-full z-20 px-5 py-1'>
|
||||||
|
<div onClick={handleModelOpen} className='flex items-center space-x-2 font-normal hover:cursor-pointer text-gray-600'>
|
||||||
|
<div><Rss size={16} /> </div>
|
||||||
|
<div className='flex space-x-2 items-center'>
|
||||||
|
<span>Updates</span>
|
||||||
|
{updates && <span className='text-xs px-2 font-bold py-0.5 rounded-full bg-rose-100 text-rose-900'>{updates.length}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isModelOpen && <motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: 1300,
|
||||||
|
damping: 70,
|
||||||
|
}}
|
||||||
|
style={{ position: 'absolute', top: '130%', right: 0 }}
|
||||||
|
>
|
||||||
|
<UpdatesSection />
|
||||||
|
</motion.div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const UpdatesSection = () => {
|
||||||
|
const [selectedView, setSelectedView] = React.useState('list')
|
||||||
|
const isAdmin = useAdminStatus() as boolean;
|
||||||
|
return (
|
||||||
|
<div className='bg-white/95 backdrop-blur-md nice-shadow rounded-lg w-[700px] overflow-hidden'>
|
||||||
|
<div className='bg-gray-50/70 flex justify-between outline outline-1 rounded-lg outline-neutral-200/40'>
|
||||||
|
<div className='py-2 px-4 font-bold text-gray-500 flex space-x-2 items-center'>
|
||||||
|
<Rss size={16} />
|
||||||
|
<span>Updates</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{isAdmin && <div
|
||||||
|
onClick={() => setSelectedView('new')}
|
||||||
|
className='py-2 px-4 space-x-2 items-center flex cursor-pointer text-xs font-medium hover:bg-gray-200 bg-gray-100 outline outline-1 outline-neutral-200/40'>
|
||||||
|
<PencilLine size={14} />
|
||||||
|
<span>New Update</span>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
<div className=''>
|
||||||
|
{selectedView === 'list' && <UpdatesListView />}
|
||||||
|
{selectedView === 'new' && <NewUpdateForm setSelectedView={setSelectedView} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const NewUpdateForm = ({ setSelectedView }: any) => {
|
||||||
|
const org = useOrg() as any;
|
||||||
|
const course = useCourse() as any;
|
||||||
|
|
||||||
|
const validate = (values: any) => {
|
||||||
|
const errors: any = {}
|
||||||
|
|
||||||
|
if (!values.title) {
|
||||||
|
errors.title = 'Title is required'
|
||||||
|
}
|
||||||
|
if (!values.content) {
|
||||||
|
errors.content = 'Content is required'
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
const formik = useFormik({
|
||||||
|
initialValues: {
|
||||||
|
title: '',
|
||||||
|
content: ''
|
||||||
|
},
|
||||||
|
validate,
|
||||||
|
onSubmit: async (values) => {
|
||||||
|
const body = {
|
||||||
|
title: values.title,
|
||||||
|
content: values.content,
|
||||||
|
course_uuid: course.courseStructure.course_uuid,
|
||||||
|
org_id: org.id
|
||||||
|
}
|
||||||
|
const res = await createCourseUpdate(body)
|
||||||
|
if (res.status === 200) {
|
||||||
|
toast.success('Update added successfully')
|
||||||
|
setSelectedView('list')
|
||||||
|
mutate(`${getAPIUrl()}courses/${course?.courseStructure.course_uuid}/updates`)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.error('Failed to add update')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enableReinitialize: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
|
||||||
|
}
|
||||||
|
, [course, org])
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='bg-white/95 backdrop-blur-md nice-shadow rounded-lg w-[700px] overflow-hidden flex flex-col -space-y-2'>
|
||||||
|
<div className='flex flex-col -space-y-2 px-4 pt-4'>
|
||||||
|
<div className='text-gray-500 px-3 py-0.5 rounded-full font-semibold text-xs'>Test Course </div>
|
||||||
|
<div className='text-black px-3 py-0.5 rounded-full text-lg font-bold'>Add new Course Update</div>
|
||||||
|
</div>
|
||||||
|
<div className='px-5 -py-2'>
|
||||||
|
<FormLayout onSubmit={formik.handleSubmit}>
|
||||||
|
<FormField name="title">
|
||||||
|
<FormLabelAndMessage
|
||||||
|
label="Title"
|
||||||
|
message={formik.errors.title}
|
||||||
|
/>
|
||||||
|
<Form.Control asChild>
|
||||||
|
<Input
|
||||||
|
style={{ backgroundColor: 'white' }}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
value={formik.values.title}
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Form.Control>
|
||||||
|
</FormField>
|
||||||
|
<FormField name="content">
|
||||||
|
<FormLabelAndMessage
|
||||||
|
label="Content"
|
||||||
|
message={formik.errors.content}
|
||||||
|
/>
|
||||||
|
<Form.Control asChild>
|
||||||
|
<Textarea
|
||||||
|
style={{ backgroundColor: 'white', height: '100px' }}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
value={formik.values.content}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Form.Control>
|
||||||
|
</FormField>
|
||||||
|
<div className='flex justify-end py-2'>
|
||||||
|
<button onClick={() => setSelectedView('list')} className='text-gray-500 px-4 py-2 rounded-md text-sm font-bold antialiased'>Cancel</button>
|
||||||
|
<button className='bg-black text-white px-4 py-2 rounded-md text-sm font-bold antialiased'>Add Update</button>
|
||||||
|
</div>
|
||||||
|
</FormLayout>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const UpdatesListView = () => {
|
||||||
|
const course = useCourse() as any;
|
||||||
|
const isAdmin = useAdminStatus() as boolean;
|
||||||
|
const { data: updates } = useSWR(`${getAPIUrl()}courses/${course?.courseStructure.course_uuid}/updates`, swrFetcher)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='px-5 bg-white overflow-y-auto' style={{ maxHeight: '400px' }}>
|
||||||
|
{updates && updates.map((update: any) => (
|
||||||
|
<div key={update.id} className='py-2 border-b border-neutral-200 antialiased'>
|
||||||
|
<div className='font-bold text-gray-500 flex space-x-2 items-center justify-between '>
|
||||||
|
<div className='flex space-x-2 items-center'>
|
||||||
|
<span> {update.title}</span>
|
||||||
|
<span
|
||||||
|
title={"Created at " + dayjs(update.creation_date).format('MMMM D, YYYY')}
|
||||||
|
className='text-xs font-semibold text-gray-300'>
|
||||||
|
{dayjs(update.creation_date).fromNow()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isAdmin && <DeleteUpdateButton update={update} />}</div>
|
||||||
|
<div className='text-gray-600'>{update.content}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(!updates || updates.length === 0) &&
|
||||||
|
<div className='text-gray-500 text-center my-10 py-2 flex flex-col space-y-2'>
|
||||||
|
<TentTree className='mx-auto' size={40} />
|
||||||
|
<p>No updates yet</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeleteUpdateButton = ({ update }: any) => {
|
||||||
|
const course = useCourse() as any;
|
||||||
|
const org = useOrg() as any;
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
const res = await deleteCourseUpdate(course.courseStructure.course_uuid, update.courseupdate_uuid)
|
||||||
|
if (res.status === 200) {
|
||||||
|
toast.success('Update deleted successfully')
|
||||||
|
mutate(`${getAPIUrl()}courses/${course?.courseStructure.course_uuid}/updates`)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.error('Failed to delete update')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmationModal
|
||||||
|
confirmationButtonText="Delete Update"
|
||||||
|
confirmationMessage="Are you sure you want to delete this update?"
|
||||||
|
dialogTitle={'Delete Update ?'}
|
||||||
|
buttonid='delete-update-button'
|
||||||
|
dialogTrigger={
|
||||||
|
<div id='delete-update-button' className='text-rose-600 text-xs bg-rose-100 rounded-full px-2 py-0.5 hover:cursor-pointer'>
|
||||||
|
Delete
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
functionToExecute={() => {
|
||||||
|
handleDelete()
|
||||||
|
}}
|
||||||
|
status="warning"
|
||||||
|
></ConfirmationModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default CourseUpdates
|
||||||
|
|
@ -50,6 +50,7 @@ function EditorWrapper(props: EditorWrapperProps): JSX.Element {
|
||||||
}, 10);
|
}, 10);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Store the Y document in the browser
|
// Store the Y document in the browser
|
||||||
new IndexeddbPersistence(props.activity.activity_uuid, doc)
|
new IndexeddbPersistence(props.activity.activity_uuid, doc)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,14 +23,8 @@ export const Menu = (props: any) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="backdrop-blur-lg h-[60px] blur-3xl z-10" style={{}}>
|
<div className="backdrop-blur-lg h-[60px] blur-3xl -z-10" style={{}}>
|
||||||
<div
|
|
||||||
className="h-[150px] blur-3xl z-0"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
'radial-gradient(1397.20% 56.18% at 75.99% 53.73%, rgba(253, 182, 207, 0.08) 0%, rgba(3, 110, 146, 0.08) 100%)',
|
|
||||||
}}
|
|
||||||
></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="backdrop-blur-lg bg-white/90 fixed flex top-0 left-0 right-0 h-[60px] ring-1 ring-inset ring-gray-500/10 items-center space-x-5 shadow-[0px_4px_16px_rgba(0,0,0,0.03)] z-50">
|
<div className="backdrop-blur-lg bg-white/90 fixed flex top-0 left-0 right-0 h-[60px] ring-1 ring-inset ring-gray-500/10 items-center space-x-5 shadow-[0px_4px_16px_rgba(0,0,0,0.03)] z-50">
|
||||||
<div className="flex items-center space-x-5 w-full max-w-screen-2xl mx-auto px-16">
|
<div className="flex items-center space-x-5 w-full max-w-screen-2xl mx-auto px-16">
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ type ModalParams = {
|
||||||
functionToExecute: any
|
functionToExecute: any
|
||||||
dialogTrigger?: React.ReactNode
|
dialogTrigger?: React.ReactNode
|
||||||
status?: 'warning' | 'info'
|
status?: 'warning' | 'info'
|
||||||
|
buttonid?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const ConfirmationModal = (params: ModalParams) => {
|
const ConfirmationModal = (params: ModalParams) => {
|
||||||
|
|
@ -58,6 +59,7 @@ const ConfirmationModal = (params: ModalParams) => {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row-reverse pt-2">
|
<div className="flex flex-row-reverse pt-2">
|
||||||
<div
|
<div
|
||||||
|
id={params.buttonid}
|
||||||
className={`rounded-md text-sm px-3 py-2 font-bold flex justify-center items-center hover:cursor-pointer ${
|
className={`rounded-md text-sm px-3 py-2 font-bold flex justify-center items-center hover:cursor-pointer ${
|
||||||
params.status === 'warning'
|
params.status === 'warning'
|
||||||
? warningButtonColors
|
? warningButtonColors
|
||||||
|
|
|
||||||
23
apps/web/services/courses/updates.ts
Normal file
23
apps/web/services/courses/updates.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { getAPIUrl } from '@services/config/config'
|
||||||
|
import { RequestBody, getResponseMetadata } from '@services/utils/ts/requests'
|
||||||
|
|
||||||
|
export async function createCourseUpdate(body: any) {
|
||||||
|
const result: any = await fetch(
|
||||||
|
`${getAPIUrl()}courses/${body.course_uuid}/updates`,
|
||||||
|
RequestBody('POST', body, null)
|
||||||
|
)
|
||||||
|
const res = await getResponseMetadata(result)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCourseUpdate(
|
||||||
|
course_uuid: string,
|
||||||
|
update_uuid: number
|
||||||
|
) {
|
||||||
|
const result: any = await fetch(
|
||||||
|
`${getAPIUrl()}courses/${course_uuid}/update/${update_uuid}`,
|
||||||
|
RequestBody('DELETE', null, null)
|
||||||
|
)
|
||||||
|
const res = await getResponseMetadata(result)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,10 @@
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
.nice-shadow {
|
||||||
|
@apply shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue