CLASSROOMIO-T-80: Replace sapper with sveltekit
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
<script>
|
||||
import { marked } from 'marked';
|
||||
import PageNav from '$lib/components/PageNav/index.svelte';
|
||||
import CloseButton from '$lib/components/Buttons/Close/index.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
// import SendButton from "$lib/components/Buttons/Send/index.svelte";
|
||||
|
||||
export let handleClose;
|
||||
|
||||
let message = '';
|
||||
let bodyRef;
|
||||
let chats = [
|
||||
{
|
||||
avatar: 'https://picsum.photos/32/32/?random',
|
||||
name: 'Evgeniy',
|
||||
message: "I'am looking at this and it looks great",
|
||||
},
|
||||
{
|
||||
avatar: 'https://picsum.photos/32/32/?random',
|
||||
name: 'Sergey Semko',
|
||||
message:
|
||||
'Thanks for the feedback, what do you think about my website. https://rotimibest.com',
|
||||
},
|
||||
{
|
||||
avatar: 'https://picsum.photos/32/32/?random',
|
||||
name: 'Vitalii Marushko',
|
||||
message: 'Всем привет',
|
||||
},
|
||||
{
|
||||
avatar: 'https://picsum.photos/32/32/?random',
|
||||
name: 'Natasha',
|
||||
message: 'Привет Виталий',
|
||||
},
|
||||
];
|
||||
|
||||
function handleSend() {
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
chats = [
|
||||
...chats,
|
||||
{
|
||||
avatar: 'https://picsum.photos/32/32/?random',
|
||||
name: 'You',
|
||||
message,
|
||||
},
|
||||
];
|
||||
|
||||
message = null;
|
||||
|
||||
setTimeout(() => {
|
||||
bodyRef.scrollTo({
|
||||
top: bodyRef.scrollHeight,
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (e.key === 'Enter') {
|
||||
handleSend();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageNav title="LiveChat" overidableStyle="padding: 0 10px">
|
||||
<div slot="widget">
|
||||
<CloseButton onClick={handleClose} />
|
||||
</div>
|
||||
</PageNav>
|
||||
|
||||
<div bind:this={bodyRef} class="overflow-auto body">
|
||||
{#each chats as chat}
|
||||
<div class="pl-2 pb-4">
|
||||
<div class="flex items-center no-underline hover:underline text-black">
|
||||
<img
|
||||
alt="Placeholder"
|
||||
class="block rounded-full"
|
||||
width="24"
|
||||
height="20"
|
||||
src={chat.avatar}
|
||||
/>
|
||||
<p class="dark:text-white ml-2 text-sm font-bold">{chat.name}</p>
|
||||
</div>
|
||||
<article class="prose prose-sm sm:prose pt-2 pl-8">
|
||||
{@html marked(chat.message)}
|
||||
</article>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<!-- <div class="flex items-center justify-between leading-none pl-2"> -->
|
||||
<TextField
|
||||
placeholder="Say something"
|
||||
bind:value={message}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<!-- <SendButton onClick={handleSend} /> -->
|
||||
<!-- </div> -->
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.body {
|
||||
height: 90%;
|
||||
}
|
||||
.footer {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script>
|
||||
import PageNav from '$lib/components/PageNav/index.svelte';
|
||||
import CloseButton from '$lib/components/Buttons/Close/index.svelte';
|
||||
|
||||
export let handleClose;
|
||||
</script>
|
||||
|
||||
<PageNav title="Note" overidableStyle="padding: 0 10px">
|
||||
<div slot="widget">
|
||||
<CloseButton onClick={handleClose} />
|
||||
</div>
|
||||
</PageNav>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script>
|
||||
export let name;
|
||||
export let src;
|
||||
export let className;
|
||||
</script>
|
||||
|
||||
<img
|
||||
alt={name}
|
||||
{src}
|
||||
class="w-10 h-10 rounded-full bg-light-blue-100 {className}"
|
||||
/>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script>
|
||||
import FormSection from './FormSection.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
|
||||
export let title = 'Poll';
|
||||
export let onSubmit;
|
||||
export let onCancel;
|
||||
|
||||
let values = {
|
||||
question: '',
|
||||
author: '',
|
||||
isPublic: false,
|
||||
expiration: '',
|
||||
options: [
|
||||
{
|
||||
label: '',
|
||||
selectedBy: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function handleAddOptions() {
|
||||
values = {
|
||||
...values,
|
||||
options: [
|
||||
...values.options,
|
||||
{
|
||||
label: '',
|
||||
selectedBy: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function handleRemoveOptions(index) {
|
||||
return () => {
|
||||
values.options = values.options.filter((o, i) => i !== index);
|
||||
};
|
||||
}
|
||||
|
||||
function finishPoll() {
|
||||
onSubmit(values);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="border rounded-md">
|
||||
<div class="border-b-2 p-4 text-center">
|
||||
{title}
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<FormSection label="Question" bind:value={values.question} />
|
||||
<FormSection
|
||||
label="Expiration"
|
||||
bind:value={values.expiration}
|
||||
type="date"
|
||||
/>
|
||||
|
||||
{#each values.options as option, index}
|
||||
<FormSection
|
||||
label={index === 0 && 'Options'}
|
||||
bind:value={option.label}
|
||||
handleLabelButton={handleAddOptions}
|
||||
handleInputButton={handleRemoveOptions(index)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="w-full flex justify-center mb-3">
|
||||
<PrimaryButton label="Create poll" onClick={finishPoll} />
|
||||
<PrimaryButton
|
||||
label="Cancel"
|
||||
variant={VARIANTS.OUTLINED}
|
||||
onClick={onCancel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script>
|
||||
export let value;
|
||||
export let label;
|
||||
export let type = 'text';
|
||||
export let name = 'text';
|
||||
export let handleLabelButton;
|
||||
export let handleInputButton;
|
||||
</script>
|
||||
|
||||
<div class="mb-3">
|
||||
{#if label}
|
||||
<p
|
||||
class="dark:text-white p-0 m-0 mb-1 uppercase text-sm font-bold text-gray-600"
|
||||
>
|
||||
{label}
|
||||
|
||||
{#if typeof handleLabelButton === 'function'}
|
||||
<button class="text-xl px-3 ml-5" on:click={handleLabelButton}>
|
||||
+
|
||||
</button>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="w-full">
|
||||
{#if type === 'text'}
|
||||
<input type="text" {name} class="p-2 rounded-md w-4/5" bind:value />
|
||||
{/if}
|
||||
|
||||
{#if type === 'date'}
|
||||
<input type="date" {name} class="p-2 rounded-md w-4/5" bind:value />
|
||||
{/if}
|
||||
|
||||
{#if typeof handleInputButton === 'function'}
|
||||
<button class="text-xl px-3 ml-5" on:click={handleInputButton}>
|
||||
-
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
<p
|
||||
class="dark:text-white p-0 m-0 mb-1 uppercase text-sm font-bold text-gray-600"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
@@ -0,0 +1,209 @@
|
||||
<script>
|
||||
import Label from './Label.svelte';
|
||||
import Avatar from './Avatar.svelte';
|
||||
|
||||
export let poll = {};
|
||||
export let onSelect = () => {};
|
||||
export let handlePollDelete = () => {};
|
||||
export let currentUserId;
|
||||
let viewResult = false;
|
||||
let selectedOptionToView;
|
||||
let isAuthor = false;
|
||||
let totalVotes = 0;
|
||||
let hasUserVoted = false;
|
||||
|
||||
function handleSelect(optionIndex) {
|
||||
return () => {
|
||||
onSelect(optionIndex);
|
||||
};
|
||||
}
|
||||
|
||||
function getSelectedUsers(users) {
|
||||
return users.map((o) => o.label).join(', ');
|
||||
}
|
||||
|
||||
function getTotalVotes(options) {
|
||||
return options.reduce((acc, cur) => acc + cur.selectedBy.length, 0);
|
||||
}
|
||||
|
||||
function calculatePercent(options, currentOption) {
|
||||
const totalVoteOfAllOptions = getTotalVotes(options);
|
||||
const totalVotesOfCurrentOption = currentOption.selectedBy.length;
|
||||
|
||||
return (
|
||||
Math.round((totalVotesOfCurrentOption / totalVoteOfAllOptions) * 100) || 0
|
||||
);
|
||||
}
|
||||
|
||||
function didUserSelect(option) {
|
||||
return option.selectedBy.find((user) => user.value === currentUserId);
|
||||
}
|
||||
|
||||
$: isAuthor = currentUserId === poll.author.value;
|
||||
$: totalVotes = getTotalVotes(poll.options);
|
||||
$: {
|
||||
hasUserVoted = poll.options.some((option) =>
|
||||
option.selectedBy.find((u) => u.value === currentUserId)
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-4 bg-white dark:bg-gray-800 border-2 rounded-md mb-3">
|
||||
{#if selectedOptionToView}
|
||||
<div>
|
||||
<div class="flex items-center mb-5">
|
||||
<button
|
||||
class="text-xl w-10 px-3"
|
||||
on:click={() => (selectedOptionToView = null)}
|
||||
>
|
||||
{`<`}
|
||||
</button>
|
||||
<div class="flex-grow text-center">
|
||||
<h3 class="dark:text-white font-bold">Voted</h3>
|
||||
</div>
|
||||
</div>
|
||||
{#each selectedOptionToView.selectedBy as user}
|
||||
<div class="flex items-center mb-2">
|
||||
<Avatar src={user.avatar} name={user.label} className="mr-2" />
|
||||
<p class="dark:text-white">
|
||||
{user.label}
|
||||
</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex mb-3 justify-between">
|
||||
<div>
|
||||
<h3 class="dark:text-white font-bold">
|
||||
{poll.question}
|
||||
</h3>
|
||||
<p class="dark:text-white text-sm italic">
|
||||
Created by <a
|
||||
class="text-blue-500"
|
||||
href="https://twitter.com/rotimi_best"
|
||||
target="_blank">@{poll.author.label}</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if isAuthor}
|
||||
<div class="flex mb-3 justify-end items-center">
|
||||
<label class="mr-2 font-bold" for="user-select">Status:</label>
|
||||
|
||||
<select name="users" id="user-select" bind:value={poll.status}>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="Public">Public</option>
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if viewResult}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="mr-5">
|
||||
<Label>Expiration</Label>
|
||||
<p class="dark:text-white text-sm text-gray-600 mb-4">
|
||||
{poll.expiration || 'Today'}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<Label>Total Votes</Label>
|
||||
<p class="dark:text-white text-sm text-gray-600 mb-4">
|
||||
{totalVotes}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Label>Responses</Label>
|
||||
{#each poll.options as option, index}
|
||||
<div class="border-b p-5">
|
||||
<p class="dark:text-white">
|
||||
{option.label}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="w-4/5 h-2 bg-gray-200 relative">
|
||||
<span
|
||||
class="absolute top-0 left-0 bg-blue-700 h-full"
|
||||
style="width: {calculatePercent(poll.options, option)}%;"
|
||||
/>
|
||||
</div>
|
||||
{#if option.selectedBy.length}
|
||||
<button
|
||||
class="text-xl px-3 ml-5"
|
||||
on:click={() => (selectedOptionToView = option)}
|
||||
>
|
||||
>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
{#each option.selectedBy.slice(0, 3) as user}
|
||||
<Avatar src={user.avatar} name={user.label} className="mr-2" />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each poll.options as option, index}
|
||||
<button
|
||||
class="bg-white dark:bg-gray-800 rounded-md border-2 border-gray-500 {didUserSelect(
|
||||
option
|
||||
) &&
|
||||
'focus:border-blue-700 border-blue-700'} text-black p-2 w-full mb-3 text-left relative"
|
||||
on:click={handleSelect(index)}
|
||||
>
|
||||
{#if hasUserVoted}
|
||||
<span
|
||||
class="progress absolute top-0 left-0 {didUserSelect(option)
|
||||
? 'bg-blue-700'
|
||||
: 'bg-gray-300'} h-full bg-opacity-25"
|
||||
style="width: {calculatePercent(poll.options, option)}%;"
|
||||
/>
|
||||
<strong class="mr-3"
|
||||
>{calculatePercent(poll.options, option)}%</strong
|
||||
>
|
||||
{/if}
|
||||
{option.label}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="flex items-center">
|
||||
<p class="dark:text-white text-sm text-gray-600">
|
||||
Expires: {poll.expiration || 'Today'}
|
||||
</p>
|
||||
<span class="mx-2">|</span>
|
||||
<p class="dark:text-white text-sm text-gray-600">
|
||||
Voted: {totalVotes}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isAuthor}
|
||||
<div class="w-full text-center mt-3">
|
||||
<button
|
||||
class="px-5 py-3 bg-blue-700 text-white"
|
||||
on:click={() => (viewResult = !viewResult)}
|
||||
>
|
||||
{#if viewResult}
|
||||
Back to poll
|
||||
{:else}
|
||||
View results
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="px-5 py-3 bg-red-500 text-white"
|
||||
on:click={handlePollDelete}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.progress {
|
||||
transition: width 0.5s linear;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script>
|
||||
export let tabs = [];
|
||||
|
||||
export let selectedTab;
|
||||
</script>
|
||||
|
||||
<div class="mb-3">
|
||||
{#each tabs as tab}
|
||||
<button
|
||||
class="mr-3 pb-2 border-t-0 border-l-0 border-r-0 {selectedTab ===
|
||||
tab.value
|
||||
? 'border-b-4 border-indigo-600 focus:border-indigo-600'
|
||||
: 'border-b-0'}"
|
||||
on:click={() => (selectedTab = tab.value)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,196 @@
|
||||
<script>
|
||||
import PageNav from '$lib/components/PageNav/index.svelte';
|
||||
import CloseButton from '$lib/components/Buttons/Close/index.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import CreatePollForm from './components/CreatePollForm.svelte';
|
||||
import Poll from './components/Poll.svelte';
|
||||
import Tabs from './components/Tabs.svelte';
|
||||
|
||||
export let handleClose;
|
||||
|
||||
const users = [
|
||||
{
|
||||
label: 'Rich Harris',
|
||||
value: 'rich-harris',
|
||||
avatar: 'https://i.pravatar.cc/150?img=1',
|
||||
},
|
||||
{
|
||||
label: 'Linus Torvalds',
|
||||
value: 'linus-torvalds',
|
||||
avatar: 'https://i.pravatar.cc/150?img=2',
|
||||
},
|
||||
{
|
||||
label: 'Elon Musk',
|
||||
value: 'elon-musk',
|
||||
avatar: 'https://i.pravatar.cc/150?img=3',
|
||||
},
|
||||
{
|
||||
label: 'Satoshi Nakamoto',
|
||||
value: 'satoshi-nakamoto',
|
||||
avatar: 'https://i.pravatar.cc/150?img=4',
|
||||
},
|
||||
];
|
||||
const tabs = [
|
||||
{
|
||||
label: 'My polls',
|
||||
value: 0,
|
||||
},
|
||||
{
|
||||
label: 'Other polls',
|
||||
value: 1,
|
||||
},
|
||||
];
|
||||
|
||||
let selectedTab = 0;
|
||||
let userId = users[0].value;
|
||||
let shouldCreatePoll = false;
|
||||
let polls = [
|
||||
{
|
||||
question: 'Who is the creator of Svelte?',
|
||||
author: users[0],
|
||||
status: 'draft', // public
|
||||
expiration: new Date().toDateString(),
|
||||
options: [
|
||||
{
|
||||
label: 'Dan Abrahmov',
|
||||
selectedBy: [],
|
||||
},
|
||||
{
|
||||
label: 'Evan You',
|
||||
selectedBy: [
|
||||
{
|
||||
label: 'Rich Harris',
|
||||
value: 'rich-harris',
|
||||
avatar: 'https://i.pravatar.cc/150?img=1',
|
||||
},
|
||||
{
|
||||
label: 'Linus Torvalds',
|
||||
value: 'linus-torvalds',
|
||||
avatar: 'https://i.pravatar.cc/150?img=2',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Rich Harris',
|
||||
selectedBy: [],
|
||||
},
|
||||
{
|
||||
label: 'Yehuda Katz',
|
||||
selectedBy: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function handleCreatePoll() {
|
||||
shouldCreatePoll = !shouldCreatePoll;
|
||||
}
|
||||
|
||||
function handlePollCreate(poll) {
|
||||
if (poll) {
|
||||
polls = [
|
||||
{
|
||||
...poll,
|
||||
author: users.find((u) => u.value === userId),
|
||||
},
|
||||
...polls,
|
||||
];
|
||||
}
|
||||
|
||||
handleCreatePoll();
|
||||
}
|
||||
|
||||
function handlePollDelete(pollId) {
|
||||
return () => {
|
||||
polls = [...polls.filter((p, i) => i !== pollId)];
|
||||
};
|
||||
}
|
||||
|
||||
function handleVote(pollId) {
|
||||
return (optionId) => {
|
||||
polls = [
|
||||
...polls.map((poll, index) => {
|
||||
if (index === pollId) {
|
||||
poll.options = poll.options.map((option, optionIndex) => {
|
||||
if (optionIndex === optionId) {
|
||||
if (option.selectedBy.find((user) => user.value === userId)) {
|
||||
// Unselect if already selected
|
||||
// <<update: let me keep this commented so after a user selects an answer it can't be unselected>>
|
||||
// option.selectedBy = option.selectedBy.filter(user => user.value !== userId)
|
||||
} else {
|
||||
// Select if already selected
|
||||
option.selectedBy = [
|
||||
...option.selectedBy,
|
||||
users.find((user) => user.value === userId),
|
||||
];
|
||||
}
|
||||
} else if (
|
||||
option.selectedBy.find((user) => user.value === userId)
|
||||
) {
|
||||
// This makes the user to select only one option
|
||||
option.selectedBy = option.selectedBy.filter(
|
||||
(user) => user.value !== userId
|
||||
);
|
||||
}
|
||||
|
||||
return option;
|
||||
});
|
||||
}
|
||||
|
||||
return poll;
|
||||
}),
|
||||
];
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageNav title="Poll" overidableStyle="padding: 0 10px">
|
||||
<div slot="widget">
|
||||
<CloseButton onClick={handleClose} />
|
||||
</div>
|
||||
</PageNav>
|
||||
|
||||
<div class="m-2 overlow-auto">
|
||||
{#if shouldCreatePoll}
|
||||
<CreatePollForm onSubmit={handlePollCreate} onCancel={handleCreatePoll} />
|
||||
{:else}
|
||||
<div>
|
||||
<Tabs {tabs} bind:selectedTab />
|
||||
|
||||
{#if selectedTab === tabs[0].value}
|
||||
<div class="w-full flex justify-center mb-3">
|
||||
<PrimaryButton
|
||||
label="Create poll"
|
||||
variant={VARIANTS.CONTAINED}
|
||||
onClick={handleCreatePoll}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each polls as poll, index}
|
||||
{#if selectedTab === tabs[0].value && poll.author.value === userId}
|
||||
<Poll
|
||||
bind:poll
|
||||
onSelect={handleVote(index)}
|
||||
handlePollDelete={handlePollDelete(index)}
|
||||
currentUserId={userId}
|
||||
/>
|
||||
{:else if selectedTab === tabs[1].value && poll.author.value !== userId}
|
||||
<Poll
|
||||
bind:poll
|
||||
onSelect={handleVote(index)}
|
||||
handlePollDelete={handlePollDelete(index)}
|
||||
currentUserId={userId}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<div
|
||||
class="bg-gray-100 dark:bg-gray-700 border rounded-md h-60 flex items-center justify-center"
|
||||
>
|
||||
<h2 class="text-xl font-bold">No polls to display</h2>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script>
|
||||
import { marked } from 'marked';
|
||||
import CheckmarkOutline from 'carbon-icons-svelte/lib/CheckmarkOutline.svelte';
|
||||
import PageNav from '$lib/components/PageNav/index.svelte';
|
||||
import CloseButton from '$lib/components/Buttons/Close/index.svelte';
|
||||
import Vote from '$lib/components/Vote/index.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
|
||||
export let handleClose;
|
||||
let bodyRef;
|
||||
let question;
|
||||
|
||||
let questions = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Evgeniy',
|
||||
question: "I'am looking at this and it looks great",
|
||||
vote: 1,
|
||||
answered: false,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Sergey Semko',
|
||||
question:
|
||||
'Thanks for the feedback, what do you think about my website. https://rotimibest.com',
|
||||
vote: 4,
|
||||
answered: false,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Vitalii Marushko',
|
||||
question: 'Всем привет',
|
||||
vote: 20,
|
||||
answered: false,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Natasha',
|
||||
question: 'Привет Виталий',
|
||||
vote: 0,
|
||||
answered: false,
|
||||
},
|
||||
];
|
||||
|
||||
function handleSend() {
|
||||
if (!question) {
|
||||
return;
|
||||
}
|
||||
|
||||
questions = [
|
||||
...questions,
|
||||
{
|
||||
id: questions.length,
|
||||
name: 'You',
|
||||
question,
|
||||
vote: 0,
|
||||
answered: false,
|
||||
},
|
||||
];
|
||||
|
||||
question = null;
|
||||
|
||||
setTimeout(() => {
|
||||
bodyRef.scrollTo({
|
||||
top: bodyRef.scrollHeight,
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function handleMarkAnswered(id) {
|
||||
return () => {
|
||||
questions = questions
|
||||
.map((question) => {
|
||||
if (question.id === id) {
|
||||
question.answered = !question.answered;
|
||||
}
|
||||
|
||||
return question;
|
||||
})
|
||||
.sort((a, b) => Number(a.answered) - Number(b.answered));
|
||||
};
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (e.key === 'Enter') {
|
||||
handleSend();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageNav title="Q/A" overidableStyle="padding: 0 10px">
|
||||
<div slot="widget">
|
||||
<CloseButton onClick={handleClose} />
|
||||
</div>
|
||||
</PageNav>
|
||||
|
||||
<div bind:this={bodyRef} class="body">
|
||||
{#each questions as question}
|
||||
<div class="flex items-start ml-2 mb-2 mt-2">
|
||||
<Vote bind:value={question.vote} />
|
||||
<div class="ml-2 flex-grow">
|
||||
<p class="dark:text-white text-sm font-bold">{question.name}</p>
|
||||
<article class="prose prose-sm sm:prose">
|
||||
{@html marked(question.question)}
|
||||
</article>
|
||||
</div>
|
||||
<button
|
||||
class={`${question.answered && 'active'}`}
|
||||
on:click={handleMarkAnswered(question.id)}
|
||||
>
|
||||
<CheckmarkOutline size={20} class="carbon-icon" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<TextField
|
||||
placeholder="Say something"
|
||||
bind:value={question}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.body {
|
||||
overflow: auto;
|
||||
height: 80%;
|
||||
|
||||
& button {
|
||||
display: none;
|
||||
}
|
||||
& div:hover button,
|
||||
& button.active {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
.footer {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
export default {
|
||||
APPS: {
|
||||
QANDA: 'QANDA',
|
||||
NOTES: 'NOTES',
|
||||
LIVE_CHAT: 'LIVE_CHAT',
|
||||
POLL: 'POLL',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import hotkeys from 'hotkeys-js';
|
||||
import Forum from 'carbon-icons-svelte/lib/Forum.svelte';
|
||||
import AlignBoxTopLeft from 'carbon-icons-svelte/lib/AlignBoxTopLeft.svelte';
|
||||
import SendAlt from 'carbon-icons-svelte/lib/SendAlt.svelte';
|
||||
import ChartPie from 'carbon-icons-svelte/lib/ChartPie.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
|
||||
import QandA from './components/QandA/index.svelte';
|
||||
import LiveChat from './components/LiveChat/index.svelte';
|
||||
import Notes from './components/Notes/index.svelte';
|
||||
import Poll from './components/Poll/index.svelte';
|
||||
import APPS_CONSTANTS from './constants';
|
||||
|
||||
let selectedApp;
|
||||
|
||||
function handleClose() {
|
||||
selectedApp = null;
|
||||
}
|
||||
|
||||
function handleAppClick(appName) {
|
||||
if (appName === selectedApp) {
|
||||
handleClose();
|
||||
} else {
|
||||
selectedApp = appName;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
hotkeys('A+1,A+2,A+3,A+4', function (event, handler) {
|
||||
event.preventDefault();
|
||||
switch (handler.key) {
|
||||
case 'A+1':
|
||||
handleAppClick(APPS_CONSTANTS.APPS.LIVE_CHAT);
|
||||
break;
|
||||
case 'A+2':
|
||||
handleAppClick(APPS_CONSTANTS.APPS.QANDA);
|
||||
break;
|
||||
case 'A+3':
|
||||
handleAppClick(APPS_CONSTANTS.APPS.NOTES);
|
||||
break;
|
||||
case 'A+4':
|
||||
handleAppClick(APPS_CONSTANTS.APPS.POLL);
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<div class="apps">
|
||||
<IconButton
|
||||
toolTipProps={{ title: 'Live Chat', hotkeys: ['A', '1'] }}
|
||||
value={APPS_CONSTANTS.APPS.LIVE_CHAT}
|
||||
onClick={handleAppClick}
|
||||
selected={APPS_CONSTANTS.APPS.LIVE_CHAT === selectedApp}
|
||||
>
|
||||
<SendAlt size={24} class="carbon-icon" />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
toolTipProps={{ title: 'QandA', hotkeys: ['A', '2'] }}
|
||||
value={APPS_CONSTANTS.APPS.QANDA}
|
||||
onClick={handleAppClick}
|
||||
selected={APPS_CONSTANTS.APPS.QANDA === selectedApp}
|
||||
>
|
||||
<Forum size={24} class="carbon-icon" />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
toolTipProps={{ title: 'Notes', hotkeys: ['A', '3'] }}
|
||||
value={APPS_CONSTANTS.APPS.NOTES}
|
||||
onClick={handleAppClick}
|
||||
selected={APPS_CONSTANTS.APPS.NOTES === selectedApp}
|
||||
>
|
||||
<AlignBoxTopLeft size={24} class="carbon-icon" />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
toolTipProps={{ title: 'Poll', hotkeys: ['A', '4'] }}
|
||||
value={APPS_CONSTANTS.APPS.POLL}
|
||||
onClick={handleAppClick}
|
||||
selected={APPS_CONSTANTS.APPS.POLL === selectedApp}
|
||||
>
|
||||
<ChartPie size={24} class="carbon-icon" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{#if !!selectedApp}
|
||||
<div class="app">
|
||||
{#if selectedApp === APPS_CONSTANTS.APPS.QANDA}
|
||||
<QandA {handleClose} />
|
||||
{:else if selectedApp === APPS_CONSTANTS.APPS.LIVE_CHAT}
|
||||
<LiveChat {handleClose} />
|
||||
{:else if selectedApp === APPS_CONSTANTS.APPS.POLL}
|
||||
<Poll {handleClose} />
|
||||
{:else if selectedApp === APPS_CONSTANTS.APPS.NOTES}
|
||||
<Notes {handleClose} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.root {
|
||||
height: 93vh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
border-left: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
z-index: 1;
|
||||
|
||||
& .apps {
|
||||
padding-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 60px;
|
||||
border-right: 1px solid var(--border-color);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
& .app {
|
||||
width: 400px;
|
||||
transition: all 1s ease-out;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script>
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import GoogleIconColored from '../Icons/GoogleIconColored.svelte';
|
||||
|
||||
export let supabase;
|
||||
export let handleSubmit;
|
||||
export let isLogin = true;
|
||||
export let showOnlyContent = false;
|
||||
export let showLogo = false;
|
||||
export let formRef;
|
||||
|
||||
async function signInWithGoogle() {
|
||||
try {
|
||||
console.log('signInWithGoogle');
|
||||
const { user, session, error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
});
|
||||
|
||||
console.log('user', user);
|
||||
console.log('session', session);
|
||||
console.log('error', error);
|
||||
} catch (error) {
|
||||
console.log('catch error', error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-full min-h-screen flex items-center justify-center mt-4">
|
||||
<div class="container border border-gray bg-white dark:bg-gray-800">
|
||||
<div class="flex items-center flex-col p-2 lg:px-8 lg:py-3">
|
||||
{#if !showOnlyContent || showLogo}
|
||||
<div class="flex items-center w-full justify-center pt-4">
|
||||
<img
|
||||
src="/logo-192.png"
|
||||
alt="ClassroomIO logo"
|
||||
height="50"
|
||||
width="50"
|
||||
data-atf="1"
|
||||
/>
|
||||
<h4 class="dark:text-white text-xl">ClassroomIO</h4>
|
||||
</div>
|
||||
{/if}
|
||||
<form
|
||||
bind:this={formRef}
|
||||
on:submit|preventDefault={handleSubmit}
|
||||
class="flex items-center flex-col w-10/12"
|
||||
>
|
||||
<slot />
|
||||
</form>
|
||||
{#if !showOnlyContent}
|
||||
<div class="w-10/12 mb-3">
|
||||
<p class="dark:text-white text-sm mb-5">or sign up with:</p>
|
||||
<PrimaryButton
|
||||
variant={VARIANTS.OUTLINED}
|
||||
onClick={signInWithGoogle}
|
||||
className="py-3 sm:w-full w-full"
|
||||
>
|
||||
<GoogleIconColored />
|
||||
<span class="ml-2"
|
||||
>{isLogin ? 'Sign in' : 'Sign up'} with Google</span
|
||||
>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !showOnlyContent}
|
||||
<div class="w-full p-6 border-t border-grey text-center">
|
||||
{#if isLogin}
|
||||
Not registered yet? <a
|
||||
class="hover:underline text-blue-700"
|
||||
href="/signup">Sign up</a
|
||||
>
|
||||
{:else}
|
||||
Already have an account? <a
|
||||
class="hover:underline text-blue-700"
|
||||
href="/login">Log In</a
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
width: 450px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script>
|
||||
export let name = '';
|
||||
export let src = '';
|
||||
export let className = '';
|
||||
export let shape = 'rounded-full';
|
||||
export let width = 'w-10';
|
||||
export let height = 'h-10';
|
||||
</script>
|
||||
|
||||
<img
|
||||
alt={name}
|
||||
{src}
|
||||
class="{className} {width} {height} {shape} bg-light-blue-100"
|
||||
/>
|
||||
@@ -0,0 +1,22 @@
|
||||
<div class="root">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
position: fixed;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
color: #fff;
|
||||
z-index: 1201;
|
||||
opacity: 1;
|
||||
transition: opacity 225ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script>
|
||||
export let className = '';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="{className} w-full h-80 flex items-center justify-center flex-col border rounded border-gray-200"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script>
|
||||
import CloseIcon from 'carbon-icons-svelte/lib/Close.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
|
||||
export let onClick;
|
||||
export let contained;
|
||||
export let color = '';
|
||||
export let size = 'large';
|
||||
</script>
|
||||
|
||||
<IconButton {onClick} {color} {contained} {size}>
|
||||
{#if size === 'large'}
|
||||
<CloseIcon size={24} class="carbon-icon" title="Delete" />
|
||||
{:else}
|
||||
<CloseIcon size={24} class="carbon-icon" title="Delete" />
|
||||
{/if}
|
||||
</IconButton>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script>
|
||||
import Close from 'carbon-icons-svelte/lib/Close.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
|
||||
export let onClick = () => {};
|
||||
</script>
|
||||
|
||||
<IconButton {onClick}>
|
||||
<Close size={24} class="carbon-icon" title="Close" />
|
||||
</IconButton>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script>
|
||||
import SendFilled from 'carbon-icons-svelte/lib/SendFilled.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
|
||||
export let onClick;
|
||||
</script>
|
||||
|
||||
<IconButton {onClick}>
|
||||
<SendFilled size={24} class="carbon-icon" />
|
||||
</IconButton>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script>
|
||||
export let className = '';
|
||||
export let value = '';
|
||||
export let size = '';
|
||||
</script>
|
||||
|
||||
<p
|
||||
class="{className} {size === 'sm'
|
||||
? 'py-1 px-1'
|
||||
: 'py-2 px-2'} org-avatar {className.includes('bg-')
|
||||
? ''
|
||||
: 'bg-gray-200'} rounded-md text-center"
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
|
||||
<style>
|
||||
.org-avatar {
|
||||
width: fit-content;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script>
|
||||
export let value = 0;
|
||||
export let className = '';
|
||||
</script>
|
||||
|
||||
<p class={className ? className : 'bg-set mt-2'}>{value}</p>
|
||||
|
||||
<style>
|
||||
p {
|
||||
display: inline-block;
|
||||
min-width: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 2em;
|
||||
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.bg-set {
|
||||
background-color: #d1d5da80;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script>
|
||||
import { marked } from 'marked';
|
||||
|
||||
export let code = '';
|
||||
</script>
|
||||
|
||||
<div class="bg-gray-200 dark:bg-gray-500 p-5 rounded-md mb-3">
|
||||
{@html marked('```js\n' + code + '\n```')}
|
||||
</div>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script>
|
||||
export let label = 'Coming soon';
|
||||
</script>
|
||||
|
||||
<p
|
||||
class="org-avatar text-xs bg-blue-200 text-blue-700 font-bold py-1 px-1 rounded-md text-center"
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
|
||||
<style>
|
||||
.org-avatar {
|
||||
width: fit-content;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import confetti from 'canvas-confetti';
|
||||
import { confettiStore } from './store';
|
||||
|
||||
let myCanvas;
|
||||
let myConfetti;
|
||||
|
||||
function showConfetti(show) {
|
||||
if (!show) return;
|
||||
|
||||
const duration = 5 * 1000;
|
||||
const animationEnd = Date.now() + duration;
|
||||
const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 };
|
||||
|
||||
function randomInRange(min, max) {
|
||||
return Math.random() * (max - min) + min;
|
||||
}
|
||||
|
||||
const interval = setInterval(function () {
|
||||
const timeLeft = animationEnd - Date.now();
|
||||
|
||||
if (timeLeft <= 0) {
|
||||
return clearInterval(interval);
|
||||
}
|
||||
|
||||
const particleCount = 50 * (timeLeft / duration);
|
||||
// since particles fall down, start a bit higher than random
|
||||
myConfetti(
|
||||
Object.assign({}, defaults, {
|
||||
particleCount,
|
||||
origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 },
|
||||
})
|
||||
);
|
||||
myConfetti(
|
||||
Object.assign({}, defaults, {
|
||||
particleCount,
|
||||
origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 },
|
||||
})
|
||||
);
|
||||
}, 250);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
myConfetti = confetti.create(myCanvas, {
|
||||
resize: true,
|
||||
useWorker: true,
|
||||
});
|
||||
});
|
||||
|
||||
$: showConfetti($confettiStore.show);
|
||||
</script>
|
||||
|
||||
<canvas bind:this={myCanvas} />
|
||||
|
||||
<style>
|
||||
canvas {
|
||||
position: absolute;
|
||||
width: 60%;
|
||||
height: 70%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const confettiStore = writable({
|
||||
show: false,
|
||||
});
|
||||
|
||||
export function toggleConfetti() {
|
||||
confettiStore.update((confetti) => ({
|
||||
...confetti,
|
||||
show: !confetti.show,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<script>
|
||||
import PageNav from '$lib/components/PageNav/index.svelte';
|
||||
</script>
|
||||
|
||||
<PageNav title="Attendance" />
|
||||
All attendance will be here with table
|
||||
@@ -0,0 +1,135 @@
|
||||
<script>
|
||||
import {
|
||||
RadioButtonGroup,
|
||||
RadioButton,
|
||||
Toggle,
|
||||
} from 'carbon-components-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { updateCourse } from '$lib/utils/services/courses';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import TextArea from '$lib/components/Form/TextArea.svelte';
|
||||
import Professional from './certificates/Professional.svelte';
|
||||
import Plain from './certificates/Plain.svelte';
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
const studentNamePlaceholder = 'Name of student';
|
||||
const themes = ['professional', 'plain'];
|
||||
|
||||
let isSaving = false;
|
||||
let Saved = false;
|
||||
let errors = {};
|
||||
|
||||
const saveCertificate = async () => {
|
||||
isSaving = true;
|
||||
if (!$course.description) {
|
||||
errors.description = 'Description cannot be empty';
|
||||
isSaving = false;
|
||||
return;
|
||||
}
|
||||
await updateCourse($course.id, undefined, {
|
||||
description: $course.description,
|
||||
is_certificate_downloadable: $course.is_certificate_downloadable,
|
||||
certificate_theme: $course.certificate_theme,
|
||||
});
|
||||
Saved = true;
|
||||
isSaving = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Qwitcher+Grypen&family=Roboto:wght@300;400;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<main class="md:-ml-4 md:-mr-4 px-2">
|
||||
<div
|
||||
class="flex-1 flex flex-col md:flex-row justify-between gap-3 w-full mb-3 h-4/5"
|
||||
>
|
||||
<section class="w-full md:w-2/5 h-full">
|
||||
<strong class="my-2 text-base font-semibold text-black"
|
||||
>Certificate settings</strong
|
||||
>
|
||||
<p class="text-xs font-normal my-4">Choose a theme</p>
|
||||
<RadioButtonGroup bind:selected={$course.certificate_theme} class="mb-10">
|
||||
{#each themes as theme}
|
||||
<div class="flex mr-3">
|
||||
<RadioButton value={theme} size="sm" />
|
||||
<img src={`/${theme}.svg`} alt="themes" />
|
||||
</div>
|
||||
{/each}
|
||||
</RadioButtonGroup>
|
||||
<div>
|
||||
<p class="text-xs font-normal text-black my-2">Brand Logo</p>
|
||||
<div>
|
||||
<p class="text-base mt-1">
|
||||
To update your brand image, go to <strong class="font-semibold"
|
||||
>Settings > Organisation settings</strong
|
||||
> and upload you brand logo
|
||||
</p>
|
||||
<PrimaryButton
|
||||
label="Go to Settings"
|
||||
className="rounded-md mt-3"
|
||||
onClick={() => goto(`org/${$currentOrg.siteName}/settings`)}
|
||||
/>
|
||||
</div>
|
||||
<span class="my-4">
|
||||
<p class="text-xs font-normal mt-4 mb-2">Course Description</p>
|
||||
<TextArea
|
||||
maxRows={6}
|
||||
rows={6}
|
||||
placeholder="a little description about the course"
|
||||
bgColor="bg-gray-100"
|
||||
bind:value={$course.description}
|
||||
errorMessage={errors.description}
|
||||
/>
|
||||
</span>
|
||||
<Toggle
|
||||
labelText="Allow students download certificate"
|
||||
bind:toggled={$course.is_certificate_downloadable}
|
||||
class="my-4"
|
||||
size="sm"
|
||||
>
|
||||
<span slot="labelA" style="color: #161616">Locked</span>
|
||||
<span slot="labelB" style="color: green">Unlocked</span>
|
||||
</Toggle>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="certificate-bg flex justify-center items-center rounded-md w-full md:w-3/5"
|
||||
>
|
||||
<div class="certificate-container flex justify-center items-center h-5/6">
|
||||
{#if $course.certificate_theme === 'professional'}
|
||||
<Professional studentName={studentNamePlaceholder} />
|
||||
{:else}
|
||||
<Plain studentName={studentNamePlaceholder} />
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="h-1/5">
|
||||
<PrimaryButton
|
||||
label={Saved == true ? 'Saved' : 'Save Changes'}
|
||||
className="rounded-md"
|
||||
onClick={saveCertificate}
|
||||
isLoading={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
p {
|
||||
color: #262626;
|
||||
}
|
||||
.certificate-container {
|
||||
width: 95%;
|
||||
max-width: 95%;
|
||||
}
|
||||
.certificate-bg {
|
||||
background-color: #f5f8fe;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script>
|
||||
import { Toggle } from 'carbon-components-svelte';
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import TextArea from '$lib/components/Form/TextArea.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import { issueCertificateModal, resetForm } from './store';
|
||||
|
||||
const onToggle = (e) => {
|
||||
console.log(e);
|
||||
};
|
||||
|
||||
const issueCertificate = () => {
|
||||
resetForm();
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
onClose={resetForm}
|
||||
bind:open={$issueCertificateModal.open}
|
||||
width="w-3/5"
|
||||
maxWidth=""
|
||||
modalHeading="Send Certificate"
|
||||
>
|
||||
<main>
|
||||
<div>
|
||||
<Toggle
|
||||
labelText="Automatically send certificates on completion"
|
||||
on:toggle={(e) => onToggle(e)}
|
||||
size="sm"
|
||||
class="mb-4"
|
||||
>
|
||||
<span slot="labelA" style="color: #161616">Automatic</span>
|
||||
<span slot="labelB" style="color: green">Automatic</span>
|
||||
</Toggle>
|
||||
<p class="text-sm font-medium my-4">
|
||||
If you set this as automatic, certificates will be issued after the
|
||||
learner completes the course.
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-xs font-normal text-gray-500 my-4">
|
||||
or send a personalised/ custom certificate below:
|
||||
</p>
|
||||
<form on:submit|preventDefault={issueCertificate}>
|
||||
<div class="flex flex-col md:flex-row gap-2 w-full">
|
||||
<TextField
|
||||
label="Email address of the student"
|
||||
className="w-full my-4"
|
||||
labelClassName="text-xs font-normal"
|
||||
inputClassName="text-sm placeholder:text-sm placeholder:font-medium "
|
||||
placeholder="email,comma seperated"
|
||||
bind:value={$issueCertificateModal.email}
|
||||
/>
|
||||
<TextField
|
||||
label="Schedule date"
|
||||
className="w-full my-4"
|
||||
labelClassName="text-xs font-normal"
|
||||
inputClassName="text-sm placeholder:text-sm placeholder:font-medium "
|
||||
placeholder="12/06/2023"
|
||||
bind:value={$issueCertificateModal.date}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextArea
|
||||
label="Add a personalized message"
|
||||
labelClassName="text-xs font-normal"
|
||||
bind:value={$issueCertificateModal.message}
|
||||
rows="2"
|
||||
maxRows="3"
|
||||
placeholder="your message here"
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<div class="mt-5 flex w-full items-end justify-end">
|
||||
<PrimaryButton
|
||||
className="px-6 py-3 rounded-md text-sm font-medium"
|
||||
label="Issue certificate"
|
||||
type="submit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</Modal>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script>
|
||||
import { CertificateReportData, students } from './mockData';
|
||||
import {
|
||||
Search,
|
||||
Checkbox,
|
||||
StructuredList,
|
||||
StructuredListHead,
|
||||
StructuredListRow,
|
||||
StructuredListCell,
|
||||
StructuredListBody,
|
||||
Pagination,
|
||||
} from 'carbon-components-svelte';
|
||||
|
||||
let searchValue = '';
|
||||
let checked = false;
|
||||
</script>
|
||||
|
||||
<section class="flex flex-wrap items-start gap-2 mb-10 -ml-4 -mr-4">
|
||||
{#each CertificateReportData as data (data.id)}
|
||||
<div
|
||||
class="box flex flex-col rounded border border-gray-200 justify-center px-2 mb-5"
|
||||
>
|
||||
<p class="text-xs font-normal">{data.title}</p>
|
||||
<bold class="text-2xl font-medium">{data.value}</bold>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<p class="text-base font-semibold w-full">Certificate issued</p>
|
||||
<Search
|
||||
placeholder="search students"
|
||||
bind:value={searchValue}
|
||||
searchClass="border-b border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-14 border-t border-gray-300">
|
||||
<StructuredList>
|
||||
<StructuredListHead class="bg-blue-50 py-0">
|
||||
<StructuredListRow head class="py-0">
|
||||
<StructuredListCell head class="py-0">
|
||||
<div
|
||||
class="flex-3 flex flex-row items-center justify-start w-full text-blue-600 text-sm font-medium"
|
||||
>
|
||||
<Checkbox bind:checked />
|
||||
<p class="text-blue-900 text-start w-full text-sm font-medium">
|
||||
Name of student
|
||||
</p>
|
||||
</div>
|
||||
</StructuredListCell>
|
||||
<StructuredListCell head class="py-0">
|
||||
<div
|
||||
class="flex-2 items-center w-full text-blue-900 text-sm font-medium"
|
||||
>
|
||||
<p class="align-middle text-sm font-medium pt-1">Date issued</p>
|
||||
<p class="opacity-0">personalized</p>
|
||||
</div>
|
||||
</StructuredListCell>
|
||||
</StructuredListRow>
|
||||
</StructuredListHead>
|
||||
<StructuredListBody>
|
||||
{#each students as item (item.id)}
|
||||
<StructuredListRow label for="row-{item}">
|
||||
<StructuredListCell>
|
||||
<div
|
||||
class="flex-3 flex flex-row items-center justify-start w-full text-sm font-medium pt-4"
|
||||
>
|
||||
<Checkbox />
|
||||
<p class="text-start w-full text-sm font-normal">{item.name}</p>
|
||||
</div>
|
||||
</StructuredListCell>
|
||||
<StructuredListCell>
|
||||
<div class="flex-2 flex flex-row items-center gap-10 w-full">
|
||||
<p class="text-sm font-normal align-middle">{item.issued}</p>
|
||||
{#if item.peronalized == true}
|
||||
<span class="text-center bg-blue-100 px-3 py-2 rounded-lg">
|
||||
<p class="opacity-100 text-xs text-blue-600">personalized</p>
|
||||
</span>
|
||||
{:else}
|
||||
<p class="opacity-0">personalized</p>
|
||||
{/if}
|
||||
</div>
|
||||
</StructuredListCell>
|
||||
</StructuredListRow>
|
||||
{/each}
|
||||
</StructuredListBody>
|
||||
</StructuredList>
|
||||
<Pagination totalItems={10} pageSizes={[10, 15, 20]} />
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.box {
|
||||
width: 246px;
|
||||
height: 165px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script>
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import UnlockedCertificate from './UnlockedCertificate.svelte';
|
||||
import LockedCertificate from './LockedCertificate.svelte';
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#if $course.is_certificate_downloadable == true}
|
||||
<UnlockedCertificate />
|
||||
{:else}
|
||||
<LockedCertificate />
|
||||
{/if}
|
||||
</div>
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
<script>
|
||||
import { course } from '$lib/components/Course/store';
|
||||
</script>
|
||||
|
||||
<section class="studentContainer flex items-center justify-center w-full">
|
||||
<div class="flex flex-col items-center justify-center w-max h-full gap-2">
|
||||
<img src={`./${$course.certificate_theme}.svg`} alt="Certificate" />
|
||||
<p class="text-xl font-normal text-center">Certificates not available</p>
|
||||
<p class="text-sm font-normal text-center">
|
||||
Your instructor needs to unlock your certificate. <br /> If you need your certificate,
|
||||
please contact your instructor.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.studentContainer {
|
||||
height: 80vh;
|
||||
}
|
||||
</style>
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
<script>
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
|
||||
let isLoading = false;
|
||||
|
||||
const downLoadCertificate = async () => {
|
||||
isLoading = true;
|
||||
const response = await fetch(
|
||||
'https://classroomio-server.fly.dev/downloadCertificate',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
theme: `${$course.certificate_theme}`,
|
||||
studentName: `${$profile.fullname}`,
|
||||
courseName: `${$course.title}`,
|
||||
courseDescription: `${$course.description}`,
|
||||
orgLogoUrl: `${$currentOrg.avatar_url}`,
|
||||
orgName: `${$currentOrg.name}`,
|
||||
}),
|
||||
}
|
||||
);
|
||||
const data = await response.blob();
|
||||
console.log(data);
|
||||
const file = new Blob([data], { type: 'application/pdf' });
|
||||
const fileURL = URL.createObjectURL(file);
|
||||
isLoading = false;
|
||||
window.open(fileURL);
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="studentContainer flex items-center justify-center w-full">
|
||||
<div class="flex flex-col items-center justify-center w-max h-full gap-5">
|
||||
<img src={`./${$course.certificate_theme}.svg`} alt="Certificate" />
|
||||
<p class="text-xl font-normal text-center">Certificate is now available</p>
|
||||
<PrimaryButton
|
||||
label="DownLoad Certificate"
|
||||
className="rounded-md"
|
||||
onClick={downLoadCertificate}
|
||||
{isLoading}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.studentContainer {
|
||||
height: 80vh;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script>
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
export let studentName = '';
|
||||
let borderwidth = 'border-4';
|
||||
let bordercolor = 'border-pink-700';
|
||||
let logo = './logo-512.png';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-center justify-center {borderwidth} {bordercolor} py-3"
|
||||
>
|
||||
<div class="flex items-center gap-1 my-2">
|
||||
<img
|
||||
src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo}
|
||||
alt="logo"
|
||||
class=" w-10 h-10 rounded-md"
|
||||
/>
|
||||
<p class="font-semibold capitalize">{$currentOrg.name}</p>
|
||||
</div>
|
||||
<div class="w-full px-5 py-3">
|
||||
<p
|
||||
class="text-sm text-center font-normal my-2 uppercase tracking-widest text-gray-500 w-full"
|
||||
>
|
||||
Certificate of completion
|
||||
</p>
|
||||
<div class="border-b border-gray-500 mb-4">
|
||||
<p class="text-xs text-black font-medium">This is to certify that</p>
|
||||
<p class="student-name text-center text-5xl">{studentName}</p>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<p class="text-xs text-black font-medium">
|
||||
has successfully completed the course
|
||||
</p>
|
||||
<div>
|
||||
<p
|
||||
class="clamp bg-transparent text-base font-semibold text-pink-700 text-center uppercase"
|
||||
>
|
||||
{$course.title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="desc text-center">{$course.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.student-name {
|
||||
font-family: 'Qwitcher Grypen', cursive;
|
||||
}
|
||||
.desc {
|
||||
font-size: 10px;
|
||||
}
|
||||
.clamp {
|
||||
font-size: clamp(14px, 3vw, 16px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script>
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
export let studentName;
|
||||
let borderwidth = 'border-l-8';
|
||||
let bordercolor = 'border-blue-800';
|
||||
let logo = './logo-512.png';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-center border border-dashed border-blue-400"
|
||||
>
|
||||
<div class="{borderwidth} {bordercolor}">
|
||||
<div class="w-full px-5 py-3">
|
||||
<p class="text-sm font-normal my-2 uppercase text-gray-500">
|
||||
Certificate<br /> of completion
|
||||
</p>
|
||||
<div class="border-b border-gray-500 mb-4">
|
||||
<p class="text-xs text-black font-medium">This is to certify that</p>
|
||||
<p class="student-name text-center text-5xl">{studentName}</p>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<p class="text-xs text-black font-medium">
|
||||
has successfully completed introductory courses in
|
||||
</p>
|
||||
<div>
|
||||
<p
|
||||
class="clamp bg-transparent text-base font-semibold text-blue-800 text-center uppercase my-2"
|
||||
>
|
||||
{$course.title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="desc mb-4 text-justify">{$course.description}</p>
|
||||
<div class="flex items-center gap-1 my-2">
|
||||
<img
|
||||
src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo}
|
||||
alt="logo"
|
||||
class="w-10 h-10 rounded-md"
|
||||
/>
|
||||
<p class="font-semibold capitalize">{$currentOrg.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.student-name {
|
||||
font-family: 'Qwitcher Grypen', cursive;
|
||||
}
|
||||
.desc {
|
||||
font-size: 10px;
|
||||
}
|
||||
.clamp {
|
||||
font-size: clamp(14px, 3vw, 16px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
export const CertificateReportData = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Total students taking course',
|
||||
value: 100,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Number of certificates issued ',
|
||||
value: 45,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Certificates left to be issued',
|
||||
value: 55,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Personalised cetificates',
|
||||
value: 15,
|
||||
},
|
||||
];
|
||||
|
||||
export const students = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Anyanwu joy victory',
|
||||
issued: '10 Dec 2020',
|
||||
peronalized: false,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Anyanwu joy victory',
|
||||
issued: '10 Dec 2020',
|
||||
peronalized: false,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Anyanwu joy victory',
|
||||
issued: '10 Dec 2020',
|
||||
peronalized: true,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Anyanwu joy victory',
|
||||
issued: '10 Dec 2020',
|
||||
peronalized: false,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Anyanwu joy victory',
|
||||
issued: '10 Dec 2020',
|
||||
peronalized: false,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Anyanwu joy victory',
|
||||
issued: '10 Dec 2020',
|
||||
peronalized: false,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: 'Anyanwu joy victory',
|
||||
issued: '10 Dec 2020',
|
||||
peronalized: false,
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'Anyanwu joy victory',
|
||||
issued: '10 Dec 2020',
|
||||
peronalized: false,
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: 'Anyanwu joy victory',
|
||||
issued: '10 Dec 2020',
|
||||
peronalized: false,
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
name: 'Anyanwu joy victory',
|
||||
issued: '10 Dec 2020',
|
||||
peronalized: true,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,18 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const issueCertificateModal = writable({
|
||||
open: false,
|
||||
automatic: false,
|
||||
email: '',
|
||||
date: '',
|
||||
message: '',
|
||||
});
|
||||
|
||||
export const resetForm = () =>
|
||||
issueCertificateModal.update(() => ({
|
||||
open: false,
|
||||
automatic: false,
|
||||
email: '',
|
||||
date: '',
|
||||
message: '',
|
||||
}));
|
||||
@@ -0,0 +1,49 @@
|
||||
<script>
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import { deleteConfirmation } from '../store/exercise';
|
||||
|
||||
export let onDelete = () => {};
|
||||
export let onCancel = () => {};
|
||||
|
||||
async function handleDelete() {
|
||||
onDelete();
|
||||
$deleteConfirmation.open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
onClose={() => ($deleteConfirmation.open = false)}
|
||||
bind:open={$deleteConfirmation.open}
|
||||
width="w-96"
|
||||
modalHeading="Delete question"
|
||||
>
|
||||
<div>
|
||||
<h1 class="dark:text-white text-lg">
|
||||
Are you sure you want to delete this question?
|
||||
</h1>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<PrimaryButton
|
||||
className="px-6 py-3"
|
||||
variant={VARIANTS.OUTLINED}
|
||||
label="No, please don't"
|
||||
onClick={() => {
|
||||
$deleteConfirmation.open = false;
|
||||
onCancel();
|
||||
}}
|
||||
/>
|
||||
<PrimaryButton
|
||||
className="px-6 py-3"
|
||||
variant={VARIANTS.CONTAINED_DANGER}
|
||||
label="Yes, delete"
|
||||
onClick={() => {
|
||||
onDelete();
|
||||
$deleteConfirmation.open = false;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -0,0 +1,349 @@
|
||||
<script>
|
||||
import {
|
||||
questionnaire,
|
||||
deleteConfirmation,
|
||||
questionnaireValidation,
|
||||
handleAddOption,
|
||||
handleRemoveOption,
|
||||
handleRemoveQuestion,
|
||||
handleCode,
|
||||
handleAnswerSelect,
|
||||
addDynamicValue,
|
||||
} from '../store/exercise';
|
||||
import TrashCanIcon from 'carbon-icons-svelte/lib/TrashCan.svelte';
|
||||
import AddFilledIcon from 'carbon-icons-svelte/lib/AddFilled.svelte';
|
||||
import CheckmarkFilledIcon from 'carbon-icons-svelte/lib/CheckmarkFilled.svelte';
|
||||
import CheckmarkOutlineIcon from 'carbon-icons-svelte/lib/CheckmarkOutline.svelte';
|
||||
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
import TextArea from '$lib/components/Form/TextArea.svelte';
|
||||
import Checkbox from '$lib/components/Form/Checkbox.svelte';
|
||||
import DateTime from '$lib/components/Form/DateTime.svelte';
|
||||
import RadioItem from '$lib/components/Form/RadioItem.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
import ErrorMessage from '$lib/components/ErrorMessage/index.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import EditContent from '$lib/components/EditContent/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import Select from '$lib/components/Form/Select.svelte';
|
||||
import QuestionContainer from '$lib/components/QuestionContainer/index.svelte';
|
||||
import DeleteConfirmationModal from './DeleteConfirmation.svelte';
|
||||
import OrderModal from './OrderModal.svelte';
|
||||
import {
|
||||
QUESTION_TYPE,
|
||||
QUESTION_TYPES,
|
||||
} from '$lib/components/Question/constants';
|
||||
import { filterOutDeleted } from './functions';
|
||||
import { deleteExercise } from '$lib/utils/services/courses';
|
||||
import { lesson } from '../store/lessons';
|
||||
// import EditContent from "$lib/components/EditContent/index.svelte";
|
||||
|
||||
// let value = readme;
|
||||
const extraActions = ['Code Snippets', 'Upload image'];
|
||||
const initialQuestionsLength = $questionnaire.questions.length;
|
||||
|
||||
export let editDescription = false;
|
||||
export let exerciseId;
|
||||
export let goBack = () => {};
|
||||
|
||||
let errors = {};
|
||||
let questionIdToDelete = null;
|
||||
let questions = [];
|
||||
let shouldDelete = false;
|
||||
let isDeleting = false;
|
||||
|
||||
function shouldScrollToLast(questionId, questions) {
|
||||
const currentQuestionsLength = questions.length;
|
||||
const index = questions.findIndex((question) => question.id === questionId);
|
||||
const isLast = index + 1 === currentQuestionsLength;
|
||||
|
||||
return isLast && initialQuestionsLength !== currentQuestionsLength;
|
||||
}
|
||||
|
||||
function onInitDeleteClicked(questionId) {
|
||||
return () => {
|
||||
questionIdToDelete = questionId;
|
||||
$deleteConfirmation.open = true;
|
||||
};
|
||||
}
|
||||
|
||||
function onFinalDeleteClicked() {
|
||||
handleRemoveQuestion(questionIdToDelete)();
|
||||
}
|
||||
|
||||
function handleOptionClick(questionIndex) {
|
||||
return (actionIndex) => {
|
||||
switch (actionIndex) {
|
||||
case 0:
|
||||
handleCode(questionIndex, true);
|
||||
break;
|
||||
case 1:
|
||||
// uploadImage(questionIndex);
|
||||
break;
|
||||
default:
|
||||
console.log('No option');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getQuestionErrorMsg(errors, question, errorKey) {
|
||||
return errors[question.id] ? errors[question.id][errorKey] : null;
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
console.log('delete');
|
||||
isDeleting = true;
|
||||
await deleteExercise(questions, exerciseId);
|
||||
|
||||
lesson.update((_lesson) => ({
|
||||
..._lesson,
|
||||
exercises: _lesson.exercises.filter(
|
||||
(exercise) => exercise.id !== exerciseId
|
||||
),
|
||||
}));
|
||||
|
||||
shouldDelete = false;
|
||||
goBack();
|
||||
}
|
||||
|
||||
$: errors = $questionnaireValidation;
|
||||
$: questions = filterOutDeleted($questionnaire.questions);
|
||||
</script>
|
||||
|
||||
<DeleteConfirmationModal
|
||||
onCancel={() => (questionIdToDelete = null)}
|
||||
onDelete={onFinalDeleteClicked}
|
||||
/>
|
||||
|
||||
<OrderModal />
|
||||
|
||||
<Modal
|
||||
onClose={() => (editDescription = false)}
|
||||
bind:open={editDescription}
|
||||
width="w-2/4"
|
||||
modalHeading="Update description"
|
||||
>
|
||||
{#if shouldDelete}
|
||||
<form on:submit|preventDefault={() => (shouldDelete = false)}>
|
||||
<h1 class="dark:text-white text-2xl">Are you sure?</h1>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<PrimaryButton
|
||||
className="px-6 py-3"
|
||||
variant={VARIANTS.OUTLINED}
|
||||
label="No, cancel"
|
||||
type="submit"
|
||||
/>
|
||||
<PrimaryButton
|
||||
className="px-6 py-3"
|
||||
variant={VARIANTS.CONTAINED_DANGER}
|
||||
label={isDeleting ? 'Deleting...' : 'Yes, delete'}
|
||||
isDisabled={isDeleting}
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<div>
|
||||
<QuestionContainer isTitle={true}>
|
||||
<TextField
|
||||
placeholder="Title"
|
||||
bind:value={$questionnaire.title}
|
||||
className="mb-2"
|
||||
onChange={() => ($questionnaire.is_title_dirty = true)}
|
||||
/>
|
||||
<DateTime
|
||||
label="Due by"
|
||||
className="w-50"
|
||||
value={$questionnaire.due_by}
|
||||
onInput={(e) => {
|
||||
$questionnaire.due_by = e.target.value;
|
||||
$questionnaire.is_due_by_dirty = true;
|
||||
}}
|
||||
/>
|
||||
<EditContent
|
||||
writeLabel="Description"
|
||||
bind:value={$questionnaire.description}
|
||||
placeholder="Start typing your lesson"
|
||||
textAreaHeight="100px"
|
||||
onInputChange={() => ($questionnaire.is_description_dirty = true)}
|
||||
/>
|
||||
</QuestionContainer>
|
||||
<div class="float-right">
|
||||
<PrimaryButton
|
||||
className="px-6 py-3"
|
||||
variant={VARIANTS.CONTAINED_DANGER}
|
||||
label="Delete this exercise"
|
||||
onClick={() => (shouldDelete = true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<div class="w-full">
|
||||
{#if Object.values(errors).length}
|
||||
<div class="w-full flex justify-center mb-4">
|
||||
<ErrorMessage message="You have some errors" />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="questions pt-6 px-6">
|
||||
{#each questions as question (question.id)}
|
||||
<!-- {#key question.id} -->
|
||||
<QuestionContainer
|
||||
onClose={onInitDeleteClicked(question.id)}
|
||||
scrollToQuestion={shouldScrollToLast(
|
||||
question.id,
|
||||
$questionnaire.questions
|
||||
)}
|
||||
bind:points={question.points}
|
||||
hasError={!!errors[question.id]}
|
||||
onPointsChange={() => {
|
||||
question.is_dirty = true;
|
||||
}}
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="mr-2 w-3/5">
|
||||
<TextField
|
||||
placeholder="Question"
|
||||
bind:value={question.title}
|
||||
isRequired={true}
|
||||
onChange={() => {
|
||||
question.is_dirty = true;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- <Dropdown
|
||||
options={extraActions}
|
||||
handleOptionClick={handleOptionClick(question.id)}
|
||||
>
|
||||
<AddFilledIcon class="carbon-icon" />
|
||||
<p class="dark:text-white ml-2 text-gray-600">Add</p>
|
||||
</Dropdown> -->
|
||||
|
||||
<Select
|
||||
bind:value={question.question_type}
|
||||
options={QUESTION_TYPES}
|
||||
onChange={() => {
|
||||
question.is_dirty = true;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if typeof question.code === 'string'}
|
||||
<div class="flex justify-between items-center my-3 w-3/5">
|
||||
<TextArea
|
||||
bind:value={question.code}
|
||||
rows="2"
|
||||
placeholder="Write your code"
|
||||
/>
|
||||
<IconButton
|
||||
value="write-code"
|
||||
onClick={() => handleCode(question.id, false)}
|
||||
>
|
||||
<TrashCanIcon size={24} class="carbon-icon" />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col mt-2">
|
||||
{#if QUESTION_TYPE.RADIO === question.question_type.id}
|
||||
{#each filterOutDeleted(question.options) as option}
|
||||
<RadioItem
|
||||
isEditable={true}
|
||||
name={question.title || 'radio-name'}
|
||||
bind:label={option.label}
|
||||
onChange={addDynamicValue(question.id, option.id)}
|
||||
>
|
||||
<div slot="iconbutton" class="flex items-center">
|
||||
<IconButton
|
||||
value={option.id}
|
||||
onClick={handleRemoveOption(question.id, option.id)}
|
||||
>
|
||||
<TrashCanIcon size={24} class="carbon-icon" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
value={option.id}
|
||||
onClick={handleAnswerSelect(question.id, option.id)}
|
||||
buttonClassName={option.is_correct && 'success'}
|
||||
>
|
||||
{#if option.is_correct}
|
||||
<CheckmarkFilledIcon size={24} class="carbon-icon" />
|
||||
{:else}
|
||||
<CheckmarkOutlineIcon size={24} class="carbon-icon" />
|
||||
{/if}
|
||||
</IconButton>
|
||||
</div>
|
||||
</RadioItem>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if QUESTION_TYPE.CHECKBOX === question.question_type.id}
|
||||
{#each filterOutDeleted(question.options) as option}
|
||||
<Checkbox
|
||||
isEditable={true}
|
||||
name={question || 'checkbox-name'}
|
||||
bind:label={option.label}
|
||||
onChange={addDynamicValue(question.id, option.id)}
|
||||
>
|
||||
<div slot="iconbutton" class="flex items-center">
|
||||
<IconButton
|
||||
value={option.id}
|
||||
onClick={handleRemoveOption(question.id, option.id)}
|
||||
>
|
||||
<TrashCanIcon size={24} class="carbon-icon" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
value={option.id}
|
||||
onClick={handleAnswerSelect(question.id, option.id)}
|
||||
buttonClassName={option.is_correct && 'success'}
|
||||
>
|
||||
{#if option.is_correct}
|
||||
<CheckmarkFilledIcon size={24} class="carbon-icon" />
|
||||
{:else}
|
||||
<CheckmarkOutlineIcon size={24} class="carbon-icon" />
|
||||
{/if}
|
||||
</IconButton>
|
||||
</div>
|
||||
</Checkbox>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if QUESTION_TYPE.TEXTAREA === question.question_type.id}
|
||||
<TextArea bind:value={question.value} />
|
||||
{/if}
|
||||
|
||||
{#if getQuestionErrorMsg(errors, question, 'option')}
|
||||
<ErrorMessage
|
||||
message={getQuestionErrorMsg(errors, question, 'option')}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if QUESTION_TYPE.TEXTAREA !== question.question_type.id}
|
||||
<div class="flex items-center mt-3">
|
||||
<PrimaryButton
|
||||
disablePadding={true}
|
||||
className="p-2"
|
||||
variant={VARIANTS.OUTLINED}
|
||||
onClick={handleAddOption(question.id)}
|
||||
>
|
||||
<AddFilledIcon size={24} class="carbon-icon" />
|
||||
<p class="dark:text-white ml-2">Add option</p>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
{/if}
|
||||
</QuestionContainer>
|
||||
|
||||
<!-- {/key} -->
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(#dnd-action-dragged-el div) {
|
||||
z-index: 100;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,173 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
import Select from '$lib/components/Form/Select.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import Preview from './Preview.svelte';
|
||||
import { SELECTABLE_STATUS } from './constants';
|
||||
import { snackbarStore } from '$lib/components/Snackbar/store';
|
||||
|
||||
export let open = false;
|
||||
export let onClose = () => {};
|
||||
export let handleSave = () => {};
|
||||
|
||||
// export let submissionId;
|
||||
export let data = {};
|
||||
export let updateStatus = () => {};
|
||||
|
||||
let status = SELECTABLE_STATUS[0];
|
||||
|
||||
let total = 0;
|
||||
let maxPoints = 0;
|
||||
|
||||
function getMaxPoints(questions) {
|
||||
return (questions || []).reduce(
|
||||
(acc, question) => acc + question.points,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
function calculateTotal(grades) {
|
||||
if (!grades) return 0;
|
||||
return Object.values(grades).reduce(
|
||||
(acc, grade) => acc + parseInt(grade),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
function handleStatusChange() {
|
||||
updateStatus({
|
||||
submissionId: data.id,
|
||||
prevStatusId: data.status_id,
|
||||
nextStatusId: status.value,
|
||||
total,
|
||||
});
|
||||
|
||||
$snackbarStore.open = true;
|
||||
$snackbarStore.message = `Submission updated to '${status.label}'`;
|
||||
}
|
||||
|
||||
function setStatus(data) {
|
||||
status = SELECTABLE_STATUS.find(
|
||||
(status) => status.value === data.status_id
|
||||
);
|
||||
}
|
||||
|
||||
function getStatusColor(status) {
|
||||
// if (!status)
|
||||
return '';
|
||||
|
||||
// switch (status.value) {
|
||||
// case STATUS.SUBMITTED:
|
||||
// return '';
|
||||
// case STATUS.IN_PROGRESS:
|
||||
// return 'text-white bg-blue-700';
|
||||
// case STATUS.GRADED:
|
||||
// return 'text-white bg-green-700';
|
||||
// }
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
console.log('mounting');
|
||||
});
|
||||
|
||||
$: total = calculateTotal(data.questionAnswerByPoint);
|
||||
$: maxPoints = getMaxPoints(data.questions);
|
||||
$: setStatus(data);
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
modalHeading={data.title}
|
||||
bind:open
|
||||
{onClose}
|
||||
width="w-4/5"
|
||||
containerClass="flex items-start"
|
||||
>
|
||||
<div class="w-full">
|
||||
<Preview
|
||||
questions={Array.isArray(data.questions)
|
||||
? data.questions.sort((a, b) => a.order - b.order)
|
||||
: []}
|
||||
questionnaireMetaData={{
|
||||
answers: data.answers || {},
|
||||
isFinished: true,
|
||||
}}
|
||||
bind:grades={data.questionAnswerByPoint}
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-4 w-2/5 sticky top-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<Select
|
||||
bind:value={status}
|
||||
options={SELECTABLE_STATUS}
|
||||
selectClassName={getStatusColor(status)}
|
||||
onChange={handleStatusChange}
|
||||
/>
|
||||
|
||||
<PrimaryButton
|
||||
onClick={() => {
|
||||
handleSave(data);
|
||||
}}
|
||||
label="Save"
|
||||
variant={VARIANTS.CONTAINED_SUCCESS}
|
||||
className="py-3 px-8"
|
||||
/>
|
||||
</div>
|
||||
<div class="border border-gray-300 rounded-md mt-2">
|
||||
<div
|
||||
class="hover:bg-gray-100 dark:bg-gray-700 border-b border-t-0 border-l-0 border-r-0 border-gray-300 p-3"
|
||||
>
|
||||
<p class="dark:text-white font-bold text-lg">
|
||||
Details
|
||||
{#if data.isEarly}
|
||||
<span
|
||||
class="ml-2 text-sm badge rounded-sm px-2 bg-green-500 text-white"
|
||||
>
|
||||
early
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="ml-2 badge text-sm rounded-sm px-2 bg-red-500 text-white"
|
||||
>
|
||||
late
|
||||
</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center text-sm p-3">
|
||||
<p class="dark:text-white font-bold w-2/5">Total grade</p>
|
||||
<p class="dark:text-white">{total} / {maxPoints}</p>
|
||||
</div>
|
||||
<!-- <div class="flex items-center text-sm p-3">
|
||||
<p class="dark:text-white font-bold w-1/2">Status</p>
|
||||
<div class="flex items-center">
|
||||
<p class="dark:text-white rounded-full w-5 h-5 bg-yellow-300 mr-2" />
|
||||
<p class="dark:text-white">Grading</p>
|
||||
</div>
|
||||
</div> -->
|
||||
<div class="flex items-center text-sm p-3">
|
||||
<p class="dark:text-white font-bold w-2/5">Student</p>
|
||||
{#if data.student}
|
||||
<img
|
||||
alt="Student avatar"
|
||||
class="block rounded-full h-6 w-6"
|
||||
src={data.student.avatar_url}
|
||||
/>
|
||||
<p class="dark:text-white ml-2 text-sm">{data.student.fullname}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- <div class="flex items-center text-sm p-3">
|
||||
<p class="dark:text-white font-bold w-1/2">Teacher</p>
|
||||
<p class="dark:text-white">rotimi-best</p>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.badge {
|
||||
width: fit-content;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { dndzone } from 'svelte-dnd-action';
|
||||
import CheckboxCheckedFilledIcon from 'carbon-icons-svelte/lib/CheckboxCheckedFilled.svelte';
|
||||
import RadioButtonCheckedIcon from 'carbon-icons-svelte/lib/RadioButtonChecked.svelte';
|
||||
import TextAlignJustifyIcon from 'carbon-icons-svelte/lib/TextAlignJustify.svelte';
|
||||
|
||||
import { flip } from 'svelte/animate';
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
|
||||
import { questionnaireOrder, questionnaire } from '../store/exercise';
|
||||
import { filterOutDeleted } from './functions';
|
||||
|
||||
const flipDurationMs = 300;
|
||||
|
||||
interface Question {
|
||||
id: number;
|
||||
name: string;
|
||||
type: number;
|
||||
}
|
||||
|
||||
let items: Array<Question> = [];
|
||||
|
||||
function handleDndConsider(e) {
|
||||
items = e.detail.items;
|
||||
}
|
||||
|
||||
function handleDndFinalize(e) {
|
||||
items = e.detail.items;
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
items.forEach((item, index) => {
|
||||
const order = index + 1;
|
||||
$questionnaire.questions.some((q) => {
|
||||
if (q.id === item.id) {
|
||||
q.is_dirty = true;
|
||||
q.order = order;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
$questionnaire.questions = $questionnaire.questions.sort(
|
||||
(a, b) => a.order - b.order
|
||||
);
|
||||
$questionnaireOrder.open = false;
|
||||
}
|
||||
|
||||
$: items = filterOutDeleted($questionnaire.questions).map((q) => ({
|
||||
id: q.id,
|
||||
name: q.title,
|
||||
type: q.question_type.id,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
onClose={handleClose}
|
||||
bind:open={$questionnaireOrder.open}
|
||||
width="w-96"
|
||||
modalHeading="Order questions"
|
||||
>
|
||||
<section
|
||||
use:dndzone={{
|
||||
items,
|
||||
flipDurationMs,
|
||||
dropTargetStyle: {
|
||||
border: '2px #1d4ed8 solid',
|
||||
'border-style': 'dashed',
|
||||
},
|
||||
}}
|
||||
on:consider={handleDndConsider}
|
||||
on:finalize={handleDndFinalize}
|
||||
class="w-full"
|
||||
>
|
||||
{#each items as item (item.id)}
|
||||
<div
|
||||
animate:flip={{ duration: flipDurationMs }}
|
||||
class="flex items-center rounded-md p-4 border border-blue-600"
|
||||
>
|
||||
{#if item.type === 1}
|
||||
<RadioButtonCheckedIcon size={16} class="carbon-icon active" />
|
||||
{:else if item.type === 2}
|
||||
<CheckboxCheckedFilledIcon size={16} class="carbon-icon active" />
|
||||
{:else}
|
||||
<TextAlignJustifyIcon size={16} class="carbon-icon active" />
|
||||
{/if}
|
||||
{` ${item.name}`}
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
section {
|
||||
padding: 0.3em;
|
||||
overflow: auto;
|
||||
}
|
||||
div {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script>
|
||||
import RadioQuestion from '$lib/components/Question/RadioQuestion/index.svelte';
|
||||
import CheckboxQuestion from '$lib/components/Question/CheckboxQuestion/index.svelte';
|
||||
import TextareaQuestion from '$lib/components/Question/TextareaQuestion/index.svelte';
|
||||
import { QUESTION_TYPE } from '$lib/components/Question/constants';
|
||||
import { getPropsForQuestion } from './functions';
|
||||
|
||||
export let questions = [];
|
||||
export let questionnaireMetaData = {};
|
||||
export let grades = {};
|
||||
export let onSubmit = () => {};
|
||||
export let onPrevious = () => {};
|
||||
export let handleGrade = () => {};
|
||||
export let disableGrading = false;
|
||||
</script>
|
||||
|
||||
{#each questions as currentQuestion, currentQuestionIndex}
|
||||
{#if QUESTION_TYPE.RADIO === currentQuestion.question_type.id}
|
||||
<RadioQuestion
|
||||
{...getPropsForQuestion(
|
||||
questions,
|
||||
currentQuestion,
|
||||
questionnaireMetaData,
|
||||
currentQuestionIndex + 1,
|
||||
onSubmit,
|
||||
onPrevious,
|
||||
true
|
||||
)}
|
||||
bind:grade={grades[currentQuestion.id]}
|
||||
gradeMax={currentQuestion.points}
|
||||
handleGrade={handleGrade(currentQuestion.id)}
|
||||
{disableGrading}
|
||||
disabled={true}
|
||||
/>
|
||||
{:else if QUESTION_TYPE.CHECKBOX === currentQuestion.question_type.id}
|
||||
<CheckboxQuestion
|
||||
{...getPropsForQuestion(
|
||||
questions,
|
||||
currentQuestion,
|
||||
questionnaireMetaData,
|
||||
currentQuestionIndex + 1,
|
||||
onSubmit,
|
||||
onPrevious,
|
||||
true
|
||||
)}
|
||||
bind:grade={grades[currentQuestion.id]}
|
||||
gradeMax={currentQuestion.points}
|
||||
handleGrade={handleGrade(currentQuestion.id)}
|
||||
{disableGrading}
|
||||
disabled={true}
|
||||
/>
|
||||
{:else if QUESTION_TYPE.TEXTAREA === currentQuestion.question_type.id}
|
||||
<TextareaQuestion
|
||||
{...getPropsForQuestion(
|
||||
questions,
|
||||
currentQuestion,
|
||||
questionnaireMetaData,
|
||||
currentQuestionIndex + 1,
|
||||
onSubmit,
|
||||
onPrevious,
|
||||
true
|
||||
)}
|
||||
bind:grade={grades[currentQuestion.id]}
|
||||
gradeMax={currentQuestion.points}
|
||||
handleGrade={handleGrade(currentQuestion.id)}
|
||||
{disableGrading}
|
||||
disabled={true}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
@@ -0,0 +1,305 @@
|
||||
<script>
|
||||
import { marked } from 'marked';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { group, course } from '$lib/components/Course/store';
|
||||
import { questionnaire } from '../store/exercise';
|
||||
import { questionnaireMetaData } from '../store/answers';
|
||||
import Preview from './Preview.svelte';
|
||||
import RadioQuestion from '$lib/components/Question/RadioQuestion/index.svelte';
|
||||
import CheckboxQuestion from '$lib/components/Question/CheckboxQuestion/index.svelte';
|
||||
import TextareaQuestion from '$lib/components/Question/TextareaQuestion/index.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
|
||||
import Progress from '$lib/components/Progress/index.svelte';
|
||||
import { removeDuplicate } from '$lib/utils/functions/removeDuplicate';
|
||||
import { QUESTION_TYPE } from '$lib/components/Question/constants';
|
||||
import { STATUS } from './constants';
|
||||
import { getPropsForQuestion, filterOutDeleted } from './functions';
|
||||
import {
|
||||
formatAnswers,
|
||||
getGroupMemberId,
|
||||
} from '$lib/components/Course/function';
|
||||
import { submitExercise } from '$lib/utils/services/courses';
|
||||
import { fetchSubmission } from '$lib/utils/services/submissions';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
|
||||
export let preview;
|
||||
|
||||
export let exerciseId;
|
||||
|
||||
let currentQuestion = {};
|
||||
let renderProps = {};
|
||||
let submission;
|
||||
let hasSubmission = null;
|
||||
|
||||
function handleStart() {
|
||||
$questionnaireMetaData.currentQuestionIndex += 1;
|
||||
}
|
||||
|
||||
function onSubmit(id, value, moveToNextQuestion = false) {
|
||||
const { answers } = $questionnaireMetaData;
|
||||
const { questions } = $questionnaire;
|
||||
|
||||
const prevAnswer = answers[id] || [];
|
||||
const formattedAnswer =
|
||||
typeof value === 'string'
|
||||
? value
|
||||
: removeDuplicate([...prevAnswer, ...(value || [])]);
|
||||
|
||||
$questionnaireMetaData.answers = {
|
||||
...answers,
|
||||
[id]: formattedAnswer,
|
||||
};
|
||||
|
||||
if (moveToNextQuestion) {
|
||||
$questionnaireMetaData.currentQuestionIndex += 1;
|
||||
}
|
||||
|
||||
const isFinished =
|
||||
!questions[$questionnaireMetaData.currentQuestionIndex - 1];
|
||||
console.log(`isFinished`, isFinished);
|
||||
console.log(
|
||||
`$questionnaireMetaData.currentQuestionIndex`,
|
||||
$questionnaireMetaData.currentQuestionIndex
|
||||
);
|
||||
|
||||
// If last question send to server
|
||||
if (isFinished) {
|
||||
$questionnaireMetaData.status = 1;
|
||||
$questionnaireMetaData.totalPossibleGrade = getTotalPossibleGrade();
|
||||
$questionnaireMetaData.grades = {};
|
||||
submitExercise(
|
||||
$questionnaireMetaData.answers,
|
||||
questions,
|
||||
exerciseId,
|
||||
$course.id,
|
||||
getGroupMemberId($group.people, $profile.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function onPrevious() {
|
||||
$questionnaireMetaData.currentQuestionIndex -= 1;
|
||||
}
|
||||
|
||||
function getProgressValue(currentQuestionIndex) {
|
||||
if ($questionnaireMetaData.isFinished) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
return (
|
||||
Math.round(
|
||||
((currentQuestionIndex - 1) / $questionnaire.questions.length) * 100
|
||||
) || 0
|
||||
);
|
||||
}
|
||||
|
||||
function getTotalPossibleGrade() {
|
||||
return $questionnaire.questions.reduce((acc, question) => {
|
||||
acc += parseFloat(question.points, 10);
|
||||
return acc;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
async function checkForSubmission(people, profileId, courseId) {
|
||||
if (!Array.isArray(people) || !profileId || !courseId || !!submission) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasSubmission) return;
|
||||
|
||||
console.log('checkForSubmission');
|
||||
|
||||
const args = {
|
||||
exerciseId,
|
||||
courseId,
|
||||
submittedBy: getGroupMemberId(people, profileId),
|
||||
};
|
||||
const { data } = await fetchSubmission(args);
|
||||
hasSubmission = true;
|
||||
|
||||
if (Array.isArray(data) && data.length) {
|
||||
submission = data[0];
|
||||
|
||||
$questionnaireMetaData.answers = formatAnswers({
|
||||
questions: $questionnaire.questions,
|
||||
answers: submission.answers,
|
||||
});
|
||||
|
||||
$questionnaireMetaData.totalPossibleGrade = getTotalPossibleGrade();
|
||||
|
||||
$questionnaireMetaData.currentQuestionIndex =
|
||||
$questionnaire.questions.length;
|
||||
$questionnaireMetaData.isFinished = true;
|
||||
$questionnaireMetaData.status = submission.status_id;
|
||||
$questionnaireMetaData.finalTotalGrade = 0;
|
||||
$questionnaireMetaData.grades = submission.answers.reduce(
|
||||
(acc, answer) => {
|
||||
acc[answer.question_id] = answer.point;
|
||||
$questionnaireMetaData.finalTotalGrade += answer.point;
|
||||
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
currentQuestion =
|
||||
$questionnaire.questions[$questionnaireMetaData.currentQuestionIndex - 1];
|
||||
if ($questionnaireMetaData.currentQuestionIndex > 0 && !currentQuestion) {
|
||||
$questionnaireMetaData.isFinished = true;
|
||||
}
|
||||
|
||||
if (currentQuestion) {
|
||||
renderProps = getPropsForQuestion(
|
||||
$questionnaire.questions,
|
||||
currentQuestion,
|
||||
$questionnaireMetaData,
|
||||
$questionnaireMetaData.currentQuestionIndex,
|
||||
onSubmit,
|
||||
onPrevious,
|
||||
preview
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$: $questionnaireMetaData.progressValue = getProgressValue(
|
||||
$questionnaireMetaData.currentQuestionIndex
|
||||
);
|
||||
|
||||
$: checkForSubmission($group.people, $profile.id, $course.id);
|
||||
</script>
|
||||
|
||||
{#if !preview && $questionnaire.questions.length && !$questionnaireMetaData.isFinished}
|
||||
<Progress value={$questionnaireMetaData.progressValue} />
|
||||
{/if}
|
||||
|
||||
{#if preview}
|
||||
<Preview
|
||||
questions={filterOutDeleted($questionnaire.questions)}
|
||||
questionnaireMetaData={$questionnaireMetaData}
|
||||
/>
|
||||
{:else if !$questionnaire.questions.length}
|
||||
<div
|
||||
class="w-4/5 h-40 m-auto flex items-center justify-center text-center border-2 border-gray-200 rounded-md"
|
||||
>
|
||||
<h3 class="dark:text-white text-2xl">
|
||||
No questions added. <br />
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
Click the <span class="text-blue-700">Edit</span> button to add.
|
||||
</RoleBasedSecurity>
|
||||
</h3>
|
||||
</div>
|
||||
{:else if $questionnaireMetaData.currentQuestionIndex === 0}
|
||||
<div>
|
||||
<h2 class="my-1">{$questionnaire.title}</h2>
|
||||
|
||||
<div class="flex items-center">
|
||||
<p class="dark:text-white mx-2">
|
||||
<strong>{$questionnaire.questions.length}</strong> questions
|
||||
</p>
|
||||
|
|
||||
<p class="dark:text-white mx-2">
|
||||
<strong>{getTotalPossibleGrade()}</strong> points.
|
||||
</p>
|
||||
|
|
||||
<p class="dark:text-white mx-2">All required</p>
|
||||
{#if $questionnaire.due_by}
|
||||
|
|
||||
<p class="dark:text-white mx-2">
|
||||
<strong>Due by:</strong>
|
||||
{new Date($questionnaire.due_by).toLocaleString()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<article class="preview prose prose-sm sm:prose p-2">
|
||||
{@html marked($questionnaire.description || 'No desription')}
|
||||
</article>
|
||||
|
||||
<PrimaryButton
|
||||
onClick={handleStart}
|
||||
label="Start"
|
||||
className="my-5 float-right"
|
||||
type="button"
|
||||
/>
|
||||
</div>
|
||||
{:else if $questionnaireMetaData.isFinished}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col justify-between w-full">
|
||||
<h2 class="text-xl mb-2 mt-0">{$questionnaire.title}</h2>
|
||||
{#if STATUS.GRADED === $questionnaireMetaData.status}
|
||||
<span
|
||||
class="status-text bg-green-700 text-white rounded-full py-3 px-6 text-center"
|
||||
title="Status: Pending Review"
|
||||
>
|
||||
Graded
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="status-text bg-yellow-600 text-white rounded-full py-3 px-6 text-center"
|
||||
title="Status: Pending Review"
|
||||
>
|
||||
Pending
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if STATUS.GRADED === $questionnaireMetaData.status}
|
||||
<span
|
||||
class="p-5 border-2 border-gray-700 rounded-full h-24 w-24 flex items-center justify-center text-2xl"
|
||||
title="Status: Pending Review"
|
||||
>
|
||||
{$questionnaireMetaData.finalTotalGrade}/{$questionnaireMetaData.totalPossibleGrade}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<Preview
|
||||
questions={$questionnaire.questions.sort((a, b) => a.order - b.order)}
|
||||
questionnaireMetaData={$questionnaireMetaData}
|
||||
grades={$questionnaireMetaData.grades}
|
||||
disableGrading={true}
|
||||
/>
|
||||
{:else if currentQuestion}
|
||||
{#key currentQuestion.id}
|
||||
<!-- <div transition:fade id="question"> -->
|
||||
<div in:fly={{ x: 500, duration: 1000 }} id="question">
|
||||
{#if QUESTION_TYPE.RADIO === currentQuestion.question_type.id}
|
||||
<RadioQuestion {...renderProps} key={currentQuestion.id} />
|
||||
{:else if QUESTION_TYPE.CHECKBOX === currentQuestion.question_type.id}
|
||||
<CheckboxQuestion {...renderProps} key={currentQuestion.id} />
|
||||
{:else if QUESTION_TYPE.TEXTAREA === currentQuestion.question_type.id}
|
||||
<TextareaQuestion {...renderProps} key={currentQuestion.id} />
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.status-text {
|
||||
width: fit-content;
|
||||
}
|
||||
:global(.shake) {
|
||||
animation: shake 0.85s;
|
||||
}
|
||||
@keyframes shake {
|
||||
10%,
|
||||
90% {
|
||||
transform: translate3d(-15px, 0, 0);
|
||||
}
|
||||
20%,
|
||||
80% {
|
||||
transform: translate3d(15px, 0, 0);
|
||||
}
|
||||
30%,
|
||||
50%,
|
||||
70% {
|
||||
transform: translate3d(-15px, 0, 0);
|
||||
}
|
||||
40%,
|
||||
60% {
|
||||
transform: translate3d(15px, 0, 0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
export const STATUS = {
|
||||
SUBMITTED: 1,
|
||||
IN_PROGRESS: 2,
|
||||
GRADED: 3,
|
||||
};
|
||||
|
||||
export const SELECTABLE_STATUS = [
|
||||
{
|
||||
label: 'Submitted',
|
||||
value: STATUS.SUBMITTED,
|
||||
},
|
||||
{
|
||||
label: 'In progress',
|
||||
value: STATUS.IN_PROGRESS,
|
||||
},
|
||||
{
|
||||
label: 'Graded',
|
||||
value: STATUS.GRADED,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,95 @@
|
||||
import { QUESTION_TYPE } from '$lib/components/Question/constants';
|
||||
import { toggleConfetti } from '$lib/components/Confetti/store';
|
||||
|
||||
export const isAnswerCorrect = (options, answer) => {
|
||||
return options.some((option) => option.is_correct && option.value === answer);
|
||||
};
|
||||
|
||||
export function wasCorrectAnswerSelected(currentQuestion, answers, isFinished) {
|
||||
if (currentQuestion.question_type.id === QUESTION_TYPE.TEXTAREA) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const answer = answers[currentQuestion.name];
|
||||
|
||||
const formattedAnswers = typeof answer === 'string' ? [answer] : answer;
|
||||
const isCorrect =
|
||||
formattedAnswers &&
|
||||
formattedAnswers.some((answer) => {
|
||||
if (isAnswerCorrect(currentQuestion.options, answer)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (isCorrect && !isFinished) {
|
||||
toggleConfetti();
|
||||
|
||||
setTimeout(toggleConfetti, 1500);
|
||||
}
|
||||
|
||||
return isCorrect;
|
||||
}
|
||||
|
||||
export function getPropsForQuestion(
|
||||
questions,
|
||||
question,
|
||||
questionnaireMetaData,
|
||||
questionIndex,
|
||||
onSubmit,
|
||||
onPrevious,
|
||||
preview
|
||||
) {
|
||||
const { answers, isFinished } = questionnaireMetaData;
|
||||
const isLast = questionIndex === questions.length;
|
||||
const isOpenQuesiton = question.question_type.id === QUESTION_TYPE.TEXTAREA;
|
||||
const isCorrect = wasCorrectAnswerSelected(question, answers, isFinished);
|
||||
|
||||
if (!isCorrect && document && document.getElementById('question')) {
|
||||
const questionElement = document.getElementById('question');
|
||||
// Shake if wrong answer was selected
|
||||
questionElement.classList.toggle('shake');
|
||||
|
||||
setTimeout(() => {
|
||||
// Remove so we can trigger animation again
|
||||
questionElement.classList.toggle('shake');
|
||||
}, 200);
|
||||
}
|
||||
|
||||
return {
|
||||
title: questionIndex + '. ' + question.title,
|
||||
name: `${question.name}`,
|
||||
options: question.options,
|
||||
code: question.code,
|
||||
defaultValue: isOpenQuesiton
|
||||
? answers[question.name] || ''
|
||||
: answers[question.name] || [],
|
||||
onSubmit,
|
||||
onPrevious,
|
||||
disablePreviousButton: questionIndex === 1,
|
||||
isLast,
|
||||
isPreview: preview || isFinished,
|
||||
isCorrect,
|
||||
disabled: (isFinished && isCorrect) || isFinished,
|
||||
nextButtonProps: isOpenQuesiton
|
||||
? {
|
||||
label: 'Next',
|
||||
isActive: true,
|
||||
}
|
||||
: isCorrect
|
||||
? {
|
||||
label: isLast ? 'Finish' : 'Next',
|
||||
isActive: true,
|
||||
disableOptionSelect: true,
|
||||
}
|
||||
: {
|
||||
label: 'Check',
|
||||
isActive: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function filterOutDeleted(array) {
|
||||
return array.filter((item) => !item.deleted_at);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<script>
|
||||
import { onDestroy } from 'svelte';
|
||||
import AddAltIcon from 'carbon-icons-svelte/lib/AddAlt.svelte';
|
||||
import TableIcon from 'carbon-icons-svelte/lib/Table.svelte';
|
||||
import SettingsIcon from 'carbon-icons-svelte/lib/Settings.svelte';
|
||||
import ViewIcon from 'carbon-icons-svelte/lib/View.svelte';
|
||||
import ViewFilledIcon from 'carbon-icons-svelte/lib/ViewFilled.svelte';
|
||||
import {
|
||||
handleAddQuestion,
|
||||
questionnaire,
|
||||
validateQuestionnaire,
|
||||
reset,
|
||||
questionnaireOrder,
|
||||
} from '../store/exercise';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import PageBody from '$lib/components/PageBody/index.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
|
||||
import ViewMode from './ViewMode.svelte';
|
||||
import EditMode from './EditMode.svelte';
|
||||
import MODES from '$lib/utils/constants/mode.js';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import { upsertExercise } from '$lib/utils/services/courses';
|
||||
|
||||
export let exerciseId;
|
||||
export let refetchExercise = () => {};
|
||||
export let goBack = () => {};
|
||||
|
||||
let mode = MODES.view;
|
||||
let preview = false;
|
||||
let editDescription = false;
|
||||
|
||||
async function handleMode() {
|
||||
if (mode === MODES.edit) {
|
||||
const errors = validateQuestionnaire($questionnaire.questions);
|
||||
if (Object.values(errors).length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
mode = MODES.view;
|
||||
|
||||
reset();
|
||||
|
||||
const updatedQuestions = await upsertExercise($questionnaire, exerciseId);
|
||||
|
||||
questionnaire.update((q) => ({
|
||||
...q,
|
||||
is_title_dirty: false,
|
||||
is_description_dirty: false,
|
||||
questions: updatedQuestions,
|
||||
}));
|
||||
} else {
|
||||
mode = MODES.edit;
|
||||
preview = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteExercise() {}
|
||||
|
||||
function onCancel() {
|
||||
mode = MODES.view;
|
||||
refetchExercise();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
reset();
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageBody padding="px-4">
|
||||
<svelte:fragment slot="header">
|
||||
<div class="flex items-center">
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<PrimaryButton
|
||||
className="mr-2"
|
||||
variant={VARIANTS.CONTAINED}
|
||||
label={mode === MODES.edit ? 'Save' : 'Edit'}
|
||||
onClick={handleMode}
|
||||
/>
|
||||
{#if mode === MODES.edit}
|
||||
<PrimaryButton
|
||||
className="mr-2"
|
||||
variant={VARIANTS.OUTLINED}
|
||||
label={'Cancel'}
|
||||
onClick={onCancel}
|
||||
/>
|
||||
{/if}
|
||||
</RoleBasedSecurity>
|
||||
|
||||
{#if mode !== MODES.edit}
|
||||
<IconButton
|
||||
onClick={() => (preview = !preview)}
|
||||
toolTipProps={{ title: 'Toggle preview', direction: 'right' }}
|
||||
selected={preview}
|
||||
contained={true}
|
||||
>
|
||||
{#if preview}
|
||||
<ViewFilledIcon size={32} class="carbon-icon" />
|
||||
{:else}
|
||||
<ViewIcon size={32} class="carbon-icon" />
|
||||
{/if}
|
||||
</IconButton>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if mode === MODES.edit}
|
||||
<div class="flex items-center">
|
||||
<IconButton
|
||||
onClick={handleAddQuestion}
|
||||
toolTipProps={{ title: 'Add question', direction: 'bottom' }}
|
||||
>
|
||||
<AddAltIcon size={32} class="carbon-icon" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => ($questionnaireOrder.open = true)}
|
||||
toolTipProps={{ title: 'Order questionnaire', direction: 'bottom' }}
|
||||
>
|
||||
<TableIcon size={32} class="carbon-icon" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => (editDescription = true)}
|
||||
toolTipProps={{ title: 'Edit description', direction: 'bottom' }}
|
||||
>
|
||||
<SettingsIcon size={32} class="carbon-icon" />
|
||||
</IconButton>
|
||||
|
||||
<!-- <IconButton
|
||||
onClick={handleDeleteExercise}
|
||||
toolTipProps={{ title: 'Delete questionnaire', direction: 'bottom' }}
|
||||
>
|
||||
<Delete32 class="carbon-icon" />
|
||||
</IconButton> -->
|
||||
</div>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
|
||||
{#if mode === MODES.edit && !preview}
|
||||
<EditMode bind:editDescription {exerciseId} {goBack} />
|
||||
{:else}
|
||||
<ViewMode {preview} {exerciseId} />
|
||||
{/if}
|
||||
</PageBody>
|
||||
@@ -0,0 +1,193 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import AudioConsoleIcon from 'carbon-icons-svelte/lib/AudioConsole.svelte';
|
||||
import Box from '$lib/components/Box/index.svelte';
|
||||
import { Moon } from 'svelte-loading-spinners';
|
||||
import Backdrop from '$lib/components/Backdrop/index.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
|
||||
import PageBody from '$lib/components/PageBody/index.svelte';
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import Exercise from '../Exercise/index.svelte';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { createExercise } from '$lib/utils/services/courses';
|
||||
import { QUESTION_TYPES } from '$lib/components/Question/constants';
|
||||
import { lesson } from '../store/lessons';
|
||||
import { questionnaire, isQuestionnaireFetching } from '../store/exercise';
|
||||
|
||||
export let path;
|
||||
export let exerciseId;
|
||||
export let lessonId;
|
||||
|
||||
let open = false;
|
||||
let isFetching = false;
|
||||
let newExercise = {
|
||||
id: 1,
|
||||
title: '',
|
||||
description: '',
|
||||
};
|
||||
|
||||
async function handleAddExercise() {
|
||||
const { data, error } = await createExercise({
|
||||
title: newExercise.title,
|
||||
lesson_id: lessonId,
|
||||
});
|
||||
|
||||
console.log(`data, error `, data, error);
|
||||
lesson.update((_lesson) => ({
|
||||
..._lesson,
|
||||
exercises: [..._lesson.exercises, data[0]],
|
||||
}));
|
||||
|
||||
handleCancelAddExercise();
|
||||
}
|
||||
|
||||
function handleCancelAddExercise() {
|
||||
open = false;
|
||||
newExercise = {
|
||||
id: $lesson.exercises.length + 1,
|
||||
title: '',
|
||||
description: '',
|
||||
};
|
||||
}
|
||||
|
||||
async function getExercises() {
|
||||
if (!lessonId) return;
|
||||
isFetching = true;
|
||||
const exercisesData = await supabase
|
||||
.from('exercise')
|
||||
.select(`id, title, created_at`)
|
||||
.match({ lesson_id: lessonId });
|
||||
|
||||
$lesson.exercises = exercisesData.data;
|
||||
isFetching = false;
|
||||
}
|
||||
|
||||
async function getExercise(exerciseId) {
|
||||
if (!exerciseId) return;
|
||||
isFetching = true;
|
||||
isQuestionnaireFetching.update(() => true);
|
||||
|
||||
let { data, error } = await supabase
|
||||
.from('exercise')
|
||||
.select(
|
||||
`
|
||||
id, title, description, due_by,
|
||||
questions:question(
|
||||
*,
|
||||
options:option(*),
|
||||
question_type:question_type_id(id, label)
|
||||
)
|
||||
`
|
||||
)
|
||||
.eq('id', exerciseId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data && Array.isArray(data.questions)) {
|
||||
// Need to set the question type inorder for the select in the questionnaire builder to match
|
||||
data.questions.forEach((question) => {
|
||||
question.question_type = QUESTION_TYPES.find(
|
||||
(type) => type.id === question.question_type.id
|
||||
);
|
||||
return question;
|
||||
});
|
||||
data.questions = data.questions.sort((a, b) => a.order - b.order);
|
||||
} else if (data) {
|
||||
data.questions = [];
|
||||
} else {
|
||||
data = {};
|
||||
}
|
||||
|
||||
data.is_title_dirty = false;
|
||||
data.is_description_dirty = false;
|
||||
data.is_due_by_dirty = false;
|
||||
|
||||
questionnaire.set(data);
|
||||
console.log('getExercise', exerciseId, $questionnaire);
|
||||
|
||||
isQuestionnaireFetching.update(() => false);
|
||||
isFetching = false;
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
goto(path);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
getExercises();
|
||||
});
|
||||
|
||||
$: getExercise(exerciseId);
|
||||
</script>
|
||||
|
||||
{#if isFetching}
|
||||
<Backdrop>
|
||||
<Moon size="60" color="#1d4ed8" unit="px" duration="1s" />
|
||||
</Backdrop>
|
||||
{/if}
|
||||
|
||||
{#if exerciseId}
|
||||
<Exercise
|
||||
{exerciseId}
|
||||
{goBack}
|
||||
refetchExercise={() => getExercise(exerciseId)}
|
||||
/>
|
||||
{:else}
|
||||
<Modal
|
||||
onClose={handleCancelAddExercise}
|
||||
bind:open
|
||||
modalHeading="Create an Exercise"
|
||||
width="w-80"
|
||||
>
|
||||
<TextField
|
||||
bind:value={newExercise.title}
|
||||
autofocus={true}
|
||||
placeholder="Exercise name"
|
||||
/>
|
||||
|
||||
<div class="mt-5 flex items-center">
|
||||
<PrimaryButton
|
||||
className="px-6 py-3"
|
||||
label="Submit"
|
||||
onClick={handleAddExercise}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<PageBody>
|
||||
<slot:fragment slot="header">
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<PrimaryButton
|
||||
className="mr-2"
|
||||
label="Add"
|
||||
onClick={() => (open = !open)}
|
||||
/>
|
||||
</RoleBasedSecurity>
|
||||
</slot:fragment>
|
||||
|
||||
<div class="flex flex-wrap">
|
||||
{#each $lesson.exercises as exercise}
|
||||
<a
|
||||
class="w-52 bg-gray-100 dark:bg-gray-700 px-4 py-7 mr-4 mb-4"
|
||||
href="{path}/{exercise.id}"
|
||||
>
|
||||
<h3 class="dark:text-white text-xl">{exercise.title}</h3>
|
||||
<p class="dark:text-white mt-4 text-sm">Created Jul 3, 2021</p>
|
||||
</a>
|
||||
{:else}
|
||||
<Box>
|
||||
<AudioConsoleIcon size={32} class="carbon-icon w-80" />
|
||||
<h3 class="text-3xl text-gray-500 dark:text-white">
|
||||
No Exercise Added
|
||||
</h3>
|
||||
</Box>
|
||||
{/each}
|
||||
</div>
|
||||
</PageBody>
|
||||
{/if}
|
||||
@@ -0,0 +1,17 @@
|
||||
export const tabs = [
|
||||
{
|
||||
label: 'Slide',
|
||||
value: 1,
|
||||
badgeValue: 0,
|
||||
},
|
||||
{
|
||||
label: 'Note',
|
||||
value: 2,
|
||||
badgeValue: 0,
|
||||
},
|
||||
{
|
||||
label: 'Video',
|
||||
value: 3,
|
||||
badgeValue: 0,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,152 @@
|
||||
<script>
|
||||
import MODES from '$lib/utils/constants/mode.js';
|
||||
import { formatYoutubeVideo } from '$lib/utils/functions/formatYoutubeVideo';
|
||||
|
||||
import Tabs from '$lib/components/Tabs/index.svelte';
|
||||
import TabContent from '$lib/components/TabContent/index.svelte';
|
||||
import MarkdownRender from '$lib/components/MarkdownRender/index.svelte';
|
||||
import Box from '$lib/components/Box/index.svelte';
|
||||
import EditContent from '$lib/components/EditContent/index.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import {
|
||||
lesson,
|
||||
handleUpdateLessonMaterials,
|
||||
isLessonDirty,
|
||||
} from '../store/lessons';
|
||||
import * as CONSTANTS from './constants';
|
||||
|
||||
export let mode = MODES.view;
|
||||
export let prevMode = null;
|
||||
export let lessonId;
|
||||
|
||||
let tabs = CONSTANTS.tabs;
|
||||
let currentTab = tabs[0].value;
|
||||
let errors = {};
|
||||
|
||||
const onChange = (tab) => () => (currentTab = tab);
|
||||
|
||||
function handleSave(prevMode) {
|
||||
if (prevMode === MODES.edit) {
|
||||
handleUpdateLessonMaterials($lesson, lessonId);
|
||||
}
|
||||
}
|
||||
|
||||
function addBadgeValueToTab(materials) {
|
||||
const { slide_url, video_url, note } = materials;
|
||||
|
||||
tabs = tabs.map((tab) => {
|
||||
let badgeValue;
|
||||
|
||||
if (tab.value === 1) {
|
||||
badgeValue = !!slide_url ? 1 : 0;
|
||||
} else if (tab.value === 2) {
|
||||
badgeValue = !!note ? 1 : 0;
|
||||
} else {
|
||||
badgeValue =
|
||||
typeof video_url === 'string' && !!video_url
|
||||
? video_url.split(',').length
|
||||
: 0;
|
||||
}
|
||||
|
||||
tab.badgeValue = badgeValue;
|
||||
return tab;
|
||||
});
|
||||
}
|
||||
|
||||
$: handleSave(prevMode);
|
||||
|
||||
$: addBadgeValueToTab($lesson.materials);
|
||||
</script>
|
||||
|
||||
<Tabs {tabs} {currentTab} {onChange}>
|
||||
<slot:fragment slot="content">
|
||||
<TabContent value={tabs[0].value} index={currentTab}>
|
||||
{#if mode === MODES.edit}
|
||||
<TextField
|
||||
label="Slide link"
|
||||
bind:value={$lesson.materials.slide_url}
|
||||
onChange={() => ($isLessonDirty = true)}
|
||||
/>
|
||||
{:else if $lesson.materials.slide_url}
|
||||
<iframe
|
||||
title="Embeded Slides"
|
||||
src={$lesson.materials.slide_url}
|
||||
frameborder="0"
|
||||
width="100%"
|
||||
height="569"
|
||||
class="iframe"
|
||||
allowfullscreen="true"
|
||||
mozallowfullscreen="true"
|
||||
webkitallowfullscreen="true"
|
||||
/>
|
||||
{:else}
|
||||
<Box>
|
||||
<h3 class="text-3xl text-gray-500 dark:text-white">No slide added</h3>
|
||||
<img alt="Slide not found" src="/notfound.webp" class="w-80" />
|
||||
</Box>
|
||||
{/if}
|
||||
</TabContent>
|
||||
|
||||
<TabContent value={tabs[1].value} index={currentTab}>
|
||||
{#if mode === MODES.edit}
|
||||
<EditContent
|
||||
writeLabel="Note"
|
||||
bind:value={$lesson.materials.note}
|
||||
placeholder="Start typing your lesson"
|
||||
onInputChange={() => ($isLessonDirty = true)}
|
||||
/>
|
||||
{:else if $lesson.materials.note}
|
||||
<MarkdownRender content={$lesson.materials.note} />
|
||||
{:else}
|
||||
<Box>
|
||||
<h3 class="text-3xl text-gray-500 dark:text-white">No note added</h3>
|
||||
<img alt="Lesson note not found" src="/notfound.webp" class="w-80" />
|
||||
</Box>
|
||||
{/if}
|
||||
</TabContent>
|
||||
|
||||
<TabContent value={tabs[2].value} index={currentTab}>
|
||||
{#if mode === MODES.edit}
|
||||
<TextField
|
||||
label="Youtube link"
|
||||
bind:value={$lesson.materials.video_url}
|
||||
onChange={() => ($isLessonDirty = true)}
|
||||
/>
|
||||
{:else if !!$lesson.materials.video_url && $lesson.materials.video_url.includes('youtube')}
|
||||
{#each $lesson.materials.video_url.split(',') as url}
|
||||
<div class="mb-5">
|
||||
<iframe
|
||||
width="100%"
|
||||
height="569"
|
||||
class="iframe"
|
||||
src={formatYoutubeVideo(url, errors)}
|
||||
title="YouTube video player"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<Box>
|
||||
<h3 class="text-3xl text-gray-500 dark:text-white">
|
||||
No youtube video added
|
||||
</h3>
|
||||
<img alt="Video not found" src="/notfound.webp" class="w-80" />
|
||||
</Box>
|
||||
{/if}
|
||||
</TabContent>
|
||||
</slot:fragment>
|
||||
</Tabs>
|
||||
|
||||
<style>
|
||||
.iframe {
|
||||
height: 569px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.iframe {
|
||||
height: 209px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
export function getUniqueValue(label) {
|
||||
return label.toLowerCase().replace(' ', '');
|
||||
}
|
||||
|
||||
export function generateQuestion(questions) {
|
||||
const generatedQuestions = [];
|
||||
|
||||
for (const question of questions) {
|
||||
const name = getUniqueValue(question.title);
|
||||
const options = question.options.map((option) => {
|
||||
option.value = getUniqueValue(option.label);
|
||||
return option;
|
||||
});
|
||||
|
||||
generatedQuestions.push({
|
||||
...question,
|
||||
name,
|
||||
options,
|
||||
});
|
||||
}
|
||||
|
||||
return generatedQuestions;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
import { STATUS } from '../Exercise/constants';
|
||||
|
||||
// export const questionnaireMetaData = writable({
|
||||
// answers: {},
|
||||
// scores: { gates: 60 },
|
||||
// currentQuestionIndex: 5,
|
||||
// isFinished: true,
|
||||
// progressValue: 100,
|
||||
// status: STATUS.GRADED,
|
||||
// });
|
||||
const initAnswerState = {
|
||||
answers: {},
|
||||
scores: {},
|
||||
grades: {},
|
||||
totalPossibleGrade: 0,
|
||||
finalTotalGrade: 0,
|
||||
currentQuestionIndex: 0,
|
||||
isFinished: false,
|
||||
progressValue: 100,
|
||||
status: STATUS.PENDING,
|
||||
};
|
||||
|
||||
export const questionnaireMetaData = writable(initAnswerState);
|
||||
@@ -0,0 +1,248 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import {
|
||||
QUESTION_TEMPLATE,
|
||||
QUESTION_TYPE,
|
||||
} from '$lib/components/Question/constants';
|
||||
import { questionnaireMetaData } from './answers';
|
||||
import { isUUID } from '$lib/utils/functions/isUUID';
|
||||
|
||||
export const isQuestionnaireFetching = writable(false);
|
||||
export const deleteConfirmation = writable({ open: false });
|
||||
export const questionnaireOrder = writable({ open: false });
|
||||
|
||||
// {'question-id': { option: '', title: '', ... }}
|
||||
export const questionnaireValidation = writable({});
|
||||
|
||||
export const questionnaire = writable({
|
||||
title: '',
|
||||
due_by: '',
|
||||
is_due_by_dirty: false,
|
||||
is_title_dirty: false,
|
||||
description: '',
|
||||
is_description_dirty: false,
|
||||
questions: [],
|
||||
});
|
||||
|
||||
export function reset() {
|
||||
questionnaireMetaData.update((metaData) => {
|
||||
metaData.answers = {};
|
||||
metaData.currentQuestionIndex = 0;
|
||||
metaData.isFinished = false;
|
||||
return metaData;
|
||||
});
|
||||
}
|
||||
|
||||
export function validateQuestionnaire(questions) {
|
||||
const errors = {};
|
||||
|
||||
for (const question of questions) {
|
||||
if (
|
||||
question.question_type.id === QUESTION_TYPE.TEXTAREA ||
|
||||
question.deleted_at
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const qErrors = errors[question.id] || {};
|
||||
|
||||
const hasAnswer = question.options
|
||||
.filter((o) => !o.deleted_at)
|
||||
.some((option) => option.is_correct);
|
||||
|
||||
if (!hasAnswer) {
|
||||
qErrors.option = 'Please mark an option as the correct answer';
|
||||
errors[question.id] = { ...qErrors };
|
||||
}
|
||||
}
|
||||
|
||||
questionnaireValidation.set(errors);
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function handleAddQuestion() {
|
||||
questionnaire.update((q) => {
|
||||
const { questions } = q;
|
||||
return {
|
||||
...q,
|
||||
questions: [
|
||||
...questions,
|
||||
{
|
||||
...QUESTION_TEMPLATE,
|
||||
id: `${questions.length + 1}-form`,
|
||||
name: `${questions.length + 1}-form`,
|
||||
value: '',
|
||||
points: 0,
|
||||
order: questions.length,
|
||||
question_type: {
|
||||
id: QUESTION_TYPE.RADIO,
|
||||
},
|
||||
options: [
|
||||
{
|
||||
id: '1-form',
|
||||
label: '',
|
||||
value: null,
|
||||
is_correct: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function handleAddOption(questionId) {
|
||||
return () => {
|
||||
questionnaire.update((_questionnaire) => {
|
||||
const { questions } = _questionnaire;
|
||||
return {
|
||||
..._questionnaire,
|
||||
questions: questions.map((question) => {
|
||||
if (question.id === questionId) {
|
||||
return {
|
||||
...question,
|
||||
options: [
|
||||
...question.options,
|
||||
{
|
||||
id: `${question.options.length + 1}-form`,
|
||||
label: '',
|
||||
value: '',
|
||||
is_correct: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return question;
|
||||
}),
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function handleRemoveOption(questionId, optionId) {
|
||||
return () => {
|
||||
questionnaire.update((q) => {
|
||||
const { questions } = q;
|
||||
|
||||
return {
|
||||
...q,
|
||||
questions: questions.map((question) => {
|
||||
if (question.id === questionId) {
|
||||
return {
|
||||
...question,
|
||||
options: question.options.map((option) => {
|
||||
if (option.id === optionId) {
|
||||
option.deleted_at = new Date();
|
||||
}
|
||||
return option;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return question;
|
||||
}),
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function handleRemoveQuestion(questionId) {
|
||||
return () => {
|
||||
questionnaire.update((q) => {
|
||||
const { questions } = q;
|
||||
|
||||
return {
|
||||
...q,
|
||||
questions: questions.map((q) => {
|
||||
if (q.id === questionId) {
|
||||
q.deleted_at = new Date();
|
||||
}
|
||||
return q;
|
||||
}),
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function handleCode(questionId, shouldAdd = true) {
|
||||
questionnaire.update((q) => {
|
||||
const { questions } = q;
|
||||
|
||||
return {
|
||||
...q,
|
||||
questions: questions.map((question) => {
|
||||
if (question.id === questionId) {
|
||||
return {
|
||||
...question,
|
||||
code: shouldAdd ? question.code || '' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return question;
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function handleAnswerSelect(questionId, optionId) {
|
||||
return () => {
|
||||
questionnaire.update((q) => {
|
||||
const { questions } = q;
|
||||
|
||||
return {
|
||||
...q,
|
||||
questions: questions.map((question) => {
|
||||
if (question.id === questionId) {
|
||||
return {
|
||||
...question,
|
||||
options: question.options.map((option) => {
|
||||
if (option.id === optionId) {
|
||||
option.is_correct = !option.is_correct;
|
||||
option.is_dirty = true;
|
||||
}
|
||||
return option;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return question;
|
||||
}),
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function addDynamicValue(questionId, optionId) {
|
||||
return (e) => {
|
||||
questionnaire.update((q) => {
|
||||
const { questions } = q;
|
||||
|
||||
return {
|
||||
...q,
|
||||
questions: questions.map((question) => {
|
||||
if (question.id === questionId) {
|
||||
return {
|
||||
...question,
|
||||
options: question.options.map((option) => {
|
||||
if (option.id === optionId) {
|
||||
const label = e.target.value;
|
||||
|
||||
if (!isUUID(option.value)) {
|
||||
option.value = label.split(' ').join('-');
|
||||
}
|
||||
|
||||
option.label = label;
|
||||
option.is_dirty = true;
|
||||
}
|
||||
|
||||
return option;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return question;
|
||||
}),
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { Writable, Updater } from 'svelte/store';
|
||||
import {
|
||||
createLesson,
|
||||
updateLesson,
|
||||
deleteLesson,
|
||||
} from '$lib/utils/services/courses';
|
||||
|
||||
import type { Lesson, Course } from '$lib/utils/types';
|
||||
|
||||
export const lessons: Writable<Lesson[]> = writable([]);
|
||||
export const lesson = writable({
|
||||
id: null,
|
||||
totalExercises: 0,
|
||||
materials: {
|
||||
note: null,
|
||||
slide_url: null,
|
||||
video_url: null,
|
||||
},
|
||||
exercises: [],
|
||||
});
|
||||
export const isLessonDirty = writable(false);
|
||||
|
||||
export function handleAddLesson() {
|
||||
lessons.update(((_lessons) => {
|
||||
return [
|
||||
..._lessons,
|
||||
{
|
||||
id: null,
|
||||
title: 'Untitled lesson',
|
||||
// profile: undefined,
|
||||
call_url: undefined,
|
||||
lesson_at: new Date(),
|
||||
is_complete: false,
|
||||
},
|
||||
];
|
||||
}) as Updater<any>);
|
||||
}
|
||||
|
||||
export function handleDelete(lessonId: Lesson['id']) {
|
||||
return async () => {
|
||||
// Need to implement soft delete
|
||||
if (lessonId) {
|
||||
const { error } = await deleteLesson(lessonId);
|
||||
|
||||
if (error) {
|
||||
return alert('Please delete all exercises first');
|
||||
}
|
||||
}
|
||||
|
||||
lessons.update((_lessons) =>
|
||||
_lessons.filter((lesson) => lesson.id !== lessonId)
|
||||
);
|
||||
console.log(`lessonId`, lessonId);
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleSaveLesson(
|
||||
lesson: Lesson,
|
||||
course_id: Course['id']
|
||||
) {
|
||||
console.log(`handleSaveLesson lesson`, lesson);
|
||||
const newLesson = {
|
||||
title: lesson.title,
|
||||
lesson_at: lesson.lesson_at,
|
||||
call_url: lesson.call_url,
|
||||
teacher_id: lesson.profile ? lesson.profile.id : undefined,
|
||||
course_id,
|
||||
is_complete: lesson.is_complete,
|
||||
};
|
||||
|
||||
let updatedLesson: any[] | null = null;
|
||||
|
||||
if (typeof lesson.id === 'string') {
|
||||
// No need to get the result of update cause we have all in local state
|
||||
await updateLesson(newLesson, lesson.id);
|
||||
} else {
|
||||
const { data } = await createLesson(newLesson);
|
||||
|
||||
updatedLesson = data;
|
||||
}
|
||||
|
||||
lessons.update((l) =>
|
||||
l.map((lesson) => {
|
||||
if (!lesson.id && updatedLesson) {
|
||||
lesson.id = updatedLesson[0].id;
|
||||
}
|
||||
|
||||
return lesson;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleUpdateLessonMaterials(
|
||||
lesson: any,
|
||||
lessonId: Lesson['id']
|
||||
) {
|
||||
const materials = {
|
||||
...lesson.materials,
|
||||
};
|
||||
|
||||
delete materials.totalExercises;
|
||||
|
||||
return await updateLesson(materials, lessonId);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<script>
|
||||
import CaretSortDownIcon from 'carbon-icons-svelte/lib/CaretSortDown.svelte';
|
||||
|
||||
let isExpanded = true;
|
||||
export let handleClick = () => {};
|
||||
export let label = '';
|
||||
export let hideSortIcon = false;
|
||||
export let isGroupActive = false;
|
||||
// export let subMenuItems = [];
|
||||
|
||||
function onClick() {
|
||||
// isExpanded = !isExpanded;
|
||||
handleClick();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div
|
||||
class="item relative flex items-center {isGroupActive && 'active'}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:click={onClick}
|
||||
>
|
||||
{#if !hideSortIcon}
|
||||
<CaretSortDownIcon
|
||||
size={20}
|
||||
class="carbon-icon-sort {isExpanded ? '' : 'carbon-icon-minimize'}"
|
||||
/>
|
||||
{/if}
|
||||
<span class="dark:text-white font-bold">{label}</span>
|
||||
</div>
|
||||
{#if isExpanded}
|
||||
<div class="flex flex-col">
|
||||
<slot />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(svg.carbon-icon-sort) {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 0;
|
||||
}
|
||||
:global(svg.carbon-icon-minimize) {
|
||||
transform: rotate(270deg);
|
||||
top: 13px;
|
||||
left: -6px;
|
||||
}
|
||||
|
||||
.active {
|
||||
background-color: #cae2f9;
|
||||
border-left: 3px solid var(--main-primary-color);
|
||||
}
|
||||
|
||||
:global(body.dark) .active,
|
||||
:global(body.dark) .item:hover {
|
||||
background-color: rgba(107, 114, 128);
|
||||
}
|
||||
|
||||
.active span {
|
||||
padding-left: 1.3rem;
|
||||
}
|
||||
|
||||
.item span {
|
||||
padding: 12px 24px;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background-color: #cae2f9;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,320 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import hotkeys from 'hotkeys-js';
|
||||
import HomeIcon from 'carbon-icons-svelte/lib/Home.svelte';
|
||||
import CheckmarkFilledIcon from 'carbon-icons-svelte/lib/CheckmarkFilled.svelte';
|
||||
import SettingsIcon from 'carbon-icons-svelte/lib/Settings.svelte';
|
||||
import ChevronLeftIcon from 'carbon-icons-svelte/lib/ChevronLeft.svelte';
|
||||
import ChevronRightIcon from 'carbon-icons-svelte/lib/ChevronRight.svelte';
|
||||
import NavExpandable from './NavExpandable.svelte';
|
||||
import PageNav from '$lib/components/PageNav/index.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
import Avatar from '$lib/components/Avatar/index.svelte';
|
||||
import {
|
||||
getNavItemRoute,
|
||||
getLessonsRoute,
|
||||
getLectureNo,
|
||||
} from '$lib/components/Course/function';
|
||||
|
||||
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
|
||||
import Settings from '../Settings/index.svelte';
|
||||
import { settingsDialog } from '../Settings/store';
|
||||
import { lessons } from '../Lesson/store/lessons';
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { updateCourse } from '$lib/utils/services/courses';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { currentOrgPath } from '$lib/utils/store/org';
|
||||
import { isMobile } from '$lib/utils/store/useMobile';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
// export let lessonId;
|
||||
export let path;
|
||||
export let isStudent = null;
|
||||
let show = null;
|
||||
|
||||
function handleMainGroupClick(href) {
|
||||
return () => {
|
||||
if ($isMobile) {
|
||||
setTimeout(() => (show = false), 500);
|
||||
}
|
||||
goto(href);
|
||||
};
|
||||
}
|
||||
|
||||
let navItems = [];
|
||||
|
||||
function getHref(item) {
|
||||
return item.to;
|
||||
// return item.is_complete ? item.to : path || $page.url.pathname
|
||||
}
|
||||
|
||||
function handleSaveTitle() {
|
||||
updateCourse($course.id, { title: $course.title });
|
||||
}
|
||||
|
||||
function handleMobileChange(isMobile) {
|
||||
if (isMobile) show = false;
|
||||
else show = true;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (localStorage.getItem('hideCourseNav')) {
|
||||
show = localStorage.getItem('hideCourseNav') === 'false';
|
||||
} else {
|
||||
show = true;
|
||||
}
|
||||
|
||||
hotkeys('b', function (event, handler) {
|
||||
event.preventDefault();
|
||||
switch (handler.key) {
|
||||
case 'b':
|
||||
show = !show;
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$: handleMobileChange($isMobile);
|
||||
|
||||
$: {
|
||||
if (browser) {
|
||||
localStorage.setItem('hideCourseNav', `${!show}`);
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
navItems = [
|
||||
{
|
||||
label: 'Overview',
|
||||
to: getNavItemRoute($course.id),
|
||||
hideSortIcon: true,
|
||||
},
|
||||
{
|
||||
label: 'Lessons',
|
||||
to: getLessonsRoute($course.id),
|
||||
hideSortIcon: false,
|
||||
isLecture: true,
|
||||
},
|
||||
{
|
||||
label: 'Attendance',
|
||||
to: getNavItemRoute($course.id, 'attendance'),
|
||||
hideSortIcon: true,
|
||||
},
|
||||
{
|
||||
label: 'Submissions',
|
||||
to: getNavItemRoute($course.id, 'submissions'),
|
||||
hideSortIcon: true,
|
||||
show() {
|
||||
return !isStudent;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Marks',
|
||||
to: getNavItemRoute($course.id, 'marks'),
|
||||
hideSortIcon: true,
|
||||
},
|
||||
// {
|
||||
// label: 'Scoreboard',
|
||||
// to: getNavItemRoute($course.id, 'scoreboard'),
|
||||
// hideSortIcon: true,
|
||||
// },
|
||||
{
|
||||
label: 'People',
|
||||
to: getNavItemRoute($course.id, 'people'),
|
||||
hideSortIcon: true,
|
||||
},
|
||||
{
|
||||
label: 'Certificates',
|
||||
to: getNavItemRoute($course.id, 'certificates'),
|
||||
hideSortIcon: true,
|
||||
},
|
||||
{
|
||||
label: 'Landing Page',
|
||||
to: getNavItemRoute($course.id, 'landingpage'),
|
||||
hideSortIcon: true,
|
||||
show() {
|
||||
return !isStudent;
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
</script>
|
||||
|
||||
<Settings />
|
||||
|
||||
<div
|
||||
class="root z-10 {!show && 'hide'} {$isMobile ? 'fixed shadow-xl' : 'sticky'}"
|
||||
>
|
||||
{#if show}
|
||||
<div class="relative h-full dark:bg-gray-700">
|
||||
<PageNav bind:title={$course.title} paddingClass="pl-2">
|
||||
<slot:fragment slot="image">
|
||||
<Avatar src={$course.logo} />
|
||||
</slot:fragment>
|
||||
<slot:fragment slot="widget">
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<IconButton
|
||||
onClick={() => ($settingsDialog.open = !$settingsDialog.open)}
|
||||
>
|
||||
<SettingsIcon size={20} class="carbon-icon" />
|
||||
</IconButton>
|
||||
</RoleBasedSecurity>
|
||||
</slot:fragment>
|
||||
</PageNav>
|
||||
<div class="navItems">
|
||||
{#each navItems as navItem}
|
||||
{#if !navItem.show || (typeof navItem.show === 'function' && navItem.show())}
|
||||
<NavExpandable
|
||||
label={navItem.label}
|
||||
handleClick={handleMainGroupClick(navItem.to)}
|
||||
isGroupActive={(path || $page.url.pathname) === navItem.to}
|
||||
hideSortIcon={navItem.hideSortIcon}
|
||||
>
|
||||
{#if navItem.isLecture}
|
||||
{#each $lessons as item, index}
|
||||
<a
|
||||
class="item flex items-center {(
|
||||
path || $page.url.pathname
|
||||
).includes(item.id) && 'active'} pl-7 py-3 {isStudent &&
|
||||
!item.is_complete
|
||||
? 'cursor-not-allowed'
|
||||
: ''}"
|
||||
href={isStudent && !item.is_complete
|
||||
? $page.url.pathname
|
||||
: getLessonsRoute($course.id, item.id)}
|
||||
on:click={() => {
|
||||
if ($isMobile) {
|
||||
setTimeout(() => (show = false), 500);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span class="course-counter">
|
||||
{getLectureNo(index + 1)}
|
||||
</span>
|
||||
<span>{item.title}</span>
|
||||
{#if item.is_complete}
|
||||
<span class="ml-2 success">
|
||||
<CheckmarkFilledIcon size={20} class="carbon-icon" />
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-md ml-2">🔒</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</NavExpandable>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="w-full footer p-3 absolute bottom-2 flex items-center justify-between"
|
||||
>
|
||||
<IconButton
|
||||
value="toggle"
|
||||
onClick={() => goto(`${$currentOrgPath}/courses`)}
|
||||
buttonClassName="mx-3"
|
||||
>
|
||||
<HomeIcon size={32} class="carbon-icon" />
|
||||
</IconButton>
|
||||
<a class="block mx-3" href={`/profile/${$profile.id}`}>
|
||||
<Avatar src={$profile.avatar_url} name={$profile.username} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="toggler rounded-full shadow-lg absolute bottom-0">
|
||||
<IconButton
|
||||
value="toggle"
|
||||
onClick={() => (show = !show)}
|
||||
size={$isMobile ? 'large' : 'small'}
|
||||
color="text-black"
|
||||
toolTipProps={$isMobile
|
||||
? {}
|
||||
: {
|
||||
title: 'Toggle sidebar',
|
||||
hotkeys: ['B'],
|
||||
direction: 'right',
|
||||
}}
|
||||
>
|
||||
{#if show}
|
||||
<ChevronLeftIcon size={16} />
|
||||
{:else}
|
||||
<ChevronRightIcon size={16} />
|
||||
{/if}
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.root {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 360px;
|
||||
max-width: 360px;
|
||||
top: 0;
|
||||
border-right: 1px solid var(--border-color);
|
||||
background-color: rgb(250, 251, 252);
|
||||
|
||||
&.hide {
|
||||
min-width: 15px;
|
||||
max-width: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
border-top: 1px solid var(--border-color);
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.active {
|
||||
background-color: #cae2f9;
|
||||
border-left: 3px solid var(--main-primary-color);
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
.course-counter {
|
||||
font-family: SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace;
|
||||
background-color: inherit;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
margin-right: 1.5rem;
|
||||
}
|
||||
|
||||
.item {
|
||||
font-size: 0.875rem;
|
||||
font-weight: bold;
|
||||
&:hover {
|
||||
background-color: #cae2f9;
|
||||
}
|
||||
}
|
||||
:global(body.dark) .active,
|
||||
:global(body.dark) .item:hover {
|
||||
background-color: rgba(107, 114, 128);
|
||||
}
|
||||
|
||||
.navItems {
|
||||
height: 82%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.toggler {
|
||||
right: -15px;
|
||||
z-index: 10;
|
||||
border: 1px solid var(--border-color);
|
||||
top: 50px;
|
||||
height: fit-content;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.root {
|
||||
width: 98%;
|
||||
min-width: unset;
|
||||
max-width: unset;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script>
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import { deleteMemberModal } from './store';
|
||||
|
||||
export let email;
|
||||
export let deletePerson = () => {};
|
||||
|
||||
async function handleDelete() {
|
||||
deletePerson();
|
||||
$deleteMemberModal.open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
onClose={() => ($deleteMemberModal.open = false)}
|
||||
bind:open={$deleteMemberModal.open}
|
||||
width="w-96"
|
||||
modalHeading="Delete"
|
||||
>
|
||||
<div>
|
||||
<h1 class="dark:text-white text-lg">
|
||||
Are you sure you want to delete <strong>{email}</strong>?
|
||||
</h1>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<PrimaryButton
|
||||
className="px-6 py-3"
|
||||
variant={VARIANTS.OUTLINED}
|
||||
label="No"
|
||||
onClick={() => ($deleteMemberModal.open = false)}
|
||||
/>
|
||||
<PrimaryButton
|
||||
className="px-6 py-3"
|
||||
variant={VARIANTS.OUTLINED}
|
||||
label="Yes"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script>
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import TextArea from '$lib/components/Form/TextArea.svelte';
|
||||
import { ROLE } from '$lib/utils/constants/roles';
|
||||
import { addGroupMember, fetchGroup } from '$lib/utils/services/courses';
|
||||
import { invitationModal, resetForm } from './store';
|
||||
import { validateForm } from '$lib/components/Courses/functions';
|
||||
import { course, setCourse } from '$lib/components/Course/store';
|
||||
|
||||
let errors = {
|
||||
emails: '',
|
||||
};
|
||||
|
||||
function addPerson() {
|
||||
const { hasError, fieldErrors, students, tutors } = validateForm({
|
||||
...$invitationModal,
|
||||
title: 'sfdsd',
|
||||
description: 'dsfdsf',
|
||||
});
|
||||
|
||||
errors = fieldErrors;
|
||||
if (hasError) return;
|
||||
|
||||
let membersStack = [];
|
||||
console.log(`$course`, $course);
|
||||
// Create groupmemebers from group_id and add teachers and students
|
||||
for (const student of students) {
|
||||
membersStack.push({
|
||||
email: student,
|
||||
group_id: $course.group.id,
|
||||
role_id: ROLE.STUDENT,
|
||||
});
|
||||
}
|
||||
|
||||
for (const tutor of tutors) {
|
||||
membersStack.push({
|
||||
email: tutor,
|
||||
group_id: $course.group.id,
|
||||
role_id: ROLE.TUTOR,
|
||||
});
|
||||
}
|
||||
|
||||
addGroupMember(membersStack).then(async (membersAdded) => {
|
||||
const group = await fetchGroup($course.group.id);
|
||||
|
||||
setCourse(
|
||||
{
|
||||
...$course,
|
||||
group: group.data,
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
resetForm();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
onClose={resetForm}
|
||||
bind:open={$invitationModal.open}
|
||||
width="w-2/5"
|
||||
maxWidth="max-w-lg"
|
||||
modalHeading="Invite people"
|
||||
>
|
||||
<form on:submit|preventDefault={addPerson}>
|
||||
<TextArea
|
||||
label="Invite Tutors"
|
||||
bind:value={$invitationModal.tutors}
|
||||
rows="2"
|
||||
maxRows="3"
|
||||
placeholder="tutor@company.com, john@gmail.com"
|
||||
helperMessage="To invite people, add their emails separated by a comma"
|
||||
errorMessage={errors.tutors}
|
||||
className="mb-4"
|
||||
/>
|
||||
<TextArea
|
||||
label="Invite Students"
|
||||
bind:value={$invitationModal.students}
|
||||
rows="2"
|
||||
maxRows="3"
|
||||
placeholder="student@gmail.com, john@gmail.com"
|
||||
helperMessage="To invite people, add their emails separated by a comma"
|
||||
errorMessage={errors.students}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<div class="mt-5 flex items-center">
|
||||
<PrimaryButton className="px-6 py-3" label="Finish" type="submit" />
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const deleteMemberModal = writable({
|
||||
open: false,
|
||||
});
|
||||
|
||||
export const invitationModal = writable({
|
||||
open: false,
|
||||
tutors: '',
|
||||
students: '',
|
||||
});
|
||||
|
||||
export const resetForm = () =>
|
||||
invitationModal.update(() => ({
|
||||
open: false,
|
||||
tutors: '',
|
||||
students: '',
|
||||
}));
|
||||
@@ -0,0 +1,21 @@
|
||||
export interface Profile {
|
||||
id: string;
|
||||
fullname: string;
|
||||
username: string;
|
||||
email: string;
|
||||
avatar_url?: string;
|
||||
}
|
||||
|
||||
export interface Person {
|
||||
id: string;
|
||||
email?: string;
|
||||
role_id: number;
|
||||
profile?: Profile;
|
||||
profile_id?: string;
|
||||
assigned_student_id: string;
|
||||
}
|
||||
|
||||
export interface ProfileRole {
|
||||
label: string;
|
||||
value: string | number;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import TextArea from '$lib/components/Form/TextArea.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import UploadImage from '$lib/components/UploadImage/index.svelte';
|
||||
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import { settingsDialog } from './store';
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { updateCourse, deleteCourse } from '$lib/utils/services/courses';
|
||||
|
||||
let shouldDelete = false;
|
||||
let avatar;
|
||||
let errors = {};
|
||||
|
||||
async function updateDescription() {
|
||||
if (!$course.title) {
|
||||
errors.title = 'Title cannot be empty';
|
||||
return;
|
||||
}
|
||||
if (!$course.description) {
|
||||
errors.description = 'Description cannot be empty';
|
||||
return;
|
||||
}
|
||||
|
||||
$settingsDialog.open = false;
|
||||
const logo = await updateCourse($course.id, avatar, {
|
||||
title: $course.title,
|
||||
description: $course.description,
|
||||
});
|
||||
|
||||
if (logo) {
|
||||
$course.logo = logo;
|
||||
}
|
||||
|
||||
avatar = undefined;
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
$settingsDialog.open = false;
|
||||
await deleteCourse($course.id);
|
||||
goto('/courses');
|
||||
}
|
||||
|
||||
// function setAvatar(logo) {
|
||||
// avatar = logo;
|
||||
// }
|
||||
|
||||
// $: setAvatar($course.logo);
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
onClose={() => ($settingsDialog.open = false)}
|
||||
bind:open={$settingsDialog.open}
|
||||
width="w-96"
|
||||
modalHeading="Update Course"
|
||||
>
|
||||
{#if shouldDelete}
|
||||
<form on:submit|preventDefault={() => (shouldDelete = false)}>
|
||||
<h1 class="dark:text-white text-2xl">Are you sure?</h1>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<PrimaryButton
|
||||
className="px-6 py-3"
|
||||
variant={VARIANTS.OUTLINED}
|
||||
label="No, cancel"
|
||||
type="submit"
|
||||
/>
|
||||
<PrimaryButton
|
||||
className="px-6 py-3"
|
||||
variant={VARIANTS.OUTLINED}
|
||||
label="Yes, delete"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<form on:submit|preventDefault={updateDescription}>
|
||||
<UploadImage bind:avatar src={$course.logo} />
|
||||
<TextField
|
||||
bind:value={$course.title}
|
||||
placeholder="Course title"
|
||||
className="mb-4"
|
||||
isRequired={true}
|
||||
errorMessage={errors.title}
|
||||
/>
|
||||
<TextArea
|
||||
label="Short description"
|
||||
bind:value={$course.description}
|
||||
rows="4"
|
||||
maxRows="4"
|
||||
placeholder="A little description about this course"
|
||||
className="mb-4"
|
||||
isRequired={true}
|
||||
errorMessage={errors.description}
|
||||
/>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<PrimaryButton
|
||||
className="px-6 py-3"
|
||||
variant={VARIANTS.CONTAINED_DANGER}
|
||||
label="Delete"
|
||||
onClick={() => (shouldDelete = true)}
|
||||
/>
|
||||
<PrimaryButton className="px-6 py-3" label="Update" type="submit" />
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</Modal>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const settingsDialog = writable({
|
||||
open: false,
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
export default `# 1. Soft Skills
|
||||
|
||||
## Notes
|
||||
Here I can write some notes to my students
|
||||
|
||||
## Community
|
||||
|
||||
The Next.js community can be found on [GitHub Discussions](https://github.com/vercel/next.js/discussions), where you can ask questions, voice ideas, and share your projects.
|
||||
|
||||
To chat with other community members you can join the [Next.js Discord](https://nextjs.org/discord).
|
||||
|
||||
Our [Code of Conduct](https://github.com/vercel/next.js/blob/canary/CODE_OF_CONDUCT.md) applies to all Next.js community channels.
|
||||
|
||||
## Contributing
|
||||
|
||||
Please see our [contributing.md](/contributing.md).
|
||||
|
||||
## Authors
|
||||
|
||||
- Tim Neutkens ([@timneutkens](https://twitter.com/timneutkens)) – [Vercel](https://vercel.com/about/timneutkens)
|
||||
- Naoyuki Kanezawa ([@nkzawa](https://twitter.com/nkzawa)) – [Vercel](https://vercel.com/about/nkzawa)
|
||||
- Guillermo Rauch ([@rauchg](https://twitter.com/rauchg)) – [Vercel](https://vercel.com/about/rauchg)
|
||||
- Arunoda Susiripala ([@arunoda](https://twitter.com/arunoda))
|
||||
- Tony Kovanen ([@tonykovanen](https://twitter.com/tonykovanen))
|
||||
- Dan Zajdband ([@impronunciable](https://twitter.com/impronunciable))
|
||||
`
|
||||
@@ -0,0 +1,4 @@
|
||||
export const ROUTES = {
|
||||
COURSES: "courses",
|
||||
LESSONS: "lessons",
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ROUTES } from './constants';
|
||||
|
||||
export function getGroupMemberId(people, profileId) {
|
||||
const groupMember = people.find((person) => person.profile_id === profileId);
|
||||
|
||||
return groupMember ? groupMember.id : null;
|
||||
}
|
||||
|
||||
export function getNavItemRoute(courseId, routeId) {
|
||||
const path = `/${ROUTES.COURSES}/${courseId}`;
|
||||
|
||||
if (!routeId) {
|
||||
return path;
|
||||
}
|
||||
|
||||
return `${path}/${routeId}`;
|
||||
}
|
||||
|
||||
export function getLessonsRoute(courseId, lessonId) {
|
||||
const path = getNavItemRoute(courseId, ROUTES.LESSONS);
|
||||
|
||||
if (!lessonId) {
|
||||
return path;
|
||||
}
|
||||
|
||||
return `${path}/${lessonId}`;
|
||||
}
|
||||
|
||||
export function getLectureNo(index, initNo = '00') {
|
||||
if (index <= 9) {
|
||||
return `${initNo}${index}`;
|
||||
}
|
||||
|
||||
return `${initNo.replace('0', '')}${index}`;
|
||||
}
|
||||
|
||||
export function formatAnswers(data) {
|
||||
const answers = {};
|
||||
const questionByIdAndName = {};
|
||||
|
||||
for (const question of data.questions) {
|
||||
questionByIdAndName[question.id] = question.name;
|
||||
}
|
||||
|
||||
for (const answer of data.answers) {
|
||||
const questionName = questionByIdAndName[answer.question_id];
|
||||
|
||||
answers[questionName] =
|
||||
Array.isArray(answer.answers) && answer.answers.length
|
||||
? answer.answers
|
||||
: answer.open_answer;
|
||||
}
|
||||
|
||||
return answers;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<script>
|
||||
import { marked } from 'marked';
|
||||
import RoleBasedSecurity from '../RoleBasedSecurity/index.svelte';
|
||||
import PageNav from '../PageNav/index.svelte';
|
||||
import MODES from '$lib/utils/constants/mode';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import PageBody from '../PageBody/index.svelte';
|
||||
import EditContent from '../EditContent/index.svelte';
|
||||
import { course } from './store';
|
||||
import { updateCourse } from '$lib/utils/services/courses';
|
||||
|
||||
let mode = MODES.view;
|
||||
let isDirty = false;
|
||||
|
||||
async function handleModeChange() {
|
||||
if (mode === MODES.edit) {
|
||||
mode = MODES.view;
|
||||
if (isDirty) {
|
||||
const { overview, id } = $course;
|
||||
await updateCourse(id, undefined, { overview });
|
||||
}
|
||||
} else {
|
||||
mode = MODES.edit;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageNav title="Overview" disableSticky={true}>
|
||||
<svelte:fragment slot="widget">
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<div class="flex items-center">
|
||||
<PrimaryButton
|
||||
className="mr-2"
|
||||
label={mode === MODES.edit ? 'Save' : 'Edit'}
|
||||
onClick={handleModeChange}
|
||||
/>
|
||||
</div>
|
||||
</RoleBasedSecurity>
|
||||
</svelte:fragment>
|
||||
</PageNav>
|
||||
|
||||
<PageBody width="max-w-3xl px-3">
|
||||
{#if mode === MODES.edit}
|
||||
<EditContent
|
||||
writeLabel="Note"
|
||||
bind:value={$course.overview}
|
||||
placeholder="Start typing your lesson"
|
||||
onInputChange={() => {
|
||||
isDirty = true;
|
||||
console.log('input change');
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<article class="preview prose prose-sm sm:prose p-2">
|
||||
{@html marked($course.overview || '')}
|
||||
</article>
|
||||
{/if}
|
||||
</PageBody>
|
||||
@@ -0,0 +1,139 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { lessons } from './components/Lesson/store/lessons';
|
||||
//@ts-ignore
|
||||
import { ROLE } from '$lib/utils/constants/roles';
|
||||
import type { Course } from '$lib/utils/types';
|
||||
|
||||
export const course: Writable<Course> = writable({
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
cost: 0,
|
||||
currency: '',
|
||||
is_certificate_downloadable: false,
|
||||
certificate_theme: 'professional',
|
||||
metadata: {
|
||||
requirements: '',
|
||||
description: '',
|
||||
goals: '',
|
||||
videoUrl: '',
|
||||
showDiscount: false,
|
||||
discount: 0,
|
||||
reward: {
|
||||
show: false,
|
||||
description: '',
|
||||
},
|
||||
instructor: {
|
||||
name: '',
|
||||
role: '',
|
||||
coursesNo: 0,
|
||||
description: '',
|
||||
imgUrl: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const mockGroupMember = {
|
||||
id: '434534534535',
|
||||
group_id: '434534534535',
|
||||
role_id: 1,
|
||||
profile_id: 'vcvbcvbcvbb----434534534535',
|
||||
email: null,
|
||||
profile: {
|
||||
id: 'vcvbcvbcvbb----434534534535',
|
||||
fullname: 'Rotimi Best',
|
||||
username: 'robertblake',
|
||||
avatar_url: '',
|
||||
created_at: '2021-08-08T13:42:13+00:00',
|
||||
updated_at: '2021-08-08T13:42:13+00:00',
|
||||
},
|
||||
};
|
||||
|
||||
export const group = writable({
|
||||
id: null,
|
||||
tutors: [],
|
||||
students: [],
|
||||
people: [],
|
||||
});
|
||||
|
||||
export async function setCourse(data: Course, setLesson = true) {
|
||||
if (!data || !(Object.values(data) && Object.values(data).length)) return;
|
||||
// const tutorsById = {};
|
||||
|
||||
if (data.group) {
|
||||
const groupData = Object.assign(data.group, {
|
||||
tutors: [],
|
||||
students: [],
|
||||
people: [],
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
if (Array.isArray(groupData.members)) {
|
||||
// @ts-ignore
|
||||
for (const member of groupData.members) {
|
||||
if (member.role_id === ROLE.STUDENT) {
|
||||
// @ts-ignore
|
||||
groupData.students.push(member);
|
||||
} else if (member.profile) {
|
||||
// @ts-ignore
|
||||
groupData.tutors.push(member.profile);
|
||||
|
||||
// tutorsById[member.profile.id] = member.profile;
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
groupData.people = groupData.members;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
delete groupData.members;
|
||||
// @ts-ignore
|
||||
group.set(groupData);
|
||||
}
|
||||
|
||||
if (setLesson) {
|
||||
// @ts-ignore
|
||||
const orderedLessons = (data.lessons || [])
|
||||
// @ts-ignore
|
||||
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at))
|
||||
// @ts-ignore
|
||||
.sort((a, b) => a.order - b.order);
|
||||
// .map((lesson) => ({
|
||||
// ...lesson,
|
||||
// profile: lesson.profile && tutorsById[lesson.profile.id],
|
||||
// }));
|
||||
lessons.set(orderedLessons);
|
||||
}
|
||||
|
||||
//@ts-ignore
|
||||
delete data.lessons;
|
||||
|
||||
if (data.metadata && !Object.values(data.metadata)) {
|
||||
data.metadata = {
|
||||
requirements: '',
|
||||
description: '',
|
||||
goals: '',
|
||||
videoUrl: '',
|
||||
showDiscount: false,
|
||||
discount: 0,
|
||||
reward: {
|
||||
show: false,
|
||||
description: '',
|
||||
},
|
||||
instructor: {
|
||||
name: '',
|
||||
role: '',
|
||||
coursesNo: 0,
|
||||
description: '',
|
||||
imgUrl: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!data.certificate_theme) {
|
||||
data.certificate_theme = 'professional';
|
||||
}
|
||||
course.set(data);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<script>
|
||||
import { Moon } from 'svelte-loading-spinners';
|
||||
import Navigation from '../Course/components/Navigation/index.svelte';
|
||||
import Backdrop from '$lib/components/Backdrop/index.svelte';
|
||||
// import { lessons, lesson } from '../Course/components/Lesson/store/lessons';
|
||||
// import { questionnaire } from '../Course/components/Lesson/Exercise/store/exercise';
|
||||
// import { questionnaireMetaData } from '../Course/components/Lesson/store/answers';
|
||||
import { course, group } from '../Course/store';
|
||||
import Confetti from '../Confetti/index.svelte';
|
||||
import { isMobile } from '$lib/utils/store/useMobile';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
|
||||
export let path = '';
|
||||
export let isExercisePage = false;
|
||||
export let isStudent = false;
|
||||
export let isFetching = false;
|
||||
|
||||
$: {
|
||||
const user = $group.people.find(
|
||||
(person) => person.profile_id === $profile.id
|
||||
);
|
||||
|
||||
if (user) {
|
||||
isStudent = user.role_id === 3;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$course.title || 'ClassroomIO Course'}</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if isFetching}
|
||||
<Backdrop>
|
||||
<Moon size="60" color="#1d4ed8" unit="px" duration="1s" />
|
||||
</Backdrop>
|
||||
{/if}
|
||||
|
||||
<div class="root">
|
||||
<Navigation {path} {isStudent} />
|
||||
<div class="rightBar" class:isMobile={$isMobile}>
|
||||
{#if isExercisePage}
|
||||
<Confetti />
|
||||
{/if}
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rightBar {
|
||||
flex-grow: 1;
|
||||
width: calc(100% - 360px);
|
||||
}
|
||||
|
||||
.rightBar.isMobile {
|
||||
margin-left: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script>
|
||||
import ChevronLeftIcon from 'carbon-icons-svelte/lib/ChevronLeft.svelte';
|
||||
import ChevronRightIcon from 'carbon-icons-svelte/lib/ChevronRight.svelte';
|
||||
import { isMobile } from '$lib/utils/store/useMobile';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
|
||||
export let show = true;
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="root z-10 {!show && 'hide'} {$isMobile ? 'fixed shadow-xl' : 'sticky'}"
|
||||
>
|
||||
{#if show}
|
||||
<div class="relative h-full bg-white dark:bg-gray-800">
|
||||
<slot />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="toggler rounded-full shadow-lg absolute bottom-0">
|
||||
<IconButton
|
||||
value="toggle"
|
||||
onClick={() => (show = !show)}
|
||||
size={$isMobile ? 'large' : 'small'}
|
||||
color="text-black"
|
||||
toolTipProps={$isMobile
|
||||
? {}
|
||||
: {
|
||||
title: 'Toggle editor',
|
||||
hotkeys: ['B'],
|
||||
direction: 'right',
|
||||
}}
|
||||
>
|
||||
{#if show}
|
||||
<ChevronLeftIcon size={16} />
|
||||
{:else}
|
||||
<ChevronRightIcon size={16} />
|
||||
{/if}
|
||||
</IconButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
.root {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 300px;
|
||||
max-width: 300px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
border-right: 1px solid var(--border-color);
|
||||
background-color: rgb(250, 251, 252);
|
||||
|
||||
&.hide {
|
||||
min-width: 15px;
|
||||
max-width: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.toggler {
|
||||
right: -15px;
|
||||
z-index: 10;
|
||||
border: 1px solid var(--border-color);
|
||||
top: 50px;
|
||||
height: fit-content;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.root {
|
||||
width: 98%;
|
||||
min-width: unset;
|
||||
max-width: unset;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script>
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import set from 'lodash/set';
|
||||
import get from 'lodash/get';
|
||||
import TextEditor from '$lib/components/TextEditor/index.svelte';
|
||||
|
||||
export let course;
|
||||
</script>
|
||||
|
||||
<div class="h-2/5">
|
||||
<TextEditor
|
||||
content={get(course, 'metadata.description')}
|
||||
onChange={(html) => {
|
||||
const _course = cloneDeep(course);
|
||||
set(_course, 'metadata.description', html);
|
||||
course = _course;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script>
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import set from 'lodash/set';
|
||||
import get from 'lodash/get';
|
||||
import TextEditor from '$lib/components/TextEditor/index.svelte';
|
||||
|
||||
export let course;
|
||||
</script>
|
||||
|
||||
<div class="h-2/5">
|
||||
<TextEditor
|
||||
content={get(course, 'metadata.goals')}
|
||||
onChange={(html) => {
|
||||
const _course = cloneDeep(course);
|
||||
set(_course, 'metadata.goals', html);
|
||||
course = _course;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script>
|
||||
import TextArea from '$lib/components/Form/TextArea.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
|
||||
export let course = {};
|
||||
</script>
|
||||
|
||||
<TextField
|
||||
className="mt-5"
|
||||
labelClassName="font-bold"
|
||||
label="Title"
|
||||
bind:value={course.title}
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
label="Description"
|
||||
bind:value={course.description}
|
||||
rows="6"
|
||||
className="mt-5"
|
||||
labelClassName="font-bold"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
className="mt-5"
|
||||
labelClassName="font-bold"
|
||||
label="Short Video"
|
||||
placeholder="www.youtube.com/watch?v=uYRq60G5XTk"
|
||||
helperMessage="Enter a link to a youtube video"
|
||||
type="text"
|
||||
bind:value={course.metadata.videoUrl}
|
||||
/>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script>
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import set from 'lodash/set';
|
||||
import get from 'lodash/get';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import TextArea from '$lib/components/Form/TextArea.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import UploadImage from '$lib/components/UploadImage/index.svelte';
|
||||
import { uploadAvatar } from '$lib/utils/services/courses';
|
||||
|
||||
export let course = {};
|
||||
let name;
|
||||
let role;
|
||||
let imgUrl;
|
||||
let description;
|
||||
let courseNo;
|
||||
let avatar;
|
||||
let hasBeenSet = false;
|
||||
|
||||
function setter(value, setterKey) {
|
||||
if (!value) return;
|
||||
|
||||
const _course = cloneDeep(course);
|
||||
set(_course, setterKey, value);
|
||||
course = _course;
|
||||
}
|
||||
|
||||
async function onAvatarChange(_avatar) {
|
||||
if (!_avatar) return;
|
||||
|
||||
const logo = await uploadAvatar(course.id, _avatar);
|
||||
imgUrl = logo;
|
||||
}
|
||||
|
||||
function setDefaults(course) {
|
||||
if (isEmpty(course) || hasBeenSet) return;
|
||||
|
||||
hasBeenSet = true;
|
||||
name = get(course, 'metadata.instructor.name');
|
||||
role = get(course, 'metadata.instructor.role');
|
||||
imgUrl = get(course, 'metadata.instructor.imgUrl');
|
||||
description = get(course, 'metadata.instructor.description');
|
||||
courseNo = get(course, 'metadata.instructor.courseNo');
|
||||
}
|
||||
|
||||
$: setter(name, 'metadata.instructor.name');
|
||||
$: setter(role, 'metadata.instructor.role');
|
||||
$: setter(imgUrl, 'metadata.instructor.imgUrl');
|
||||
$: setter(description, 'metadata.instructor.description');
|
||||
$: setter(courseNo, 'metadata.instructor.courseNo');
|
||||
|
||||
$: onAvatarChange(avatar);
|
||||
|
||||
$: setDefaults(course);
|
||||
</script>
|
||||
|
||||
<div class="mt-5">
|
||||
<label for="upload" class="font-bold">Instructor Logo</label>
|
||||
<UploadImage bind:avatar src={imgUrl} />
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
className="mt-5"
|
||||
labelClassName="font-bold"
|
||||
label="Instructor name"
|
||||
placeholder="Your company name"
|
||||
bind:value={name}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
className="mt-5"
|
||||
labelClassName="font-bold"
|
||||
label="Instructor Role"
|
||||
placeholder="e.g Software developer"
|
||||
bind:value={role}
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
label="About instructor"
|
||||
placeholder="A short background about the instructor"
|
||||
rows="6"
|
||||
className="mt-5"
|
||||
labelClassName="font-bold"
|
||||
bind:value={description}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
className="mt-5"
|
||||
labelClassName="font-bold"
|
||||
label="Total number of courses"
|
||||
type="number"
|
||||
bind:value={courseNo}
|
||||
/>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script>
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import set from 'lodash/set';
|
||||
import get from 'lodash/get';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { Toggle } from 'carbon-components-svelte';
|
||||
import TextEditor from '$lib/components/TextEditor/index.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
|
||||
export let course = {};
|
||||
|
||||
let discount = 0;
|
||||
let showDiscount;
|
||||
let giftToggled;
|
||||
|
||||
let hasBeenSet = false;
|
||||
|
||||
function setDefaults(course) {
|
||||
console.log('\n\nsetDefaults course', course);
|
||||
if (isEmpty(course) || hasBeenSet) return;
|
||||
hasBeenSet = true;
|
||||
|
||||
discount = get(course, 'metadata.discount', 0);
|
||||
showDiscount = get(course, 'metadata.showDiscount', false);
|
||||
giftToggled = get(course, 'metadata.reward.show', false);
|
||||
}
|
||||
|
||||
function setter(value, setterKey) {
|
||||
console.log('\n\nsetter course', value);
|
||||
if (typeof value !== 'boolean' && !value) return;
|
||||
|
||||
const _course = cloneDeep(course);
|
||||
set(_course, setterKey, value);
|
||||
course = _course;
|
||||
}
|
||||
|
||||
$: setter(showDiscount, 'metadata.showDiscount');
|
||||
$: setter(discount, 'metadata.discount');
|
||||
$: setter(giftToggled, 'metadata.reward.show');
|
||||
|
||||
$: setDefaults(course);
|
||||
</script>
|
||||
|
||||
<TextField
|
||||
className="mt-5"
|
||||
labelClassName="font-bold"
|
||||
label="Currency"
|
||||
value={course.currency}
|
||||
isDisabled={true}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
className="mt-5"
|
||||
labelClassName="font-bold"
|
||||
label="Cost"
|
||||
type="number"
|
||||
bind:value={course.cost}
|
||||
/>
|
||||
|
||||
<div class="mt-5">
|
||||
<!-- <label for="text-field" class="m-0 font-bold mb-2">Discount</label> -->
|
||||
<Toggle labelText="Discount" bind:toggled={showDiscount}>
|
||||
<span slot="labelA">No</span>
|
||||
<span slot="labelB">Yes</span>
|
||||
</Toggle>
|
||||
</div>
|
||||
|
||||
{#if showDiscount}
|
||||
<TextField
|
||||
className="mt-5"
|
||||
labelClassName="font-bold"
|
||||
label="Discout Percent"
|
||||
type="number"
|
||||
bind:value={discount}
|
||||
helperMessage="In Percentage %"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="mt-5">
|
||||
<!-- <label for="text-field" class="m-0 font-bold mb-2">Discount</label> -->
|
||||
<Toggle labelText="Gift on Completion" bind:toggled={giftToggled}>
|
||||
<span slot="labelA">No</span>
|
||||
<span slot="labelB">Yes</span>
|
||||
</Toggle>
|
||||
</div>
|
||||
|
||||
{#if giftToggled}
|
||||
<p class="dark:text-white font-bold mt-5">About Gift</p>
|
||||
|
||||
<div class="h-2/5">
|
||||
<TextEditor
|
||||
content={get(course, 'metadata.reward.description')}
|
||||
onChange={(html) => {
|
||||
const _course = cloneDeep(course);
|
||||
set(_course, 'metadata.reward.description', html);
|
||||
course = _course;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,19 @@
|
||||
<script>
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import set from 'lodash/set';
|
||||
import get from 'lodash/get';
|
||||
import TextEditor from '$lib/components/TextEditor/index.svelte';
|
||||
|
||||
export let course;
|
||||
</script>
|
||||
|
||||
<div class="h-2/5">
|
||||
<TextEditor
|
||||
content={get(course, 'metadata.requirements')}
|
||||
onChange={(html) => {
|
||||
const _course = cloneDeep(course);
|
||||
set(_course, 'metadata.requirements', html);
|
||||
course = _course;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script>
|
||||
import SaveIcon from 'carbon-icons-svelte/lib/Save.svelte';
|
||||
import DeleteIcon from 'carbon-icons-svelte/lib/Delete.svelte';
|
||||
import { Toggle } from 'carbon-components-svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
import UploadImage from '$lib/components/UploadImage/index.svelte';
|
||||
import TextArea from '$lib/components/Form/TextArea.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import { uploadAvatar } from '$lib/utils/services/courses';
|
||||
|
||||
export let courseId;
|
||||
export let reviews = [];
|
||||
export let review = {};
|
||||
export let errors = {};
|
||||
export let onExpand = () => {};
|
||||
|
||||
let avatar;
|
||||
|
||||
// function to delete review
|
||||
function deleteReviewData() {
|
||||
reviews = reviews.filter((r) => r.id !== review.id);
|
||||
}
|
||||
|
||||
async function onAvatarChange(_avatar) {
|
||||
if (!_avatar) return;
|
||||
|
||||
review.avatar_url = await uploadAvatar(courseId, _avatar);
|
||||
}
|
||||
|
||||
$: onAvatarChange(avatar);
|
||||
$: review.rating = parseInt(review.rating);
|
||||
</script>
|
||||
|
||||
<div class="flex items-center flex-col w-full">
|
||||
<UploadImage
|
||||
bind:avatar
|
||||
src={review.avatar_url}
|
||||
widthHeight="w-20 h-20"
|
||||
errorMessage={errors.avatar_url}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
className="mt-2 w-full"
|
||||
labelClassName="font-normal"
|
||||
label="Full Name"
|
||||
placeholder=""
|
||||
type="text"
|
||||
bind:value={review.name}
|
||||
errorMessage={errors.name}
|
||||
/>
|
||||
|
||||
<!-- Description -->
|
||||
<TextArea
|
||||
label="Description"
|
||||
rows="4"
|
||||
className="mt-2 w-full"
|
||||
labelClassName="font-normal"
|
||||
placeholder=""
|
||||
bind:value={review.description}
|
||||
errorMessage={errors.description}
|
||||
/>
|
||||
|
||||
<div class="flex justify-between w-full items-center mt-2">
|
||||
<!-- Rating -->
|
||||
<TextField
|
||||
className="mt-2 w-20"
|
||||
labelClassName="font-normal"
|
||||
label="Rating"
|
||||
type="number"
|
||||
min={1}
|
||||
max={5}
|
||||
bind:value={review.rating}
|
||||
errorMessage={errors.rating}
|
||||
/>
|
||||
|
||||
<!-- Toggle -->
|
||||
<div class="w-24">
|
||||
<Toggle labelText="Hide Review" size="sm" bind:toggled={review.hide} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex justify-between items-center w-full">
|
||||
<IconButton
|
||||
contained={true}
|
||||
value="delete"
|
||||
onClick={deleteReviewData}
|
||||
size="large"
|
||||
>
|
||||
<DeleteIcon size={24} class="carbon-icon" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
contained={true}
|
||||
value="save"
|
||||
onClick={() => onExpand(review.id)}
|
||||
size="large"
|
||||
>
|
||||
<SaveIcon size={24} class="carbon-icon" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script>
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import get from 'lodash/get';
|
||||
import set from 'lodash/set';
|
||||
import z from 'zod';
|
||||
import ChevronDownIcon from 'carbon-icons-svelte/lib/ChevronDown.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
import { isMobile } from '$lib/utils/store/useMobile';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import ReviewFormEditor from './ReviewFormEditor.svelte';
|
||||
import Avatar from '$lib/components/Avatar/index.svelte';
|
||||
import { processErrors } from '$lib/utils/functions/validator';
|
||||
|
||||
export let course = {};
|
||||
|
||||
let reviews = get(course, 'metadata.reviews', []);
|
||||
let reviewToExpand = null;
|
||||
let errors = {};
|
||||
|
||||
function setter(value, setterKey) {
|
||||
if (!value) return;
|
||||
|
||||
const _course = cloneDeep(course);
|
||||
set(_course, setterKey, value);
|
||||
course = _course;
|
||||
}
|
||||
|
||||
function addReviewForm() {
|
||||
const _review = {
|
||||
id: new Date().getTime(),
|
||||
hide: false,
|
||||
name: '',
|
||||
avatar_url: '',
|
||||
rating: 1,
|
||||
created_at: new Date().getTime(),
|
||||
description: '',
|
||||
};
|
||||
reviews = [...reviews, _review];
|
||||
reviewToExpand = _review.id;
|
||||
}
|
||||
|
||||
function validateReviews(id) {
|
||||
const review = reviews.find((r) => r.id === id);
|
||||
const reviewSchema = z.object({
|
||||
name: z.string().min(5, {
|
||||
message: 'Name is too short, min of 5 character',
|
||||
invalid_type_error: 'Must not be empty',
|
||||
}),
|
||||
avatar_url: z.string().min(6, {
|
||||
message: 'Upload an image',
|
||||
invalid_type_error: 'Must not be empty',
|
||||
}),
|
||||
rating: z
|
||||
.number()
|
||||
.min(1, {
|
||||
message: 'Rating must be from 1-5',
|
||||
invalid_type_error: 'Must not be empty',
|
||||
})
|
||||
.max(5, {
|
||||
message: 'Rating must be from 1-5',
|
||||
invalid_type_error: 'Must not be empty',
|
||||
}),
|
||||
description: z.string().min(10, {
|
||||
message: 'Description is too short, min of 5 character',
|
||||
invalid_type_error: 'Must not be empty',
|
||||
}),
|
||||
});
|
||||
|
||||
const { error } = reviewSchema.safeParse(review);
|
||||
return processErrors(error);
|
||||
}
|
||||
|
||||
function onExpand(id) {
|
||||
errors = {};
|
||||
|
||||
if (reviewToExpand) {
|
||||
const validationRes = validateReviews(reviewToExpand);
|
||||
if (Object.keys(validationRes).length) {
|
||||
errors = Object.assign(errors, validationRes);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (id === reviewToExpand) {
|
||||
reviewToExpand = null;
|
||||
return;
|
||||
}
|
||||
|
||||
reviewToExpand = id;
|
||||
}
|
||||
|
||||
$: setter(reviews, 'metadata.reviews');
|
||||
</script>
|
||||
|
||||
<!-- Sections - Reviews -->
|
||||
<section id="reviews">
|
||||
<div class="">
|
||||
{#each reviews || [] as review}
|
||||
<div
|
||||
id={review.id}
|
||||
class="relative flex items-center flex-col border border-gray-300 rounded-lg p-2 my-2.5"
|
||||
>
|
||||
{#if review.id !== reviewToExpand}
|
||||
<!-- the headers -->
|
||||
<div class="flex w-full justify-between items-center">
|
||||
<Avatar
|
||||
src={review.avatar_url}
|
||||
name={review.name}
|
||||
className="mt-1"
|
||||
/>
|
||||
<p class="text-sm">{review.name}</p>
|
||||
|
||||
<IconButton
|
||||
value="expand"
|
||||
onClick={() => onExpand(review.id)}
|
||||
size={$isMobile ? 'large' : 'small'}
|
||||
>
|
||||
<ChevronDownIcon size={16} class="carbon-icon" />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- the body -->
|
||||
{#if review.id === reviewToExpand}
|
||||
<ReviewFormEditor
|
||||
bind:reviews
|
||||
bind:review
|
||||
{errors}
|
||||
courseId={course.id}
|
||||
{onExpand}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- create reviews button -->
|
||||
<PrimaryButton
|
||||
label="Add Reviews"
|
||||
variant={VARIANTS.CONTAINED}
|
||||
onClick={addReviewForm}
|
||||
className="w-5 rounded-md mt-8"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,188 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import ChevronRightIcon from 'carbon-icons-svelte/lib/ChevronRight.svelte';
|
||||
import ArrowLeftIcon from 'carbon-icons-svelte/lib/ArrowLeft.svelte';
|
||||
import ArrowUpRightIcon from 'carbon-icons-svelte/lib/ArrowUpRight.svelte';
|
||||
|
||||
import Container from './Container.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
import CloseButton from '$lib/components/Buttons/Close/index.svelte';
|
||||
import HeaderForm from './HeaderForm.svelte';
|
||||
import RequirementForm from './RequirementForm.svelte';
|
||||
import DescriptionForm from './DescriptionForm.svelte';
|
||||
import PricingForm from './PricingForm.svelte';
|
||||
import GoalsForm from './GoalsForm.svelte';
|
||||
import ReviewsForm from './ReviewsForm.svelte';
|
||||
import InstructorForm from './InstructorForm.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import { updateCourse } from '$lib/utils/services/courses';
|
||||
import generateSlug from '$lib/utils/functions/generateSlug';
|
||||
|
||||
export let course;
|
||||
export let courseId;
|
||||
|
||||
let borderBottomGrey = 'border-r-0 border-b border-l-0 border-gray-300';
|
||||
let loading = false;
|
||||
|
||||
const sections = [
|
||||
{
|
||||
key: 1,
|
||||
title: 'Header',
|
||||
},
|
||||
{
|
||||
key: 2,
|
||||
title: 'Requirement',
|
||||
},
|
||||
{
|
||||
key: 3,
|
||||
title: 'Description',
|
||||
},
|
||||
{
|
||||
key: 4,
|
||||
title: 'Goals',
|
||||
},
|
||||
{
|
||||
key: 5,
|
||||
title: 'Reviews',
|
||||
},
|
||||
{
|
||||
key: 6,
|
||||
title: 'Instructor',
|
||||
},
|
||||
{
|
||||
key: 7,
|
||||
title: 'Pricing',
|
||||
},
|
||||
];
|
||||
let selectedSection = null;
|
||||
|
||||
function handleClose() {
|
||||
console.log('closing');
|
||||
if (!selectedSection) {
|
||||
goto(`/courses/${courseId}`);
|
||||
}
|
||||
|
||||
selectedSection = null;
|
||||
}
|
||||
|
||||
function handleSectionSelect(sectionKey) {
|
||||
return () => {
|
||||
selectedSection = sections.find((section) => section.key === sectionKey);
|
||||
if (selectedSection) {
|
||||
const sectionId = selectedSection.title.toLowerCase();
|
||||
const sectionEl = document.getElementById(sectionId);
|
||||
if (sectionEl) {
|
||||
sectionEl.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
loading = true;
|
||||
course.slug = course.slug || generateSlug(course.title);
|
||||
|
||||
await updateCourse(courseId, undefined, {
|
||||
...course,
|
||||
attendance: undefined,
|
||||
group: undefined,
|
||||
lessons: undefined,
|
||||
slug: course.slug,
|
||||
});
|
||||
loading = false;
|
||||
|
||||
return `${window.location.origin}/course/${course.slug}`;
|
||||
}
|
||||
/**
|
||||
* 1. Use all you have for editing now
|
||||
* 2. Make course one interface we might need to computation
|
||||
*/
|
||||
async function handlePreview() {
|
||||
const link = `${window.location.origin}/course/${course.slug}`;
|
||||
window.open(link, '_blank');
|
||||
}
|
||||
|
||||
async function handlePublish() {
|
||||
loading = true;
|
||||
await updateCourse(courseId, undefined, {
|
||||
is_published: true,
|
||||
});
|
||||
loading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Container show={true}>
|
||||
<!-- Publish Header Section -->
|
||||
{#if !selectedSection}
|
||||
<div class="flex justify-between items-center px-2 w-full">
|
||||
<CloseButton onClick={handleClose} />
|
||||
<div class="flex items-center">
|
||||
<PrimaryButton
|
||||
label="Save"
|
||||
type="button"
|
||||
className="mr-1"
|
||||
variant={VARIANTS.OUTLINED}
|
||||
onClick={handleSave}
|
||||
isLoading={loading}
|
||||
/>
|
||||
<PrimaryButton
|
||||
label="Publish"
|
||||
type="button"
|
||||
onClick={handlePublish}
|
||||
isDisabled={loading}
|
||||
/>
|
||||
<IconButton onClick={handlePreview} disabled={loading || !course.slug}>
|
||||
<ArrowUpRightIcon size={24} class="carbon-icon" title="Preview" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-2 w-full mb-2">
|
||||
<h3 class="dark:text-white">Page Builder</h3>
|
||||
</div>
|
||||
{#each sections as section}
|
||||
<button
|
||||
class="w-full flex items-center justify-between px-2 py-3 border border-b-0 border-gray-300"
|
||||
on:click={handleSectionSelect(section.key)}
|
||||
>
|
||||
<p class="dark:text-white mr-2">{section.title} section</p>
|
||||
<ChevronRightIcon size={24} class="carbon-class" />
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<!-- Title -->
|
||||
<div class="flex items-center {borderBottomGrey} w-full">
|
||||
<IconButton onClick={handleClose}>
|
||||
<ArrowLeftIcon size={24} class="carbon-icon" title="Go back" />
|
||||
</IconButton>
|
||||
<h3 class="dark:text-white">{selectedSection.title}</h3>
|
||||
</div>
|
||||
|
||||
<div class="title-content p-2 overflow-y-auto">
|
||||
{#if selectedSection.key === 1}
|
||||
<HeaderForm bind:course />
|
||||
{:else if selectedSection.key === 2}
|
||||
<RequirementForm bind:course />
|
||||
{:else if selectedSection.key === 3}
|
||||
<DescriptionForm bind:course />
|
||||
{:else if selectedSection.key === 4}
|
||||
<GoalsForm bind:course />
|
||||
{:else if selectedSection.key === 5}
|
||||
<ReviewsForm bind:course />
|
||||
{:else if selectedSection.key === 6}
|
||||
<InstructorForm bind:course />
|
||||
{:else if selectedSection.key === 7}
|
||||
<PricingForm bind:course />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</Container>
|
||||
|
||||
<style>
|
||||
.title-content {
|
||||
height: 90%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,146 @@
|
||||
<script lang="ts">
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import type { Course } from '$lib/utils/types';
|
||||
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
|
||||
import get from 'lodash/get';
|
||||
|
||||
export let className = '';
|
||||
export let courseData: Course = {
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
};
|
||||
|
||||
let calculatedCost = 0;
|
||||
let discount = 0;
|
||||
let formatter: Intl.NumberFormat | undefined;
|
||||
|
||||
function calcDisc(percent: number, cost: number) {
|
||||
if (!percent) return cost;
|
||||
return Math.round((percent / 100) * cost) || cost;
|
||||
}
|
||||
|
||||
function addToCart() {}
|
||||
|
||||
function buyNow() {}
|
||||
|
||||
function setFormatter(currency: string | undefined) {
|
||||
if (!currency) return;
|
||||
formatter = getCurrencyFormatter(currency);
|
||||
}
|
||||
|
||||
$: setFormatter(courseData.currency);
|
||||
$: discount = get(courseData, 'metadata.discount', 0);
|
||||
$: calculatedCost = calcDisc(discount, courseData.cost || 0);
|
||||
</script>
|
||||
|
||||
<!-- Pricing Details -->
|
||||
<aside
|
||||
class="{className} price-container md:sticky md:top-0 md:shadow-2xl md:rounded-lg m-h-fit"
|
||||
>
|
||||
<div class="p-10">
|
||||
<!-- Pricing -->
|
||||
<div class="mb-6">
|
||||
<p class="dark:text-white font-medium text-lg">
|
||||
{formatter?.format(calculatedCost) || calculatedCost}
|
||||
</p>
|
||||
{#if courseData?.metadata?.showDiscount}
|
||||
<p class="dark:text-white font-light text-sm text-gray-500">
|
||||
{discount}% Discount.
|
||||
<span class="line-through"
|
||||
>{formatter?.format(courseData?.cost || 0) || courseData.cost}</span
|
||||
>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Call To Action Buttons -->
|
||||
<div class="flex flex-col w-full items-center">
|
||||
<PrimaryButton
|
||||
label="Add to Cart"
|
||||
className="w-full sm:w-full py-3 mb-3"
|
||||
onClick={addToCart}
|
||||
/>
|
||||
<PrimaryButton
|
||||
label="Buy Now"
|
||||
className="w-full sm:w-full py-3 mb-3"
|
||||
variant={VARIANTS.OUTLINED}
|
||||
onClick={addToCart}
|
||||
/>
|
||||
<p class="dark:text-white font-light text-sm text-gray-500">
|
||||
Early bird offer. Buy ASAP
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gift Container -->
|
||||
{#if courseData?.metadata?.reward?.show}
|
||||
<div
|
||||
class="p-10 flex items-center flex-col border-t border-b border-gray-300"
|
||||
>
|
||||
{@html get(courseData, 'metadata.reward.description', '')}
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<style lang="scss">
|
||||
.banner {
|
||||
background-color: #040f2d;
|
||||
min-height: 472px;
|
||||
}
|
||||
|
||||
.author {
|
||||
color: #7888b7;
|
||||
}
|
||||
|
||||
.banner-image {
|
||||
max-width: 559px;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.active {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.active::after {
|
||||
position: absolute;
|
||||
content: '';
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background-color: var(--main-primary-color);
|
||||
display: block;
|
||||
bottom: -14px;
|
||||
left: 0px;
|
||||
}
|
||||
|
||||
.price-container {
|
||||
width: 405px;
|
||||
min-width: 330px;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.price-container {
|
||||
width: 100%;
|
||||
min-width: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.course-content {
|
||||
max-width: 608px;
|
||||
}
|
||||
|
||||
:global(.list ul li) {
|
||||
margin-left: 1rem;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
:global(.plyr) {
|
||||
width: 100% !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
export const SECTION_KEYS = {
|
||||
HEADER: 'header',
|
||||
REQUIREMENT: '#requirement',
|
||||
DESCRIPTION: '#description',
|
||||
GOALS: '#goals',
|
||||
LESSONS: '#lessons',
|
||||
REVIEWS: '#reviews',
|
||||
INSTRUCTOR: '#instructor',
|
||||
};
|
||||
|
||||
export const NAV_ITEMS = [
|
||||
{
|
||||
key: SECTION_KEYS.REQUIREMENT,
|
||||
label: 'Requirement',
|
||||
},
|
||||
{
|
||||
key: SECTION_KEYS.DESCRIPTION,
|
||||
label: 'Description',
|
||||
},
|
||||
{
|
||||
key: SECTION_KEYS.GOALS,
|
||||
label: 'Goals',
|
||||
},
|
||||
{
|
||||
key: SECTION_KEYS.LESSONS,
|
||||
label: 'Lessons',
|
||||
},
|
||||
{
|
||||
key: SECTION_KEYS.REVIEWS,
|
||||
label: 'Reviews',
|
||||
},
|
||||
{
|
||||
key: SECTION_KEYS.INSTRUCTOR,
|
||||
label: 'Instructor',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,532 @@
|
||||
<script lang="ts">
|
||||
// import paystack from 'paystack';
|
||||
import get from 'lodash/get';
|
||||
import { onMount } from 'svelte';
|
||||
import pluralize from 'pluralize';
|
||||
import { page } from '$app/stores';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import Notebook from 'carbon-icons-svelte/lib/Notebook.svelte'; //note
|
||||
import PresentationFile from 'carbon-icons-svelte/lib/PresentationFile.svelte'; // exercise
|
||||
import Video from 'carbon-icons-svelte/lib/Video.svelte'; //video
|
||||
import PageNumber from 'carbon-icons-svelte/lib/PageNumber.svelte';
|
||||
import PlayFilled from 'carbon-icons-svelte/lib/PlayFilled.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import PricingSection from './components/PricingSection.svelte';
|
||||
import { getLectureNo } from '../Course/function';
|
||||
import { NAV_ITEMS } from './constants';
|
||||
import Avatar from '$lib/components/Avatar/index.svelte';
|
||||
import Chip from '../Chip/index.svelte';
|
||||
import { getEmbedId } from '$lib/utils/functions/formatYoutubeVideo';
|
||||
import type { Course, Lesson, Review } from '$lib/utils/types';
|
||||
import Modal from '../Modal/index.svelte';
|
||||
import { reviewsModalStore } from './store';
|
||||
import { VARIANTS } from './$lib/components/PrimaryButton/constants';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export let courseData: Course = {
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
};
|
||||
const ratingsImg = [
|
||||
'/images/rating-1.svg',
|
||||
'/images/rating-2.svg',
|
||||
'/images/rating-3.svg',
|
||||
'/images/rating-4.svg',
|
||||
'/images/rating-5.svg',
|
||||
];
|
||||
|
||||
let lessons: Array<Lesson> = [];
|
||||
let reviews: Review[] = [];
|
||||
let player: any;
|
||||
let averageRating = 0;
|
||||
let totalRatings = 0;
|
||||
|
||||
// initialize the expandDescription array with 'false' values for each review.
|
||||
let expandDescription = Array(reviews.length).fill(false);
|
||||
|
||||
// const paystackApi = paystack('');
|
||||
|
||||
let activeNav = NAV_ITEMS[0].key;
|
||||
let instructor = {};
|
||||
let video: string | undefined;
|
||||
|
||||
function playVideo() {
|
||||
console.log('open modal with video');
|
||||
}
|
||||
|
||||
function locationHashChanged() {
|
||||
activeNav = window.location.hash;
|
||||
}
|
||||
|
||||
function addToCart() {}
|
||||
|
||||
function buyNow() {
|
||||
// if (courseData.cost) {
|
||||
// paystackApi.tra;
|
||||
// } else {
|
||||
// // Course is free
|
||||
// }
|
||||
}
|
||||
|
||||
function initPlyr(_player: any, _video: string | undefined) {
|
||||
if (!player) return;
|
||||
|
||||
// @ts-ignore
|
||||
const plyr = new Plyr('#player', {});
|
||||
// @ts-ignore
|
||||
window.player = plyr;
|
||||
}
|
||||
|
||||
function toggleDescription(id: number) {
|
||||
expandDescription[id] = !expandDescription[id];
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.onhashchange = locationHashChanged;
|
||||
});
|
||||
|
||||
$: video = get(courseData, 'metadata.videoUrl');
|
||||
$: lessons = get(courseData, 'lessons', []);
|
||||
$: instructor = courseData?.metadata?.instructor || {};
|
||||
$: initPlyr(player, video);
|
||||
$: {
|
||||
reviews = get(courseData, 'metadata.reviews') || [];
|
||||
totalRatings = reviews?.reduce(
|
||||
(acc = 0, review) => acc + (review?.rating || 0),
|
||||
0
|
||||
);
|
||||
averageRating = totalRatings / reviews?.length;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{get(courseData, 'title', '')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="w-full bg-white dark:bg-gray-800 flex flex-col items-center">
|
||||
<!-- Header Section -->
|
||||
<header id="header" class="banner w-full flex items-center justify-center p-">
|
||||
<div
|
||||
class="md:w-5/6 w-full flex items-center justify-between flex-col-reverse md:flex-row"
|
||||
>
|
||||
<!-- Course Description -->
|
||||
<div class="md:w-2/5 w-11/12 py-10">
|
||||
<h1 class="dark:text-white text-5xl text-white my-4 font-bold">
|
||||
{get(courseData, 'title', '')}
|
||||
</h1>
|
||||
<p class="dark:text-white text-md text-white mb-6">
|
||||
{get(courseData, 'description', '')}
|
||||
</p>
|
||||
|
||||
<p class="dark:text-white author my-3 text-sm">
|
||||
{get(courseData, 'metadata.instructor.name', '')}
|
||||
</p>
|
||||
<PrimaryButton
|
||||
label="Start Course"
|
||||
className="px-6 py-5 mt-6 sm:w-fit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Banner Image getEmbedId(videoUrl) -->
|
||||
{#if video}
|
||||
<div class="banner-image md:w-2/3 w-full flex">
|
||||
<div
|
||||
bind:this={player}
|
||||
id="player"
|
||||
data-plyr-provider="youtube"
|
||||
data-plyr-embed-id={getEmbedId(video)}
|
||||
/>
|
||||
</div>
|
||||
<!-- <div class="container">
|
||||
<div
|
||||
bind:this={player}
|
||||
id="player"
|
||||
class="banner-image w-2/3 h-96 relative"
|
||||
data-plyr-provider="youtube"
|
||||
data-plyr-embed-id="bTqVqk7FSmY"
|
||||
/>
|
||||
</div> -->
|
||||
{:else}
|
||||
<!-- <div
|
||||
class="banner-image w-2/3 h-96 relative cursor-pointer"
|
||||
on:click={playVideo}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="absolute top-0 left-0 backdrop w-full h-full"
|
||||
/>
|
||||
<img
|
||||
src="/images/course-banner-image.png"
|
||||
alt="banner"
|
||||
class="w-full h-full"
|
||||
/>
|
||||
<div
|
||||
class="absolute top-0 left-0 w-full h-full z-50 flex items-center justify-center"
|
||||
>
|
||||
<img src="/images/play_video.png" alt="play video" />
|
||||
</div>
|
||||
</div> -->
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
<PricingSection className="md:hidden" {courseData} />
|
||||
<!-- Body -->
|
||||
<div class="bg-white dark:bg-gray-800 w-full">
|
||||
<div class="py-8 lg:w-10/12 w-full m-2 lg:m-auto flex justify-between">
|
||||
<!-- Course Details -->
|
||||
<div class="course-content w-full p-3 md:w-9/12 md:mr-14">
|
||||
<!-- Navigation -->
|
||||
<nav class="flex items-center border-b border-gray-300 pb-3">
|
||||
{#each NAV_ITEMS as navItem}
|
||||
<a
|
||||
href="{$page.url.pathname}{navItem.key}"
|
||||
class="{navItem.key === activeNav &&
|
||||
'active text-blue-700'} rounded-lg px-2 mr-6 text-slate-700 font-normal hover:bg-slate-100 hover:text-slate-900"
|
||||
>
|
||||
{navItem.label}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- Sections - Requirement -->
|
||||
<section id="requirement" class="border-b border-gray-300 mt-8 pb-10">
|
||||
<h3 class="text-2xl font-bold mt-0 mb-3">Requirement</h3>
|
||||
<ul class="list font-light">
|
||||
{@html get(courseData, 'metadata.requirements', '')}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Sections - Course Description -->
|
||||
<section id="description" class="border-b border-gray-300 mt-8 pb-10">
|
||||
<h3 class="text-2xl font-bold mt-0 mb-3">Course Description</h3>
|
||||
<p class="dark:text-white text-sm font-light">
|
||||
{@html get(courseData, 'metadata.description', '')}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Sections - Goal -->
|
||||
<section id="goals" class="mt-8 pb-10">
|
||||
<h3 class="text-2xl font-bold mt-0 mb-3">What you'll learn</h3>
|
||||
<ul class="list font-light">
|
||||
{@html get(courseData, 'metadata.goals', '')}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Sections - Certificate -->
|
||||
<section class="border-b border-gray-300 mt-8 pb-10">
|
||||
<h3 class="text-2xl font-bold mt-0">Certificate</h3>
|
||||
<p class="dark:text-white text-sm font-light mb-3">
|
||||
When you complete all of the courses in the program, you'll earn a
|
||||
Certificate to share with your professional network.
|
||||
</p>
|
||||
<img
|
||||
src="/images/certificate-template.svg"
|
||||
alt="certificate template"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Sections - Lessons -->
|
||||
<section id="lessons" class="border-b border-gray-300 mt-8 pb-10">
|
||||
<div class="flex items-center justify-between w-full mb-3">
|
||||
<h3 class="text-2xl font-bold mt-0 mb-3">Course Content</h3>
|
||||
<p class="dark:text-white text-sm font-light">
|
||||
<!-- {lessons.length} lessons -->
|
||||
{pluralize('lesson', lessons.length, true)}
|
||||
</p>
|
||||
</div>
|
||||
{#each lessons as lesson, index}
|
||||
<div
|
||||
class="w-full flex md:items-center justify-between mb-6 flex-col md:flex-row"
|
||||
>
|
||||
<div class="flex items-center mb-3">
|
||||
<Chip
|
||||
value={getLectureNo(index + 1, '0')}
|
||||
className="bg-blue-100 text-blue-700"
|
||||
/>
|
||||
<p class="dark:text-white ml-2 text-sm font-light">
|
||||
{lesson.title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="w-2/4">
|
||||
<div class="flex items-center">
|
||||
{#if lesson.slide_url}
|
||||
<span class="text-sm font-light flex w-2/4"
|
||||
><PresentationFile size={16} class="mr-1" />1 slide</span
|
||||
>
|
||||
{/if}
|
||||
{#if lesson.note}
|
||||
<span class="text-sm font-light flex w-2/4"
|
||||
><Notebook size={16} class="mr-1" />1 note</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
{#if lesson.video_url}
|
||||
<span class="text-sm font-light flex w-2/4"
|
||||
><Video size={16} class="mr-1" />1 video</span
|
||||
>
|
||||
{/if}
|
||||
{#if get(lesson, 'totalExercises[0].count')}
|
||||
<span class="text-sm font-light flex w-2/4"
|
||||
><PageNumber size={16} class="mr-1" />{pluralize(
|
||||
'exercise',
|
||||
get(lesson, 'totalExercises[0].count', 0),
|
||||
true
|
||||
)}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<!-- Sections - Reviews -->
|
||||
{#if reviews && reviews.length > 0}
|
||||
<section id="reviews">
|
||||
<h2 class="my-16 mr-0 mb-6 ml-0 font-semibold">Reviews</h2>
|
||||
<div class="flex flex-wrap">
|
||||
{#each reviews.slice(0, 4) as review, id}
|
||||
{#if !review.hide}
|
||||
<!-- review -->
|
||||
<div class="flex flex-row item-start w-2/4 my-2">
|
||||
<!-- image container -->
|
||||
{#if review.avatar_url}
|
||||
<Avatar
|
||||
src={review.avatar_url}
|
||||
name="Avatar"
|
||||
className="mt-1"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- profile content -->
|
||||
<div class="pl-2.5 w-11/12">
|
||||
<p class="mb-0.5 font-medium">{review.name}</p>
|
||||
<!-- ratings -->
|
||||
<div class="flex flex-row items-center">
|
||||
{#if review.rating}
|
||||
<img
|
||||
src={ratingsImg[review.rating - 1]}
|
||||
class="w-24 mr-4 mt-1"
|
||||
alt=""
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="read-more-content mb-2"
|
||||
style="max-height: {expandDescription[id]
|
||||
? 'none'
|
||||
: '50px'}"
|
||||
>
|
||||
<p class="text-sm my-2 leading-5 text-gray-600">
|
||||
{review.description}
|
||||
</p>
|
||||
</div>
|
||||
{#if !expandDescription[id] && review.description.split(' ').length > 9}
|
||||
<button
|
||||
class="underline text-blue-700 mt-2 font-normal"
|
||||
on:click={() => toggleDescription(id)}
|
||||
>See More</button
|
||||
>
|
||||
{/if}
|
||||
{#if expandDescription[id]}
|
||||
<button
|
||||
class="underline text-blue-700 mt-2 font-normal"
|
||||
on:click={() => toggleDescription(id)}
|
||||
>See Less</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{#if reviews.length > 4}
|
||||
<PrimaryButton
|
||||
label="See All"
|
||||
className="w-3/12 p-4 mt-2"
|
||||
variant={VARIANTS.OUTLINED}
|
||||
onClick={() => ($reviewsModalStore.open = true)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Reviews Modal -->
|
||||
<Modal
|
||||
onClose={() => ($reviewsModalStore.open = false)}
|
||||
bind:open={$reviewsModalStore.open}
|
||||
width="w-9/12"
|
||||
modalHeading="Reviews"
|
||||
>
|
||||
<div class="flex">
|
||||
<!-- ratings -->
|
||||
<div class="w-1/3">
|
||||
<h2 class="text-xl font-bold">
|
||||
{averageRating} Rating
|
||||
</h2>
|
||||
<h2 class="text-lg font-semibold mt-2">
|
||||
{reviews.length} Ratings
|
||||
</h2>
|
||||
</div>
|
||||
<!-- reviews -->
|
||||
<div class="flex flex-wrap w-4/6">
|
||||
{#each reviews as review, id}
|
||||
<!-- review -->
|
||||
<div class="flex flex-row item-start w-full my-2">
|
||||
<!-- image container -->
|
||||
{#if review.avatar_url}
|
||||
<Avatar
|
||||
src={review.avatar_url}
|
||||
name={review.name}
|
||||
className="mt-1"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- profile content -->
|
||||
<div class="pl-2.5 w-11/12">
|
||||
<p class="mb-0.5 font-medium">{review.name}</p>
|
||||
<!-- ratings -->
|
||||
<div class="flex flex-row">
|
||||
<img
|
||||
src="/images/rating-full.svg"
|
||||
alt=""
|
||||
class="mr-2"
|
||||
/>
|
||||
<p class="text-xs text-gray-600">
|
||||
{dayjs(review.created_at).fromNow(true)} ago
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="read-more-content mb-2"
|
||||
style="max-height: {expandDescription[id]
|
||||
? 'none'
|
||||
: '50px'}"
|
||||
>
|
||||
<p class="text-sm my-2 leading-5 text-gray-600">
|
||||
{review.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Sections - Instructor -->
|
||||
<section id="instructor" class="mt-8 pb-10">
|
||||
<h3 class="text-2xl font-bold mt-0 mb-3">Instructor</h3>
|
||||
<div class="flex items-center mb-4">
|
||||
<img
|
||||
alt="Author Avatar"
|
||||
class="block rounded-full h-20 w-20 mr-3"
|
||||
src={get(instructor, 'imgUrl', '')}
|
||||
/>
|
||||
<div>
|
||||
<p class="dark:text-white text-md font-light">
|
||||
{get(instructor, 'name', '')}
|
||||
</p>
|
||||
<p class="dark:text-white text-xs font-light">
|
||||
{get(instructor, 'role', '')}
|
||||
</p>
|
||||
<p class="dark:text-white text-md font-light flex items-center">
|
||||
<PlayFilled size={16} class="text-blue-700" />
|
||||
<span class="ml-1"
|
||||
>{get(instructor, 'courseNo', '')} courses</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="dark:text-white text-sm font-light">
|
||||
{get(instructor, 'description', '')}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Pricing Details -->
|
||||
<PricingSection className="hidden md:block" {courseData} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.banner {
|
||||
background-color: #040f2d;
|
||||
min-height: 472px;
|
||||
}
|
||||
|
||||
.author {
|
||||
color: #7888b7;
|
||||
}
|
||||
|
||||
.banner-image {
|
||||
max-width: 559px;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.active {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.active::after {
|
||||
position: absolute;
|
||||
content: '';
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background-color: var(--main-primary-color);
|
||||
display: block;
|
||||
bottom: -14px;
|
||||
left: 0px;
|
||||
}
|
||||
|
||||
.price-container {
|
||||
width: 405px;
|
||||
min-width: 330px;
|
||||
height: fit-content;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.course-content {
|
||||
max-width: 608px;
|
||||
}
|
||||
|
||||
nav {
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
overflow-y: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:global(.list ul li) {
|
||||
margin-left: 1rem;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
:global(.plyr) {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.read-more-content {
|
||||
max-height: 100px; /* Adjust this value to set the maximum height for the truncated content */
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.read-more-button {
|
||||
cursor: pointer;
|
||||
color: blue;
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const landingPageStore = writable({
|
||||
header: {
|
||||
title: 'Online Business Masterclass: Sell Your Own Digital Products',
|
||||
description:
|
||||
'Create, Launch & Market Your Own Online Business w/ Digital Products such as Online Courses, Coaching, eBooks, Webinars+',
|
||||
duration: 'Estimated 8 weeks',
|
||||
cost: 300,
|
||||
currency: '$',
|
||||
buttonLabel: 'Enrol',
|
||||
},
|
||||
requirement: {
|
||||
content:
|
||||
"You don't need any experience creating an online business or digital product. \nYou don't need an online presence, website, or following - but it will help kickstart your online business if you do.",
|
||||
},
|
||||
description: {
|
||||
content:
|
||||
"### Have you ever dreamed of starting your own online business?. \n\nOf course I know that :-) I was just thinking if there is some way to do same as getStaticProps for other components too. I don't think such feature is completely necessary, though. \n### Enroll today and get instant access to. \n\nOf course I know that :-) I was just thinking if there is some way to do same as getStaticProps for other components too. I don't think such feature is completely necessary, though.\n ### You will learn many types of digital marketing:\n\n Of course I know that :-) I was just thinking if there is some way to do same as getStaticProps for other components too. I don't think such feature is completely necessary, though. Hello, thank you for reply. Of course I know that :-) I was just thinking if there is some way to do same as getStaticProps for other components too. I don't think such feature is completely necessary, though. Hello, thank you for reply. Of course I know that :-) I was just thinking if there is some way to do same as getStaticProps for other components too. I don't think such feature is completely necessary, though. Hello, thank you for reply. Of course I know that :-) I was just thinking if there is some way to do same as getStaticProps for other components too. I don't think such feature is completely necessary, though. Hello, thank you for reply. Of course I know that :-) I was just thinking if there is some way to do same as getStaticProps for other components too. I don't think such feature is completely necessary, though.",
|
||||
},
|
||||
syllabus: {
|
||||
lessons: [
|
||||
{
|
||||
title: 'What is FinTech?',
|
||||
},
|
||||
{
|
||||
title: 'Payments, Cryptocurrencies and Blockchain',
|
||||
},
|
||||
{
|
||||
title: 'Digital Finance and Alternative Finance',
|
||||
},
|
||||
{
|
||||
title: 'FinTech Regulation and RegTech',
|
||||
},
|
||||
{
|
||||
title: 'Data & TechFin',
|
||||
},
|
||||
],
|
||||
},
|
||||
aboutUs: {
|
||||
companyName: 'The Brad School',
|
||||
logoUrl: 'https://img-c.udemycdn.com/user/200_H/19701574_95d1_13.jpg',
|
||||
description:
|
||||
'*Learn creative skills, from absolute beginner to advanced mastery.*\nVideo School exists to help you succeed in life. Each course has been hand-tailored to teach a specific skill from photography and video to art, design and business.\n**Whether you’re trying to learn a new skill from scratch, or want to refresh your memory on something you’ve learned in the past, you’ve come to the right place.**\nEducation makes the world a better place. Make your world better with new skills!\nOur courses can be watched 24/7 wherever you are. Most are fully downloadable so you can take them with you. You can also view them on mobile devices with the Udemy mobile app.\nAll courses have a 30-day money-back guarantee so that you can check it out, make sure it’s the right course for you, and get a refund if it’s not!',
|
||||
},
|
||||
reviews: [
|
||||
{
|
||||
id: 1,
|
||||
show: true,
|
||||
name: 'Adeyemi Adetunji',
|
||||
avatar_url: '/logo-192.png',
|
||||
rating: 1,
|
||||
created_at: new Date(),
|
||||
description:
|
||||
'rice ventore officiis maxime laudantium recusandae sit dolor.',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const reviewsModalStore = writable({
|
||||
open: false,
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
|
||||
export async function fetchCourses(profileId) {
|
||||
if (!profileId) return;
|
||||
|
||||
const { data: allCourses } = await supabase
|
||||
.rpc('get_courses')
|
||||
.eq('profile_id', profileId);
|
||||
|
||||
console.log(`allCourses`, allCourses);
|
||||
if (!Array.isArray(allCourses)) {
|
||||
return {
|
||||
allCourses: [],
|
||||
};
|
||||
}
|
||||
|
||||
return { allCourses };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { SkeletonPlaceholder, SkeletonText } from 'carbon-components-svelte';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="root w-72 border bg-gray-100 dark:bg-gray-700 mr-4 mb-4 rounded-md transition ease-in-out relative"
|
||||
>
|
||||
<SkeletonPlaceholder style="width: 100%; height: 10rem;" />
|
||||
<div class="p-3 pb-0">
|
||||
<!-- Title -->
|
||||
<SkeletonText style="width: 50%;" />
|
||||
<!-- Description -->
|
||||
<SkeletonText style="width: 100%; margin-top: 1rem;" />
|
||||
<SkeletonText style="width: 100%; margin-bottom: 1rem;" />
|
||||
|
||||
<div class="flex justify-between mb-3">
|
||||
<SkeletonText style="width: 25%" />
|
||||
<SkeletonText style="width: 25%" />
|
||||
</div>
|
||||
<SkeletonText style="width: 25%" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
min-height: 370px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,132 @@
|
||||
<script lang="ts">
|
||||
import Dropdown from '$lib/components/Dropdown/index.svelte';
|
||||
import { copyCourseModal } from '$lib/components/Courses/store';
|
||||
import { ROLE } from '$lib/utils/constants/roles';
|
||||
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
|
||||
import OverflowMenuHorizontalIcon from 'carbon-icons-svelte/lib/OverflowMenuHorizontal.svelte';
|
||||
import ArrowRight from 'carbon-icons-svelte/lib/ArrowRight.svelte';
|
||||
|
||||
export let bannerImage: string | undefined;
|
||||
export let id = '';
|
||||
export let slug = '';
|
||||
export let title = '';
|
||||
export let description = '';
|
||||
export let role_id = ROLE.STUDENT;
|
||||
export let isPublished = false;
|
||||
export let cost = 0;
|
||||
export let totalLessons = 0;
|
||||
export let currency = 'NGN';
|
||||
export let isOnLandingPage = false;
|
||||
|
||||
let formatter = getCurrencyFormatter(currency);
|
||||
|
||||
function getOptions() {
|
||||
return [
|
||||
{
|
||||
label: 'Make a copy',
|
||||
// Show this option only if user has admin control over this course.
|
||||
show: () => role_id === ROLE.ADMIN || role_id === ROLE.TUTOR,
|
||||
onClick() {
|
||||
// IIFI of copyCourseModal
|
||||
(() => {
|
||||
$copyCourseModal.id = id;
|
||||
$copyCourseModal.open = true;
|
||||
$copyCourseModal.title = `Copy of ${title}`;
|
||||
})();
|
||||
},
|
||||
},
|
||||
// {
|
||||
// label: 'Delete',
|
||||
// onClick() {
|
||||
// console.log('Deleting');
|
||||
// },
|
||||
// },
|
||||
].filter((option) => option.show());
|
||||
}
|
||||
</script>
|
||||
|
||||
<a
|
||||
rel="prefetch"
|
||||
href={isOnLandingPage ? `/course/${slug}` : `/courses/${id}`}
|
||||
class=" text-black"
|
||||
>
|
||||
<div
|
||||
class="root w-80 border bg-gray-100 dark:bg-gray-700 sm:mr-4 mb-4 rounded-md hover:shadow-2xl transition ease-in-out relative"
|
||||
>
|
||||
{#if !isOnLandingPage}
|
||||
<Dropdown
|
||||
options={getOptions()}
|
||||
classNames="absolute top-4 right-4"
|
||||
isIcon={true}
|
||||
>
|
||||
<div class="p-1 rounded-full bg-white dark:bg-gray-600">
|
||||
<OverflowMenuHorizontalIcon size={20} class="carbon-icon" />
|
||||
</div>
|
||||
</Dropdown>
|
||||
{/if}
|
||||
<img
|
||||
class="h-2/5 w-full rounded-tl rounded-tr"
|
||||
src={bannerImage ?? '/images/classroomio-course-img-template.jpg'}
|
||||
alt="Course Logo"
|
||||
/>
|
||||
<div class="px-3 py-5">
|
||||
<h4 class="dark:text-white title text-md font-bold">
|
||||
{title}
|
||||
</h4>
|
||||
<p
|
||||
class="description text-left text-sm font-small flex flex-col text-gray-500 dark:text-white my-3"
|
||||
title={description}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
<div class="flex justify-between">
|
||||
<p class="dark:text-white">{totalLessons} lessons</p>
|
||||
|
||||
{#if isOnLandingPage}
|
||||
<ArrowRight size={24} class="carbon-class" />
|
||||
{:else}
|
||||
<div
|
||||
class="rounded dark:text-white bg-{isPublished
|
||||
? 'green'
|
||||
: 'red'}-200 py-1 px-2"
|
||||
>
|
||||
{#if isPublished}
|
||||
Published
|
||||
{:else}
|
||||
Unpublished
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="dark:text-white text-gray-500 text-xs mt-1">
|
||||
{!cost ? 'free' : formatter.format(cost)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
min-height: 370px;
|
||||
}
|
||||
|
||||
a,
|
||||
a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
.title,
|
||||
.description {
|
||||
height: 40px;
|
||||
line-height: 20px;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-moz-box-orient: vertical;
|
||||
-ms-box-orient: vertical;
|
||||
box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
-moz-line-clamp: 2;
|
||||
-ms-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
import { snackbarStore } from '$lib/components/Snackbar/store';
|
||||
import { SNACKBAR_SEVERITY } from '$lib/components/Snackbar/constants';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import {
|
||||
copyCourseModalInitialState,
|
||||
copyCourseModal,
|
||||
} from '$lib/components/Courses/store';
|
||||
import { cloneCourse } from '$lib/utils/services/courses/clone';
|
||||
|
||||
// clone, show spinner and redirect to new course
|
||||
async function createCourse() {
|
||||
console.log('Create course');
|
||||
$copyCourseModal.isSaving = true;
|
||||
|
||||
try {
|
||||
const newCourse = await cloneCourse(
|
||||
$copyCourseModal.id,
|
||||
$copyCourseModal.title
|
||||
);
|
||||
|
||||
goto(`/courses/${newCourse.id}`);
|
||||
|
||||
copyCourseModal.set(copyCourseModalInitialState);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
// IIFI of snackbarStore to Show notification error
|
||||
(() => {
|
||||
$snackbarStore.message = "Something's not right - Please try later";
|
||||
$snackbarStore.severity = SNACKBAR_SEVERITY.ERROR;
|
||||
$snackbarStore.open = true;
|
||||
})();
|
||||
|
||||
// Stop loader
|
||||
$copyCourseModal.isSaving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
onClose={() => ($copyCourseModal.open = false)}
|
||||
bind:open={$copyCourseModal.open}
|
||||
width="w-96"
|
||||
modalHeading="Create a Course"
|
||||
>
|
||||
<form on:submit|preventDefault={createCourse}>
|
||||
<TextField
|
||||
label="Course name"
|
||||
bind:value={$copyCourseModal.title}
|
||||
autoFocus={true}
|
||||
placeholder="Your course name"
|
||||
className="mb-4"
|
||||
isRequired={true}
|
||||
autoComplete={false}
|
||||
/>
|
||||
|
||||
<div class="mt-5 flex items-center flex-row-reverse">
|
||||
<PrimaryButton
|
||||
className="px-6 py-3"
|
||||
label="Create"
|
||||
type="submit"
|
||||
isLoading={$copyCourseModal.isSaving}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
@@ -0,0 +1,139 @@
|
||||
<script>
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import TextArea from '$lib/components/Form/TextArea.svelte';
|
||||
import { courses, createCourseModal } from '$lib/components/Courses/store';
|
||||
import { validateForm } from '$lib/components/Courses/functions';
|
||||
import { ROLE } from '$lib/utils/constants/roles';
|
||||
import { addGroupMember } from '$lib/utils/services/courses';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
let errors = {
|
||||
emails: '',
|
||||
};
|
||||
|
||||
async function createCourse(e) {
|
||||
const { hasError, fieldErrors, students, tutors } =
|
||||
validateForm($createCourseModal);
|
||||
|
||||
errors = fieldErrors;
|
||||
if (hasError) return;
|
||||
|
||||
const { title, description } = $createCourseModal;
|
||||
// 1. Create group
|
||||
const { data: newGroup } = await supabase
|
||||
.from('group')
|
||||
.insert({ name: title, description, organization_id: $currentOrg.id })
|
||||
.select();
|
||||
|
||||
console.log(`newGroup`, newGroup);
|
||||
const { id: group_id } = newGroup[0];
|
||||
|
||||
// 2. Create course with group_id
|
||||
const { data: newCourse } = await supabase
|
||||
.from('course')
|
||||
.insert({
|
||||
title,
|
||||
description,
|
||||
group_id,
|
||||
})
|
||||
.select();
|
||||
console.log(`newCourse`, newCourse);
|
||||
|
||||
courses.update((_courses) => [..._courses, newCourse[0]]);
|
||||
|
||||
// 3. Add group members
|
||||
|
||||
// Get profile of author and add as member
|
||||
await addGroupMember({
|
||||
profile_id: $profile.id,
|
||||
email: $profile.email,
|
||||
group_id,
|
||||
role_id: ROLE.TUTOR,
|
||||
});
|
||||
|
||||
let membersStack = [];
|
||||
|
||||
// Create groupmemebers from group_id and add teachers and students
|
||||
for (const student of students) {
|
||||
membersStack.push({
|
||||
email: student,
|
||||
group_id,
|
||||
role_id: ROLE.STUDENT,
|
||||
});
|
||||
}
|
||||
|
||||
for (const tutor of tutors) {
|
||||
membersStack.push({
|
||||
email: tutor,
|
||||
group_id,
|
||||
role_id: ROLE.TUTOR,
|
||||
});
|
||||
}
|
||||
|
||||
addGroupMember(membersStack).then((membersAdded) => {
|
||||
console.log(`membersAdded`, membersAdded);
|
||||
$createCourseModal.open = false;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Courses - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Modal
|
||||
onClose={() => ($createCourseModal.open = false)}
|
||||
bind:open={$createCourseModal.open}
|
||||
width="w-2/5"
|
||||
modalHeading="Create a Course"
|
||||
>
|
||||
<form on:submit|preventDefault={createCourse}>
|
||||
<TextField
|
||||
label="Course name"
|
||||
bind:value={$createCourseModal.title}
|
||||
autofocus={true}
|
||||
placeholder="Your course name"
|
||||
className="mb-4"
|
||||
isRequired={true}
|
||||
errorMessage={errors.title}
|
||||
autoComplete={false}
|
||||
/>
|
||||
<TextArea
|
||||
label="Invite Tutors"
|
||||
bind:value={$createCourseModal.tutors}
|
||||
rows="2"
|
||||
maxRows="3"
|
||||
placeholder="tutor@company.com, john@gmail.com"
|
||||
helperMessage="To invite people, add their emails separated by a comma"
|
||||
errorMessage={errors.tutors}
|
||||
className="mb-4"
|
||||
/>
|
||||
<TextArea
|
||||
label="Invite Students"
|
||||
bind:value={$createCourseModal.students}
|
||||
rows="2"
|
||||
maxRows="3"
|
||||
placeholder="student@gmail.com, john@gmail.com"
|
||||
helperMessage="To invite people, add their emails separated by a comma"
|
||||
errorMessage={errors.students}
|
||||
className="mb-4"
|
||||
/>
|
||||
<TextArea
|
||||
label="Short description"
|
||||
bind:value={$createCourseModal.description}
|
||||
rows="4"
|
||||
maxRows="4"
|
||||
placeholder="A little description about this course"
|
||||
className="mb-4"
|
||||
isRequired={true}
|
||||
/>
|
||||
|
||||
<div class="mt-5 flex items-center">
|
||||
<PrimaryButton className="px-6 py-3" label="Finish" type="submit" />
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
@@ -0,0 +1,10 @@
|
||||
export const statuses = [
|
||||
{
|
||||
chipClassName: 'bg-green-600 text-white',
|
||||
chipLabel: 'In progress',
|
||||
},
|
||||
{
|
||||
chipClassName: 'bg-red-600 text-white',
|
||||
chipLabel: 'Completed',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,67 @@
|
||||
import { validateEmail } from '$lib/utils/functions/validateEmail';
|
||||
|
||||
export function validateForm(fields) {
|
||||
let hasError = false;
|
||||
const { students = '', tutors = '', title, description } = fields;
|
||||
const fieldErrors = {
|
||||
tutors: '',
|
||||
students: '',
|
||||
title: '',
|
||||
description: '',
|
||||
};
|
||||
|
||||
const _tutors =
|
||||
tutors.length < 5
|
||||
? []
|
||||
: tutors.split(',').map((_email) => {
|
||||
const email = _email.trim();
|
||||
|
||||
if (!validateEmail(email)) {
|
||||
hasError = true;
|
||||
fieldErrors.tutors += !fieldErrors.tutors.length
|
||||
? `Not valid email: ${email}`
|
||||
: `, ${email}`;
|
||||
}
|
||||
|
||||
return email;
|
||||
});
|
||||
|
||||
const _students =
|
||||
students.length < 5
|
||||
? []
|
||||
: students.split(',').map((_email) => {
|
||||
const email = _email.trim();
|
||||
|
||||
if (!validateEmail(email)) {
|
||||
hasError = true;
|
||||
fieldErrors.students += !fieldErrors.students.length
|
||||
? `Not valid email: ${email}`
|
||||
: `, ${email}`;
|
||||
}
|
||||
|
||||
return email;
|
||||
});
|
||||
|
||||
if (!title) {
|
||||
fieldErrors.title = 'Title is required';
|
||||
return {
|
||||
hasError: true,
|
||||
fieldErrors,
|
||||
};
|
||||
}
|
||||
|
||||
if (!description) {
|
||||
fieldErrors.description = 'Description is required';
|
||||
return {
|
||||
hasError: true,
|
||||
fieldErrors,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hasError,
|
||||
fieldErrors,
|
||||
tutors: _tutors,
|
||||
students: _students,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<script>
|
||||
import Box from '../Box/index.svelte';
|
||||
import Card from './components/Card/index.svelte';
|
||||
import CardLoader from './components/Card/Loader.svelte';
|
||||
import CoursesEmptyIcon from '../Icons/CoursesEmptyIcon.svelte';
|
||||
import CopyCourseModal from './components/CopyCourseModal/index.svelte';
|
||||
import { courses, courseMetaDeta } from './store';
|
||||
</script>
|
||||
|
||||
<CopyCourseModal />
|
||||
|
||||
<div
|
||||
class="flex items-center justify-center lg:justify-start flex-wrap my-4 m-auto"
|
||||
>
|
||||
{#if $courseMetaDeta.isLoading}
|
||||
<CardLoader />
|
||||
<CardLoader />
|
||||
<CardLoader />
|
||||
{:else}
|
||||
{#each $courses as courseData}
|
||||
<Card
|
||||
id={courseData.id}
|
||||
logo={courseData.logo}
|
||||
bannerImage={courseData.banner_image}
|
||||
title={courseData.title}
|
||||
description={courseData.description}
|
||||
role_id={courseData.role_id}
|
||||
isPublished={courseData.is_published}
|
||||
cost={courseData.cost}
|
||||
currency={courseData.currency}
|
||||
totalLessons={courseData.total_lessons}
|
||||
/>
|
||||
{:else}
|
||||
<Box>
|
||||
<CoursesEmptyIcon />
|
||||
<h3 class="dark:text-white text-2xl my-5">No Courses Created</h3>
|
||||
<p class="dark:text-white w-1/3 text-center">
|
||||
Share your knowledge with the world by creating engaging courses for
|
||||
your students.
|
||||
</p>
|
||||
</Box>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const courses = writable([]);
|
||||
export const courseMetaDeta = writable({
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
export const createCourseModal = writable({
|
||||
open: false,
|
||||
title: '',
|
||||
description: '',
|
||||
emails: '',
|
||||
tutors: '',
|
||||
students: '',
|
||||
});
|
||||
|
||||
export const copyCourseModalInitialState = {
|
||||
open: false,
|
||||
courseIdToCopy: null,
|
||||
id: '',
|
||||
title: '',
|
||||
isSaving: false,
|
||||
error: null,
|
||||
};
|
||||
export const copyCourseModal = writable({ ...copyCourseModalInitialState });
|
||||
@@ -0,0 +1,69 @@
|
||||
<script>
|
||||
export let options = [];
|
||||
export let classNames = 'relative';
|
||||
export let isIcon = false;
|
||||
|
||||
let open = false;
|
||||
|
||||
const notIconClass =
|
||||
'border rounded-lg border-grey p-3 focus:outline-none focus:border-gray-400 focus:bg-gray-200';
|
||||
|
||||
function isOptionLast(index) {
|
||||
return index === options.length - 1;
|
||||
}
|
||||
|
||||
function onClick(option) {
|
||||
return () => {
|
||||
open = !open;
|
||||
if (option && option.onClick) {
|
||||
option.onClick();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
// In case an option was clicked, let's wait for the on:click of that button before we close
|
||||
setTimeout(() => (open = false), 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="{classNames} z-10 {!options.length && 'hidden'}">
|
||||
<button
|
||||
class="flex flex-row items-center {!isIcon && notIconClass}"
|
||||
on:click={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
open = !open;
|
||||
}}
|
||||
on:blur={onBlur}
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="dropdown whitespace-nowrap bg-white dark:bg-gray-800 border border-grey rounded-lg mt-2 py-3 absolute"
|
||||
>
|
||||
{#each options as option, index}
|
||||
<button
|
||||
class="block px-4 py-3 text-left hover:bg-blue-100 {!isOptionLast(
|
||||
index
|
||||
) && 'border-b'} w-full dark:text-white dark:hover:text-black"
|
||||
on:click={onClick(option)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dropdown {
|
||||
box-shadow: 0px 5px 5px -3px rgb(0 0 0 / 20%),
|
||||
0px 8px 10px 1px rgb(0 0 0 / 14%), 0px 3px 14px 2px rgb(0 0 0 / 12%);
|
||||
right: 0px;
|
||||
top: 20px;
|
||||
left: unset;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,107 @@
|
||||
<script>
|
||||
import { marked } from 'marked';
|
||||
|
||||
let isWriteMode = true;
|
||||
export let writeLabel = 'Write';
|
||||
export let value;
|
||||
export let placeholder = ``;
|
||||
export let onInputChange = () => {};
|
||||
export let textAreaHeight = '';
|
||||
const activeClassName = 'border-b-2 border-blue-700';
|
||||
|
||||
const handleTabClick = (tab) => () => {
|
||||
isWriteMode = tab === 1;
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<div class="flex">
|
||||
<button
|
||||
on:click={handleTabClick(1)}
|
||||
class="focus:outline-none p-2 {isWriteMode && `${activeClassName}`}"
|
||||
>
|
||||
{writeLabel}
|
||||
</button>
|
||||
<button
|
||||
on:click={handleTabClick(2)}
|
||||
class="focus:outline-none p-2 {!isWriteMode && `${activeClassName}`}"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
<div class="m-2 mb-0 p-0">
|
||||
{#if isWriteMode}
|
||||
<textarea
|
||||
bind:value
|
||||
{placeholder}
|
||||
style="height: {textAreaHeight}"
|
||||
class="border border-gray rounded-md p-2"
|
||||
on:change={onInputChange}
|
||||
/>
|
||||
{:else}
|
||||
<article class="preview prose prose-sm sm:prose p-2">
|
||||
{@html marked(value || 'Nothing to preview')}
|
||||
</article>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !isWriteMode}
|
||||
<hr />
|
||||
{/if}
|
||||
<div class="flex justify-between items-center px-2 pb-1">
|
||||
<div class="flex items-center text-sm">
|
||||
<svg
|
||||
class="octicon mr-2"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
version="1.1"
|
||||
width="16"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M14.85 3H1.15C.52 3 0 3.52 0 4.15v7.69C0 12.48.52 13 1.15 13h13.69c.64 0 1.15-.52 1.15-1.15v-7.7C16 3.52 15.48 3 14.85 3zM9 11H7V8L5.5 9.92 4 8v3H2V5h2l1.5 2L7 5h2v6zm2.99.5L9.5 8H11V5h2v3h1.5l-2.51 3.5z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="dark:text-white">
|
||||
Styling with Markdown is supported.
|
||||
<a
|
||||
target="_blank"
|
||||
class="text-blue-500 underline"
|
||||
href="https://guides.github.com/features/mastering-markdown"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- {@html marked(readme, {
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
})} -->
|
||||
<style>
|
||||
.root {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.octicon {
|
||||
display: inline-block;
|
||||
vertical-align: text-top;
|
||||
fill: currentColor;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
<script>
|
||||
export let message;
|
||||
</script>
|
||||
|
||||
<p class="text-sm text-red-500">{message}</p>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import ChevronDownIcon from 'carbon-icons-svelte/lib/ChevronDown.svelte';
|
||||
import ChevronUpIcon from 'carbon-icons-svelte/lib/ChevronUp.svelte';
|
||||
import LinkIcon from 'carbon-icons-svelte/lib/Link.svelte';
|
||||
|
||||
export let id = '';
|
||||
export let title = '';
|
||||
export let titleClass = '';
|
||||
export let disableContainerPadding = false;
|
||||
export let rootClass = '';
|
||||
export let supportsLink = false;
|
||||
|
||||
export let isExpanded = true;
|
||||
|
||||
function handleClick(e) {
|
||||
e.stopPropagation();
|
||||
|
||||
isExpanded = !isExpanded;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div {id} class="w-full bg-white dark:bg-gray-800 {rootClass}">
|
||||
<div
|
||||
class="w-full relative flex items-center justify-between {!disableContainerPadding &&
|
||||
'p-5'}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:click={handleClick}
|
||||
>
|
||||
<p class="dark:text-white font-bold flex items-center {titleClass}">
|
||||
{title}
|
||||
{#if supportsLink}
|
||||
<a
|
||||
class="ml-2"
|
||||
href="{$page.url.pathname}#{id}"
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
>
|
||||
<LinkIcon size={24} class="carbon-icon" />
|
||||
</a>
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#if isExpanded}
|
||||
<ChevronUpIcon size={24} class="carbon-icon" />
|
||||
{:else}
|
||||
<ChevronDownIcon size={24} class="carbon-icon" />
|
||||
{/if}
|
||||
</div>
|
||||
{#if isExpanded}
|
||||
<div class="flex flex-col p-5">
|
||||
<slot />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script>
|
||||
</script>
|
||||
|
||||
<!-- markup (zero or more items) goes here -->
|
||||
<div class="my-1">
|
||||
<hr />
|
||||
<div class="flex justify-center w-full">
|
||||
<span class="text-gray-700 text-sm py-3"
|
||||
>Created with love by
|
||||
<a
|
||||
class="underline text-blue-700"
|
||||
href="https://twitter.com/rotimi_best"
|
||||
target="_blank">Rotimi Best</a
|
||||
></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* your styles go here */
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script>
|
||||
export let label;
|
||||
export let value;
|
||||
export let checked;
|
||||
export let name;
|
||||
export let isEditable = false;
|
||||
export let disabled = false;
|
||||
export let className = '';
|
||||
export let onChange;
|
||||
</script>
|
||||
|
||||
<label
|
||||
class="{className} inline-flex items-center w-full {disabled
|
||||
? 'cursor-not-allowed'
|
||||
: 'cursor-pointer'}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-300 focus:ring focus:ring-offset-0 focus:ring-indigo-200 focus:ring-opacity-50"
|
||||
{name}
|
||||
{value}
|
||||
disabled={disabled || isEditable}
|
||||
bind:checked
|
||||
/>
|
||||
{#if isEditable}
|
||||
<input
|
||||
class="form-input p-2 mt-1 ml-1 block w-2/4 bg-gray-100 dark:bg-gray-700 border-t-0 border-l-0 border-r-0 border-b-1"
|
||||
type="text"
|
||||
placeholder="Your option"
|
||||
bind:value={label}
|
||||
on:change={onChange}
|
||||
/>
|
||||
{:else}
|
||||
<span class="dark:text-white ml-2">{label}</span>
|
||||
{/if}
|
||||
|
||||
<slot name="iconbutton" />
|
||||
</label>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script>
|
||||
export let label;
|
||||
export let placeholder;
|
||||
export let value;
|
||||
export let className = '';
|
||||
export let onChange = () => {}; // This is to know if element is 'dirty'
|
||||
</script>
|
||||
|
||||
<label class="block {className}">
|
||||
<span class="text-gray-700 dark:text-white">{label}</span>
|
||||
<input
|
||||
bind:value
|
||||
{placeholder}
|
||||
type="date"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
|
||||
on:blur={onChange}
|
||||
/>
|
||||
</label>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script>
|
||||
export let label;
|
||||
export let placeholder;
|
||||
export let value;
|
||||
export let className = '';
|
||||
export let onInput = () => {};
|
||||
$: console.log(`value`, value);
|
||||
</script>
|
||||
|
||||
<label class="block {className}">
|
||||
<span class="text-gray-700 dark:text-white">{label}</span>
|
||||
<input
|
||||
bind:value
|
||||
on:input={onInput}
|
||||
{placeholder}
|
||||
type="datetime-local"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
|
||||
/>
|
||||
</label>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script>
|
||||
export let label = '';
|
||||
export let value = '';
|
||||
export let checked = false;
|
||||
export let name = '';
|
||||
export let isEditable = false;
|
||||
export let disabled = false;
|
||||
export let className = '';
|
||||
export let onChange = () => {}; // This is to know if element is 'dirty'
|
||||
</script>
|
||||
|
||||
<label
|
||||
class="{className} inline-flex items-center w-full {disabled
|
||||
? 'cursor-not-allowed'
|
||||
: 'cursor-pointer'}"
|
||||
>
|
||||
<input
|
||||
class="form-radio"
|
||||
type="radio"
|
||||
{checked}
|
||||
{name}
|
||||
{value}
|
||||
disabled={disabled || isEditable}
|
||||
/>
|
||||
{#if isEditable}
|
||||
<input
|
||||
class="form-input p-2 mt-1 ml-1 block w-2/4 bg-gray-100 dark:bg-gray-700 border-t-0 border-l-0 border-r-0 border-b-1"
|
||||
type="text"
|
||||
placeholder="Your option"
|
||||
bind:value={label}
|
||||
on:change={onChange}
|
||||
/>
|
||||
{:else}
|
||||
<span class="dark:text-white ml-2">{label}</span>
|
||||
{/if}
|
||||
|
||||
<slot name="iconbutton" />
|
||||
</label>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let label = '';
|
||||
export let value;
|
||||
export let options = [];
|
||||
export let labelKey = 'label';
|
||||
export let isRequired = false;
|
||||
export let className = '';
|
||||
export let selectClassName = '';
|
||||
export let onChange = () => {}; // This is to know if element is 'dirty'
|
||||
|
||||
// onMount(() => {
|
||||
// value = options[0];
|
||||
// });
|
||||
</script>
|
||||
|
||||
<label class="block {className}">
|
||||
{#if label}
|
||||
<span class="text-gray-700 dark:text-white mr-2">{label}</span>
|
||||
{/if}
|
||||
<select
|
||||
bind:value
|
||||
class="form-select block w-auto mt-1 {selectClassName}"
|
||||
required={isRequired}
|
||||
on:blur={() => {}}
|
||||
on:change={onChange}
|
||||
class:customColor={!!selectClassName}
|
||||
>
|
||||
{#each options as option}
|
||||
<option class="bg-white dark:bg-gray-800 text-black" value={option}>
|
||||
{option[labelKey]}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<style>
|
||||
select.customColor {
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' class='text-white' viewBox='0 0 20 20'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
|
||||
}
|
||||
|
||||
select.customColor option {
|
||||
background-color: #fff;
|
||||
color: #000;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script>
|
||||
export let label = '';
|
||||
export let placeholder = 'Write your answer...';
|
||||
export let value = '';
|
||||
export let rows = '3';
|
||||
export let maxRows = 3;
|
||||
export let className = '';
|
||||
export let labelClassName = '';
|
||||
export let bgColor = 'bg-gray-200 dark:bg-gray-500';
|
||||
export let helperMessage = '';
|
||||
export let errorMessage = '';
|
||||
export let isRequired = false;
|
||||
export let onChange = () => {}; // This is to know if element is 'dirty'
|
||||
|
||||
$: minHeight = `${1 + parseInt(rows, 10) * 1.2}em`;
|
||||
$: maxHeight = maxRows ? `${1 + maxRows * 1.2}em` : `auto`;
|
||||
</script>
|
||||
|
||||
<label class="block w-full relative {className}">
|
||||
{#if label}
|
||||
<p for="text-field" class="dark:text-white {labelClassName}">
|
||||
{label}
|
||||
|
||||
{#if isRequired}
|
||||
<span class="text-red-700">*</span>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- <pre
|
||||
aria-hidden="true"
|
||||
style="min-height: {minHeight}; max-height: {maxHeight}">
|
||||
{value + '\n'}
|
||||
</pre> -->
|
||||
|
||||
<textarea
|
||||
{rows}
|
||||
{placeholder}
|
||||
bind:value
|
||||
required={isRequired}
|
||||
class="form-textarea mt-1 block w-full
|
||||
{bgColor} border-t-0 border-l-0 border-r-0 border-b-1 {!!errorMessage
|
||||
? 'border-red-500'
|
||||
: 'border-gray-300'}"
|
||||
on:change={onChange}
|
||||
/>
|
||||
{#if !!errorMessage}
|
||||
<p class="text-sm text-red-500">
|
||||
{errorMessage}
|
||||
</p>
|
||||
{:else if helperMessage}
|
||||
<p class="text-sm text-gray-500 dark:text-white">
|
||||
{helperMessage}
|
||||
</p>
|
||||
{/if}
|
||||
<slot name="iconbutton" />
|
||||
</label>
|
||||
|
||||
<style>
|
||||
/* pre,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
padding: 0.5em;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #eee;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
} */
|
||||
|
||||
/* textarea {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
resize: none;
|
||||
}
|
||||
textarea.has-label {
|
||||
top: 30px;
|
||||
} */
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user