diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index b9d514b..a6cc386 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -1,5 +1,3 @@
-# 🚀 Pull Request
-
## Type of Change
diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
index cd9c324..d43ee29 100644
--- a/.storybook/preview.tsx
+++ b/.storybook/preview.tsx
@@ -58,7 +58,7 @@ const preview: Preview = {
return isDualMode ? (
{Story(context.args, context)}
) : (
-
+
{Story(context.args, context)}
);
diff --git a/README.md b/README.md
index dd9a9d3..12c1fee 100644
--- a/README.md
+++ b/README.md
@@ -76,6 +76,7 @@ Qupid comes with pre-built sections that you can use to create your landing page
| Steps | Section with title, subtitle, and multiple steps (numbered icons with title and description). |
| FAQ | Section with title, subtitle, and multiple questions with answers. |
| Terminal | Section with title, subtitle, and terminal-like code block with optional prompt. |
+| Video | Section with title, subtitle, and embedded video player. |
> [!TIP]
> See [Storybook](https://kungfux.github.io/qupid/storybook/) for live demo of each section and how to configure them or use [Stackblitz](https://stackblitz.com/github/kungfux/qupid?file=configuration.yaml) to play with the configuration.
@@ -252,6 +253,7 @@ jobs:
twitter:
icon: fab fa-twitter
href: #
+
```
@@ -339,7 +341,7 @@ site:
Use `sections` block to define which sections should display and in which order.
-Each section has it's own configuration entries.Most of the elements are optional. Omit property declaration in the configuration if you don't need a section to display particular element.
+Each section has it's own configuration entries. Most of the elements are optional. Omit property declaration in the configuration if you don't need a section to display particular element.
```yml
sections:
@@ -466,7 +468,7 @@ Here are some common special characters and how to handle them:
- `` ` `` (backtick)
- Use quotes if the backtick is part of a string value. Example: ```code: "Here is some `inline code` in a sentence."``` or use backslash to escape it: ```code: Here is some \`inline code\` in a sentence.```.
+ Use quotes if the backtick is part of a string value. Example: ``code: "Here is some `inline code` in a sentence."`` or use backslash to escape it: ``code: Here is some \`inline code\` in a sentence.``.
## 🤝 Feedback and Contributions
diff --git a/configuration.yaml b/configuration.yaml
index 4a427c8..d1ded1e 100644
--- a/configuration.yaml
+++ b/configuration.yaml
@@ -189,6 +189,14 @@ sections:
description: Integrate with popular analytics providers like [Google Analytics](https://analytics.google.com/) and others to track your website's performance.
icon: fas fa-chart-line
+ video:
+ type: video
+ title: Watch Qupid in Action
+ subtitle: See how easy it is to create a landing page with Qupid.
+ video:
+ src: https://www.youtube.com/embed/xz5jZ1zwFC4
+ ratio: 1.7777777777778 # 16 / 9 aspect ratio
+
build_now:
type: hero
title: Build Now
diff --git a/package-lock.json b/package-lock.json
index d0eeaa7..51a3181 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"dependencies": {
"@fortawesome/fontawesome-free": "^7.1.0",
"@radix-ui/react-accordion": "^1.2.12",
+ "@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.3",
@@ -114,6 +115,7 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -1324,6 +1326,70 @@
}
}
},
+ "node_modules/@radix-ui/react-aspect-ratio": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.8.tgz",
+ "integrity": "sha512-5nZrJTF7gH+e0nZS7/QxFz6tJV4VimhQb1avEgtsJxvvIp5JilL+c58HICsKzPxghdwaDt48hEfPM1au4zGy+w==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-aspect-ratio/node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
+ "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-aspect-ratio/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
+ "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-collapsible": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
@@ -2797,6 +2863,7 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -3038,6 +3105,7 @@
"integrity": "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~7.14.0"
}
@@ -3047,6 +3115,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -3057,6 +3126,7 @@
"integrity": "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -3130,6 +3200,7 @@
"integrity": "sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.0",
"@typescript-eslint/types": "8.46.0",
@@ -3388,6 +3459,7 @@
"integrity": "sha512-tJxiPrWmzH8a+w9nLKlQMzAKX/7VjFs50MWgcAj7p9XQ7AQ9/35fByFYptgPELyLw+0aixTnC4pUWV+APcZ/kw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@testing-library/dom": "^10.4.0",
"@testing-library/user-event": "^14.6.1",
@@ -3525,6 +3597,7 @@
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@vitest/utils": "3.2.4",
"pathe": "^2.0.3",
@@ -3583,6 +3656,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3818,6 +3892,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
@@ -4254,6 +4329,7 @@
"integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==",
"hasInstallScript": true,
"license": "MIT",
+ "peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -4331,6 +4407,7 @@
"integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -9331,6 +9408,7 @@
"dev": true,
"inBundle": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -9768,6 +9846,7 @@
"integrity": "sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"playwright-core": "1.56.0"
},
@@ -9946,6 +10025,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -9987,6 +10067,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -10397,6 +10478,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz",
"integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -10587,6 +10669,7 @@
"integrity": "sha512-4+U7gF9hMpGilQmdVJwQaVZZEkD7XwC4ZDmBa51mobaPYelELEMoMfNM2hLyvB2x12gk1IJui1DnwOE4t+MXhw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@storybook/global": "^5.0.0",
"@testing-library/jest-dom": "^6.6.3",
@@ -11119,6 +11202,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -11399,6 +11483,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz",
"integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -11533,6 +11618,7 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -11792,6 +11878,7 @@
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/package.json b/package.json
index 99a8ce2..d61b83b 100644
--- a/package.json
+++ b/package.json
@@ -8,12 +8,13 @@
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
- "storybook": "storybook dev -p 6006",
+ "storybook": "storybook dev -p 6006 --no-open",
"build-storybook": "storybook build"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^7.1.0",
"@radix-ui/react-accordion": "^1.2.12",
+ "@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.3",
diff --git a/src/configuration/types/sections/video-section.ts b/src/configuration/types/sections/video-section.ts
new file mode 100644
index 0000000..99fcd65
--- /dev/null
+++ b/src/configuration/types/sections/video-section.ts
@@ -0,0 +1,15 @@
+import type { RepeatableSection } from './repeatable-section';
+
+export const VIDEO_SECTION_TYPE = 'video' as const;
+
+export interface VideoItem {
+ src: string;
+ ratio?: number;
+}
+
+export interface VideoSection extends RepeatableSection {
+ type?: typeof VIDEO_SECTION_TYPE;
+ title?: string;
+ subtitle?: string;
+ video: VideoItem;
+}
diff --git a/src/sections.tsx b/src/sections.tsx
index 9dadbba..0addab2 100644
--- a/src/sections.tsx
+++ b/src/sections.tsx
@@ -19,11 +19,16 @@ import {
TERMINAL_SECTION_TYPE,
type TerminalSection,
} from './configuration/types/sections/terminal-section';
+import {
+ VIDEO_SECTION_TYPE,
+ type VideoSection,
+} from './configuration/types/sections/video-section';
import Cards from './ui/sections/cards';
import Faq from './ui/sections/faq';
import Hero from './ui/sections/hero';
import Steps from './ui/sections/steps';
import Terminal from './ui/sections/terminal';
+import Video from './ui/sections/video';
export default function Sections({ sections }: Record
) {
return (
@@ -97,6 +102,19 @@ export default function Sections({ sections }: Record) {
);
}
+ case VIDEO_SECTION_TYPE: {
+ const video = section as VideoSection;
+ return (
+
+ );
+ }
+
default:
return null;
}
diff --git a/src/stories/sections/Video.stories.tsx b/src/stories/sections/Video.stories.tsx
new file mode 100644
index 0000000..cf2efc3
--- /dev/null
+++ b/src/stories/sections/Video.stories.tsx
@@ -0,0 +1,53 @@
+import type { VideoSection } from '@/configuration/types/sections/video-section';
+import Video from '@/ui/sections/video';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+const meta = {
+ title: 'Sections/Video',
+ component: Video,
+ parameters: {
+ layout: 'fullscreen',
+ },
+ tags: ['autodocs'],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+const data: VideoSection = {
+ id: 'video',
+ title: 'Video File',
+ subtitle: 'Local video file playback.',
+ video: {
+ src: 'https://www.w3schools.com/html/mov_bbb.mp4',
+ },
+};
+
+export const Default: Story = {
+ args: {
+ ...data,
+ },
+};
+
+export const YouTubeVideo: Story = {
+ args: {
+ id: 'video-youtube',
+ title: 'YouTube Video',
+ subtitle: 'YouTube video playback.',
+ video: {
+ src: 'https://www.youtube.com/embed/xz5jZ1zwFC4',
+ },
+ },
+};
+
+export const CustomRatio: Story = {
+ args: {
+ id: 'video-custom-ratio',
+ title: 'Custom Ratio Video',
+ subtitle: 'Video with custom aspect ratio of 4:3.',
+ video: {
+ src: 'https://www.w3schools.com/html/mov_bbb.mp4',
+ ratio: 4 / 3,
+ },
+ },
+};
diff --git a/src/ui/components/aspect-ratio.tsx b/src/ui/components/aspect-ratio.tsx
new file mode 100644
index 0000000..9b491fb
--- /dev/null
+++ b/src/ui/components/aspect-ratio.tsx
@@ -0,0 +1,9 @@
+import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
+
+function AspectRatio({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+export { AspectRatio }
diff --git a/src/ui/components/section-title.tsx b/src/ui/components/section-title.tsx
deleted file mode 100644
index 02421f0..0000000
--- a/src/ui/components/section-title.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { Markdown } from './markdown';
-
-interface SectionTitleProps {
- title?: string;
- subtitle?: string;
-}
-
-export function SectionTitle({ title, subtitle }: SectionTitleProps) {
- return title || subtitle ? (
-
- {title && (
-
- )}
- {subtitle && (
-
- )}
-
- ) : null;
-}
diff --git a/src/ui/components/section.tsx b/src/ui/components/section.tsx
new file mode 100644
index 0000000..fa6b371
--- /dev/null
+++ b/src/ui/components/section.tsx
@@ -0,0 +1,45 @@
+import { cn } from '@/lib/utils';
+
+import { Markdown } from './markdown';
+
+export function Section({
+ id,
+ title,
+ subtitle,
+ className,
+ children,
+}: {
+ id: string;
+ title?: string;
+ subtitle?: string;
+ className?: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {(title || subtitle) && (
+
+ {title && (
+
+ )}
+ {subtitle && (
+
+ )}
+
+ )}
+ {children}
+
+ );
+}
diff --git a/src/ui/sections/cards.tsx b/src/ui/sections/cards.tsx
index b232329..18f3b7a 100644
--- a/src/ui/sections/cards.tsx
+++ b/src/ui/sections/cards.tsx
@@ -1,14 +1,10 @@
import type { CardsSection } from '@/configuration/types/sections/cards-section';
import { Card } from '@/ui/components/card';
-import { SectionTitle } from '@/ui/components/section-title';
+import { Section } from '@/ui/components/section';
export default function Cards({ id, title, subtitle, cards }: CardsSection) {
return (
-
-
+
{cards?.map((card, index) => (
))}
-
+
);
}
diff --git a/src/ui/sections/faq.tsx b/src/ui/sections/faq.tsx
index 55db493..3f6c669 100644
--- a/src/ui/sections/faq.tsx
+++ b/src/ui/sections/faq.tsx
@@ -6,14 +6,13 @@ import {
AccordionTrigger,
} from '@/ui/components/accordion';
import { Markdown } from '@/ui/components/markdown';
-import { SectionTitle } from '@/ui/components/section-title';
+import { Section } from '@/ui/components/section';
-export default function Faq(props: FaqSection) {
+export default function Faq({ id, title, subtitle, items }: FaqSection) {
return (
-
-
+
- {props.items?.map((item, index) => (
+ {items?.map((item, index) => (
@@ -24,6 +23,6 @@ export default function Faq(props: FaqSection) {
))}
-
+
);
}
diff --git a/src/ui/sections/hero.tsx b/src/ui/sections/hero.tsx
index 075dce4..3d822df 100644
--- a/src/ui/sections/hero.tsx
+++ b/src/ui/sections/hero.tsx
@@ -2,6 +2,7 @@ import type { HeroSection } from '@/configuration/types/sections/hero-section';
import { Button } from '@/ui/components/button';
import { Image } from '@/ui/components/image';
import { Markdown } from '@/ui/components/markdown';
+import { Section } from '@/ui/components/section';
export default function Hero({
id,
@@ -12,10 +13,7 @@ export default function Hero({
secondary_button: secondaryButton,
}: HeroSection) {
return (
-
+
{image && image.href && (
@@ -68,6 +66,6 @@ export default function Hero({
)}
)}
-
+
);
}
diff --git a/src/ui/sections/steps.tsx b/src/ui/sections/steps.tsx
index 8609473..4e04fd4 100644
--- a/src/ui/sections/steps.tsx
+++ b/src/ui/sections/steps.tsx
@@ -1,15 +1,11 @@
import type { StepsSection } from '@/configuration/types/sections/steps-section';
import { cn } from '@/lib/utils';
import { Markdown } from '@/ui/components/markdown';
-import { SectionTitle } from '@/ui/components/section-title';
+import { Section } from '@/ui/components/section';
export default function Steps({ id, title, subtitle, steps }: StepsSection) {
return (
-
-
+
{steps?.map((step, index) => (
))}
-
+
);
}
diff --git a/src/ui/sections/terminal.tsx b/src/ui/sections/terminal.tsx
index 55831bf..1e86cd3 100644
--- a/src/ui/sections/terminal.tsx
+++ b/src/ui/sections/terminal.tsx
@@ -1,16 +1,17 @@
import type { TerminalSection } from '@/configuration/types/sections/terminal-section';
import { Markdown } from '@/ui/components/markdown';
-import { SectionTitle } from '@/ui/components/section-title';
+import { Section } from '@/ui/components/section';
-export default function Terminal({ id, title, subtitle, window }: TerminalSection) {
+export default function Terminal({
+ id,
+ title,
+ subtitle,
+ window,
+}: TerminalSection) {
const { title: window_title, code, prompt = false } = window;
return (
-
-
+
@@ -34,6 +35,6 @@ export default function Terminal({ id, title, subtitle, window }: TerminalSectio
-
+
);
}
diff --git a/src/ui/sections/video.tsx b/src/ui/sections/video.tsx
new file mode 100644
index 0000000..7d3e2c8
--- /dev/null
+++ b/src/ui/sections/video.tsx
@@ -0,0 +1,100 @@
+import { type VideoSection } from '@/configuration/types/sections/video-section';
+import { AspectRatio } from '@/ui/components/aspect-ratio';
+import { Section } from '@/ui/components/section';
+
+function getYouTubeEmbedUrl(src: string): string | null {
+ try {
+ const url = new URL(src, location.href);
+ const hostname = url.hostname.toLowerCase();
+
+ if (hostname === 'youtu.be') {
+ const id = url.pathname.replace(/^\//, '');
+ if (!id) return null;
+ const params = url.searchParams.toString();
+ return `https://www.youtube.com/embed/${id}${params ? `?${params}` : ''}`;
+ }
+
+ if (hostname.endsWith('youtube.com')) {
+ if (url.pathname === '/watch') {
+ const v = url.searchParams.get('v');
+ if (!v) return null;
+ const params = new URLSearchParams(url.searchParams);
+ params.delete('v');
+ const paramStr = params.toString();
+ return `https://www.youtube.com/embed/${v}${paramStr ? `?${paramStr}` : ''}`;
+ }
+
+ if (url.pathname.startsWith('/embed/')) {
+ url.protocol = 'https:';
+ url.hostname = 'www.youtube.com';
+ return url.toString();
+ }
+ }
+ } catch {
+ // unexpected absolute URL — fallthrough to string checks below
+ }
+
+ if (src.includes('youtube.com') || src.includes('youtu.be')) {
+ if (src.includes('/embed/')) return src;
+ const match = src.match(/[?&]v=([^&]+)/);
+ if (match?.[1]) return `https://www.youtube.com/embed/${match[1]}`;
+ const short = src.match(/youtu\.be\/(.+)$/);
+ if (short?.[1]) return `https://www.youtube.com/embed/${short[1]}`;
+ }
+
+ return null;
+}
+
+const EXTENSION_MIME: Record
= {
+ mp4: 'video/mp4',
+ webm: 'video/webm',
+ ogv: 'video/ogg',
+ ogg: 'video/ogg',
+ mov: 'video/quicktime',
+};
+
+export default function Video({ id, title, subtitle, video }: VideoSection) {
+ const { src } = video;
+ const ratio = video.ratio ?? 16 / 9;
+
+ const embedUrl = getYouTubeEmbedUrl(src);
+ const isYoutube = Boolean(embedUrl);
+
+ let fileMime: string | undefined;
+ if (!isYoutube) {
+ const ext = src.split('?')[0].split('.').pop()?.toLowerCase();
+ fileMime = ext ? EXTENSION_MIME[ext] : undefined;
+ }
+
+ const iframeTitle = title ? `${title} — video` : 'Video player';
+
+ return (
+
+
+
+ {isYoutube && embedUrl && (
+
+ )}
+
+ {!isYoutube && (
+
+ )}
+
+
+
+ );
+}