CLASSROOMIO-T-177: Upload Widget

improve banner image

Merge branch 'main' of https://gitlab.com/thenextgreat/classroomio into CLASSROOMIO-T-177

Merge branch 'CLASSROOMIO-T-177' of https://gitlab.com/thenextgreat/classroomio into CLASSROOMIO-T-177

CLASSROOMIO-T-177: Moved API call to backend

general ux improvements

Merge branch 'CLASSROOMIO-T-177' of https://gitlab.com/thenextgreat/classroomio into CLASSROOMIO-T-177

CLASSROOMIO-T-177: Resolved Feedbacks

CLASSROOMIO-T-177: UI improvements

CLASSROOMIO-T-177: fix styling or image

CLASSROOMIO-T-177: Resolved Feedbacks

CLASSROOMIO-T-177: Landing Page Max Width

CLASSROOMIO-T-177: Refactor Upload Widget

Merge branch 'main' into CLASSROOMIO-T-177

CLASSROOMIO-T-177: Completion of Upload Widget

Merge branch 'main' into CLASSROOMIO-T-177

CLASSROOMIO-T-177: Widget Test


Co-authored-by: Rotimi Best <[email protected]>
Co-authored-by: Tunji Adeyemi <[email protected]>


Merge-request: CLASSROOMIO-MR-86
Merged-by: Best Admin <[email protected]>
This commit is contained in:
tunny17
2023-10-05 09:22:39 +00:00
committed by Space Cloud
co-authored by Rotimi Best
parent 589a61fe44
commit 804e5cdeb9
14 changed files with 287 additions and 125 deletions
+1 -1
View File
@@ -28,7 +28,6 @@
"@types/paystack": "^2.0.1", "@types/paystack": "^2.0.1",
"@types/pluralize": "0.0.30", "@types/pluralize": "0.0.30",
"ai": "^2.1.31", "ai": "^2.1.31",
"axios": "^1.4.0",
"body-parser": "^1.20.2", "body-parser": "^1.20.2",
"canvas-confetti": "^1.6.0", "canvas-confetti": "^1.6.0",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
@@ -43,6 +42,7 @@
"pluralize": "^8.0.0", "pluralize": "^8.0.0",
"sirv": "^2.0.3", "sirv": "^2.0.3",
"svelte-email": "^0.0.4", "svelte-email": "^0.0.4",
"unsplash-js": "^7.0.18",
"wait-on": "^7.0.1", "wait-on": "^7.0.1",
"zod": "^3.21.4" "zod": "^3.21.4"
}, },
@@ -73,7 +73,7 @@
type type
? 'border-primary-400' ? 'border-primary-400'
: `border-gray-200 dark:border-gray-400 ${ : `border-gray-200 dark:border-gray-400 ${
!option.isDisabled && 'hover:scale-90' !option.isDisabled && 'hover:scale-95'
}`} flex flex-col {option.isDisabled && }`} flex flex-col {option.isDisabled &&
'cursor-not-allowed opacity-60'} transition-all ease-in-out" 'cursor-not-allowed opacity-60'} transition-all ease-in-out"
type="button" type="button"
@@ -145,7 +145,7 @@
<div class="flex flex-wrap items-start gap-2"> <div class="flex flex-wrap items-start gap-2">
{#each TEMPLATES[selectedTag] as template} {#each TEMPLATES[selectedTag] as template}
<button <button
class="w-[161px] h-[140px] p-5 rounded-md dark:bg-neutral-700 border {template.id === class="w-[161px] h-[140px] hover:scale-95 p-5 rounded-md dark:bg-neutral-700 border-2 {template.id ===
selectedTemplateId selectedTemplateId
? 'border-primary-400' ? 'border-primary-400'
: `border-gray-200 dark:border-gray-400`} flex flex-col transition-all ease-in-out" : `border-gray-200 dark:border-gray-400`} flex flex-col transition-all ease-in-out"
@@ -211,16 +211,16 @@
hideSortIcon: true hideSortIcon: true
}, },
{ {
label: 'Landing Page', label: 'Settings',
to: getNavItemRoute($course.id, 'landingpage'), to: getNavItemRoute($course.id, 'settings'),
hideSortIcon: true, hideSortIcon: true,
show() { show() {
return !isStudent; return !isStudent;
} }
}, },
{ {
label: 'Settings', label: 'Landing Page',
to: getNavItemRoute($course.id, 'settings'), to: getNavItemRoute($course.id, 'landingpage'),
hideSortIcon: true, hideSortIcon: true,
show() { show() {
return !isStudent; return !isStudent;
@@ -19,6 +19,8 @@
import { lessons } from '../Lesson/store/lessons'; import { lessons } from '../Lesson/store/lessons';
import { currentOrg } from '$lib/utils/store/org'; import { currentOrg } from '$lib/utils/store/org';
import { snackbar } from '$lib/components/Snackbar/store'; import { snackbar } from '$lib/components/Snackbar/store';
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
import { handleOpenWidget } from '$lib/components/CourseLandingPage/store';
let uploadingImage = false; let uploadingImage = false;
let isSaving = false; let isSaving = false;
@@ -29,37 +31,10 @@
let avatar; let avatar;
let imagebuffer; let imagebuffer;
// FILE UPLOAD // controls widget open and close
const uploadImage = async (image: File) => { function widgetControl() {
if (image) { $handleOpenWidget.open = true;
const filename = `bannerimage/${Date.now()}` + image.name; }
const { data } = await supabase.storage.from('avatars').upload(filename, image, {
cacheControl: '3600',
upsert: false
});
if (data) {
const { data: response } = await supabase.storage.from('avatars').getPublicUrl(filename);
$settings.image = response.publicUrl;
uploadingImage = false;
}
}
};
const onFileSelected = () => {
uploadingImage = true;
const image = fileInput.files[0];
if (image) {
let reader = new FileReader();
reader.readAsDataURL(image);
reader.onload = (e) => {
imagebuffer = image;
uploadImage(image);
};
}
};
function getLessonOrder(id) { function getLessonOrder(id) {
const index = $lessons.findIndex((lesson) => lesson.id === id); const index = $lessons.findIndex((lesson) => lesson.id === id);
@@ -205,17 +180,17 @@
<Grid class="border-c rounded border-gray-200"> <Grid class="border-c rounded border-gray-200">
<Row class="flex lg:flex-row flex-col py-7 border-bottom-c"> <Row class="flex lg:flex-row flex-col py-7 border-bottom-c">
<Column sm={8} md={8} lg={8}> <Column sm={8} md={8} lg={8}>
<SectionTitle>Banner Image</SectionTitle> <SectionTitle>Cover Image</SectionTitle>
<p> <p>
This optional image will show up on your welcome page. If you include one, it should be at This optional image will show up on your welcome page. If you include one, it should be at
least 280 x 200" least 280 x 200"
</p> </p>
<span class="flex items-center justify-start"> <span class="flex items-center justify-start">
<PrimaryButton <PrimaryButton
variant={VARIANTS.LINK} variant={VARIANTS.OUTLINED}
label="Replace Banner image" label="Replace"
className="-ml-6" className="mr-2"
onClick={() => fileInput.click()} onClick={widgetControl}
/> />
<PrimaryButton <PrimaryButton
variant={VARIANTS.CONTAINED_DANGER} variant={VARIANTS.CONTAINED_DANGER}
@@ -223,22 +198,18 @@
onClick={deleteBannerImage} onClick={deleteBannerImage}
/> />
</span> </span>
{#if $handleOpenWidget.open}
<UploadWidget />
{/if}
</Column> </Column>
<Column sm={8} md={8} lg={6}> <Column sm={8} md={8} lg={6}>
<div class="w-fit relative"> <div class="w-fit relative z-[20]">
<input
type="file"
accept=".jpg, .jpeg, .png"
style="display: none;"
bind:this={fileInput}
on:change={onFileSelected}
/>
<img <img
style="min-width:280px; min-height:200px" style="min-width:280px; min-height:200px"
alt="About us" alt="About us"
src={$settings.image ? $settings.image : '/images/classroomio-course-img-template.jpg'} src={$settings.image ? $settings.image : '/images/classroomio-course-img-template.jpg'}
class="mt-2 md:mt-0 rounded-md w-full relative" class="mt-2 md:mt-0 w-[280px] h-[200px] rounded-md relative"
/> />
{#if uploadingImage} {#if uploadingImage}
<Loading withOverlay={true} small class="absolute top-0 w-[280px]" /> <Loading withOverlay={true} small class="absolute top-0 w-[280px]" />
@@ -1,55 +1,15 @@
<script lang="ts"> <script lang="ts">
import { settings } from '$lib/components/Course/components/Settings/store';
import TextArea from '$lib/components/Form/TextArea.svelte'; import TextArea from '$lib/components/Form/TextArea.svelte';
import TextField from '$lib/components/Form/TextField.svelte'; import TextField from '$lib/components/Form/TextField.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants'; import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { supabase } from '$lib/utils/functions/supabase'; import { handleOpenWidget } from '../../store';
import { updateCourse } from '$lib/utils/services/courses';
import { landingPage } from '../../store';
export let course = {}; export let course = {};
let fileInput;
let imageBuffer;
let avatar;
const uploadImage = async (image: File) => { function widgetControl() {
if (image) { $handleOpenWidget.open = true;
const filename = `landingpage/${Date.now()}` + image.name; }
const { data } = await supabase.storage.from('avatars').upload(filename, image, {
cacheControl: '3600',
upsert: false
});
if (data) {
const { data: response } = await supabase.storage.from('avatars').getPublicUrl(filename);
$landingPage.imageUrl = response.publicUrl;
$settings.image = $landingPage.imageUrl;
await updateCourse(course.id, avatar, {
logo: $settings.image
});
course.logo = $settings.image;
$landingPage.uploadingImage = false;
}
}
};
const onFileSelected = () => {
$landingPage.uploadingImage = true;
const image = fileInput.files[0];
if (image) {
let reader = new FileReader();
reader.readAsDataURL(image);
reader.onload = (e) => {
imageBuffer = image;
uploadImage(image);
};
}
};
</script> </script>
<TextField className="mt-5" labelClassName="font-bold" label="Title" bind:value={course.title} /> <TextField className="mt-5" labelClassName="font-bold" label="Title" bind:value={course.title} />
@@ -71,18 +31,7 @@
type="text" type="text"
bind:value={course.metadata.videoUrl} bind:value={course.metadata.videoUrl}
/> />
<div> <div class="mt-7">
<PrimaryButton <p class="font-bold mb-3">Replace cover image</p>
variant={VARIANTS.LINK} <PrimaryButton label="Replace" variant={VARIANTS.OUTLINED} onClick={widgetControl} />
label="Replace Banner image"
className="-ml-6"
onClick={() => fileInput.click()}
/>
<input
type="file"
accept=".jpg, .jpeg, .png"
style="display: none;"
bind:this={fileInput}
on:change={onFileSelected}
/>
</div> </div>
@@ -1,29 +1,33 @@
<script lang="ts"> <script lang="ts">
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
import get from 'lodash/get'; import get from 'lodash/get';
import { observeIntersection } from './components/IntersectionObserver';
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import pluralize from 'pluralize'; import pluralize from 'pluralize';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { landingPage } from './store';
import { Loading } from 'carbon-components-svelte';
import Notebook from 'carbon-icons-svelte/lib/Notebook.svelte'; //note import Notebook from 'carbon-icons-svelte/lib/Notebook.svelte'; //note
import PresentationFile from 'carbon-icons-svelte/lib/PresentationFile.svelte'; // exercise import PresentationFile from 'carbon-icons-svelte/lib/PresentationFile.svelte'; // exercise
import Video from 'carbon-icons-svelte/lib/Video.svelte'; //video import Video from 'carbon-icons-svelte/lib/Video.svelte'; //video
import PageNumber from 'carbon-icons-svelte/lib/PageNumber.svelte'; import PageNumber from 'carbon-icons-svelte/lib/PageNumber.svelte';
import PlayFilled from 'carbon-icons-svelte/lib/PlayFilled.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 PricingSection from './components/PricingSection.svelte';
import { observeIntersection } from './components/IntersectionObserver';
import { getLectureNo } from '../Course/function'; import { getLectureNo } from '../Course/function';
import { NAV_ITEMS } from './constants'; 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 Modal from '../Modal/index.svelte';
import { reviewsModalStore } from './store'; import { reviewsModalStore } from './store';
import Chip from '../Chip/index.svelte';
import Avatar from '$lib/components/Avatar/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { getEmbedId } from '$lib/utils/functions/formatYoutubeVideo';
import type { Course, Lesson, Review } from '$lib/utils/types';
import { VARIANTS } from '$lib/components/PrimaryButton/constants'; import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte'; import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
import { calDateDiff } from '$lib/utils/functions/date'; import { calDateDiff } from '$lib/utils/functions/date';
import { landingPage } from './store'; import { handleOpenWidget } from './store';
import { Loading } from 'carbon-components-svelte';
export let editMode = false; export let editMode = false;
export let courseData: Course; export let courseData: Course;
const ratingsImg = [ const ratingsImg = [
@@ -124,6 +128,10 @@
startCoursePayment = true; startCoursePayment = true;
}} }}
/> />
{#if $handleOpenWidget.open}
<UploadWidget />
{/if}
</div> </div>
<!-- Banner Image getEmbedId(videoUrl) --> <!-- Banner Image getEmbedId(videoUrl) -->
@@ -146,12 +154,12 @@
/> />
</div> --> </div> -->
{:else} {:else}
<div class="w-2/3 relative rounded-md overflow-hidden"> <div class="banner-image md:w-2/3 relative rounded-md overflow-hidden">
<img <img
style="min-width:280px; min-height:200px" style="min-width:280px; min-height:200px"
alt="About us" alt="About us"
src={bannerImage ? bannerImage : '/images/classroomio-course-img-template.jpg'} src={bannerImage ? bannerImage : '/images/classroomio-course-img-template.jpg'}
class="mt-2 md:mt-0 rounded-md w-full relative" class="mt-2 md:mt-0 rounded-md w-full h-full max-w-[500px] max-h-[400px] relative"
/> />
{#if $landingPage.uploadingImage} {#if $landingPage.uploadingImage}
<Loading withOverlay={true} small class="absolute top-0 w-full h-full" /> <Loading withOverlay={true} small class="absolute top-0 w-full h-full" />
@@ -64,3 +64,7 @@ export const landingPage = writable({
uploadingImage: false, uploadingImage: false,
imageUrl: '' imageUrl: ''
}); });
export let handleOpenWidget = writable({
open: false
});
@@ -23,7 +23,7 @@
<a <a
rel="prefetch" rel="prefetch"
href={isOnLandingPage ? `/course/${slug}` : `/courses/${id}`} href={isOnLandingPage ? `/course/${slug}` : `/courses/${id}`}
class="text-black border border-gray rounded w-full max-w-[320px] relative hover:shadow-lg transition-all ease-in-out" class="text-black border border-gray rounded w-full max-w-[320px] relative hover:scale-95 transition-all ease-in-out"
> >
<div class="p-4"> <div class="p-4">
<div class=" mb-5"> <div class=" mb-5">
+2 -2
View File
@@ -10,7 +10,7 @@
export let onKeyDown = () => {}; export let onKeyDown = () => {};
export let className = ''; export let className = '';
export let inputClassName = ''; export let inputClassName = '';
export let labelClassName = ''; export let labelClassName = 'font-light';
export let bgColor = 'bg-gray-100 focus:bg-primary-50 dark:bg-neutral-800 dark:text-white'; export let bgColor = 'bg-gray-100 focus:bg-primary-50 dark:bg-neutral-800 dark:text-white';
export let type = 'text'; export let type = 'text';
export let isPassword = false; export let isPassword = false;
@@ -57,7 +57,7 @@
<label class="block relative {className}"> <label class="block relative {className}">
{#if label} {#if label}
<p for="text-field" class="text-sm dark:text-white text-left m-0 font-light {labelClassName}"> <p for="text-field" class="text-sm dark:text-white text-left m-0 {labelClassName}">
{label} {label}
{#if isRequired} {#if isRequired}
+1 -1
View File
@@ -58,7 +58,7 @@
{/each} {/each}
</div> </div>
<div class="mt-4"> <div class="">
<slot name="content" /> <slot name="content" />
</div> </div>
</div> </div>
@@ -0,0 +1,173 @@
<script lang="ts">
import { onMount } from 'svelte';
import Tabs from '$lib/components/Tabs/index.svelte';
import { course } from '$lib/components/Course/store';
import { getSupabase } from '$lib/utils/functions/supabase';
import TabContent from '$lib/components/TabContent/index.svelte';
import { updateCourse } from '$lib/utils/services/courses';
import { snackbar } from '../Snackbar/store';
import { isObject } from '$lib/utils/functions/isObject';
import Modal from '$lib/components/Modal/index.svelte';
import { handleOpenWidget } from '$lib/components/CourseLandingPage/store';
import { settings } from '../Course/components/Settings/store';
import { queryUnsplash } from './utils';
export let selectedImgUrl = '';
let tabs = [
{ label: 'Unsplash', value: 'unsplash' },
{ label: 'Upload', value: 'upload' }
];
let currentTab = tabs[0].value;
let searchQuery = '';
let unsplashImages = [];
let avatar;
let fileInput;
let imagebuffer;
const supabase = getSupabase();
const onChange = (tabValue) => {
currentTab = tabValue;
};
async function handleImageClick(imageUrl) {
selectedImgUrl = imageUrl;
uploadImage(selectedImgUrl);
}
const onFileSelected = () => {
const image = fileInput.files[0];
if (image) {
let reader = new FileReader();
reader.readAsDataURL(image);
reader.onload = (e) => {
imagebuffer = image;
uploadImage(image);
};
}
};
const uploadImage = async (image) => {
if (image) {
if (typeof image === 'string') {
$settings.image = image;
} else if (image instanceof File) {
const filename = `bannerimage/${Date.now()}` + image.name;
const { data } = await supabase.storage.from('avatars').upload(filename, image, {
cacheControl: '3600',
upsert: false
});
if (data) {
const { data: response } = await supabase.storage.from('avatars').getPublicUrl(filename);
$settings.image = response.publicUrl;
}
}
try {
const { image } = $settings;
await updateCourse($course.id, avatar, {
logo: image,
metadata: {
...(isObject($course.metadata) ? $course.metadata : {})
}
});
$course.logo = image;
$course.metadata = {
...(isObject($course.metadata) ? $course.metadata : {})
};
snackbar.success('Saved successfully');
$handleOpenWidget.open = false;
} catch (error) {
snackbar.error();
console.log(error);
}
}
};
function handleUpload() {
fileInput.click();
}
async function handleSubmit() {
try {
unsplashImages = await queryUnsplash(searchQuery || 'rocks');
} catch (error) {
console.error('Error fetching images from Unsplash:', error);
}
}
onMount(handleSubmit);
</script>
<Modal
onClose={() => ($handleOpenWidget.open = false)}
bind:open={$handleOpenWidget.open}
width="w-3/5"
maxWidth=""
modalHeading="Banner Widget"
>
<div class="w-full bg-white dark:bg-inherit p-5">
<Tabs {tabs} {currentTab} {onChange}>
<slot:fragment slot="content">
<TabContent value={tabs[1].value} index={currentTab}>
<!-- Your Upload content here -->
<div class="w-full mt-2">
<input
type="file"
style="display: none;"
bind:this={fileInput}
on:change={onFileSelected}
/>
<button
on:click={handleUpload}
class="py-2 w-full font-semibold hover:bg-gray-200 border-[1px]">Upload Image</button
>
<p class="text-center text-sm text-gray-500 my-2">
Images wider than 1500 pixels work best.
</p>
<p class="text-center text-sm text-gray-500">The maximum size per file is 5 MB.</p>
</div>
</TabContent>
<TabContent value={tabs[0].value} index={currentTab}>
<!-- Your Images content here -->
<div class="h-full py-5 overflow-y-scroll">
<form on:submit|preventDefault={handleSubmit} class="flex gap-2">
<input
type="text"
bind:value={searchQuery}
name=""
id=""
class="rounded-lg w-[87%] ml-2 dark:text-black"
/>
<button
type="submit"
class="px-3 py-1 bg-white rounded-lg border-[1px] border-gray-500 text-black"
>Submit</button
>
</form>
{#if unsplashImages && unsplashImages.length > 0}
<div class="flex flex-row items-center flex-wrap gap-2 py-4 px-[10px] max-h-[300px]">
{#each unsplashImages as unsplashImages (unsplashImages.id)}
<div class="w-[190px] h-[130px] overflow-hidden relative">
<img
src={unsplashImages.urls.regular}
alt={unsplashImages.alt_description}
class="w-full h-full object-cover rounded-md hover:opacity-80 cursor-pointer"
on:click={() => handleImageClick(unsplashImages.urls.regular)}
/>
</div>
{/each}
</div>
{:else}
<p class="text-center pt-7">No images available.</p>
{/if}
</div>
</TabContent>
</slot:fragment>
</Tabs>
</div>
</Modal>
+24
View File
@@ -0,0 +1,24 @@
import { supabase } from '$lib/utils/functions/supabase';
export async function queryUnsplash(searchQuery: string) {
const { data } = await supabase.auth.getSession();
const accessToken = data.session?.access_token || '';
try {
const response = await fetch('/api/unsplash', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: accessToken
},
body: JSON.stringify({ searchQuery })
});
if (!response.ok) {
throw new Error(await response.text());
}
const result = await response.json();
return result.photos;
} catch (error) {
console.error('Error sending fetch request', error);
}
}
+33
View File
@@ -0,0 +1,33 @@
import { json } from '@sveltejs/kit';
import { UNSPLASH_API_KEY } from '$env/static/private';
const UNSPLASH_API_URL = `https://api.unsplash.com/search/photos?page=2&per_page=15&auto=format&fit=crop&w=2970&q=80&client_id=${UNSPLASH_API_KEY}`;
// API to fetch photos from Unsplash
export async function POST({ request }) {
const body = await request.json();
const { searchQuery } = body;
const query = searchQuery || 'rocks';
try {
const response = await fetch(`${UNSPLASH_API_URL}&query=${encodeURIComponent(query)}`, {
method: 'GET',
});
if (!response.ok) {
throw new Error('Unable to fetch photos from Unsplash API');
}
const data = await response.json();
return json({
success: true,
photos: data.results,
});
} catch (error) {
console.error(error);
return json({ success: false, message: 'Error fetching photos from Unsplash' }, { status: 500 });
}
}
@@ -306,7 +306,7 @@
class="px-2 my-2 pr-4 border-t-0 border-b-0 border-l-0 border border-gray-300 flex items-center" class="px-2 my-2 pr-4 border-t-0 border-b-0 border-l-0 border border-gray-300 flex items-center"
on:click={() => goto(path)} on:click={() => goto(path)}
> >
<CourseIcon color="#fff" /> <CourseIcon />
</button> </button>
{/if} {/if}
<button <button