mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
feat: implement author roles in course management
This commit is contained in:
parent
5f302106a9
commit
4ab8f52b09
11 changed files with 623 additions and 96 deletions
|
|
@ -0,0 +1,319 @@
|
|||
import { useCourse, useCourseDispatch } from '@components/Contexts/CourseContext'
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||
import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'
|
||||
import { getAPIUrl } from '@services/config/config'
|
||||
import { editContributor, getCourseContributors } from '@services/courses/courses'
|
||||
import { swrFetcher } from '@services/utils/ts/requests'
|
||||
import { Check, ChevronDown, UserPen, Users } from 'lucide-react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import useSWR, { mutate } from 'swr'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import UserAvatar from '@components/Objects/UserAvatar'
|
||||
|
||||
type EditCourseContributorsProps = {
|
||||
orgslug: string
|
||||
course_uuid?: string
|
||||
}
|
||||
|
||||
type ContributorRole = 'CREATOR' | 'CONTRIBUTOR' | 'MAINTAINER' | 'REPORTER'
|
||||
type ContributorStatus = 'ACTIVE' | 'INACTIVE' | 'PENDING'
|
||||
|
||||
interface Contributor {
|
||||
id: string;
|
||||
user_id: string;
|
||||
authorship: ContributorRole;
|
||||
authorship_status: ContributorStatus;
|
||||
user: {
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
avatar_image: string;
|
||||
}
|
||||
}
|
||||
|
||||
function EditCourseContributors(props: EditCourseContributorsProps) {
|
||||
const session = useLHSession() as any;
|
||||
const access_token = session?.data?.tokens?.access_token;
|
||||
const course = useCourse() as any;
|
||||
const { isLoading, courseStructure } = course as any;
|
||||
const dispatchCourse = useCourseDispatch() as any;
|
||||
|
||||
const { data: contributors } = useSWR<Contributor[]>(
|
||||
courseStructure ? `${getAPIUrl()}courses/${courseStructure.course_uuid}/contributors` : null,
|
||||
(url: string) => swrFetcher(url, access_token)
|
||||
);
|
||||
|
||||
const [isOpenToContributors, setIsOpenToContributors] = useState<boolean | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && courseStructure?.open_to_contributors !== undefined) {
|
||||
setIsOpenToContributors(courseStructure.open_to_contributors);
|
||||
}
|
||||
}, [isLoading, courseStructure]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && courseStructure?.open_to_contributors !== undefined && isOpenToContributors !== undefined) {
|
||||
if (isOpenToContributors !== courseStructure.open_to_contributors) {
|
||||
dispatchCourse({ type: 'setIsNotSaved' });
|
||||
const updatedCourse = {
|
||||
...courseStructure,
|
||||
open_to_contributors: isOpenToContributors,
|
||||
};
|
||||
dispatchCourse({ type: 'setCourseStructure', payload: updatedCourse });
|
||||
}
|
||||
}
|
||||
}, [isLoading, isOpenToContributors, courseStructure, dispatchCourse]);
|
||||
|
||||
const updateContributor = async (contributorId: string, data: { authorship?: ContributorRole; authorship_status?: ContributorStatus }) => {
|
||||
try {
|
||||
// Find the current contributor to get their current values
|
||||
const currentContributor = contributors?.find(c => c.user_id === contributorId);
|
||||
if (!currentContributor) return;
|
||||
|
||||
// Don't allow editing if the user is a CREATOR
|
||||
if (currentContributor.authorship === 'CREATOR') {
|
||||
toast.error('Cannot modify a creator\'s role or status');
|
||||
return;
|
||||
}
|
||||
|
||||
// Always send both values in the request
|
||||
const updatedData = {
|
||||
authorship: data.authorship || currentContributor.authorship,
|
||||
authorship_status: data.authorship_status || currentContributor.authorship_status
|
||||
};
|
||||
|
||||
const res = await editContributor(courseStructure.course_uuid, contributorId, updatedData.authorship, updatedData.authorship_status, access_token);
|
||||
if (res.status === 200 && res.data?.status === 'success') {
|
||||
toast.success(res.data.detail || 'Successfully updated contributor');
|
||||
mutate(`${getAPIUrl()}courses/${courseStructure.course_uuid}/contributors`);
|
||||
} else {
|
||||
toast.error(`Error: ${res.data?.detail || 'Failed to update contributor'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('An error occurred while updating the contributor.');
|
||||
}
|
||||
};
|
||||
|
||||
const RoleDropdown = ({ contributor }: { contributor: Contributor }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-[200px] justify-between"
|
||||
disabled={contributor.authorship === 'CREATOR'}
|
||||
>
|
||||
{contributor.authorship}
|
||||
<ChevronDown className="ml-2 h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[200px]">
|
||||
{['CREATOR', 'CONTRIBUTOR', 'MAINTAINER', 'REPORTER'].map((role) => (
|
||||
<DropdownMenuItem
|
||||
key={role}
|
||||
onClick={() => updateContributor(contributor.user_id, { authorship: role as ContributorRole })}
|
||||
className="justify-between"
|
||||
>
|
||||
{role}
|
||||
{contributor.authorship === role && <Check className="ml-2 h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
const StatusDropdown = ({ contributor }: { contributor: Contributor }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`w-[200px] justify-between ${getStatusStyle(contributor.authorship_status)}`}
|
||||
disabled={contributor.authorship === 'CREATOR'}
|
||||
>
|
||||
{contributor.authorship_status}
|
||||
<ChevronDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[200px]">
|
||||
{['ACTIVE', 'INACTIVE', 'PENDING'].map((status) => (
|
||||
<DropdownMenuItem
|
||||
key={status}
|
||||
onClick={() => updateContributor(contributor.user_id, { authorship_status: status as ContributorStatus })}
|
||||
className="justify-between"
|
||||
>
|
||||
{status}
|
||||
{contributor.authorship_status === status && <Check className="ml-2 h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
const getStatusStyle = (status: ContributorStatus) => {
|
||||
switch (status) {
|
||||
case 'ACTIVE':
|
||||
return 'bg-green-50 text-green-700 hover:bg-green-100 hover:text-green-800';
|
||||
case 'INACTIVE':
|
||||
return 'bg-gray-50 text-gray-700 hover:bg-gray-100 hover:text-gray-800';
|
||||
case 'PENDING':
|
||||
return 'bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800';
|
||||
default:
|
||||
return 'bg-gray-50 text-gray-700 hover:bg-gray-100 hover:text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const sortContributors = (contributors: Contributor[] | undefined) => {
|
||||
if (!contributors) return [];
|
||||
|
||||
return [...contributors].sort((a, b) => {
|
||||
// First sort by role priority
|
||||
const rolePriority: Record<ContributorRole, number> = {
|
||||
'CREATOR': 0,
|
||||
'MAINTAINER': 1,
|
||||
'CONTRIBUTOR': 2,
|
||||
'REPORTER': 3
|
||||
};
|
||||
|
||||
const roleDiff = rolePriority[a.authorship] - rolePriority[b.authorship];
|
||||
if (roleDiff !== 0) return roleDiff;
|
||||
|
||||
// Then sort by name
|
||||
const nameA = `${a.user.first_name} ${a.user.last_name}`.toLowerCase();
|
||||
const nameB = `${b.user.first_name} ${b.user.last_name}`.toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{courseStructure && (
|
||||
<div>
|
||||
<div className="h-6"></div>
|
||||
<div className="mx-4 sm:mx-10 bg-white rounded-xl shadow-xs px-4 py-4">
|
||||
<div className="flex flex-col bg-gray-50 -space-y-1 px-3 sm:px-5 py-3 rounded-md mb-3">
|
||||
<h1 className="font-bold text-lg sm:text-xl text-gray-800">Course Contributors</h1>
|
||||
<h2 className="text-gray-500 text-xs sm:text-sm">
|
||||
Choose if you want your course to be open for contributors and manage existing contributors
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row sm:space-x-2 space-y-2 sm:space-y-0 mx-auto mb-3">
|
||||
<ConfirmationModal
|
||||
confirmationButtonText="Open to Contributors"
|
||||
confirmationMessage="Are you sure you want to open this course to contributors?"
|
||||
dialogTitle="Open to Contributors?"
|
||||
dialogTrigger={
|
||||
<div className="w-full h-[200px] bg-slate-100 rounded-lg cursor-pointer hover:bg-slate-200 transition-all">
|
||||
{isOpenToContributors && (
|
||||
<div className="bg-green-200 text-green-600 font-bold w-fit my-3 mx-3 absolute text-sm px-3 py-1 rounded-lg">
|
||||
Active
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col space-y-1 justify-center items-center h-full p-2 sm:p-4">
|
||||
<UserPen className="text-slate-400" size={32} />
|
||||
<div className="text-xl sm:text-2xl text-slate-700 font-bold">
|
||||
Open to Contributors
|
||||
</div>
|
||||
<div className="text-gray-400 text-sm sm:text-md tracking-tight w-full sm:w-[500px] leading-5 text-center">
|
||||
The course is open for contributors. Users can apply to become contributors and help improve the course content.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
functionToExecute={() => setIsOpenToContributors(true)}
|
||||
status="info"
|
||||
/>
|
||||
<ConfirmationModal
|
||||
confirmationButtonText="Close to Contributors"
|
||||
confirmationMessage="Are you sure you want to close this course to contributors?"
|
||||
dialogTitle="Close to Contributors?"
|
||||
dialogTrigger={
|
||||
<div className="w-full h-[200px] bg-slate-100 rounded-lg cursor-pointer hover:bg-slate-200 transition-all">
|
||||
{!isOpenToContributors && (
|
||||
<div className="bg-green-200 text-green-600 font-bold w-fit my-3 mx-3 absolute text-sm px-3 py-1 rounded-lg">
|
||||
Active
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col space-y-1 justify-center items-center h-full p-2 sm:p-4">
|
||||
<Users className="text-slate-400" size={32} />
|
||||
<div className="text-xl sm:text-2xl text-slate-700 font-bold">
|
||||
Closed to Contributors
|
||||
</div>
|
||||
<div className="text-gray-400 text-sm sm:text-md tracking-tight w-full sm:w-[500px] leading-5 text-center">
|
||||
The course is closed for contributors. Only existing contributors can modify the course content.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
functionToExecute={() => setIsOpenToContributors(false)}
|
||||
status="info"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col bg-gray-50 -space-y-1 px-3 sm:px-5 py-3 rounded-md mb-3">
|
||||
<h1 className="font-bold text-lg sm:text-xl text-gray-800">Current Contributors</h1>
|
||||
<h2 className="text-gray-500 text-xs sm:text-sm">
|
||||
Manage the current contributors of this course
|
||||
</h2>
|
||||
</div>
|
||||
<div className="max-h-[600px] overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortContributors(contributors)?.map((contributor) => (
|
||||
<TableRow key={contributor.id}>
|
||||
<TableCell>
|
||||
<UserAvatar
|
||||
width={30}
|
||||
border='border-2'
|
||||
avatar_url={contributor.user.avatar_image}
|
||||
rounded="rounded"
|
||||
predefined_avatar={contributor.user.avatar_image === '' ? 'empty' : undefined}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{contributor.user.first_name} {contributor.user.last_name}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-500">
|
||||
{contributor.user.email}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RoleDropdown contributor={contributor} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusDropdown contributor={contributor} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditCourseContributors;
|
||||
|
|
@ -14,11 +14,14 @@ import CoursePaidOptions from './CoursePaidOptions'
|
|||
import { checkPaidAccess } from '@services/payments/payments'
|
||||
|
||||
interface Author {
|
||||
user_uuid: string
|
||||
avatar_image: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
username: string
|
||||
user: {
|
||||
user_uuid: string
|
||||
avatar_image: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
username: string
|
||||
}
|
||||
authorship: 'CREATOR' | 'CONTRIBUTOR' | 'MAINTAINER' | 'REPORTER'
|
||||
}
|
||||
|
||||
interface CourseRun {
|
||||
|
|
@ -55,23 +58,23 @@ const AuthorInfo = ({ author, isMobile }: { author: Author, isMobile: boolean })
|
|||
<div className="flex flex-row md:flex-col mx-auto space-y-0 md:space-y-3 space-x-4 md:space-x-0 px-2 py-2 items-center">
|
||||
<UserAvatar
|
||||
border="border-8"
|
||||
avatar_url={author.avatar_image ? getUserAvatarMediaDirectory(author.user_uuid, author.avatar_image) : ''}
|
||||
predefined_avatar={author.avatar_image ? undefined : 'empty'}
|
||||
avatar_url={author.user.avatar_image ? getUserAvatarMediaDirectory(author.user.user_uuid, author.user.avatar_image) : ''}
|
||||
predefined_avatar={author.user.avatar_image ? undefined : 'empty'}
|
||||
width={isMobile ? 60 : 100}
|
||||
/>
|
||||
<div className="md:-space-y-2">
|
||||
<div className="text-[12px] text-neutral-400 font-semibold">Author</div>
|
||||
<div className="text-lg md:text-xl font-bold text-neutral-800">
|
||||
{(author.first_name && author.last_name) ? (
|
||||
{(author.user.first_name && author.user.last_name) ? (
|
||||
<div className="flex space-x-2 items-center">
|
||||
<p>{`${author.first_name} ${author.last_name}`}</p>
|
||||
<p>{`${author.user.first_name} ${author.user.last_name}`}</p>
|
||||
<span className="text-xs bg-neutral-100 p-1 px-3 rounded-full text-neutral-400 font-semibold">
|
||||
@{author.username}
|
||||
@{author.user.username}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex space-x-2 items-center">
|
||||
<p>@{author.username}</p>
|
||||
<p>@{author.user.username}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -270,10 +273,20 @@ function CoursesActions({ courseuuid, orgslug, course }: CourseActionsProps) {
|
|||
const session = useLHSession() as any
|
||||
const isMobile = useMediaQuery('(max-width: 768px)')
|
||||
|
||||
// Sort authors by role priority
|
||||
const sortedAuthors = [...course.authors].sort((a, b) => {
|
||||
const rolePriority: Record<string, number> = {
|
||||
'CREATOR': 0,
|
||||
'MAINTAINER': 1,
|
||||
'CONTRIBUTOR': 2,
|
||||
'REPORTER': 3
|
||||
};
|
||||
return rolePriority[a.authorship] - rolePriority[b.authorship];
|
||||
});
|
||||
|
||||
return (
|
||||
<div className=" space-y-3 antialiased flex flex-col p-3 py-5 bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden">
|
||||
<AuthorInfo author={course.authors[0]} isMobile={isMobile} />
|
||||
<AuthorInfo author={sortedAuthors[0]} isMobile={isMobile} />
|
||||
<div className='px-3 py-2'>
|
||||
<Actions courseuuid={courseuuid} orgslug={orgslug} course={course} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ type UserAvatarProps = {
|
|||
border?: 'border-2' | 'border-4' | 'border-8'
|
||||
borderColor?: string
|
||||
predefined_avatar?: 'ai' | 'empty'
|
||||
backgroundColor?: 'bg-white' | 'bg-gray-100'
|
||||
}
|
||||
|
||||
function UserAvatar(props: UserAvatarProps) {
|
||||
|
|
@ -78,6 +79,7 @@ function UserAvatar(props: UserAvatarProps) {
|
|||
${props.avatar_url && session?.data?.user?.avatar_image ? '' : 'bg-gray-700'}
|
||||
${props.border ? `border ${props.border}` : ''}
|
||||
${props.borderColor ?? 'border-white'}
|
||||
${props.backgroundColor ?? 'bg-gray-100'}
|
||||
shadow-xl
|
||||
aspect-square
|
||||
w-[${props.width ?? 50}px]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue