feat: clone course (#582)
* feat: clone course work * feat: clone and delete a course * feat: clone and delete a course * feat: clean up * feat: attending to reviews * feat: attended to revies
This commit is contained in:
@@ -16,7 +16,7 @@
|
||||
import GrowthIcon from 'carbon-icons-svelte/lib/Growth.svelte';
|
||||
import RadioButtonChecked from 'carbon-icons-svelte/lib/RadioButtonChecked.svelte';
|
||||
import UserProfileIcon from 'carbon-icons-svelte/lib/UserProfile.svelte';
|
||||
|
||||
import { copyCourseModal, deleteCourseModal } from '$lib/components/Courses/store';
|
||||
export let bannerImage: string | undefined;
|
||||
export let id = '';
|
||||
export let slug = '';
|
||||
@@ -43,8 +43,10 @@
|
||||
$: formatter = getCurrencyFormatter(currency);
|
||||
|
||||
function handleCloneCourse() {
|
||||
// TODO: Clone course functionality
|
||||
alert('WIP: Clone course');
|
||||
$copyCourseModal.open = true;
|
||||
$copyCourseModal.id = id;
|
||||
$copyCourseModal.title = title;
|
||||
$copyCourseModal.description = description;
|
||||
}
|
||||
|
||||
function handleShareCourse() {
|
||||
@@ -56,7 +58,9 @@
|
||||
}
|
||||
|
||||
function handleDeleteCourse() {
|
||||
goto(`/courses/${id}/settings#delete`);
|
||||
$deleteCourseModal.open = true;
|
||||
$deleteCourseModal.id = id;
|
||||
$deleteCourseModal.title = title;
|
||||
}
|
||||
|
||||
const COURSE_TAG: Record<
|
||||
@@ -122,7 +126,10 @@
|
||||
>
|
||||
<OverflowMenuItem
|
||||
text={$t('courses.course_card.context_menu.clone')}
|
||||
on:click={handleCloneCourse}
|
||||
on:click={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCloneCourse();
|
||||
}}
|
||||
/>
|
||||
<OverflowMenuItem
|
||||
text={$t('courses.course_card.context_menu.share')}
|
||||
@@ -135,7 +142,10 @@
|
||||
<OverflowMenuItem
|
||||
danger
|
||||
text={$t('courses.course_card.context_menu.delete')}
|
||||
on:click={handleDeleteCourse}
|
||||
on:click={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteCourse();
|
||||
}}
|
||||
/>
|
||||
</OverflowMenu>
|
||||
{/if}
|
||||
|
||||
@@ -1,23 +1,56 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import TextArea from '$lib/components/Form/TextArea.svelte';
|
||||
import { copyCourseModalInitialState, copyCourseModal } from '$lib/components/Courses/store';
|
||||
import { cloneCourse } from '$lib/utils/services/courses/clone';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
import generateSlug from '$lib/utils/functions/generateSlug';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface CloneCourseResponse {
|
||||
success: boolean;
|
||||
course: { id: string };
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// clone, show spinner and redirect to new course
|
||||
async function createCourse() {
|
||||
// Prevent double submission (Option 1: Early Return Guard)
|
||||
if ($copyCourseModal.isSaving) return;
|
||||
|
||||
console.log('Create course');
|
||||
$copyCourseModal.isSaving = true;
|
||||
|
||||
try {
|
||||
const newCourse = await cloneCourse($copyCourseModal.id, $copyCourseModal.title);
|
||||
// Generate unique slug from the title
|
||||
const slug = generateSlug($copyCourseModal.title);
|
||||
|
||||
goto(`/courses/${newCourse.id}`);
|
||||
const response = await classroomio.course.clone.$post({
|
||||
json: {
|
||||
id: $copyCourseModal.id,
|
||||
title: $copyCourseModal.title,
|
||||
description: $copyCourseModal.description,
|
||||
slug,
|
||||
organizationId: $currentOrg.id
|
||||
}
|
||||
});
|
||||
|
||||
const result = (await response.json()) as CloneCourseResponse;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || $t('courses.copy_course.error.failed_to_clone'));
|
||||
}
|
||||
|
||||
// Option 3: Close modal and reset state before navigation
|
||||
copyCourseModal.set(copyCourseModalInitialState);
|
||||
|
||||
// Navigate to the new course
|
||||
goto(`/courses/${result.course.id}`);
|
||||
$copyCourseModal.open = false;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
@@ -36,25 +69,34 @@
|
||||
onClose={() => ($copyCourseModal.open = false)}
|
||||
bind:open={$copyCourseModal.open}
|
||||
width="w-96"
|
||||
modalHeading="Create a Course"
|
||||
modalHeading={$t('courses.copy_course.title')}
|
||||
>
|
||||
<form on:submit|preventDefault={createCourse}>
|
||||
<TextField
|
||||
label="Course name"
|
||||
label={$t('courses.copy_course.course_name_label')}
|
||||
bind:value={$copyCourseModal.title}
|
||||
autoFocus={true}
|
||||
placeholder="Your course name"
|
||||
placeholder={$t('courses.copy_course.course_name_placeholder')}
|
||||
className="mb-4"
|
||||
isRequired={true}
|
||||
autoComplete={false}
|
||||
/>
|
||||
|
||||
<div class="mt-5 flex items-center flex-row-reverse">
|
||||
<TextArea
|
||||
label={$t('courses.copy_course.course_description_label')}
|
||||
bind:value={$copyCourseModal.description}
|
||||
placeholder={$t('courses.copy_course.course_description_placeholder')}
|
||||
className="mb-4"
|
||||
rows={4}
|
||||
/>
|
||||
|
||||
<div class="mt-5 flex flex-row-reverse items-center">
|
||||
<PrimaryButton
|
||||
className="px-6 py-3"
|
||||
label="Create"
|
||||
label={$t('courses.copy_course.create_button')}
|
||||
type="submit"
|
||||
isLoading={$copyCourseModal.isSaving}
|
||||
isDisabled={$copyCourseModal.isSaving}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -15,7 +15,12 @@
|
||||
StructuredListBody
|
||||
} from 'carbon-components-svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
import CopyCourseModal from './components/CopyCourseModal/index.svelte';
|
||||
import DeleteModal from '$lib/components/Modal/DeleteModal.svelte';
|
||||
import { deleteCourse } from '$lib/utils/services/courses';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { deleteCourseModal, deleteCourseModalInitialState, courses as coursesStore } from './store';
|
||||
|
||||
export let courses: Course[] = [];
|
||||
export let emptyTitle = $t('courses.course_card.empty_title');
|
||||
export let emptyDescription = $t('courses.course_card.empty_description');
|
||||
@@ -28,9 +33,39 @@
|
||||
|
||||
return Math.round((progressRate / totalLessons) * 100);
|
||||
}
|
||||
|
||||
async function handleDeleteCourse() {
|
||||
if (!$deleteCourseModal.id) return;
|
||||
|
||||
$deleteCourseModal.isDeleting = true;
|
||||
|
||||
try {
|
||||
await deleteCourse($deleteCourseModal.id);
|
||||
|
||||
// Remove the course from the courses store
|
||||
$coursesStore = $coursesStore.filter(course => course.id !== $deleteCourseModal.id);
|
||||
|
||||
// Show success message
|
||||
snackbar.success('snackbar.course_deleted');
|
||||
|
||||
// Close modal and reset state
|
||||
deleteCourseModal.set(deleteCourseModalInitialState);
|
||||
} catch (error) {
|
||||
console.error('Error deleting course:', error);
|
||||
snackbar.error('snackbar.course_settings.error.went_wrong');
|
||||
|
||||
// Stop deleting state on error
|
||||
$deleteCourseModal.isDeleting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- <CopyCourseModal /> -->
|
||||
<CopyCourseModal />
|
||||
<DeleteModal
|
||||
onDelete={handleDeleteCourse}
|
||||
bind:open={$deleteCourseModal.open}
|
||||
isLoading={$deleteCourseModal.isDeleting}
|
||||
/>
|
||||
|
||||
<div class="mx-auto my-4 w-full">
|
||||
{#if $courseMetaDeta.isLoading}
|
||||
|
||||
@@ -40,8 +40,18 @@ export const copyCourseModalInitialState = {
|
||||
courseIdToCopy: null,
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
isSaving: false,
|
||||
error: null
|
||||
};
|
||||
|
||||
export const copyCourseModal = writable({ ...copyCourseModalInitialState });
|
||||
|
||||
export const deleteCourseModalInitialState = {
|
||||
open: false,
|
||||
id: '',
|
||||
title: '',
|
||||
isDeleting: false
|
||||
};
|
||||
|
||||
export const deleteCourseModal = writable({ ...deleteCourseModalInitialState });
|
||||
|
||||
@@ -6,15 +6,11 @@
|
||||
|
||||
export let open = false;
|
||||
export let onDelete = () => {};
|
||||
|
||||
function handleDelete() {
|
||||
onDelete();
|
||||
open = false;
|
||||
}
|
||||
export let isLoading = false;
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
onClose={() => (open = false)}
|
||||
onClose={() => !isLoading && (open = false)}
|
||||
bind:open
|
||||
width="w-96"
|
||||
containerClass="p-6"
|
||||
@@ -32,12 +28,14 @@
|
||||
variant={VARIANTS.OUTLINED}
|
||||
label={$t('delete_modal.no')}
|
||||
onClick={() => (open = false)}
|
||||
{isLoading}
|
||||
/>
|
||||
<PrimaryButton
|
||||
className="px-6 py-3"
|
||||
variant={VARIANTS.CONTAINED_DANGER}
|
||||
label={$t('delete_modal.yes')}
|
||||
onClick={handleDelete}
|
||||
onClick={onDelete}
|
||||
{isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
import type { Course, Lesson, Profile } from '$lib/utils/types';
|
||||
|
||||
import type { PostgrestError } from '@supabase/supabase-js';
|
||||
// @ts-ignore
|
||||
import { QUESTION_TYPE } from '$lib/components/Question/constants';
|
||||
// @ts-ignore
|
||||
import { ROLE } from '$lib/utils/constants/roles';
|
||||
// @ts-ignore
|
||||
import { addGroupMember } from './index';
|
||||
import { get } from 'svelte/store';
|
||||
// @ts-ignore
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
|
||||
type GetCourseResponse = {
|
||||
data: Course;
|
||||
error: PostgrestError | null;
|
||||
};
|
||||
|
||||
async function fetchCourseData(courseId: string): Promise<GetCourseResponse> {
|
||||
const { data, error } = await supabase
|
||||
.from('course')
|
||||
.select(
|
||||
`
|
||||
*,
|
||||
lessons:lesson(
|
||||
*,
|
||||
exercise:id(
|
||||
id, title, description, due_by,
|
||||
questions:question(
|
||||
*,
|
||||
options:option(*),
|
||||
question_type:question_type_id(id, label)
|
||||
)
|
||||
)
|
||||
)
|
||||
`
|
||||
)
|
||||
.match({ id: courseId })
|
||||
.single();
|
||||
|
||||
return { data, error };
|
||||
}
|
||||
|
||||
async function cloneGroupAndBasicCourse(
|
||||
course: Course,
|
||||
newTitle: string
|
||||
): Promise<{ newCourse: Course }> {
|
||||
const { description } = course;
|
||||
|
||||
const { data: newGroup } = await supabase
|
||||
.from('group')
|
||||
.insert({ name: newTitle, description })
|
||||
.select();
|
||||
|
||||
console.log(`newGroup`, newGroup);
|
||||
if (!Array.isArray(newGroup)) {
|
||||
throw new Error('Group wasnt created successfully');
|
||||
}
|
||||
|
||||
const { id: group_id } = newGroup[0];
|
||||
|
||||
// 2. Create course with group_id
|
||||
const { data: newCourse } = await supabase
|
||||
.from('course')
|
||||
.insert({
|
||||
...course,
|
||||
title: newTitle,
|
||||
description,
|
||||
group_id,
|
||||
lessons: undefined,
|
||||
id: undefined,
|
||||
updated_at: undefined,
|
||||
created_at: undefined
|
||||
})
|
||||
.select();
|
||||
|
||||
console.log(`newCourse`, newCourse);
|
||||
|
||||
// 3. Add group members
|
||||
|
||||
const profileStoreValue: Profile = get(profile);
|
||||
|
||||
// Get profile of author and add as member
|
||||
await addGroupMember({
|
||||
profile_id: profileStoreValue.id,
|
||||
email: profileStoreValue.email,
|
||||
group_id,
|
||||
role_id: ROLE.TUTOR
|
||||
});
|
||||
|
||||
console.log('profileStoreValue', profileStoreValue);
|
||||
|
||||
return {
|
||||
newCourse: newCourse?.[0]
|
||||
};
|
||||
}
|
||||
|
||||
async function cloneLessons(lessons: Lesson[], courseId: Course['id']) {
|
||||
const clonedLessons = lessons
|
||||
.sort((a, b) => new Date(a.created_at || '').getTime() - new Date(b.created_at || '').getTime())
|
||||
.map((lesson, index) => ({
|
||||
call_url: lesson.call_url,
|
||||
course_id: courseId,
|
||||
is_unlocked: lesson.is_unlocked,
|
||||
lesson_at: new Date(),
|
||||
note: lesson.note,
|
||||
order: lesson.order || index + 1,
|
||||
public: lesson.public,
|
||||
slide_url: lesson.slide_url,
|
||||
title: lesson.title,
|
||||
videos: lesson.videos
|
||||
}));
|
||||
|
||||
const { data } = await supabase.from('lesson').insert(clonedLessons).select();
|
||||
|
||||
const newLessons = data?.map((dataItem, dataIndex) => {
|
||||
return {
|
||||
...dataItem,
|
||||
// @ts-ignore
|
||||
exercise: lessons[dataIndex].exercise
|
||||
};
|
||||
});
|
||||
|
||||
console.log('newLessons', newLessons);
|
||||
return { newLessons };
|
||||
}
|
||||
|
||||
async function cloneExercises(lessons: Lesson[]) {
|
||||
console.log('lessons', lessons);
|
||||
|
||||
// Lessons
|
||||
for (const lesson of lessons) {
|
||||
// @ts-ignore
|
||||
const { id: lessonId, exercise = [] } = lesson;
|
||||
|
||||
// Exercises
|
||||
for (const exerciseItem of exercise) {
|
||||
const { title, description, questions = [] } = exerciseItem;
|
||||
// Insert
|
||||
const { data } = await supabase
|
||||
.from('exercise')
|
||||
.insert({
|
||||
description,
|
||||
due_by: new Date(),
|
||||
lesson_id: lessonId,
|
||||
title
|
||||
})
|
||||
.select();
|
||||
const newExercise = Array.isArray(data) ? data[0] : data;
|
||||
console.log('newExercise', newExercise);
|
||||
|
||||
// Questions
|
||||
for (const question of questions) {
|
||||
const { options = [] } = question;
|
||||
|
||||
// Insert
|
||||
const { data } = await supabase
|
||||
.from('question')
|
||||
.insert({
|
||||
name: question.name,
|
||||
title: question.title,
|
||||
points: question.points,
|
||||
order: question.order,
|
||||
question_type_id: question.question_type_id,
|
||||
exercise_id: newExercise.id
|
||||
})
|
||||
.select();
|
||||
const newQuestion = Array.isArray(data) ? data[0] : data;
|
||||
console.log('newQuestion', newQuestion);
|
||||
|
||||
// Options: Don't map options for 'Paragraph' questions
|
||||
if (QUESTION_TYPE.TEXTAREA !== newQuestion.question_type_id) {
|
||||
// @ts-ignore
|
||||
const newOptions = options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
is_correct: option.is_correct,
|
||||
question_id: newQuestion.id
|
||||
}));
|
||||
|
||||
// Insert[Batch]
|
||||
await supabase.from('option').insert(newOptions).select();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function cloneCourse(courseId: string, newTitle: string): Promise<Course> {
|
||||
const { data: course, error } = await fetchCourseData(courseId);
|
||||
console.log(`course`, course);
|
||||
|
||||
if (error) {
|
||||
console.error('Error when cloning', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Copy course table
|
||||
* 2. Copy lessons table (NOTE: Copy sections table here)
|
||||
* 3. Copy exercises table
|
||||
* 4. Copy questions table
|
||||
* 5. Copy options table
|
||||
*/
|
||||
|
||||
// tables: group, groupmember, course(group_id)
|
||||
const { newCourse } = await cloneGroupAndBasicCourse(course, newTitle);
|
||||
console.log('returned -> newCourse', newCourse);
|
||||
|
||||
// @ts-ignore
|
||||
const { newLessons } = await cloneLessons(course.lessons, newCourse.id);
|
||||
console.log('returned -> newLessons', newLessons);
|
||||
|
||||
const cloneExercisesRes = await cloneExercises(newLessons || []);
|
||||
console.log('returned -> cloneExercisesRes', cloneExercisesRes);
|
||||
|
||||
return newCourse;
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
1. Click on 3 dots to clone course
|
||||
|
||||
2. Show a modal with a new name "Copy of previous course name"
|
||||
|
||||
-> Note the following information will be copied
|
||||
|
||||
3. Things to copy
|
||||
Lessons
|
||||
Exercises - questions, options, everything basically.
|
||||
|
||||
Technical approach
|
||||
- Fetch all the data to the client
|
||||
- Manually call functions to create
|
||||
|
||||
- call a psql function with newname, course_id
|
||||
|
||||
- copy all in course table
|
||||
- copy all in group table by course_id
|
||||
- add only one group member as admin
|
||||
|
||||
- get all lessons in course
|
||||
- copy each one with unique ids
|
||||
|
||||
- get all exercises by course id
|
||||
- copy exercise
|
||||
|
||||
- get questions
|
||||
- copy questions
|
||||
|
||||
- get options
|
||||
- copy options
|
||||
*/
|
||||
@@ -1090,6 +1090,17 @@
|
||||
"date_created": "Dato oprettet",
|
||||
"published": "Offentliggjort",
|
||||
"lessons": "Lektioner"
|
||||
},
|
||||
"copy_course": {
|
||||
"title": "Opret et kursus",
|
||||
"course_name_label": "Kursusnavn",
|
||||
"course_name_placeholder": "Dit kursusnavn",
|
||||
"course_description_label": "Kursusbeskrivelse",
|
||||
"course_description_placeholder": "Indtast en beskrivelse til dit kursus",
|
||||
"create_button": "Skabe",
|
||||
"error": {
|
||||
"failed_to_clone": "Kunne ikke klone kursus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ai": {
|
||||
|
||||
@@ -937,6 +937,17 @@
|
||||
"date_created": "Datum erstellt",
|
||||
"published": "Veröffentlicht",
|
||||
"lessons": "Unterricht"
|
||||
},
|
||||
"copy_course": {
|
||||
"title": "Erstellen Sie einen Kurs",
|
||||
"course_name_label": "Kursname",
|
||||
"course_name_placeholder": "Ihr Kursname",
|
||||
"course_description_label": "Kursbeschreibung",
|
||||
"course_description_placeholder": "Geben Sie eine Beschreibung für Ihren Kurs ein",
|
||||
"create_button": "Erstellen",
|
||||
"error": {
|
||||
"failed_to_clone": "Der Kurs konnte nicht geklont werden"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ai": {
|
||||
|
||||
@@ -540,6 +540,17 @@
|
||||
"button": "Finish",
|
||||
"back": "Back",
|
||||
"next": "Next"
|
||||
},
|
||||
"copy_course": {
|
||||
"title": "Create a Course",
|
||||
"course_name_label": "Course name",
|
||||
"course_name_placeholder": "Your course name",
|
||||
"course_description_label": "Course description",
|
||||
"course_description_placeholder": "Enter a description for your course",
|
||||
"create_button": "Create",
|
||||
"error": {
|
||||
"failed_to_clone": "Failed to clone course"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -937,6 +937,17 @@
|
||||
"date_created": "fecha de creacion",
|
||||
"published": "Publicado",
|
||||
"lessons": "Lecciones"
|
||||
},
|
||||
"copy_course": {
|
||||
"title": "Crear un curso",
|
||||
"course_name_label": "Nombre del curso",
|
||||
"course_name_placeholder": "Nombre de tu curso",
|
||||
"course_description_label": "Descripción del curso",
|
||||
"course_description_placeholder": "Introduce una descripción para tu curso.",
|
||||
"create_button": "Crear",
|
||||
"error": {
|
||||
"failed_to_clone": "No se pudo clonar el curso"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ai": {
|
||||
|
||||
@@ -937,6 +937,17 @@
|
||||
"date_created": "date créée",
|
||||
"published": "Publié",
|
||||
"lessons": "Cours"
|
||||
},
|
||||
"copy_course": {
|
||||
"title": "Créer un cours",
|
||||
"course_name_label": "Nom du cours",
|
||||
"course_name_placeholder": "Le nom de votre cours",
|
||||
"course_description_label": "Descriptif du cours",
|
||||
"course_description_placeholder": "Entrez une description pour votre cours",
|
||||
"create_button": "Créer",
|
||||
"error": {
|
||||
"failed_to_clone": "Échec du clonage du cours"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ai": {
|
||||
|
||||
@@ -937,6 +937,17 @@
|
||||
"date_created": "बनाया गया दिनांक",
|
||||
"published": "प्रकाशित",
|
||||
"lessons": "पाठ"
|
||||
},
|
||||
"copy_course": {
|
||||
"title": "एक कोर्स बनाएं",
|
||||
"course_name_label": "कोर्स का नाम",
|
||||
"course_name_placeholder": "आपके पाठ्यक्रम का नाम",
|
||||
"course_description_label": "पाठ्यक्रम विवरण",
|
||||
"course_description_placeholder": "अपने पाठ्यक्रम के लिए विवरण दर्ज करें",
|
||||
"create_button": "बनाएं",
|
||||
"error": {
|
||||
"failed_to_clone": "पाठ्यक्रम क्लोन करने में विफल"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ai": {
|
||||
|
||||
@@ -1086,6 +1086,17 @@
|
||||
"date_created": "Utworzona data",
|
||||
"published": "Opublikowany",
|
||||
"lessons": "Lekcje"
|
||||
},
|
||||
"copy_course": {
|
||||
"title": "Utwórz kurs",
|
||||
"course_name_label": "Nazwa kursu",
|
||||
"course_name_placeholder": "Nazwa Twojego kursu",
|
||||
"course_description_label": "Opis kursu",
|
||||
"course_description_placeholder": "Wprowadź opis swojego kursu",
|
||||
"create_button": "Tworzyć",
|
||||
"error": {
|
||||
"failed_to_clone": "Nie udało się sklonować kursu"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ai": {
|
||||
|
||||
@@ -937,6 +937,17 @@
|
||||
"date_created": "Data Criada",
|
||||
"published": "Publicados",
|
||||
"lessons": "Lições"
|
||||
},
|
||||
"copy_course": {
|
||||
"title": "Crie um curso",
|
||||
"course_name_label": "Nome do curso",
|
||||
"course_name_placeholder": "Nome do seu curso",
|
||||
"course_description_label": "Descrição do curso",
|
||||
"course_description_placeholder": "Insira uma descrição para o seu curso",
|
||||
"create_button": "Criar",
|
||||
"error": {
|
||||
"failed_to_clone": "Falha ao clonar curso"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ai": {
|
||||
|
||||
@@ -937,6 +937,17 @@
|
||||
"date_created": "Дата создания",
|
||||
"published": "Опубликовано",
|
||||
"lessons": "Уроки"
|
||||
},
|
||||
"copy_course": {
|
||||
"title": "Создать курс",
|
||||
"course_name_label": "Название курса",
|
||||
"course_name_placeholder": "Название вашего курса",
|
||||
"course_description_label": "Описание курса",
|
||||
"course_description_placeholder": "Введите описание вашего курса",
|
||||
"create_button": "Создавать",
|
||||
"error": {
|
||||
"failed_to_clone": "Не удалось клонировать курс."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ai": {
|
||||
|
||||
@@ -937,6 +937,17 @@
|
||||
"date_created": "Ngày tạo",
|
||||
"published": "Được phát hành",
|
||||
"lessons": "Những bài học"
|
||||
},
|
||||
"copy_course": {
|
||||
"title": "Tạo một khóa học",
|
||||
"course_name_label": "Tên khóa học",
|
||||
"course_name_placeholder": "Tên khóa học của bạn",
|
||||
"course_description_label": "Mô tả khóa học",
|
||||
"course_description_placeholder": "Nhập mô tả cho khóa học của bạn",
|
||||
"create_button": "Tạo nên",
|
||||
"error": {
|
||||
"failed_to_clone": "Không thể sao chép khóa học"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ai": {
|
||||
|
||||
Reference in New Issue
Block a user