feat: Improve the contributors admin UX/UI

This commit is contained in:
swve 2025-06-08 11:52:36 +02:00
parent 77aec2cf92
commit c0c32f9564
8 changed files with 899 additions and 50 deletions

View file

@ -36,6 +36,8 @@ from src.services.courses.contributors import (
apply_course_contributor,
update_course_contributor,
get_course_contributors,
add_bulk_course_contributors,
remove_bulk_course_contributors,
)
from src.db.resource_authors import ResourceAuthorshipEnum, ResourceAuthorshipStatusEnum
@ -318,3 +320,45 @@ async def api_update_course_contributor(
current_user,
db_session
)
@router.post("/{course_uuid}/bulk-add-contributors")
async def api_add_bulk_course_contributors(
request: Request,
course_uuid: str,
usernames: List[str],
db_session: Session = Depends(get_db_session),
current_user: PublicUser = Depends(get_current_user),
):
"""
Add multiple contributors to a course by their usernames
Only administrators can perform this action
"""
return await add_bulk_course_contributors(
request,
course_uuid,
usernames,
current_user,
db_session
)
@router.put("/{course_uuid}/bulk-remove-contributors")
async def api_remove_bulk_course_contributors(
request: Request,
course_uuid: str,
usernames: List[str],
db_session: Session = Depends(get_db_session),
current_user: PublicUser = Depends(get_current_user),
):
"""
Remove multiple contributors from a course by their usernames
Only administrators can perform this action
"""
return await remove_bulk_course_contributors(
request,
course_uuid,
usernames,
current_user,
db_session
)

View file

@ -174,3 +174,199 @@ async def get_course_contributors(
}
for contributor, user in results
]
async def add_bulk_course_contributors(
request: Request,
course_uuid: str,
usernames: List[str],
current_user: PublicUser | AnonymousUser,
db_session: Session,
):
"""
Add multiple contributors to a course by their usernames
Only administrators can perform this action
"""
# Verify user is not anonymous
await authorization_verify_if_user_is_anon(current_user.id)
# RBAC check - verify if user has admin rights
authorized = await authorization_verify_based_on_roles_and_authorship(
request, current_user.id, "update", course_uuid, db_session
)
if not authorized:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not authorized to add contributors",
)
# Check if course exists
statement = select(Course).where(Course.course_uuid == course_uuid)
course = db_session.exec(statement).first()
if not course:
raise HTTPException(
status_code=404,
detail="Course not found",
)
# Process results
results = {
"successful": [],
"failed": []
}
current_time = str(datetime.now())
for username in usernames:
try:
# Find user by username
user_statement = select(User).where(User.username == username)
user = db_session.exec(user_statement).first()
if not user or user.id is None:
results["failed"].append({
"username": username,
"reason": "User not found or invalid"
})
continue
# Check if user already has any authorship role for this course
existing_authorship = db_session.exec(
select(ResourceAuthor).where(
and_(
ResourceAuthor.resource_uuid == course_uuid,
ResourceAuthor.user_id == user.id
)
)
).first()
if existing_authorship:
results["failed"].append({
"username": username,
"reason": "User already has an authorship role for this course"
})
continue
# Create contributor
resource_author = ResourceAuthor(
resource_uuid=course_uuid,
user_id=user.id,
authorship=ResourceAuthorshipEnum.CONTRIBUTOR,
authorship_status=ResourceAuthorshipStatusEnum.PENDING,
creation_date=current_time,
update_date=current_time,
)
db_session.add(resource_author)
db_session.commit()
db_session.refresh(resource_author)
results["successful"].append({
"username": username,
"user_id": user.id
})
except Exception as e:
results["failed"].append({
"username": username,
"reason": str(e)
})
return results
async def remove_bulk_course_contributors(
request: Request,
course_uuid: str,
usernames: List[str],
current_user: PublicUser | AnonymousUser,
db_session: Session,
):
"""
Remove multiple contributors from a course by their usernames
Only administrators can perform this action
"""
# Verify user is not anonymous
await authorization_verify_if_user_is_anon(current_user.id)
# RBAC check - verify if user has admin rights
authorized = await authorization_verify_based_on_roles_and_authorship(
request, current_user.id, "update", course_uuid, db_session
)
if not authorized:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not authorized to remove contributors",
)
# Check if course exists
statement = select(Course).where(Course.course_uuid == course_uuid)
course = db_session.exec(statement).first()
if not course:
raise HTTPException(
status_code=404,
detail="Course not found",
)
# Process results
results = {
"successful": [],
"failed": []
}
for username in usernames:
try:
# Find user by username
user_statement = select(User).where(User.username == username)
user = db_session.exec(user_statement).first()
if not user or user.id is None:
results["failed"].append({
"username": username,
"reason": "User not found or invalid"
})
continue
# Check if user has any authorship role for this course
existing_authorship = db_session.exec(
select(ResourceAuthor).where(
and_(
ResourceAuthor.resource_uuid == course_uuid,
ResourceAuthor.user_id == user.id
)
)
).first()
if not existing_authorship:
results["failed"].append({
"username": username,
"reason": "User is not a contributor for this course"
})
continue
# Don't allow removing the creator
if existing_authorship.authorship == ResourceAuthorshipEnum.CREATOR:
results["failed"].append({
"username": username,
"reason": "Cannot remove the course creator"
})
continue
# Remove the contributor
db_session.delete(existing_authorship)
db_session.commit()
results["successful"].append({
"username": username,
"user_id": user.id
})
except Exception as e:
results["failed"].append({
"username": username,
"reason": str(e)
})
return results

View file

@ -1,10 +1,12 @@
import { useCourse, useCourseDispatch } from '@components/Contexts/CourseContext'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { useOrg } from '@components/Contexts/OrgContext'
import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'
import { getAPIUrl } from '@services/config/config'
import { editContributor, getCourseContributors } from '@services/courses/courses'
import { bulkAddContributors, bulkRemoveContributors, editContributor, getCourseContributors } from '@services/courses/courses'
import { searchOrgContent } from '@services/search/search'
import { swrFetcher } from '@services/utils/ts/requests'
import { Check, ChevronDown, UserPen, Users } from 'lucide-react'
import { Check, ChevronDown, Search, UserPen, Users } from 'lucide-react'
import React, { useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import useSWR, { mutate } from 'swr'
@ -24,6 +26,9 @@ import {
} from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import UserAvatar from '@components/Objects/UserAvatar'
import { Input } from '@/components/ui/input'
import { useDebounce } from '@/hooks/useDebounce'
import { getUserAvatarMediaDirectory } from '@services/media/media'
type EditCourseContributorsProps = {
orgslug: string
@ -33,26 +38,58 @@ type EditCourseContributorsProps = {
type ContributorRole = 'CREATOR' | 'CONTRIBUTOR' | 'MAINTAINER' | 'REPORTER'
type ContributorStatus = 'ACTIVE' | 'INACTIVE' | 'PENDING'
interface SearchUser {
username: string;
first_name: string;
last_name: string;
email: string;
avatar_image: string;
avatar_url?: string;
id: number;
user_uuid: string;
}
interface Contributor {
id: string;
user_id: string;
authorship: ContributorRole;
authorship_status: ContributorStatus;
creation_date: string;
user: {
username: string;
first_name: string;
last_name: string;
email: string;
avatar_image: string;
user_uuid: string;
}
}
interface BulkAddResponse {
successful: string[];
failed: {
username: string;
reason: string;
}[];
}
// Helper function for date formatting
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
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 org = useOrg() as any;
const { data: contributors } = useSWR<Contributor[]>(
courseStructure ? `${getAPIUrl()}courses/${courseStructure.course_uuid}/contributors` : null,
@ -60,6 +97,13 @@ function EditCourseContributors(props: EditCourseContributorsProps) {
);
const [isOpenToContributors, setIsOpenToContributors] = useState<boolean | undefined>(undefined);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SearchUser[]>([]);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const [isSearching, setIsSearching] = useState(false);
const debouncedSearch = useDebounce(searchQuery, 300);
const [selectedContributors, setSelectedContributors] = useState<string[]>([]);
const [masterCheckboxChecked, setMasterCheckboxChecked] = useState(false);
useEffect(() => {
if (!isLoading && courseStructure?.open_to_contributors !== undefined) {
@ -80,6 +124,95 @@ function EditCourseContributors(props: EditCourseContributorsProps) {
}
}, [isLoading, isOpenToContributors, courseStructure, dispatchCourse]);
useEffect(() => {
const searchUsers = async () => {
if (debouncedSearch.trim().length === 0) {
setSearchResults([]);
setIsSearching(false);
return;
}
setIsSearching(true);
try {
const response = await searchOrgContent(
org?.slug,
debouncedSearch,
1,
5,
null,
access_token
);
if (response.success && response.data?.users) {
const users = response.data.users.map((user: SearchUser) => ({
...user,
avatar_url: user.avatar_image ? getUserAvatarMediaDirectory(user.user_uuid, user.avatar_image) : ''
}));
setSearchResults(users);
} else {
setSearchResults([]);
}
} catch (error) {
console.error('Error searching users:', error);
setSearchResults([]);
}
setIsSearching(false);
};
if (org?.slug && access_token) {
searchUsers();
}
}, [debouncedSearch, org?.slug, access_token]);
useEffect(() => {
if (contributors) {
const nonCreatorContributors = contributors.filter(c => c.authorship !== 'CREATOR');
setMasterCheckboxChecked(
nonCreatorContributors.length > 0 &&
selectedContributors.length === nonCreatorContributors.length
);
}
}, [contributors, selectedContributors]);
const handleUserSelect = (username: string) => {
setSelectedUsers(prev => {
if (prev.includes(username)) {
return prev.filter(u => u !== username);
}
return [...prev, username];
});
};
const handleAddContributors = async () => {
if (selectedUsers.length === 0) return;
try {
const response = await bulkAddContributors(courseStructure.course_uuid, selectedUsers, access_token);
if (response.status === 200) {
const result = response.data as BulkAddResponse;
// Show success message for successful adds
if (result.successful.length > 0) {
toast.success(`Successfully added ${result.successful.length} contributor(s)`);
}
// Show error messages for failed adds
result.failed.forEach(failure => {
toast.error(`Failed to add ${failure.username}: ${failure.reason}`);
});
// Refresh contributors list
mutate(`${getAPIUrl()}courses/${courseStructure.course_uuid}/contributors`);
// Clear selection and search
setSelectedUsers([]);
setSearchQuery('');
}
} catch (error) {
console.error('Error adding contributors:', error);
toast.error('Failed to add contributors');
}
};
const updateContributor = async (contributorId: string, data: { authorship?: ContributorRole; authorship_status?: ContributorStatus }) => {
try {
// Find the current contributor to get their current values
@ -188,6 +321,45 @@ function EditCourseContributors(props: EditCourseContributorsProps) {
return creator ? [creator, ...otherContributors] : otherContributors;
};
const handleContributorSelect = (userId: string) => {
setSelectedContributors(prev => {
if (prev.includes(userId)) {
return prev.filter(id => id !== userId);
}
return [...prev, userId];
});
};
const handleBulkRemove = async () => {
if (selectedContributors.length === 0) return;
try {
// Get the usernames from the selected contributors
const selectedUsernames = contributors
?.filter(c => selectedContributors.includes(c.user_id))
.map(c => c.user.username) || [];
console.log('Sending usernames:', selectedUsernames); // Debug log
const response = await bulkRemoveContributors(
courseStructure.course_uuid,
selectedUsernames, // Send as raw array, not stringified
access_token
);
if (response.status === 200) {
toast.success(`Successfully removed ${selectedContributors.length} contributor(s)`);
// Refresh contributors list
mutate(`${getAPIUrl()}courses/${courseStructure.course_uuid}/contributors`);
// Clear selection
setSelectedContributors([]);
}
} catch (error) {
console.error('Error removing contributors:', error);
toast.error('Failed to remove contributors');
}
};
return (
<div>
{courseStructure && (
@ -197,7 +369,7 @@ function EditCourseContributors(props: EditCourseContributorsProps) {
<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
Manage contributors and add new ones to your course
</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">
@ -252,31 +424,206 @@ function EditCourseContributors(props: EditCourseContributorsProps) {
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 className="space-y-4">
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search users by name or username to add as contributors..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
{searchQuery && (
<div className="bg-white rounded-xl nice-shadow divide-y">
{isSearching ? (
<div className="p-4 text-center text-sm text-gray-500">
Searching...
</div>
) : searchResults && searchResults.length > 0 ? (
<>
{selectedUsers.length > 0 && (
<div className="p-3 bg-gray-100">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-700">
{selectedUsers.length} user{selectedUsers.length > 1 ? 's' : ''} selected
</span>
<div className="flex gap-2">
<Button
onClick={() => setSelectedUsers([])}
variant="outline"
className="text-sm"
>
Clear
</Button>
<Button
onClick={handleAddContributors}
className="bg-gray-900 text-white hover:bg-gray-800 text-sm"
>
Add Selected
</Button>
</div>
</div>
</div>
)}
{searchResults.map((user) => {
const isSelected = selectedUsers.includes(user.username);
const isExistingContributor = contributors?.some(
c => c.user.username === user.username
);
return (
<div
key={user.username}
className={`flex items-center justify-between p-4 ${
isSelected ? 'bg-gray-100' : ''
} ${!isExistingContributor ? 'cursor-pointer hover:bg-gray-50' : ''} transition-colors`}
onClick={(e) => {
// Don't handle click if it's on a checkbox
if (e.target instanceof HTMLElement && e.target.closest('input[type="checkbox"]')) {
return;
}
if (!isExistingContributor) {
handleUserSelect(user.username);
}
}}
>
<div className="flex items-center space-x-3">
<div onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={isSelected || false}
onChange={() => !isExistingContributor && handleUserSelect(user.username)}
disabled={isExistingContributor}
className="h-4 w-4 rounded border-gray-300 text-gray-600 focus:ring-gray-500 disabled:opacity-50"
/>
</div>
<UserAvatar
width={40}
avatar_url={user.avatar_url}
predefined_avatar={user.avatar_image ? undefined : 'empty'}
userId={user.id.toString()}
showProfilePopup
rounded="rounded-full"
backgroundColor="bg-gray-100"
/>
<div>
<div className="font-medium text-gray-900">
{user.first_name} {user.last_name}
</div>
<div className="text-sm text-gray-500">
@{user.username}
</div>
</div>
</div>
{isExistingContributor && (
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded">
Already a contributor
</span>
)}
</div>
);
})}
</>
) : (
<div className="p-4 text-center text-sm text-gray-500">
No users found
</div>
)}
</div>
)}
<div className="bg-white rounded-xl nice-shadow">
{selectedContributors.length > 0 && (
<div className="p-3 bg-gray-100 rounded-t-xl border-b">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-700">
{selectedContributors.length} contributor{selectedContributors.length > 1 ? 's' : ''} selected
</span>
<div className="flex gap-2">
<Button
onClick={() => setSelectedContributors([])}
variant="outline"
className="text-sm"
>
Clear
</Button>
<Button
onClick={handleBulkRemove}
className="bg-red-600 text-white hover:bg-red-700 text-sm"
>
Remove Selected
</Button>
</div>
</div>
</div>
)}
<div className="max-h-[600px] overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[30px]">
<input
type="checkbox"
checked={masterCheckboxChecked}
onChange={(e) => {
setMasterCheckboxChecked(e.target.checked);
if (contributors) {
if (e.target.checked) {
// Select all non-creator contributors
const nonCreatorContributors = contributors
.filter(c => c.authorship !== 'CREATOR')
.map(c => c.user_id);
setSelectedContributors(nonCreatorContributors);
} else {
setSelectedContributors([]);
}
}
}}
className="h-4 w-4 rounded border-gray-300 text-gray-600 focus:ring-gray-500"
/>
</TableHead>
<TableHead className="w-[50px]"></TableHead>
<TableHead>Name</TableHead>
<TableHead>Username</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
<TableHead>Added On</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortContributors(contributors)?.map((contributor) => (
<TableRow key={contributor.id}>
<TableRow
key={`${contributor.user_id}-${contributor.id}`}
className={`${selectedContributors.includes(contributor.user_id) ? 'bg-gray-50' : ''} ${contributor.authorship !== 'CREATOR' ? 'cursor-pointer hover:bg-gray-50' : ''}`}
onClick={(e) => {
// Don't handle click if it's on a dropdown or checkbox
if (
e.target instanceof HTMLElement &&
(e.target.closest('button') ||
e.target.closest('input[type="checkbox"]'))
) {
return;
}
if (contributor.authorship !== 'CREATOR') {
handleContributorSelect(contributor.user_id);
}
}}
>
<TableCell onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedContributors.includes(contributor.user_id)}
onChange={() => handleContributorSelect(contributor.user_id)}
disabled={contributor.authorship === 'CREATOR'}
className="h-4 w-4 rounded border-gray-300 text-gray-600 focus:ring-gray-500 disabled:opacity-50"
/>
</TableCell>
<TableCell>
<UserAvatar
width={30}
border='border-2'
avatar_url={contributor.user.avatar_image}
avatar_url={contributor.user.avatar_image ? getUserAvatarMediaDirectory(contributor.user.user_uuid, contributor.user.avatar_image) : ''}
rounded="rounded"
predefined_avatar={contributor.user.avatar_image === '' ? 'empty' : undefined}
/>
@ -284,6 +631,9 @@ function EditCourseContributors(props: EditCourseContributorsProps) {
<TableCell className="font-medium">
{contributor.user.first_name} {contributor.user.last_name}
</TableCell>
<TableCell className="text-gray-500">
@{contributor.user.username}
</TableCell>
<TableCell className="text-gray-500">
{contributor.user.email}
</TableCell>
@ -293,6 +643,9 @@ function EditCourseContributors(props: EditCourseContributorsProps) {
<TableCell>
<StatusDropdown contributor={contributor} />
</TableCell>
<TableCell className="text-gray-500 text-sm">
{formatDate(contributor.creation_date)}
</TableCell>
</TableRow>
))}
</TableBody>
@ -300,6 +653,8 @@ function EditCourseContributors(props: EditCourseContributorsProps) {
</div>
</div>
</div>
</div>
</div>
)}
</div>
);

View file

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "@radix-ui/react-icons"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<CheckIcon className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View file

@ -17,6 +17,7 @@
"@icons-pack/react-simple-icons": "^10.2.0",
"@radix-ui/colors": "^0.1.9",
"@radix-ui/react-aspect-ratio": "^1.1.2",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-form": "^0.0.3",

207
apps/web/pnpm-lock.yaml generated
View file

@ -30,6 +30,9 @@ importers:
'@radix-ui/react-aspect-ratio':
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-checkbox':
specifier: ^1.3.2
version: 1.3.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-dialog':
specifier: ^1.1.6
version: 1.1.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@ -728,6 +731,9 @@ packages:
'@radix-ui/primitive@1.1.1':
resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==}
'@radix-ui/primitive@1.1.2':
resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==}
'@radix-ui/react-arrow@1.1.2':
resolution: {integrity: sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==}
peerDependencies:
@ -754,6 +760,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-checkbox@1.3.2':
resolution: {integrity: sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA==}
peerDependencies:
'@types/react': 19.0.10
'@types/react-dom': 19.0.4
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-collection@1.1.2':
resolution: {integrity: sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==}
peerDependencies:
@ -790,6 +809,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-compose-refs@1.1.2':
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
peerDependencies:
'@types/react': 19.0.10
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-context@1.0.0':
resolution: {integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==}
peerDependencies:
@ -813,6 +841,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-context@1.1.2':
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
peerDependencies:
'@types/react': 19.0.10
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-dialog@1.0.0':
resolution: {integrity: sha512-Yn9YU+QlHYLWwV1XfKiqnGVpWYWk6MeBVM6x/bcoyPvxgjQGoeT35482viLPctTMWoMw0PoHgqfSox7Ig+957Q==}
peerDependencies:
@ -1137,6 +1174,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-presence@1.1.4':
resolution: {integrity: sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==}
peerDependencies:
'@types/react': 19.0.10
'@types/react-dom': 19.0.4
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-primitive@1.0.0':
resolution: {integrity: sha512-EyXe6mnRlHZ8b6f4ilTDrXmkLShICIuOTTj0GX4w1rp+wSxf3+TD05u1UOITC8VsJ2a9nwHvdXtOXEOl0Cw/zQ==}
peerDependencies:
@ -1169,6 +1219,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-primitive@2.1.3':
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
peerDependencies:
'@types/react': 19.0.10
'@types/react-dom': 19.0.4
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-roving-focus@1.1.2':
resolution: {integrity: sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==}
peerDependencies:
@ -1218,6 +1281,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-slot@1.2.3':
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
peerDependencies:
'@types/react': 19.0.10
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-switch@1.1.3':
resolution: {integrity: sha512-1nc+vjEOQkJVsJtWPSiISGT6OKm4SiOdjMo+/icLxo2G4vxz1GntC5MzfL4v8ey9OEfw787QCD1y3mUv0NiFEQ==}
peerDependencies:
@ -1329,6 +1401,24 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-controllable-state@1.2.2':
resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==}
peerDependencies:
'@types/react': 19.0.10
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-effect-event@0.0.2':
resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==}
peerDependencies:
'@types/react': 19.0.10
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-escape-keydown@1.0.0':
resolution: {integrity: sha512-JwfBCUIfhXRxKExgIqGa4CQsiMemo1Xt0W/B4ei3fpzpvPENKpMKQ8mZSB6Acj3ebrAEgi2xiQvcI1PAAodvyg==}
peerDependencies:
@ -1375,6 +1465,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-layout-effect@1.1.1':
resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
peerDependencies:
'@types/react': 19.0.10
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-previous@1.1.0':
resolution: {integrity: sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==}
peerDependencies:
@ -1384,6 +1483,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-previous@1.1.1':
resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==}
peerDependencies:
'@types/react': 19.0.10
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-rect@1.1.0':
resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==}
peerDependencies:
@ -1402,6 +1510,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-size@1.1.1':
resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==}
peerDependencies:
'@types/react': 19.0.10
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-visually-hidden@1.1.2':
resolution: {integrity: sha512-1SzA4ns2M1aRlvxErqhLHsBHoS5eI5UUcI2awAMgGUp4LoaoWOKYmvqDY2s/tltuPkh3Yk77YF/r3IRj+Amx4Q==}
peerDependencies:
@ -4023,6 +4140,8 @@ snapshots:
'@radix-ui/primitive@1.1.1': {}
'@radix-ui/primitive@1.1.2': {}
'@radix-ui/react-arrow@1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@ -4041,6 +4160,22 @@ snapshots:
'@types/react': 19.0.10
'@types/react-dom': 19.0.4(@types/react@19.0.10)
'@radix-ui/react-checkbox@1.3.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/primitive': 1.1.2
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-presence': 1.1.4(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-use-size': 1.1.1(@types/react@19.0.10)(react@19.0.0)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
optionalDependencies:
'@types/react': 19.0.10
'@types/react-dom': 19.0.4(@types/react@19.0.10)
'@radix-ui/react-collection@1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.10)(react@19.0.0)
@ -4071,6 +4206,12 @@ snapshots:
optionalDependencies:
'@types/react': 19.0.10
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.0.10)(react@19.0.0)':
dependencies:
react: 19.0.0
optionalDependencies:
'@types/react': 19.0.10
'@radix-ui/react-context@1.0.0(react@19.0.0)':
dependencies:
'@babel/runtime': 7.27.0
@ -4089,6 +4230,12 @@ snapshots:
optionalDependencies:
'@types/react': 19.0.10
'@radix-ui/react-context@1.1.2(@types/react@19.0.10)(react@19.0.0)':
dependencies:
react: 19.0.0
optionalDependencies:
'@types/react': 19.0.10
'@radix-ui/react-dialog@1.0.0(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@babel/runtime': 7.27.0
@ -4464,6 +4611,16 @@ snapshots:
'@types/react': 19.0.10
'@types/react-dom': 19.0.4(@types/react@19.0.10)
'@radix-ui/react-presence@1.1.4(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.10)(react@19.0.0)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
optionalDependencies:
'@types/react': 19.0.10
'@types/react-dom': 19.0.4(@types/react@19.0.10)
'@radix-ui/react-primitive@1.0.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@babel/runtime': 7.27.0
@ -4490,6 +4647,15 @@ snapshots:
'@types/react': 19.0.10
'@types/react-dom': 19.0.4(@types/react@19.0.10)
'@radix-ui/react-primitive@2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/react-slot': 1.2.3(@types/react@19.0.10)(react@19.0.0)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
optionalDependencies:
'@types/react': 19.0.10
'@types/react-dom': 19.0.4(@types/react@19.0.10)
'@radix-ui/react-roving-focus@1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/primitive': 1.1.1
@ -4557,6 +4723,13 @@ snapshots:
optionalDependencies:
'@types/react': 19.0.10
'@radix-ui/react-slot@1.2.3(@types/react@19.0.10)(react@19.0.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0)
react: 19.0.0
optionalDependencies:
'@types/react': 19.0.10
'@radix-ui/react-switch@1.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/primitive': 1.1.1
@ -4673,6 +4846,21 @@ snapshots:
optionalDependencies:
'@types/react': 19.0.10
'@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.0.10)(react@19.0.0)':
dependencies:
'@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.10)(react@19.0.0)
react: 19.0.0
optionalDependencies:
'@types/react': 19.0.10
'@radix-ui/react-use-effect-event@0.0.2(@types/react@19.0.10)(react@19.0.0)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.10)(react@19.0.0)
react: 19.0.0
optionalDependencies:
'@types/react': 19.0.10
'@radix-ui/react-use-escape-keydown@1.0.0(react@19.0.0)':
dependencies:
'@babel/runtime': 7.27.0
@ -4712,12 +4900,24 @@ snapshots:
optionalDependencies:
'@types/react': 19.0.10
'@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.0.10)(react@19.0.0)':
dependencies:
react: 19.0.0
optionalDependencies:
'@types/react': 19.0.10
'@radix-ui/react-use-previous@1.1.0(@types/react@19.0.10)(react@19.0.0)':
dependencies:
react: 19.0.0
optionalDependencies:
'@types/react': 19.0.10
'@radix-ui/react-use-previous@1.1.1(@types/react@19.0.10)(react@19.0.0)':
dependencies:
react: 19.0.0
optionalDependencies:
'@types/react': 19.0.10
'@radix-ui/react-use-rect@1.1.0(@types/react@19.0.10)(react@19.0.0)':
dependencies:
'@radix-ui/rect': 1.1.0
@ -4732,6 +4932,13 @@ snapshots:
optionalDependencies:
'@types/react': 19.0.10
'@radix-ui/react-use-size@1.1.1(@types/react@19.0.10)(react@19.0.0)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.10)(react@19.0.0)
react: 19.0.0
optionalDependencies:
'@types/react': 19.0.10
'@radix-ui/react-visually-hidden@1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)

View file

@ -153,3 +153,21 @@ export async function applyForContributor(course_uuid: string, data: any, access
const res = await getResponseMetadata(result)
return res
}
export async function bulkAddContributors(course_uuid: string, data: any, access_token:string | null | undefined) {
const result: any = await fetch(
`${getAPIUrl()}courses/${course_uuid}/bulk-add-contributors`,
RequestBodyWithAuthHeader('POST', data, null,access_token || undefined)
)
const res = await getResponseMetadata(result)
return res
}
export async function bulkRemoveContributors(course_uuid: string, data: any, access_token:string | null | undefined) {
const result: any = await fetch(
`${getAPIUrl()}courses/${course_uuid}/bulk-remove-contributors`,
RequestBodyWithAuthHeader('PUT', data, null,access_token || undefined)
)
const res = await getResponseMetadata(result)
return res
}

View file

@ -32,7 +32,7 @@ export const RequestBodyWithAuthHeader = (
headers: HeadersConfig,
redirect: 'follow',
credentials: 'include',
body: (method === 'POST' || method === 'PUT') ? JSON.stringify(data) : null,
body: (method === 'POST' || method === 'PUT' || method === 'DELETE') && data !== null ? JSON.stringify(data) : null,
// Next.js
next: next,
}