Initial commmit.
This commit is contained in:
18
src/app/(main)/contact-success/page.jsx
Normal file
18
src/app/(main)/contact-success/page.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Container } from '@/components/Container'
|
||||
|
||||
export default async function Page({}) {
|
||||
return (
|
||||
<div className="pt-16 pb-12 sm:pb-4 lg:pt-12">
|
||||
<Container>
|
||||
<h1 className="text-2xl font-bold leading-7 text-slate-900">
|
||||
Contact Us
|
||||
</h1>
|
||||
<div className="divide-y divide-slate-100 sm:mt-4 lg:mt-8 lg:border-t lg:border-slate-100">
|
||||
<p>
|
||||
Message sent successfully! Thank you!
|
||||
</p>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
src/app/(main)/contact-us/head.jsx
Normal file
10
src/app/(main)/contact-us/head.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import StandardHead from '@/components/StandardHead';
|
||||
|
||||
export default async function Head({ params }) {
|
||||
return (
|
||||
<StandardHead
|
||||
title="Contact Us - The React Show"
|
||||
description="Details on how to contact The React Show."
|
||||
/>
|
||||
);
|
||||
};
|
||||
155
src/app/(main)/contact-us/page.jsx
Normal file
155
src/app/(main)/contact-us/page.jsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import nodemailer from 'nodemailer';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
|
||||
import { Container } from '@/components/Container'
|
||||
|
||||
export default async function Page({ searchParams }) {
|
||||
const firstName = searchParams['first-name'];
|
||||
const lastName = searchParams['last-name'];
|
||||
const email = searchParams['email'];
|
||||
const message = searchParams['message'];
|
||||
const submitted = firstName || lastName || email || message;
|
||||
const valid = submitted && firstName && lastName && email && message;
|
||||
let emailSentSuccessfully = false;
|
||||
|
||||
if (valid) {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.CONTACT_HOST,
|
||||
port: 587,
|
||||
secure: false, // true for 465, false for other ports
|
||||
auth: {
|
||||
user: process.env.CONTACT_USER,
|
||||
pass: process.env.CONTACT_PASSWORD,
|
||||
},
|
||||
});
|
||||
|
||||
// send mail with defined transport object
|
||||
await transporter.sendMail({
|
||||
from: `"${firstName} ${lastName}" <${process.env.CONTACT_FROM_ADDRESS}>`,
|
||||
replyTo: `"${firstName} ${lastName}" <${email}>`,
|
||||
to: process.env.CONTACT_TO_ADDRESS,
|
||||
subject: "The React Show - Form Submission",
|
||||
text: message,
|
||||
html: sanitizeHtml(message, {
|
||||
allowedTags: [],
|
||||
allowedAttributes: {}
|
||||
}),
|
||||
});
|
||||
redirect('/contact-success')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-16 pb-12 sm:pb-4 lg:pt-12">
|
||||
<Container>
|
||||
<h1 className="text-2xl font-bold leading-7 text-slate-900">
|
||||
Contact Us
|
||||
</h1>
|
||||
{valid && !emailSentSuccessfully && (
|
||||
<div className="divide-y divide-slate-100 sm:mt-4 lg:mt-8 lg:border-t lg:border-slate-100">
|
||||
<p>
|
||||
Unable to send message. Please go back and reload the page and try again or try again later. Sorry!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!valid && (
|
||||
<div className="divide-y divide-slate-100 sm:mt-4 lg:mt-8 lg:border-t lg:border-slate-100">
|
||||
<p className="mt-4 italic">
|
||||
Like the show? Want to hear us talk about something specific? Or just want to say hi? We’d love to hear from you!
|
||||
</p>
|
||||
<form className="space-y-8">
|
||||
<div className="space-y-8">
|
||||
<div className="pt-8">
|
||||
<div className="mt-6 grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="first-name" className="block text-sm font-medium text-gray-700">
|
||||
First name
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
name="first-name"
|
||||
id="first-name"
|
||||
autoComplete="given-name"
|
||||
defaultValue={firstName}
|
||||
required
|
||||
title="First/Given Name (required)"
|
||||
className="block w-full rounded-md shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="last-name" className="block text-sm font-medium text-gray-700">
|
||||
Last name
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
name="last-name"
|
||||
id="last-name"
|
||||
autoComplete="family-name"
|
||||
defaultValue={lastName}
|
||||
required
|
||||
title="Last/Family Name (required)"
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-4">
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email address
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
defaultValue={email}
|
||||
title="Email Address (required)"
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-6">
|
||||
<label htmlFor="message" className="block text-sm font-medium text-gray-700">
|
||||
Your Message
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
rows={3}
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||
defaultValue={message}
|
||||
required
|
||||
title="Your message to us! (required)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-5">
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="ml-3 inline-flex justify-center rounded-md border border-transparent bg-indigo-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
src/app/(main)/head.jsx
Normal file
10
src/app/(main)/head.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import StandardHead from '@/components/StandardHead';
|
||||
|
||||
export default function Head({ params }) {
|
||||
return (
|
||||
<StandardHead
|
||||
title="The React Show - Weekly React Focused Podcast"
|
||||
description="Weekly podcast focused on React, programming, and software engineering."
|
||||
/>
|
||||
);
|
||||
}
|
||||
280
src/app/(main)/layout.jsx
Normal file
280
src/app/(main)/layout.jsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import { Fragment, useId } from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import NavBar from '@/components/NavBar'
|
||||
import posterImage from '@/images/poster.png'
|
||||
|
||||
function randomBetween(min, max, seed = 1) {
|
||||
return () => {
|
||||
let rand = Math.sin(seed++) * 10000
|
||||
rand = rand - Math.floor(rand)
|
||||
return Math.floor(rand * (max - min + 1) + min)
|
||||
}
|
||||
}
|
||||
|
||||
function Waveform(props) {
|
||||
let id = useId()
|
||||
let bars = {
|
||||
total: 100,
|
||||
width: 2,
|
||||
gap: 2,
|
||||
minHeight: 40,
|
||||
maxHeight: 100,
|
||||
}
|
||||
|
||||
let barHeights = Array.from(
|
||||
{ length: bars.total },
|
||||
randomBetween(bars.minHeight, bars.maxHeight)
|
||||
)
|
||||
|
||||
return (
|
||||
<svg aria-hidden="true" {...props}>
|
||||
<defs>
|
||||
<linearGradient id={`${id}-fade`} x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="40%" stopColor="white" />
|
||||
<stop offset="100%" stopColor="black" />
|
||||
</linearGradient>
|
||||
<linearGradient id={`${id}-gradient`}>
|
||||
<stop offset="0%" stopColor="#4989E8" />
|
||||
<stop offset="50%" stopColor="#6159DA" />
|
||||
<stop offset="100%" stopColor="#FF54AD" />
|
||||
</linearGradient>
|
||||
<mask id={`${id}-mask`}>
|
||||
<rect width="100%" height="100%" fill={`url(#${id}-pattern)`} />
|
||||
</mask>
|
||||
<pattern
|
||||
id={`${id}-pattern`}
|
||||
width={bars.total * bars.width + bars.total * bars.gap}
|
||||
height="100%"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
{Array.from({ length: bars.total }, (_, index) => (
|
||||
<rect
|
||||
key={index}
|
||||
width={bars.width}
|
||||
height={`${barHeights[index]}%`}
|
||||
x={bars.gap * (index + 1) + bars.width * index}
|
||||
fill={`url(#${id}-fade)`}
|
||||
/>
|
||||
))}
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
fill={`url(#${id}-gradient)`}
|
||||
mask={`url(#${id}-mask)`}
|
||||
opacity="0.25"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function TinyWaveFormIcon({ colors = [], ...props }) {
|
||||
return (
|
||||
<svg aria-hidden="true" viewBox="0 0 10 10" {...props}>
|
||||
<path
|
||||
d="M0 5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1V5Z"
|
||||
className={colors[0]}
|
||||
/>
|
||||
<path
|
||||
d="M6 1a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V1Z"
|
||||
className={colors[1]}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SpotifyIcon(props) {
|
||||
return (
|
||||
<svg aria-hidden="true" viewBox="0 0 32 32" {...props}>
|
||||
<path d="M15.8 3a12.8 12.8 0 1 0 0 25.6 12.8 12.8 0 0 0 0-25.6Zm5.87 18.461a.8.8 0 0 1-1.097.266c-3.006-1.837-6.787-2.252-11.244-1.234a.796.796 0 1 1-.355-1.555c4.875-1.115 9.058-.635 12.432 1.427a.8.8 0 0 1 .265 1.096Zm1.565-3.485a.999.999 0 0 1-1.371.33c-3.44-2.116-8.685-2.728-12.755-1.493a1 1 0 0 1-.58-1.91c4.65-1.41 10.428-.726 14.378 1.7a1 1 0 0 1 .33 1.375l-.002-.002Zm.137-3.629c-4.127-2.45-10.933-2.675-14.871-1.478a1.196 1.196 0 1 1-.695-2.291c4.52-1.374 12.037-1.107 16.785 1.711a1.197 1.197 0 1 1-1.221 2.06" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ApplePodcastIcon(props) {
|
||||
return (
|
||||
<svg aria-hidden="true" viewBox="0 0 32 32" {...props}>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M27.528 24.8c-.232.592-.768 1.424-1.536 2.016-.44.336-.968.664-1.688.88-.768.232-1.72.304-2.904.304H10.6c-1.184 0-2.128-.08-2.904-.304a4.99 4.99 0 0 1-1.688-.88c-.76-.584-1.304-1.424-1.536-2.016C4.008 23.608 4 22.256 4 21.4V10.6c0-.856.008-2.208.472-3.4.232-.592.768-1.424 1.536-2.016.44-.336.968-.664 1.688-.88C8.472 4.08 9.416 4 10.6 4h10.8c1.184 0 2.128.08 2.904.304a4.99 4.99 0 0 1 1.688.88c.76.584 1.304 1.424 1.536 2.016C28 8.392 28 9.752 28 10.6v10.8c0 .856-.008 2.208-.472 3.4Zm-9.471-6.312a1.069 1.069 0 0 0-.32-.688c-.36-.376-.992-.624-1.736-.624-.745 0-1.377.24-1.737.624-.183.2-.287.4-.32.688-.063.558-.024 1.036.04 1.807v.009c.065.736.184 1.72.336 2.712.112.712.2 1.096.28 1.368.136.448.625.832 1.4.832.776 0 1.273-.392 1.4-.832.08-.272.169-.656.28-1.368.152-1 .273-1.976.337-2.712.072-.776.104-1.256.04-1.816ZM16 16.375c1.088 0 1.968-.88 1.968-1.967 0-1.08-.88-1.968-1.968-1.968s-1.968.88-1.968 1.968.88 1.967 1.968 1.967Zm-.024-9.719c-4.592.016-8.352 3.744-8.416 8.336-.048 3.72 2.328 6.904 5.648 8.072.08.032.16-.04.152-.12a35.046 35.046 0 0 0-.041-.288c-.029-.192-.057-.384-.079-.576a.317.317 0 0 0-.168-.232 7.365 7.365 0 0 1-4.424-6.824c.04-4 3.304-7.256 7.296-7.288 4.088-.032 7.424 3.28 7.424 7.36 0 3.016-1.824 5.608-4.424 6.752a.272.272 0 0 0-.168.232l-.12.864c-.016.088.072.152.152.12a8.448 8.448 0 0 0 5.648-7.968c-.016-4.656-3.816-8.448-8.48-8.44Zm-5.624 8.376c.04-2.992 2.44-5.464 5.432-5.576 3.216-.128 5.88 2.456 5.872 5.64a5.661 5.661 0 0 1-2.472 4.672c-.08.056-.184-.008-.176-.096.016-.344.024-.648.008-.96 0-.104.04-.2.112-.272a4.584 4.584 0 0 0 1.448-3.336 4.574 4.574 0 0 0-4.752-4.568 4.585 4.585 0 0 0-4.392 4.448 4.574 4.574 0 0 0 1.448 3.456c.08.072.12.168.112.272-.016.32-.016.624.008.968 0 .088-.104.144-.176.096a5.65 5.65 0 0 1-2.472-4.744Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function OvercastIcon(props) {
|
||||
return (
|
||||
<svg aria-hidden="true" viewBox="0 0 32 32" {...props}>
|
||||
<path d="M16 28.8A12.77 12.77 0 0 1 3.2 16 12.77 12.77 0 0 1 16 3.2 12.77 12.77 0 0 1 28.8 16 12.77 12.77 0 0 1 16 28.8Zm0-5.067.96-.96-.96-3.68-.96 3.68.96.96Zm-1.226-.054-.48 1.814 1.12-1.12-.64-.694Zm2.453 0-.64.64 1.12 1.12-.48-1.76Zm.907 3.307L16 24.853l-2.133 2.133c.693.107 1.387.213 2.133.213.747 0 1.44-.053 2.134-.213ZM16 4.799C9.814 4.8 4.8 9.813 4.8 16c0 4.907 3.147 9.067 7.52 10.56l2.4-8.906c-.533-.374-.853-1.014-.853-1.707A2.14 2.14 0 0 1 16 13.813a2.14 2.14 0 0 1 2.134 2.133c0 .693-.32 1.28-.854 1.707l2.4 8.906A11.145 11.145 0 0 0 27.2 16c0-6.186-5.013-11.2-11.2-11.2Zm7.307 16.747c-.267.32-.747.427-1.12.16-.373-.267-.427-.747-.16-1.067 0 0 1.44-1.92 1.44-4.64 0-2.72-1.44-4.64-1.44-4.64-.267-.32-.213-.8.16-1.066.373-.267.853-.16 1.12.16.107.106 1.76 2.293 1.76 5.546 0 3.254-1.653 5.44-1.76 5.547Zm-3.893-2.08c-.32-.32-.267-.907.053-1.227 0 0 .8-.853.8-2.24 0-1.386-.8-2.186-.8-2.24-.32-.32-.32-.853-.053-1.226.32-.374.8-.374 1.12-.054.053.054 1.333 1.387 1.333 3.52 0 2.134-1.28 3.467-1.333 3.52-.32.32-.8.267-1.12-.053Zm-6.827 0c-.32.32-.8.373-1.12.053-.053-.106-1.333-1.386-1.333-3.52 0-2.133 1.28-3.413 1.333-3.52.32-.32.853-.32 1.12.054.32.32.267.906-.053 1.226 0 .054-.8.854-.8 2.24 0 1.387.8 2.24.8 2.24.32.32.373.854.053 1.227Zm-2.773 2.24c-.374.267-.854.16-1.12-.16-.107-.107-1.76-2.293-1.76-5.547 0-3.253 1.653-5.44 1.76-5.546.266-.32.746-.427 1.12-.16.373.266.426.746.16 1.066 0 0-1.44 1.92-1.44 4.64 0 2.72 1.44 4.64 1.44 4.64.266.32.16.8-.16 1.067Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function RSSIcon(props) {
|
||||
return (
|
||||
<svg aria-hidden="true" viewBox="0 0 32 32" {...props}>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M8.5 4h15A4.5 4.5 0 0 1 28 8.5v15a4.5 4.5 0 0 1-4.5 4.5h-15A4.5 4.5 0 0 1 4 23.5v-15A4.5 4.5 0 0 1 8.5 4ZM13 22a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm-6-6a9 9 0 0 1 9 9h3A12 12 0 0 0 7 13v3Zm5.74-4.858A15 15 0 0 0 7 10V7a18 18 0 0 1 18 18h-3a15 15 0 0 0-9.26-13.858Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function PersonIcon(props) {
|
||||
return (
|
||||
<svg aria-hidden="true" viewBox="0 0 11 12" {...props}>
|
||||
<path d="M5.019 5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Zm3.29 7c1.175 0 2.12-1.046 1.567-2.083A5.5 5.5 0 0 0 5.019 7 5.5 5.5 0 0 0 .162 9.917C-.39 10.954.554 12 1.73 12h6.578Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function AboutSection(props) {
|
||||
/* let [isExpanded, setIsExpanded] = useState(false) */
|
||||
const isExpanded = false
|
||||
const setIsExpanded = {}
|
||||
/* onClick={() => setIsExpanded(true)} */
|
||||
return (
|
||||
<section {...props}>
|
||||
<h2 className="flex items-center font-mono text-sm font-medium leading-7 text-slate-900">
|
||||
<TinyWaveFormIcon
|
||||
colors={['fill-violet-300', 'fill-pink-300']}
|
||||
className="h-2.5 w-2.5"
|
||||
/>
|
||||
<span className="ml-2.5">About</span>
|
||||
</h2>
|
||||
<p
|
||||
className="mt-2 text-base leading-7 text-slate-700"
|
||||
>
|
||||
In this show, Thomas digs deep to understand React and how best to utilize it while discussing real world experiences with: React, programming, and software engineering. Tune in every Friday (usually) to hear the latest in the React community.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Layout({ children }) {
|
||||
let hosts = ['Thomas Hintz']
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="bg-slate-50 lg:fixed lg:inset-y-0 lg:left-0 lg:flex lg:w-112 lg:items-start lg:overflow-y-auto xl:w-120">
|
||||
<div className="hidden lg:sticky lg:top-0 lg:flex lg:w-16 lg:flex-none lg:items-center lg:whitespace-nowrap lg:py-12 lg:text-sm lg:leading-7 lg:[writing-mode:vertical-rl]">
|
||||
<span className="font-mono text-slate-500">Hosted by</span>
|
||||
<span className="mt-6 flex gap-6 font-bold text-slate-900">
|
||||
{hosts.map((host, hostIndex) => (
|
||||
<Fragment key={host}>
|
||||
{hostIndex !== 0 && (
|
||||
<span aria-hidden="true" className="text-slate-400">
|
||||
/
|
||||
</span>
|
||||
)}
|
||||
{host}
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative z-10 mx-auto pb-4 pt-10 md:max-w-2xl lg:min-h-full lg:flex-auto lg:border-x lg:border-slate-200 lg:py-12">
|
||||
<div className="px-4 sm:px-6 md:px-4 lg:px-8 xl:px-12 mb-10">
|
||||
<Link
|
||||
href="/"
|
||||
className="relative mx-auto block w-48 overflow-hidden rounded-lg bg-slate-200 shadow-xl shadow-slate-200 sm:w-64 sm:rounded-xl lg:w-auto lg:rounded-2xl"
|
||||
aria-label="Homepage"
|
||||
>
|
||||
<Image
|
||||
className="w-full"
|
||||
src={posterImage}
|
||||
alt=""
|
||||
sizes="(min-width: 1024px) 20rem, (min-width: 640px) 16rem, 12rem"
|
||||
priority
|
||||
/>
|
||||
<div className="absolute inset-0 rounded-lg ring-1 ring-inset ring-black/10 sm:rounded-xl lg:rounded-2xl" />
|
||||
</Link>
|
||||
</div>
|
||||
<NavBar />
|
||||
<div className="mt-6 text-center lg:mt-12 lg:text-left px-4 sm:px-6 md:px-4 lg:px-8 xl:px-12">
|
||||
<p className="text-xl font-bold text-slate-900">
|
||||
<Link href="/">The React Show</Link>
|
||||
</p>
|
||||
<p className="mt-3 text-lg font-medium leading-8 text-slate-700">
|
||||
A podcast focused on React, programming in general, and the intersection of programming and the rest of the world. Come join us on this journey.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-4 sm:px-6 md:px-4 lg:px-8 xl:px-12">
|
||||
<AboutSection className="mt-12 hidden lg:block" />
|
||||
<section className="mt-10 lg:mt-12">
|
||||
<h2 className="sr-only flex items-center font-mono text-sm font-medium leading-7 text-slate-900 lg:not-sr-only">
|
||||
<TinyWaveFormIcon
|
||||
colors={['fill-indigo-300', 'fill-blue-300']}
|
||||
className="h-2.5 w-2.5"
|
||||
/>
|
||||
<span className="ml-2.5">Listen</span>
|
||||
</h2>
|
||||
<div className="h-px bg-gradient-to-r from-slate-200/0 via-slate-200 to-slate-200/0 lg:hidden" />
|
||||
<ul
|
||||
role="list"
|
||||
className="mt-4 flex justify-center gap-10 text-base font-medium leading-7 text-slate-700 sm:gap-8 lg:flex-col lg:gap-4"
|
||||
>
|
||||
{[
|
||||
['Spotify', SpotifyIcon, 'https://open.spotify.com/show/7tqGIm6Y1g5idqU87TcpJv'],
|
||||
['Apple Podcast', ApplePodcastIcon, 'https://podcasts.apple.com/podcast/id1566939019'],
|
||||
['Overcast', OvercastIcon, 'https://overcast.fm/itunes1566939019'],
|
||||
['RSS Feed', RSSIcon, 'https://feeds.buzzsprout.com/1764837.rss'],
|
||||
].map(([label, Icon, url]) => (
|
||||
<li key={label} className="flex">
|
||||
<Link
|
||||
href={url}
|
||||
className="group flex items-center"
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon className="h-8 w-8 fill-slate-400 group-hover:fill-slate-600" />
|
||||
<span className="hidden sm:ml-3 sm:block">{label}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="border-t border-slate-200 lg:relative lg:mb-28 lg:ml-112 lg:border-t-0 xl:ml-120">
|
||||
<Waveform className="absolute left-0 top-0 h-20 w-full" />
|
||||
<div className="relative">{children}</div>
|
||||
</main>
|
||||
<footer className="border-t border-slate-200 bg-slate-50 py-10 pb-40 sm:py-16 sm:pb-32 lg:hidden">
|
||||
<div className="mx-auto px-4 sm:px-6 md:max-w-2xl md:px-4">
|
||||
<AboutSection />
|
||||
<h2 className="mt-8 flex items-center font-mono text-sm font-medium leading-7 text-slate-900">
|
||||
<PersonIcon className="h-3 w-auto fill-slate-300" />
|
||||
<span className="ml-2.5">Hosted by</span>
|
||||
</h2>
|
||||
<div className="mt-2 flex gap-6 text-sm font-bold leading-7 text-slate-900">
|
||||
{hosts.map((host, hostIndex) => (
|
||||
<Fragment key={host}>
|
||||
{hostIndex !== 0 && (
|
||||
<span aria-hidden="true" className="text-slate-400">
|
||||
/
|
||||
</span>
|
||||
)}
|
||||
{host}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MainLayout({children}) {
|
||||
return (
|
||||
<Layout>
|
||||
{children}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
8
src/app/(main)/loading.jsx
Normal file
8
src/app/(main)/loading.jsx
Normal file
@@ -0,0 +1,8 @@
|
||||
export default function Loading() {
|
||||
// You can add any UI inside Loading, including a Skeleton.
|
||||
return (
|
||||
<>
|
||||
<h1>Loading...</h1>
|
||||
</>
|
||||
)
|
||||
}
|
||||
245
src/app/(main)/page.jsx
Normal file
245
src/app/(main)/page.jsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import Link from 'next/link'
|
||||
|
||||
import { ArrowLongLeftIcon, ArrowLongRightIcon } from '@heroicons/react/20/solid'
|
||||
|
||||
import { Container } from '@/components/Container'
|
||||
import { FormattedDate } from '@/components/FormattedDate'
|
||||
|
||||
import { getEpisodes } from '@/data/episodes'
|
||||
|
||||
import Player from '@/app/Player'
|
||||
|
||||
function EpisodeEntry({ episode }) {
|
||||
let date = new Date(episode.published)
|
||||
|
||||
return (
|
||||
<article
|
||||
aria-labelledby={`episode-${episode.id}-title`}
|
||||
className="py-10 sm:py-12"
|
||||
>
|
||||
<Container>
|
||||
<div className="flex flex-col items-start">
|
||||
<h2
|
||||
id={`episode-${episode.id}-title`}
|
||||
className="mt-2 text-lg font-bold text-slate-900"
|
||||
>
|
||||
<Link href={`/podcast/${episode?.slug}`}>{episode.title}</Link>
|
||||
</h2>
|
||||
<FormattedDate
|
||||
date={date}
|
||||
className="order-first font-mono text-sm leading-7 text-slate-500"
|
||||
/>
|
||||
<p className="mt-1 text-base leading-7 text-slate-700">
|
||||
{episode.description}
|
||||
</p>
|
||||
<div className="mt-4 flex items-center gap-4">
|
||||
<Player episode={episode}>
|
||||
<span className="ml-3" aria-hidden="true">
|
||||
Listen
|
||||
</span>
|
||||
</Player>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="text-sm font-bold text-slate-400"
|
||||
>
|
||||
/
|
||||
</span>
|
||||
<Link
|
||||
href={`/podcast/${episode.slug}`}
|
||||
className="flex items-center text-sm font-bold leading-6 text-pink-500 hover:text-pink-700 active:text-pink-900"
|
||||
aria-label={`Show notes for episode ${episode.title}`}
|
||||
>
|
||||
Show notes
|
||||
</Link>
|
||||
{episode?.transcript &&
|
||||
(
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="text-sm font-bold text-slate-400"
|
||||
>
|
||||
/
|
||||
</span>
|
||||
<Link
|
||||
href={`/podcast/${episode.slug}#transcript`}
|
||||
className="flex items-center text-sm font-bold leading-6 text-pink-500 hover:text-pink-700 active:text-pink-900"
|
||||
aria-label={`Transcript for episode ${episode.title}`}
|
||||
>
|
||||
Transcript
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function pageClasses(page, i) {
|
||||
return `inline-flex items-center border-t-2 border-transparent px-4 pt-4 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700 ${page === i ? 'border-indigo-500 text-indigo-600': ''}`
|
||||
};
|
||||
|
||||
async function Content({ page }) {
|
||||
const episodes = await getEpisodes()
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
const pages = Math.ceil(episodes.length / 10);
|
||||
return (
|
||||
<>
|
||||
<div className="divide-y divide-slate-100 sm:mt-4 lg:mt-8 lg:border-t lg:border-slate-100">
|
||||
{episodes.slice((page - 1) * 10, page * 10).map((episode) => (
|
||||
<EpisodeEntry key={episode.id} episode={episode} />
|
||||
))}
|
||||
</div>
|
||||
<Container>
|
||||
<nav className="flex items-center justify-between border-t border-gray-200 px-4 sm:px-0">
|
||||
<div className="-mt-px flex w-0 flex-1">
|
||||
<Link
|
||||
href={`/?page=${page - 1}`}
|
||||
className={`inline-flex items-center border-t-2 border-transparent pt-4 pr-1 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700 ${page <= 1 ? 'pointer-events-none' : ''}`}
|
||||
>
|
||||
<ArrowLongLeftIcon className="mr-3 h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||
Previous
|
||||
</Link>
|
||||
</div>
|
||||
<div className="hidden md:-mt-px md:flex">
|
||||
{page > 2 && (
|
||||
<Link
|
||||
href="/?page=1"
|
||||
className={pageClasses(page, 1)}
|
||||
>
|
||||
1
|
||||
</Link>
|
||||
)}
|
||||
{page > 2 && (
|
||||
<span className="inline-flex items-center border-t-2 border-transparent px-4 pt-4 text-sm font-medium text-gray-500">
|
||||
...
|
||||
</span>
|
||||
)}
|
||||
{page > 1 && (
|
||||
<Link
|
||||
href={`/?page=${page - 1}`}
|
||||
className={pageClasses(page - 1, page)}
|
||||
>
|
||||
{page - 1}
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href={`/?page=${page}`}
|
||||
className={pageClasses(page, page)}
|
||||
>
|
||||
{page}
|
||||
</Link>
|
||||
{page < pages && (
|
||||
<Link
|
||||
href={`/?page=${page + 1}`}
|
||||
className={pageClasses(page + 1, page)}
|
||||
>
|
||||
{page + 1}
|
||||
</Link>
|
||||
)}
|
||||
{(page + 1) < pages && (
|
||||
<span className="inline-flex items-center border-t-2 border-transparent px-4 pt-4 text-sm font-medium text-gray-500">
|
||||
...
|
||||
</span>
|
||||
)}
|
||||
{(page + 1) < pages && (
|
||||
<Link
|
||||
href={`/?page=${pages}`}
|
||||
className={pageClasses(page, pages)}
|
||||
>
|
||||
{pages}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="-mt-px flex w-0 flex-1 justify-end">
|
||||
<Link
|
||||
href={`/?page=${page + 1}`}
|
||||
className={`inline-flex items-center border-t-2 border-transparent pt-4 pl-1 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700 ${page >= pages ? 'pointer-events-none' : ''}`}
|
||||
>
|
||||
Next
|
||||
<ArrowLongRightIcon className="ml-3 h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Skeleton({ width, height, className, color }) {
|
||||
const w = width ? '' : 'w-full';
|
||||
const c = color ? color : 'bg-slate-200';
|
||||
return (
|
||||
<div className={`animate-pulse flex ${c} ${w} ${className}`} style={{ width, height }} />
|
||||
);
|
||||
};
|
||||
|
||||
function EpisodeEntryLoading() {
|
||||
return (
|
||||
<div
|
||||
className="py-10 sm:py-12"
|
||||
>
|
||||
<Container>
|
||||
<div className="flex flex-col items-start w-full">
|
||||
<div className="order-first font-mono text-sm leading-7 text-slate-500">
|
||||
<Skeleton height="1.75rem" width="140px" />
|
||||
</div>
|
||||
<h2
|
||||
className="mt-2 text-lg font-bold text-slate-900 w-full"
|
||||
>
|
||||
<Skeleton height="1.75rem" />
|
||||
</h2>
|
||||
<div className="mt-1 text-base leading-7 text-slate-700 w-full">
|
||||
<Skeleton height="1rem" className="mt-3" />
|
||||
<Skeleton height="1rem" className="mt-3" />
|
||||
<Skeleton height="1rem" className="mt-3" />
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-4">
|
||||
<Skeleton width="0.625rem" height="0.625rem" />
|
||||
<span className="ml-3">
|
||||
<Skeleton height="1.5rem" width="42.275px" />
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="text-sm font-bold text-slate-400"
|
||||
>
|
||||
/
|
||||
</span>
|
||||
<Skeleton height="1.5rem" width="80.175px" />
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Loading() {
|
||||
// You can add any UI inside Loading, including a Skeleton.
|
||||
return (
|
||||
<div className="divide-y divide-slate-100 sm:mt-4 lg:mt-8 lg:border-t lg:border-slate-100">
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7].map((i) => (
|
||||
<EpisodeEntryLoading key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function Home({ searchParams }) {
|
||||
return (
|
||||
<>
|
||||
<div className="pt-16 pb-12 sm:pb-4 lg:pt-12">
|
||||
<Container>
|
||||
<h1 className="text-2xl font-bold leading-7 text-slate-900">
|
||||
Episodes
|
||||
</h1>
|
||||
</Container>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Content page={searchParams?.page ? parseInt(searchParams?.page, 10) : 1} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
1
src/app/(main)/podcast/[slug]/chapters.json
Normal file
1
src/app/(main)/podcast/[slug]/chapters.json
Normal file
@@ -0,0 +1 @@
|
||||
{"version":"1.1.0","chapters":[{"startTime":2.0,"title":"Introduction to this episode."},{"startTime":181.0,"title":"Goal: build a weather app using React server components."},{"startTime":498.0,"title":"Data and data fetching is one of the biggest places where things could change. "},{"startTime":673.0,"title":"Server Side Rendering on the Server."},{"startTime":941.0,"title":"Fetch load and process data in a much more natural and efficient way with responsive clients."},{"startTime":1060.0,"title":"Adding client side interactivity."},{"startTime":1216.0,"title":"[Ad] Dive deep into the motivations and mechanics behind some very successful people.","url":"https://www.forwarddrinkingpodcast.com","img":"https://storage.buzzsprout.com/variants/jzp1bmnjrzryk78pb7iqev70j9dg/6861a7550229613e3387373f20ad829ba4bc5767dd8eb92e70a0abe304d4e657"},{"startTime":1259.86,"title":"(Cont.) Adding client side interactivity."},{"startTime":1391.86,"title":"Passing data from the client to the server. "},{"startTime":1537.86,"title":"What’s new in the react server component world. "},{"startTime":1770.86,"title":"React server components will fundamentally change how we’re going to fetch data and react. "},{"startTime":1907.86,"title":"server components can access server-side data sources directly, such as databases, file systems, and micro-services. "}]}
|
||||
13
src/app/(main)/podcast/[slug]/head.jsx
Normal file
13
src/app/(main)/podcast/[slug]/head.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { getEpisode } from '@/data/episodes'
|
||||
|
||||
import StandardHead from '@/components/StandardHead';
|
||||
|
||||
export default async function Head({ params }) {
|
||||
const episode = await getEpisode({ episodeSlug: params.slug })
|
||||
return (
|
||||
<StandardHead
|
||||
title={`${episode.title} - The React Show`}
|
||||
description={episode.description}
|
||||
/>
|
||||
);
|
||||
};
|
||||
104
src/app/(main)/podcast/[slug]/page.jsx
Normal file
104
src/app/(main)/podcast/[slug]/page.jsx
Normal file
@@ -0,0 +1,104 @@
|
||||
/* import chaptersStatic from './chapters.json' assert { type: 'json' }; */
|
||||
|
||||
import Head from 'next/head'
|
||||
|
||||
import { Container } from '@/components/Container'
|
||||
import { FormattedDate } from '@/components/FormattedDate'
|
||||
import { PlayButtonClient } from '@/components/player/PlayButtonClient'
|
||||
import Player from '@/app/Player'
|
||||
|
||||
import { getEpisode } from '@/data/episodes'
|
||||
|
||||
function parseTime(time) {
|
||||
const stepOne = time.split(',');
|
||||
const hms = stepOne.length > 0 && stepOne[0].split(':')
|
||||
const h = parseInt(hms[0], 10);
|
||||
const m = parseInt(hms[1], 10);
|
||||
const s = parseInt(hms[2], 10);
|
||||
return (h * 3600) + (m * 60) + s;
|
||||
}
|
||||
|
||||
function humanTime(time) {
|
||||
const stepOne = time.split(',');
|
||||
return stepOne.length > 0 ? stepOne[0] : '';
|
||||
}
|
||||
|
||||
export default async function Page({ params }) {
|
||||
const episode = await getEpisode({ episodeSlug: params.slug })
|
||||
const chaptersRes = episode?.chapters && await fetch(episode.chapters, { cache: 'no-store' });
|
||||
/* const { chapters } = chaptersStatic */
|
||||
const { chapters } = chaptersRes ? await chaptersRes.json() : { chapters: null }
|
||||
let chapterOffsets = [[0, 0]]
|
||||
if (chapters) {
|
||||
chapters.reduce(({ startTime: prevStartTime, title: prevTitle, acc }, { title, startTime }) => {
|
||||
const containsAd = prevTitle.includes('[Ad]')
|
||||
if (containsAd) {
|
||||
chapterOffsets.push([prevStartTime, acc + (startTime - prevStartTime)])
|
||||
}
|
||||
return { startTime, title, acc: containsAd ? acc + (startTime - prevStartTime) : acc }
|
||||
}, { startTime: 0, title: '', acc: 0 })
|
||||
}
|
||||
chapterOffsets = chapterOffsets.reverse()
|
||||
let date = new Date(episode.published)
|
||||
|
||||
let audioPlayerData = {
|
||||
title: episode.title,
|
||||
audio: {
|
||||
src: episode.audio?.src,
|
||||
type: episode.audio?.type,
|
||||
},
|
||||
link: `/${episode.slug}`,
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{`${episode.title} - Their Side`}</title>
|
||||
<meta name="description" content={episode.description} />
|
||||
</Head>
|
||||
<article className="py-16 lg:py-36">
|
||||
<Container>
|
||||
<header className="flex flex-col">
|
||||
<div className="flex items-center gap-6">
|
||||
<PlayButtonClient audioPlayerData={audioPlayerData} size="large" />
|
||||
<div className="flex flex-col">
|
||||
<h1 className="mt-2 text-4xl font-bold text-slate-900">
|
||||
{episode.title}
|
||||
</h1>
|
||||
<FormattedDate
|
||||
date={date}
|
||||
className="order-first font-mono text-sm leading-7 text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="ml-24 mt-3 text-lg font-medium leading-8 text-slate-700">
|
||||
{episode.description}
|
||||
</p>
|
||||
</header>
|
||||
<hr className="my-12 border-gray-200" />
|
||||
<div
|
||||
className="prose prose-slate mt-14 [&>h2]:mt-12 [&>h2]:flex [&>h2]:items-center [&>h2]:font-mono [&>h2]:text-sm [&>h2]:font-medium [&>h2]:leading-7 [&>h2]:text-slate-900 [&>h2]:before:mr-3 [&>h2]:before:h-3 [&>h2]:before:w-1.5 [&>h2]:before:rounded-r-full [&>h2]:before:bg-cyan-200 [&>ul]:mt-6 [&>ul]:list-['\2013\20'] [&>ul]:pl-5 [&>h2:nth-of-type(3n+2)]:before:bg-indigo-200 [&>h2:nth-of-type(3n)]:before:bg-violet-200"
|
||||
dangerouslySetInnerHTML={{ __html: episode.content || 'CONTENT' }}
|
||||
/>
|
||||
{episode?.transcript && (
|
||||
<>
|
||||
<hr className="my-12 border-gray-200" />
|
||||
<h2 className="mt-2 text-3xl font-bold text-slate-900" id="transcript">Transcript</h2>
|
||||
<div className="space-y-4">
|
||||
{episode.transcript.map(({ id, startTime, endTime, text }) => (
|
||||
<p key={id}>
|
||||
<Player
|
||||
episode={episode}
|
||||
startTime={parseTime(startTime) + chapterOffsets.find(([start]) => parseTime(startTime) > start)[1]}
|
||||
endTime={parseTime(endTime) + chapterOffsets.find(([start]) => parseTime(startTime) > start)[1]} />
|
||||
<strong><time>{humanTime(startTime)}</time></strong> {text}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
</article>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user