Skip to content

Added carrousel with tutorial videos #389

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/components/VirtualLab/labs-listing/no-vlabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { motion } from 'framer-motion';

import { NewWindowAdd, UserCircle } from '@/components/icons/EditorIcons';
import { ActionCard, HeroSection } from '@/components/VirtualLab/labs-listing/elements';
import { TutorialsCarrousel } from '@/components/tutorials-carrousel';

type Props = {
showCreateSubscription?: boolean;
Expand Down Expand Up @@ -43,6 +44,7 @@ export default function VirtualSplashScreen({ showCreateSubscription = true }: P
</motion.div>
)}
</div>
<TutorialsCarrousel />
</div>
);
}
9 changes: 9 additions & 0 deletions src/components/tutorials-carrousel/hooks.groq
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
*[_type=="tutorial"][]
{
title,
description,
"url": videoUrl,
"imageURL": thumbnail.asset->url,
"imageWidth": thumbnail.asset->metadata.dimensions.width,
"imageHeight": thumbnail.asset->metadata.dimensions.height
}
42 changes: 42 additions & 0 deletions src/components/tutorials-carrousel/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import query from './hooks.groq';
import { assertType, TypeDef } from '@/util/type-guards';
import { useSanity } from '@/services/sanity';
import { logError } from '@/util/logger';

export function useSanityContentForTutorialsList() {
return useSanity(query, isContentForTutorialsList) ?? [];
}

export interface ContentForTutorialItem {
url: string;
title: string;
description: string;
imageURL: string;
imageWidth: number;
imageHeight: number;
}

function isContentForTutorialsList(data: unknown): data is ContentForTutorialItem[] {
const typeStringOrNull: TypeDef = ['|', 'string', 'null'];
try {
assertType(
data,
[
'array',
{
url: 'string',
title: typeStringOrNull,
description: typeStringOrNull,
imageURL: 'string',
imageWidth: 'number',
imageHeight: 'number',
},
],
'ContentForTutorialsList'
);
return true;
} catch (ex) {
logError(ex);
return false;
}
}
1 change: 1 addition & 0 deletions src/components/tutorials-carrousel/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './tutorials-carrousel';
1 change: 1 addition & 0 deletions src/components/tutorials-carrousel/navigation/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './navigation';
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
.navigation {
flex: 0 0 auto;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
flex-wrap: nowrap;
gap: 8px;
}

.navigation > button.page {
content: '';
width: 24px;
height: 6px;
border-radius: 99vmax;
background-color: #096dd9;
}

.navigation > button.page.selected {
background-color: #fff;
}

.navigation > button.arrow {
width: 3em;
display: grid;
place-items: center;
}
54 changes: 54 additions & 0 deletions src/components/tutorials-carrousel/navigation/navigation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React from 'react';

import { classNames } from '@/util/utils';
import { IconChevronLeft } from '@/components/LandingPage/icons/IconChevronLeft';
import { IconChevronRight } from '@/components/LandingPage/icons/IconChevronRight';

import styles from './navigation.module.css';

export interface NavigationProps {
className?: string;
count: number;
value: number;
onChange(value: number): void;
}

export function Navigation({ className, count, value, onChange }: NavigationProps) {
const handleMoveLeft = () => {
onChange((value + count - 1) % count);
};
const handleMoveRight = () => {
onChange((value + 1) % count);
};

return (
<nav className={classNames(className, styles.navigation)}>
<button
className={styles.arrow}
type="button"
onClick={handleMoveLeft}
aria-label="Previous card"
>
<IconChevronLeft />
</button>
{new Array(count).fill(0).map((_, index) => (
<button
className={classNames(styles.page, index === value && styles.selected)}
// eslint-disable-next-line react/no-array-index-key
key={index}
onClick={() => onChange(index)}
type="button"
aria-label={`Go to card #${index + 1}`}
/>
))}
<button
className={styles.arrow}
type="button"
onClick={handleMoveRight}
aria-label="Next card"
>
<IconChevronRight />
</button>
</nav>
);
}
8 changes: 8 additions & 0 deletions src/components/tutorials-carrousel/scroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function scrollCardIntoView(container: HTMLDivElement, cardIndex: number) {
const node = container.querySelectorAll('a').item(cardIndex);
if (node) {
node.scrollIntoView({
behavior: 'smooth',
});
}
}
1 change: 1 addition & 0 deletions src/components/tutorials-carrousel/tutorial-card/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './tutorial-card';
14 changes: 14 additions & 0 deletions src/components/tutorials-carrousel/tutorial-card/play-icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export function PlayIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="-150 -150 300 300"
preserveAspectRatio="xMidYMid meet"
>
<g fill="currentcolor" stroke="currentcolor" strokeWidth="10" strokeLinejoin="round">
<circle fill="none" cx="0" cy="0" r="60" />
<path d="M30,0L-20,25,-20,-25Z" />
</g>
</svg>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
.tutorialCard {
color: #003a8c;
background-color: #fff;
border-radius: 1em;
padding: 1em;
display: inline-block;
width: 360px;
height: 245px;
line-height: 1;
flex: 0 0 auto;
}

.tutorialCard .thumbnail {
border-radius: 0.5em;
overflow: hidden;
width: 180px;
height: 116px;
position: relative;
background-color: #000;
overflow: hidden;
}

.tutorialCard .thumbnail::after {
content: '';
position: absolute;
left: -0.5em;
top: -30%;
width: calc(100% + 1em);
height: 80%;
border-radius: 2em;
transform: skew(0, -20deg);
background: linear-gradient(to top, #fff5, #fff3);
opacity: 0.7;
transition: opcity 0.2s;
}

.tutorialCard:hover .thumbnail::after {
opacity: 1;
}

.tutorialCard .thumbnail > img {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
object-fit: cover;
}

.tutorialCard .thumbnail > svg {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
color: #fff;
opacity: 0.6;
transition: opcity 0.2s;
}

.tutorialCard:hover .thumbnail > svg {
opacity: 0.9;
}

.tutorialCard h2 {
font-weight: bold;
font-size: 120%;
margin-top: 1em;
margin-bottom: 0.25em;
}
34 changes: 34 additions & 0 deletions src/components/tutorials-carrousel/tutorial-card/tutorial-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import React from 'react';
import Image from 'next/image';

import { ContentForTutorialItem } from '../hooks';
import { PlayIcon } from './play-icon';
import { classNames } from '@/util/utils';

import styles from './tutorial-card.module.css';

export interface TutorialCardProps {
className?: string;
value: ContentForTutorialItem;
}

export function TutorialCard({ className, value }: TutorialCardProps) {
const aspectRatio = value.imageWidth / value.imageHeight;
const imageHeight = 160;

return (
<a className={classNames(className, styles.tutorialCard)} target="_blank" href={value.url}>
<div className={styles.thumbnail}>
<Image
alt={value.title}
width={imageHeight * aspectRatio}
height={imageHeight}
src={value.imageURL}
/>
<PlayIcon />
</div>
<h2>{value.title}</h2>
<p>{value.description}</p>
</a>
);
}
28 changes: 28 additions & 0 deletions src/components/tutorials-carrousel/tutorials-carrousel.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
.tutorialsCarrousel {
margin: 2em 0;
}

.tutorialsCarrousel > header {
display: flex;
flex-wrap: nowrap;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 1em;
}

.tutorialsCarrousel > header > h1 {
margin: 0;
font-size: 2.25rem;
font-weight: bolder;
}

.tutorialsCarrousel > div {
display: flex;
flex-wrap: nowrap;
flex-direction: row;
justify-content: flex-start;
align-items: stretch;
gap: 1em;
overflow-x: auto;
}
59 changes: 59 additions & 0 deletions src/components/tutorials-carrousel/tutorials-carrousel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React from 'react';

import { useSanityContentForTutorialsList } from './hooks';
import { TutorialCard } from './tutorial-card';
import { scrollCardIntoView } from './scroll';
import { Navigation } from './navigation';
import { classNames } from '@/util/utils';

import styles from './tutorials-carrousel.module.css';

export interface TutorialsCarrouselProps {
className?: string;
}

export function TutorialsCarrousel({ className }: TutorialsCarrouselProps) {
const refContainer = React.useRef<HTMLDivElement | null>(null);
const [showNavigation, setShowNavigation] = React.useState(false);
const [cardIndex, setCardIndex] = React.useState(0);
const tutorials = useSanityContentForTutorialsList();
React.useEffect(() => {
const container = refContainer.current;
if (!container) return;

const handleResize = () => {
setShowNavigation(container.scrollWidth > container.clientWidth);
};
const observer = new ResizeObserver(handleResize);
observer.observe(container);
handleResize();
return () => observer.unobserve(container);
});
const handleScrollCardIntoView = (index: number) => {
const container = refContainer.current;
if (!container) return;

scrollCardIntoView(container, index);
setCardIndex(index);
};

return (
<div className={classNames(className, styles.tutorialsCarrousel)}>
<header>
<h1>Our tutorials</h1>
{showNavigation && (
<Navigation
count={tutorials.length}
value={cardIndex}
onChange={handleScrollCardIntoView}
/>
)}
</header>
<div ref={refContainer}>
{tutorials.map((value) => (
<TutorialCard key={value.url} value={value} />
))}
</div>
</div>
);
}