Skip to content
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

Resources page #780

Merged
merged 11 commits into from
Nov 24, 2023
71 changes: 70 additions & 1 deletion docs/keystatic.config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export default config({
name: 'Keystatic Docs',
},
navigation: {
Pages: ['pages', 'blog', 'projects'],
Pages: ['pages', 'blog', 'projects', 'resources'],
Config: ['authors', 'navigation'],
Experimental: ['pagesWithMarkdocField'],
},
Expand Down Expand Up @@ -261,6 +261,75 @@ export default config({
},
}),

// ------------------------------
// Resources
// ------------------------------
resources: collection({
label: 'Resources',
path: 'src/content/resources/*',
slugField: 'title',
schema: {
title: fields.slug({ name: { label: 'Title' } }),
type: fields.conditional(
fields.select({
label: 'Resource type',
options: [
{ label: 'YouTube video', value: 'youtube-video' },
{ label: 'Article', value: 'article' },
],
defaultValue: 'youtube-video',
}),
{
none: fields.empty(),
emmatown marked this conversation as resolved.
Show resolved Hide resolved
'youtube-video': fields.object({
videoId: fields.text({
label: 'Video ID',
description: 'The ID of the video (not the URL!)',
validation: { length: { min: 1 } },
}),
thumbnail: fields.cloudImage({
label: 'Video thumbnail',
description: 'A 16/9 thumbnail image for the video.',
}),
kind: fields.select({
label: 'Video kind',
options: [
{ label: 'Talk', value: 'talk' },
{ label: 'Screencast', value: 'screencast' },
],
defaultValue: 'screencast',
}),
description: fields.text({
label: 'Video description',
multiline: true,
validation: { length: { min: 1 } },
}),
}),
article: fields.object({
url: fields.url({
label: 'Article URL',
validation: { isRequired: true },
}),
authorName: fields.text({
label: 'Author name',
validation: { length: { min: 1 } },
}),
description: fields.text({
label: 'Article description',
multiline: true,
}),
}),
}
),
sortIndex: fields.integer({
label: 'Sort index',
description:
'A number value to sort items (low to high) on the front end.',
defaultValue: 10,
}),
},
}),

// ------------------------------
// For testing purposes only
// ------------------------------
Expand Down
48 changes: 48 additions & 0 deletions docs/src/app/(public)/resources/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Main } from '../../../components/main';
import Footer from '../../../components/footer';

export const metadata = {
title: {
template: '%s - Resources | Keystatic',
default: 'Resources',
},
description:
'A collection of videos, talks, articles and other resources to help you learn Keystatic and dig deeper.',
openGraph: {
title: 'Resources',
description:
'A collection of videos, talks, articles and other resources to help you learn Keystatic and dig deeper.',
images: [
{
url: '/og?title=Resources',
},
],
siteName: 'Keystatic',
type: 'website',
url: 'https://keystatic.com/resources',
},
twitter: {
card: 'summary_large_image',
title: 'Resources',
description:
'A collection of videos, talks, articles and other resources to help you learn Keystatic and dig deeper.',
site: '@thekeystatic',
},
};

export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<div className="mx-auto min-h-screen w-full max-w-7xl px-6">
<Main className="flex gap-8">
<div className="flex-1">{children}</div>
</Main>
</div>
<Footer />
</>
);
}
213 changes: 213 additions & 0 deletions docs/src/app/(public)/resources/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import Image from 'next/image';
import Link from 'next/link';
import { notFound } from 'next/navigation';

import { ArrowRightIcon } from '../../../components/icons/arrow-right';
import { reader } from '../../../utils/reader';
import Button from '../../../components/button';
import { Entry } from '@keystatic/core/reader';
import keystaticConfig from '../../../../keystatic.config';

type ResourceEntry = Entry<
(typeof keystaticConfig)['collections']['resources']
>;

type VideoProps = {
title: ResourceEntry['title'];
} & Omit<
Extract<ResourceEntry['type'], { discriminant: 'youtube-video' }>['value'],
'kind'
>;

type ArticleProps = {
title: ResourceEntry['title'];
} & Omit<
Extract<ResourceEntry['type'], { discriminant: 'article' }>['value'],
'kind'
>;

export default async function Resources() {
const resources = await reader().collections.resources.all();
if (!resources) notFound();

const sortedVideos = resources
.filter(
resource =>
resource.entry.type.discriminant === 'youtube-video' &&
resource.entry.type.value.kind === 'screencast'
)
.sort((a, b) => {
return (a.entry.sortIndex as number) - (b.entry.sortIndex as number);
})
.map(resource => ({
title: resource.entry.title,
sortIndex: resource.entry.sortIndex,
...resource.entry.type.value,
})) as VideoProps[];

const sortedTalks = resources
.filter(
resource =>
resource.entry.type.discriminant === 'youtube-video' &&
resource.entry.type.value.kind === 'talk'
)
.sort((a, b) => {
return (a.entry.sortIndex as number) - (b.entry.sortIndex as number);
})
.map(resource => ({
title: resource.entry.title,
sortIndex: resource.entry.sortIndex,
...resource.entry.type.value,
})) as VideoProps[];

const sortedArticles = resources
.filter(resource => resource.entry.type.discriminant === 'article')
.sort((a, b) => {
return (a.entry.sortIndex as number) - (b.entry.sortIndex as number);
})
.map(resource => ({
title: resource.entry.title,
sortIndex: resource.entry.sortIndex,
...resource.entry.type.value,
})) as ArticleProps[];

return (
<div className="mt-24 pt-10">
<header className="mx-auto max-w-2xl text-center">
<h1 className="text-4xl font-medium md:text-5xl">Resources</h1>
<div className="mt-6 space-y-4 text-lg">
<p>
A collection of videos, talks, articles and other resources to help
you learn Keystatic and dig deeper.
</p>
</div>
</header>

<div className="mt-12 divide-y divide-slate-5">
<Section title="YouTube Videos">
<p>
The{' '}
<Link
href="https://youtube.com/@thinkmill"
className="underline hover:no-underline"
>
Thinkmill channel
</Link>{' '}
has a growing collection of content about Keystatic.
</p>
<ResourceGrid>
{sortedVideos.map(video => (
<Video
title={video.title}
videoId={video.videoId}
description={video.description}
thumbnail={video.thumbnail}
/>
))}
</ResourceGrid>
<Button
variant="regular"
impact="light"
className="mt-12 inline-flex items-center gap-2"
href="https://www.youtube.com/playlist?list=PLYyvXL46d-pzqwOKdofd5aKiqPTAN3ql6"
>
<span>Watch more videos</span>
<ArrowRightIcon />
</Button>
</Section>
<Section title="Talks">
<p>Recorded Keystatic talks from local meetups and conferences.</p>
<ResourceGrid>
{sortedTalks.map(video => (
<Video
videoId={video.videoId}
title={video.title}
description={video.description}
thumbnail={video.thumbnail}
/>
))}
</ResourceGrid>
</Section>
<Section title="Articles">
<ResourceGrid>
{sortedArticles.map(article => (
<li className="mb-4 mr-4">
<h3 className="text-xl font-medium">
<Link href={article.url} className="hover:underline">
{article.title}
</Link>
</h3>
<p className="mt-1 text-sm text-slate-10">
by {article.authorName}
</p>
{article.description && (
<p className="mt-4">{article.description}</p>
)}
</li>
))}
</ResourceGrid>
</Section>

<Section>
<div className="inline-flex flex-col gap-4 rounded-lg bg-slate-3 px-4 py-6 sm:flex-row">
<div className="flex h-6 items-center text-3xl">⏳</div>
<div className="flex flex-col gap-3">
<p className="text-md text-slate-12">
This page is a work in progress — more resources coming soon!
</p>
</div>
</div>
</Section>
</div>
</div>
);
}

function Video({ videoId, title, description, thumbnail }: VideoProps) {
const videoUrl = `https://www.youtube.com/watch?v=${videoId}`;
return (
<li>
<Link
href={videoUrl}
className="group relative block aspect-video w-full"
>
<Image
fill
src={thumbnail.src}
alt={thumbnail.alt || ''}
className="h-full-w-full absolute inset-0 rounded-lg shadow-md transition-shadow group-hover:shadow-sm"
/>
</Link>
<h3 className="mt-6 text-xl font-medium">
<Link href={videoUrl} className="hover:underline">
{title}
</Link>
</h3>
<p className="mt-2">{description}</p>
</li>
);
}

function ResourceGrid(props: React.ComponentProps<'ul'>) {
return (
<ul
className="mt-8 grid items-start gap-x-6 gap-y-10 md:grid-cols-2 lg:grid-cols-3"
{...props}
/>
);
}

type SectionProps = {
title?: string | React.ReactNode;
introText?: string;
children: React.ReactNode;
};

function Section({ title, children }: SectionProps) {
return (
<section className="py-16">
{title && <h2 className="mb-4 text-2xl font-medium">{title}</h2>}
{children}
</section>
);
}
22 changes: 22 additions & 0 deletions docs/src/app/(public)/showcase/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,28 @@ export const metadata = {
template: '%s - Showcase | Keystatic',
default: 'Showcase',
},
description:
'A collection of projects using Keystatic to manage parts of their codebase.',
openGraph: {
title: 'Showcase',
description:
'A collection of projects using Keystatic to manage parts of their codebase.',
images: [
{
url: '/og?title=Showcase',
},
],
siteName: 'Keystatic',
type: 'website',
url: 'https://keystatic.com/showcase',
},
twitter: {
card: 'summary_large_image',
title: 'Showcase',
description:
'A collection of projects using Keystatic to manage parts of their codebase.',
site: '@thekeystatic',
},
};

export default async function RootLayout({
Expand Down
5 changes: 5 additions & 0 deletions docs/src/content/navigation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ navGroups:
discriminant: page
value: quick-start
isNew: false
- label: Resources
link:
discriminant: url
value: /resources
isNew: true
- groupName: Integration guides
items:
- label: Astro
Expand Down
Loading