- Created new podcast episode files for "Freelance Client Stories", "Productivity Without Burnout", "Conversations with Builders", "Stable Frontend Systems", "Writing Cleaner Service Layers", and "Foundations Over Tools" with relevant metadata and episode notes. - Introduced a new TypeScript type definition for PodcastKind and PodcastEpisode to enhance type safety. - Implemented a content reading function to fetch and sort podcast episodes by date. - Updated package.json to include new dependencies: gray-matter for markdown parsing and pdfjs-dist for PDF handling. - Added PDF files for Atomic Design and personal resume to the public directory.
372 lines
13 KiB
TypeScript
372 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import Link from "next/link";
|
|
import ReactMarkdown from "react-markdown";
|
|
import remarkGfm from "remark-gfm";
|
|
import { Icon } from "@iconify/react";
|
|
import { Badge, Button, Card, Typography } from "poyraz-ui/atoms";
|
|
import { Modal, ModalContent, ModalTitle, Sheet, SheetContent, SheetTitle } from "poyraz-ui/molecules";
|
|
import type { PodcastEpisode } from "@/data/content-types";
|
|
|
|
type ContentContentProps = {
|
|
yazilimEpisodes: PodcastEpisode[];
|
|
masaBasiEpisodes: PodcastEpisode[];
|
|
youtubeLinks: readonly string[];
|
|
pdfFiles: string[];
|
|
};
|
|
|
|
function PdfFirstPagePreview({ src, title }: { src: string; title: string }) {
|
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
const [failed, setFailed] = useState(false);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
let loadingTask: { promise: Promise<unknown>; destroy?: () => void } | null = null;
|
|
|
|
const render = async () => {
|
|
try {
|
|
const pdfjs = await import("pdfjs-dist");
|
|
const lib = pdfjs as unknown as {
|
|
version: string;
|
|
getDocument: (src: string) => { promise: Promise<unknown>; destroy?: () => void };
|
|
GlobalWorkerOptions: { workerSrc: string };
|
|
};
|
|
|
|
lib.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${lib.version}/build/pdf.worker.min.mjs`;
|
|
loadingTask = lib.getDocument(src);
|
|
|
|
const pdf = (await loadingTask.promise) as {
|
|
getPage: (page: number) => Promise<{
|
|
getViewport: (opts: { scale: number }) => { width: number; height: number };
|
|
render: (opts: {
|
|
canvasContext: CanvasRenderingContext2D;
|
|
viewport: { width: number; height: number };
|
|
}) => { promise: Promise<void> };
|
|
}>;
|
|
};
|
|
|
|
const page = await pdf.getPage(1);
|
|
const viewport = page.getViewport({ scale: 1.2 });
|
|
const canvas = canvasRef.current;
|
|
if (!canvas || cancelled) return;
|
|
|
|
const context = canvas.getContext("2d");
|
|
if (!context) return;
|
|
|
|
const ratio = window.devicePixelRatio || 1;
|
|
canvas.width = Math.floor(viewport.width * ratio);
|
|
canvas.height = Math.floor(viewport.height * ratio);
|
|
canvas.style.width = `${viewport.width}px`;
|
|
canvas.style.height = `${viewport.height}px`;
|
|
|
|
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
|
await page.render({ canvasContext: context, viewport }).promise;
|
|
} catch {
|
|
if (!cancelled) {
|
|
setFailed(true);
|
|
}
|
|
}
|
|
};
|
|
|
|
void render();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (loadingTask?.destroy) {
|
|
loadingTask.destroy();
|
|
}
|
|
};
|
|
}, [src]);
|
|
|
|
if (failed) {
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center bg-muted/20 p-2">
|
|
<Typography variant="small" className="text-muted-foreground">
|
|
Preview unavailable
|
|
</Typography>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center bg-white p-2">
|
|
<canvas ref={canvasRef} aria-label={title} className="h-auto max-h-full w-auto max-w-full" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function getYoutubeEmbedUrl(link: string) {
|
|
try {
|
|
const url = new URL(link);
|
|
const videoId = url.searchParams.get("v");
|
|
return videoId ? `https://www.youtube.com/embed/${videoId}` : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function PodcastColumn({
|
|
title,
|
|
subtitle,
|
|
episodes,
|
|
onOpenEpisode,
|
|
}: {
|
|
title: string;
|
|
subtitle: string;
|
|
episodes: PodcastEpisode[];
|
|
onOpenEpisode: (episode: PodcastEpisode) => void;
|
|
}) {
|
|
return (
|
|
<Card className="rounded-sm border-border p-4">
|
|
<Typography variant="large" className="text-base">
|
|
{title}
|
|
</Typography>
|
|
<Typography variant="small" className="mt-1 text-muted-foreground">
|
|
{subtitle}
|
|
</Typography>
|
|
|
|
<div className="mt-3 grid gap-2">
|
|
{episodes.slice(0, 3).map((episode) => (
|
|
<button
|
|
key={`${episode.podcast}-${episode.slug}`}
|
|
type="button"
|
|
onClick={() => onOpenEpisode(episode)}
|
|
className="cursor-pointer text-left"
|
|
>
|
|
<Card className="rounded-sm border-border p-3 transition-colors hover:border-zinc-700">
|
|
<Typography variant="small" className="font-semibold text-foreground">
|
|
{episode.title}
|
|
</Typography>
|
|
<Typography variant="small" className="mt-0.5 text-muted-foreground">
|
|
{episode.date}
|
|
</Typography>
|
|
</Card>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export function ContentContent({
|
|
yazilimEpisodes,
|
|
masaBasiEpisodes,
|
|
youtubeLinks,
|
|
pdfFiles,
|
|
}: ContentContentProps) {
|
|
const [selectedEpisode, setSelectedEpisode] = useState<PodcastEpisode | null>(null);
|
|
const [sheetOpen, setSheetOpen] = useState(false);
|
|
const [pdfModalOpen, setPdfModalOpen] = useState(false);
|
|
const [activePdfIndex, setActivePdfIndex] = useState(0);
|
|
|
|
const activePdf = pdfFiles[activePdfIndex] ?? null;
|
|
const canGoPrev = activePdfIndex > 0;
|
|
const canGoNext = activePdfIndex < pdfFiles.length - 1;
|
|
|
|
const embeddedVideos = useMemo(
|
|
() =>
|
|
youtubeLinks
|
|
.map((link) => ({ link, embedUrl: getYoutubeEmbedUrl(link) }))
|
|
.slice(0, 3),
|
|
[youtubeLinks],
|
|
);
|
|
|
|
const openEpisode = (episode: PodcastEpisode) => {
|
|
setSelectedEpisode(episode);
|
|
setSheetOpen(true);
|
|
};
|
|
|
|
const openPdfModal = (index: number) => {
|
|
setActivePdfIndex(index);
|
|
setPdfModalOpen(true);
|
|
};
|
|
|
|
return (
|
|
<section className="flex h-full flex-col gap-3 overflow-y-auto">
|
|
<section className="grid gap-3 md:grid-cols-2">
|
|
<PodcastColumn
|
|
title="Poyraz ile Yazilim"
|
|
subtitle="Regular schedule, every Sunday."
|
|
episodes={yazilimEpisodes}
|
|
onOpenEpisode={openEpisode}
|
|
/>
|
|
<PodcastColumn
|
|
title="Poyraz ile Masa Basi"
|
|
subtitle="Irregular schedule, guest-focused."
|
|
episodes={masaBasiEpisodes}
|
|
onOpenEpisode={openEpisode}
|
|
/>
|
|
</section>
|
|
|
|
<section className="space-y-2">
|
|
<Typography variant="large" className="text-base">
|
|
Latest YouTube Videos
|
|
</Typography>
|
|
<div className="grid gap-2 md:grid-cols-3">
|
|
{embeddedVideos.map((item) => (
|
|
<Card key={item.link} className="overflow-hidden rounded-sm border-border p-0">
|
|
{item.embedUrl ? (
|
|
<div className="aspect-video w-full">
|
|
<iframe
|
|
src={item.embedUrl}
|
|
title="YouTube video player"
|
|
className="h-full w-full"
|
|
loading="lazy"
|
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
|
referrerPolicy="strict-origin-when-cross-origin"
|
|
allowFullScreen
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="p-3">
|
|
<Typography variant="small" className="text-muted-foreground">
|
|
Invalid video link.
|
|
</Typography>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="space-y-2">
|
|
<Typography variant="large" className="text-base">
|
|
LinkedIn PDF Notes
|
|
</Typography>
|
|
<div className="grid gap-2 grid-cols-2 md:grid-cols-3">
|
|
{pdfFiles.map((pdf, index) => (
|
|
<button
|
|
key={pdf}
|
|
type="button"
|
|
onClick={() => openPdfModal(index)}
|
|
className="cursor-pointer text-left"
|
|
>
|
|
<Card className="overflow-hidden rounded-sm border-border p-0 transition-colors hover:border-zinc-700">
|
|
<div className="aspect-square w-full">
|
|
<PdfFirstPagePreview src={`/pdf/${pdf}`} title={`${pdf} preview`} />
|
|
</div>
|
|
</Card>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
|
<SheetContent side="right" className="w-full max-w-xl p-0">
|
|
<div className="border-b border-border px-4 py-3">
|
|
<SheetTitle>{selectedEpisode?.title ?? "Episode"}</SheetTitle>
|
|
{selectedEpisode ? (
|
|
<Typography variant="small" className="mt-1 text-muted-foreground">
|
|
{selectedEpisode.date}
|
|
</Typography>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="flex max-h-[calc(100dvh-64px)] flex-col gap-3 overflow-y-auto p-4">
|
|
{selectedEpisode ? (
|
|
<>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Badge className="rounded-sm">{selectedEpisode.podcast}</Badge>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<Link
|
|
href={selectedEpisode.youtubeUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="inline-flex items-center gap-1.5 rounded-sm border border-border px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
|
>
|
|
<Icon icon="mdi:youtube" width={16} height={16} className="text-red-600" />
|
|
YouTube
|
|
</Link>
|
|
<Link
|
|
href={selectedEpisode.spotifyUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="inline-flex items-center gap-1.5 rounded-sm border border-border px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
|
>
|
|
<Icon icon="mdi:spotify" width={16} height={16} className="text-green-600" />
|
|
Spotify
|
|
</Link>
|
|
</div>
|
|
|
|
<Card className="rounded-sm border-border p-3">
|
|
<ReactMarkdown
|
|
remarkPlugins={[remarkGfm]}
|
|
components={{
|
|
h2: ({ children }) => (
|
|
<Typography variant="large" className="mt-3 text-foreground first:mt-0">
|
|
{children}
|
|
</Typography>
|
|
),
|
|
p: ({ children }) => (
|
|
<Typography variant="small" className="mt-1 text-muted-foreground first:mt-0">
|
|
{children}
|
|
</Typography>
|
|
),
|
|
li: ({ children }) => (
|
|
<li className="ml-5 list-disc text-sm text-muted-foreground">{children}</li>
|
|
),
|
|
}}
|
|
>
|
|
{selectedEpisode.markdown}
|
|
</ReactMarkdown>
|
|
</Card>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
|
|
<Modal open={pdfModalOpen} onOpenChange={setPdfModalOpen}>
|
|
<ModalContent size="xl" className="rounded-sm p-4">
|
|
<ModalTitle>{activePdf ? activePdf.replace(/\.pdf$/i, "") : "PDF Note"}</ModalTitle>
|
|
|
|
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
|
|
<Typography variant="small" className="text-muted-foreground">
|
|
{pdfFiles.length === 0 ? "0 / 0" : `${activePdfIndex + 1} / ${pdfFiles.length}`}
|
|
</Typography>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="rounded-sm"
|
|
disabled={!canGoPrev}
|
|
onClick={() => setActivePdfIndex((prev) => Math.max(0, prev - 1))}
|
|
>
|
|
Prev
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="rounded-sm"
|
|
disabled={!canGoNext}
|
|
onClick={() => setActivePdfIndex((prev) => Math.min(pdfFiles.length - 1, prev + 1))}
|
|
>
|
|
Next
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{activePdf ? (
|
|
<div className="mt-3 h-[70dvh] overflow-hidden rounded-sm border border-border">
|
|
<iframe
|
|
src={`/pdf/${activePdf}`}
|
|
title={activePdf}
|
|
className="h-full w-full"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<Card className="mt-3 rounded-sm border-border p-3">
|
|
<Typography variant="small" className="text-muted-foreground">
|
|
No PDF found in /public/pdf.
|
|
</Typography>
|
|
</Card>
|
|
)}
|
|
</ModalContent>
|
|
</Modal>
|
|
</section>
|
|
);
|
|
}
|