diff --git a/starter/blog-next-log/README.md b/starter/blog-next-log/README.md new file mode 100644 index 0000000000..dd8273445e --- /dev/null +++ b/starter/blog-next-log/README.md @@ -0,0 +1,90 @@ +--- +name: Blog Starter with Next.js and MDX +slug: blog-next-log +description: A personal developer blog powered by Next.js 15, MDX, and Tailwind CSS v4. Features dark mode, dynamic OG images, RSS feed, Table of Contents, and resume page. +framework: Next.js +useCase: + - Starter + - Blog +css: Tailwind CSS +deployUrl: https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fexamples%2Ftree%2Fmain%2Fstarter%2Fblog-next-log&project-name=blog-next-log&repository-name=blog-next-log +demoUrl: https://create-next-log-demo.vercel.app +relatedTemplates: + - blog-starter-kit + - nextjs-boilerplate +--- + +# Blog Starter with Next.js and MDX + +A fully-featured, config-driven personal developer blog built with Next.js 15 (App Router), MDX, and Tailwind CSS v4. + +## Features + +- MDX blog posts with syntax highlighting and copy button +- Dark / Light mode with system preference detection +- Dynamic OG image generation per post +- Auto-generated sitemap and RSS feed +- Table of Contents (auto-generated from headings) +- Resume page generator +- SEO optimizations (JSON-LD, canonical URLs, robots.txt) +- Fully responsive (desktop, tablet, mobile) + +## Demo + +[create-next-log-demo.vercel.app](https://create-next-log-demo.vercel.app) + +## Deploy your own + +Deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=vercel-examples): + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fexamples%2Ftree%2Fmain%2Fstarter%2Fblog-next-log&project-name=blog-next-log&repository-name=blog-next-log) + +## How to use + +You can also use the CLI scaffolder for a fully customized setup: + +```bash +npx create-next-log +``` + +Or clone and deploy manually: + +```bash +git clone https://github.com/vercel/examples/tree/main/starter/blog-next-log +cd blog-next-log +npm install +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000) to see your blog. + +## Configuration + +All settings live in `next-log.config.ts`: + +```typescript +const config = { + title: "My Dev Blog", + description: "Thoughts on web development", + url: "https://myblog.com", + language: "en", + author: { name: "Jane Doe" }, + social: { github: "", linkedin: "" }, + theme: { primaryColor: "#2563eb" }, +}; +``` + +## Writing posts + +```bash +npm run new-post "my-first-post" +``` + +Edit `posts/my-first-post/index.mdx` and set `published: true` when ready. + +## Tech Stack + +- [Next.js 15](https://nextjs.org) — App Router +- [MDX](https://mdxjs.com) — Rich content with React components +- [Tailwind CSS v4](https://tailwindcss.com) — CSS-first config +- [Radix UI](https://www.radix-ui.com) — Accessible primitives diff --git a/starter/blog-next-log/app/api/og/[slug]/route.tsx b/starter/blog-next-log/app/api/og/[slug]/route.tsx new file mode 100644 index 0000000000..9395c0c496 --- /dev/null +++ b/starter/blog-next-log/app/api/og/[slug]/route.tsx @@ -0,0 +1,158 @@ +import { NextRequest, NextResponse } from "next/server"; +import { ImageResponse } from "next/og"; +import { getConfig } from "~lib/config"; + +async function loadFonts() { + const [bold, regular] = await Promise.all([ + fetch( + "https://github.com/orioncactus/pretendard/raw/main/packages/pretendard/dist/public/static/Pretendard-Bold.otf" + ).then((res) => res.arrayBuffer()), + fetch( + "https://github.com/orioncactus/pretendard/raw/main/packages/pretendard/dist/public/static/Pretendard-Regular.otf" + ).then((res) => res.arrayBuffer()), + ]); + return { bold, regular }; +} + +function renderDots() { + const dots = []; + const cols = 24; + const rows = 12; + const spacingX = 50; + const spacingY = 52; + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + dots.push( +
+ ); + } + } + return dots; +} + +function renderTitle(title: string, highlightWords?: string) { + if (!highlightWords) { + return title; + } + + const words = highlightWords.split(",").map((w) => w.trim()).filter(Boolean); + const pattern = words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"); + const regex = new RegExp(`(${pattern})`, "g"); + const parts = title.split(regex); + + return parts.map((part, i) => + words.includes(part) ? ( + + {part} + + ) : ( + {part} + ) + ); +} + +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const title = searchParams.get("title") || "Title"; + const highlightWord = searchParams.get("highlightWord") || undefined; + + const fonts = await loadFonts(); + + return new ImageResponse( + ( +
+ {renderDots()} +
+ {renderTitle(title, highlightWord)} +
+
+ {getConfig().author.name} +
+
+ ), + { + width: 1200, + height: 630, + fonts: [ + { + name: "Pretendard", + data: fonts.bold, + weight: 700 as const, + style: "normal" as const, + }, + { + name: "Pretendard", + data: fonts.regular, + weight: 400 as const, + style: "normal" as const, + }, + ], + } + ); + } catch (e) { + console.error("OG image generation failed:", e); + return NextResponse.json( + { error: "Failed to generate image" }, + { status: 500 } + ); + } +} diff --git a/starter/blog-next-log/app/components/GoogleAnalytics.tsx b/starter/blog-next-log/app/components/GoogleAnalytics.tsx new file mode 100644 index 0000000000..ef74fbc2c0 --- /dev/null +++ b/starter/blog-next-log/app/components/GoogleAnalytics.tsx @@ -0,0 +1,28 @@ +"use client"; + +import Script from "next/script"; + +interface Props { + gaId: string; +} + +export default function GoogleAnalytics({ gaId }: Props) { + if (!gaId) return null; + + return ( + <> + + + ); +} diff --git a/starter/blog-next-log/app/components/header/index.tsx b/starter/blog-next-log/app/components/header/index.tsx new file mode 100644 index 0000000000..267e23b5c3 --- /dev/null +++ b/starter/blog-next-log/app/components/header/index.tsx @@ -0,0 +1,22 @@ +import { getNavItems } from "~lib/nav"; +import NavToggles from "./toggle"; +import PageNav from "./nav"; + +function Header() { + const navItems = getNavItems(); + + return ( +
+ +
+ ); +} + +export default Header; diff --git a/starter/blog-next-log/app/components/header/nav/index.tsx b/starter/blog-next-log/app/components/header/nav/index.tsx new file mode 100644 index 0000000000..fe2955e5b8 --- /dev/null +++ b/starter/blog-next-log/app/components/header/nav/index.tsx @@ -0,0 +1,51 @@ +"use client"; + +import Link from "next/link"; +import useIsRouteActive from "~hooks/useIsActive"; +import { getConfig } from "~lib/config"; +import { cn } from "~lib/utils"; +import NavSheet from "../navSheet"; + +interface NavItem { + label: string; + path: string; +} + +function PageNav({ navItems }: { navItems: NavItem[] }) { + const config = getConfig(); + + return ( +
+ + {config.author.name} + + + +
+ ); +} + +function NavLink({ href, label }: { href: string; label: string }) { + const isActive = useIsRouteActive(href); + + return ( + + {label} + + ); +} + +export default PageNav; diff --git a/starter/blog-next-log/app/components/header/navSheet/index.tsx b/starter/blog-next-log/app/components/header/navSheet/index.tsx new file mode 100644 index 0000000000..7765a41a91 --- /dev/null +++ b/starter/blog-next-log/app/components/header/navSheet/index.tsx @@ -0,0 +1,72 @@ +"use client"; + +import Link from "next/link"; +import { getConfig } from "~lib/config"; +import MenuIcon from "~components/icon/menuIcon"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "~components/ui/sheet"; + +interface NavItem { + label: string; + path: string; +} + +function NavSheet({ navItems }: { navItems: NavItem[] }) { + const config = getConfig(); + + const socialLinks = [ + config.social.github && { label: "GitHub", href: config.social.github }, + config.social.linkedin && { + label: "LinkedIn", + href: config.social.linkedin, + }, + ].filter(Boolean) as { label: string; href: string }[]; + + return ( + + + + + + + + + {config.author.name} + + + +
+ {navItems.map((item) => ( + + {item.label} + + ))} + {socialLinks.map((link) => ( + + {link.label} + + ))} +
+
+
+
+
+ ); +} + +export default NavSheet; diff --git a/starter/blog-next-log/app/components/header/toggle/index.tsx b/starter/blog-next-log/app/components/header/toggle/index.tsx new file mode 100644 index 0000000000..a84e8766e7 --- /dev/null +++ b/starter/blog-next-log/app/components/header/toggle/index.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useTheme } from "~styles/themeProvider"; +import Link from "next/link"; + +import { getConfig } from "~lib/config"; +import GithubIcon from "~components/icon/githubIcon"; +import LinkedInIcon from "~components/icon/linkedInIcon"; +import MoonIcon from "~components/icon/moonIcon"; +import SunIcon from "~components/icon/sunIcon"; + +import { Button } from "~components/ui/button"; + +function NavToggles() { + const { resolvedTheme, setTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + const config = getConfig(); + + useEffect(() => setMounted(true), []); + + const changeTheme = () => { + if (resolvedTheme === "dark") { + setTheme("light"); + } else { + setTheme("dark"); + } + }; + + return ( + + ); +} + +export default NavToggles; diff --git a/starter/blog-next-log/app/components/icon/githubIcon/index.tsx b/starter/blog-next-log/app/components/icon/githubIcon/index.tsx new file mode 100644 index 0000000000..8dc173ba86 --- /dev/null +++ b/starter/blog-next-log/app/components/icon/githubIcon/index.tsx @@ -0,0 +1,16 @@ +import { IconProps } from "~types/icon"; + +function GithubIcon({ ...props }: IconProps) { + return ( + + + + ); +} + +export default GithubIcon; diff --git a/starter/blog-next-log/app/components/icon/linkedInIcon/index.tsx b/starter/blog-next-log/app/components/icon/linkedInIcon/index.tsx new file mode 100644 index 0000000000..6c600e2711 --- /dev/null +++ b/starter/blog-next-log/app/components/icon/linkedInIcon/index.tsx @@ -0,0 +1,16 @@ +import { IconProps } from "~types/icon"; + +function LinkedInIcon({ ...props }: IconProps) { + return ( + + + + ); +} + +export default LinkedInIcon; diff --git a/starter/blog-next-log/app/components/icon/menuIcon/index.tsx b/starter/blog-next-log/app/components/icon/menuIcon/index.tsx new file mode 100644 index 0000000000..4b9387681e --- /dev/null +++ b/starter/blog-next-log/app/components/icon/menuIcon/index.tsx @@ -0,0 +1,21 @@ +import { IconProps } from "~types/icon"; + +const MenuIcon = (props: IconProps) => ( + + + + + +); +export default MenuIcon; diff --git a/starter/blog-next-log/app/components/icon/moonIcon/index.tsx b/starter/blog-next-log/app/components/icon/moonIcon/index.tsx new file mode 100644 index 0000000000..2a9247a104 --- /dev/null +++ b/starter/blog-next-log/app/components/icon/moonIcon/index.tsx @@ -0,0 +1,22 @@ +import { IconProps } from "~types/icon"; + +function MoonIcon({ ...props }: IconProps) { + return ( + + + + ); +} + +export default MoonIcon; diff --git a/starter/blog-next-log/app/components/icon/sunIcon/index.tsx b/starter/blog-next-log/app/components/icon/sunIcon/index.tsx new file mode 100644 index 0000000000..010a4a2924 --- /dev/null +++ b/starter/blog-next-log/app/components/icon/sunIcon/index.tsx @@ -0,0 +1,30 @@ +import { IconProps } from "~types/icon"; + +function SunIcon({ ...props }: IconProps) { + return ( + + + + + + + + + + + + ); +} + +export default SunIcon; diff --git a/starter/blog-next-log/app/components/mdx/CodeBlock.tsx b/starter/blog-next-log/app/components/mdx/CodeBlock.tsx new file mode 100644 index 0000000000..1f93a7680f --- /dev/null +++ b/starter/blog-next-log/app/components/mdx/CodeBlock.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useState, useRef } from "react"; + +export function CodeBlock(props: React.HTMLAttributes) { + const [copied, setCopied] = useState(false); + const preRef = useRef(null); + + const handleCopy = async () => { + const code = preRef.current?.querySelector("code")?.textContent || ""; + await navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+ +
+    
+ ); +} diff --git a/starter/blog-next-log/app/components/mdx/FileTree.tsx b/starter/blog-next-log/app/components/mdx/FileTree.tsx new file mode 100644 index 0000000000..4d476e7b39 --- /dev/null +++ b/starter/blog-next-log/app/components/mdx/FileTree.tsx @@ -0,0 +1,261 @@ +"use client"; + +import { + useState, + useRef, + useEffect, + createContext, + useContext, + Children, + type ReactNode, +} from "react"; + +const INDENT = 20; + +const DepthContext = createContext(0); + +/* ── Lucide-style icons (stroke-based, 24x24) ── */ + +function ChevronIcon({ open }: { open: boolean }) { + return ( + + + + ); +} + +function FolderIcon({ open }: { open: boolean }) { + return open ? ( + + + + ) : ( + + + + ); +} + +function FileIcon() { + return ( + + + + + ); +} + +/* ── Animated collapse wrapper ── */ + +function Collapsible({ open, children }: { open: boolean; children: ReactNode }) { + const ref = useRef(null); + const [height, setHeight] = useState(open ? "auto" : 0); + const initial = useRef(true); + + useEffect(() => { + if (initial.current) { + initial.current = false; + return; + } + if (!ref.current) return; + if (open) { + const h = ref.current.scrollHeight; + setHeight(h); + const timer = setTimeout(() => setHeight("auto"), 200); + return () => clearTimeout(timer); + } else { + setHeight(ref.current.scrollHeight); + requestAnimationFrame(() => setHeight(0)); + } + }, [open]); + + return ( +
+ {children} +
+ ); +} + +/* ── Tree lines ── */ + +function TreeLine({ depth }: { depth: number }) { + if (depth === 0) return null; + return ( + <> + {Array.from({ length: depth }).map((_, i) => ( + + ))} + + ); +} + +/* ── FileTree (root) ── */ + +interface FileTreeProps { + children: ReactNode; +} + +function FileTree({ children }: FileTreeProps) { + return ( +
+ {/* grid background with edge fade */} +
+
+ {children} +
+
+ ); +} + +/* ── Folder ── */ + +interface FolderProps { + name: string; + defaultOpen?: boolean; + children?: ReactNode; +} + +function Folder({ name, defaultOpen = false, children }: FolderProps) { + const [open, setOpen] = useState(defaultOpen); + const depth = useContext(DepthContext); + const hasChildren = !!children; + + return ( +
+ + {hasChildren && ( + + + {children} + + + )} +
+ ); +} + +/* ── File ── */ + +interface FileProps { + name: string; +} + +function File({ name }: FileProps) { + const depth = useContext(DepthContext); + + return ( +
+ + + + {name} +
+ ); +} + +export { FileTree, Folder, File }; diff --git a/starter/blog-next-log/app/components/mdx/MdxRenderer.tsx b/starter/blog-next-log/app/components/mdx/MdxRenderer.tsx new file mode 100644 index 0000000000..82f8e8e04e --- /dev/null +++ b/starter/blog-next-log/app/components/mdx/MdxRenderer.tsx @@ -0,0 +1,34 @@ +import { MDXRemote } from "next-mdx-remote/rsc"; +import rehypeSlug from "rehype-slug"; +import rehypeCodeTitles from "rehype-code-titles"; +import rehypePrism from "rehype-prism-plus"; +import rehypeExternalLinks from "rehype-external-links"; +import remarkGfm from "remark-gfm"; +import { mdxComponents } from "./index"; + +interface MdxRendererProps { + source: string; +} + +export function MdxRenderer({ source }: MdxRendererProps) { + return ( + + ); +} diff --git a/starter/blog-next-log/app/components/mdx/Timeline.tsx b/starter/blog-next-log/app/components/mdx/Timeline.tsx new file mode 100644 index 0000000000..c72c0036a8 --- /dev/null +++ b/starter/blog-next-log/app/components/mdx/Timeline.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useRef } from "react"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "~components/ui/accordion"; + +interface TimelineItemProps { + title: string; + summary: string; + children: React.ReactNode; +} + +function TimelineItem({ title, summary, children }: TimelineItemProps) { + const triggerRef = useRef(null); + + const handleClick = () => { + triggerRef.current?.click(); + }; + + return ( + +
+ {/* Title + summary + chevron (click to toggle) */} +
+
+

{title}

+ +
+

+ {summary} +

+
+ + {/* Expanded content */} + +
+
+ {children} +
+
+
+
+
+ ); +} + +interface TimelineProps { + children: React.ReactNode; +} + +function Timeline({ children }: TimelineProps) { + return ( + + {children} + + ); +} + +export { Timeline, TimelineItem }; diff --git a/starter/blog-next-log/app/components/mdx/index.ts b/starter/blog-next-log/app/components/mdx/index.ts new file mode 100644 index 0000000000..284d2824f4 --- /dev/null +++ b/starter/blog-next-log/app/components/mdx/index.ts @@ -0,0 +1,12 @@ +import { Timeline, TimelineItem } from "./Timeline"; +import { FileTree, Folder, File } from "./FileTree"; +import { CodeBlock } from "./CodeBlock"; + +export const mdxComponents = { + Timeline, + TimelineItem, + FileTree, + Folder, + File, + pre: CodeBlock, +}; diff --git a/starter/blog-next-log/app/components/toc/TableOfContents.tsx b/starter/blog-next-log/app/components/toc/TableOfContents.tsx new file mode 100644 index 0000000000..4c7d1e6567 --- /dev/null +++ b/starter/blog-next-log/app/components/toc/TableOfContents.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import type { TableOfContents as TOCType } from "~core/blog/types"; + +interface Props { + toc: TOCType; +} + +export default function TableOfContents({ toc }: Props) { + const [activeSlug, setActiveSlug] = useState(""); + + useEffect(() => { + const h2Headings = Array.from( + document.querySelectorAll("h2") + ) as HTMLElement[]; + + h2Headings.forEach((h) => { + h.style.scrollMarginTop = "100px"; + }); + + if (h2Headings.length === 0) return; + + // Set initial active based on current scroll position + let initial = h2Headings[0].id; + for (const h of h2Headings) { + if (h.offsetTop <= window.scrollY + 120) initial = h.id; + } + setActiveSlug(initial); + + const observer = new IntersectionObserver( + (entries) => { + const intersecting = entries + .filter((e) => e.isIntersecting) + .sort((a, b) => (a.target as HTMLElement).offsetTop - (b.target as HTMLElement).offsetTop); + + if (intersecting.length > 0) { + setActiveSlug(intersecting[0].target.id); + } + }, + { rootMargin: "0px 0px -30% 0px", threshold: 0 } + ); + + h2Headings.forEach((h) => observer.observe(h)); + return () => observer.disconnect(); + }, []); + + const handleClick = useCallback( + (e: React.MouseEvent, slug: string) => { + e.preventDefault(); + const el = document.getElementById(slug); + if (el) { + el.scrollIntoView({ behavior: "smooth" }); + setActiveSlug(slug); + } + }, + [] + ); + + if (toc.length === 0) return null; + + const tocList = ( + + ); + + return ( + <> + {/* Desktop: sticky sidebar, positioned outside content area */} +
+ +
+ + ); +} diff --git a/starter/blog-next-log/app/components/ui/accordion.tsx b/starter/blog-next-log/app/components/ui/accordion.tsx new file mode 100644 index 0000000000..c6d654c710 --- /dev/null +++ b/starter/blog-next-log/app/components/ui/accordion.tsx @@ -0,0 +1,58 @@ +"use client" + +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "~lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) + +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/starter/blog-next-log/app/components/ui/button.tsx b/starter/blog-next-log/app/components/ui/button.tsx new file mode 100644 index 0000000000..02b73ca9a3 --- /dev/null +++ b/starter/blog-next-log/app/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "~lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + } +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/starter/blog-next-log/app/components/ui/dialog.tsx b/starter/blog-next-log/app/components/ui/dialog.tsx new file mode 100644 index 0000000000..d819ddab5e --- /dev/null +++ b/starter/blog-next-log/app/components/ui/dialog.tsx @@ -0,0 +1,120 @@ +"use client"; + +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; + +import { cn } from "~lib/utils"; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = ({ ...props }: DialogPrimitive.DialogPortalProps) => ( + +); +DialogPortal.displayName = DialogPrimitive.Portal.displayName; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/starter/blog-next-log/app/components/ui/sheet.tsx b/starter/blog-next-log/app/components/ui/sheet.tsx new file mode 100644 index 0000000000..e6e960f2f0 --- /dev/null +++ b/starter/blog-next-log/app/components/ui/sheet.tsx @@ -0,0 +1,143 @@ +"use client"; + +import * as React from "react"; +import * as SheetPrimitive from "@radix-ui/react-dialog"; +import { cva, type VariantProps } from "class-variance-authority"; +import { X } from "lucide-react"; + +import { cn } from "~lib/utils"; + +const Sheet = SheetPrimitive.Root; + +const SheetTrigger = SheetPrimitive.Trigger; + +const SheetClose = SheetPrimitive.Close; + +const SheetPortal = ({ ...props }: SheetPrimitive.DialogPortalProps) => ( + +); +SheetPortal.displayName = SheetPrimitive.Portal.displayName; + +const SheetOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; + +const sheetVariants = cva( + "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", + { + variants: { + side: { + top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + bottom: + "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", + right: + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", + }, + }, + defaultVariants: { + side: "right", + }, + } +); + +interface SheetContentProps + extends React.ComponentPropsWithoutRef, + VariantProps {} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = "right", className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +SheetContent.displayName = SheetPrimitive.Content.displayName; + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +SheetHeader.displayName = "SheetHeader"; + +const SheetFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +SheetFooter.displayName = "SheetFooter"; + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetTitle.displayName = SheetPrimitive.Title.displayName; + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetDescription.displayName = SheetPrimitive.Description.displayName; + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +}; diff --git a/starter/blog-next-log/app/core/blog/serializeMdx.ts b/starter/blog-next-log/app/core/blog/serializeMdx.ts new file mode 100644 index 0000000000..c34bab5182 --- /dev/null +++ b/starter/blog-next-log/app/core/blog/serializeMdx.ts @@ -0,0 +1,34 @@ +import { TableOfContents } from "./types"; + +export const parseToc = (source: string) => { + return source + .split("\n") + .filter((line) => line.match(/(^#{2})\s/)) + .reduce((ac, rawHeading) => { + const nac = [...ac]; + const removeMdx = rawHeading + .replace(/^##*\s/, "") + .replace(/[\*,\~]{2,}/g, "") + .replace(/(?<=\])\((.*?)\)/g, "") + .replace(/(? ` + + <![CDATA[${post.metadata.title}]]> + ${siteUrl}/post/${post.slug} + ${siteUrl}/post/${post.slug} + + ${new Date(post.metadata.date).toUTCString()} + ${post.metadata.author} + ` + ) + .join(""); + + const feed = ` + + + <![CDATA[${config.title}]]> + ${siteUrl} + + ${config.language || "en"} + ${new Date().toUTCString()} + + ${items} + +`; + + return new Response(feed, { + headers: { + "Content-Type": "application/rss+xml; charset=utf-8", + }, + }); +} diff --git a/starter/blog-next-log/app/hooks/useIsActive.ts b/starter/blog-next-log/app/hooks/useIsActive.ts new file mode 100644 index 0000000000..2d4032717b --- /dev/null +++ b/starter/blog-next-log/app/hooks/useIsActive.ts @@ -0,0 +1,21 @@ +import { usePathname } from "next/navigation"; + +/** + * Function checks if the current route is active + * @param {string} pathToCheck - path to check + * @returns {boolean} - true if the current route is active not flase + */ +function useIsRouteActive(pathToCheck: string) { + const pathname = usePathname(); + const currentPath = pathname.split("?")[0]; // drop query params + + // currentPath and pathToCheck are the same (e.g. /blog) + if (currentPath === pathToCheck) return true; + + // currentPath is included pathToCheck (e.g. /blog/1 is included in /blog) + if (currentPath.includes(pathToCheck)) return true; + + return false; +} + +export default useIsRouteActive; diff --git a/starter/blog-next-log/app/layout.tsx b/starter/blog-next-log/app/layout.tsx new file mode 100644 index 0000000000..5d4ae4eaf6 --- /dev/null +++ b/starter/blog-next-log/app/layout.tsx @@ -0,0 +1,90 @@ +import "~styles/globals.css"; +import { Metadata } from "next"; +import Header from "~components/header"; +import ThemeProvider from "~styles/themeProvider"; +import { getConfig } from "~lib/config"; +import GoogleAnalytics from "~components/GoogleAnalytics"; + +const config = getConfig(); + +export const metadata: Metadata = { + metadataBase: new URL(config.url || "http://localhost:3000"), + title: { + default: config.title, + template: `%s | ${config.title}`, + }, + description: config.description, + authors: [{ name: config.author.name }], + icons: { + icon: [ + { url: "/favicon-light.svg", media: "(prefers-color-scheme: light)" }, + { url: "/favicon-dark.svg", media: "(prefers-color-scheme: dark)" }, + ], + }, + alternates: { + types: { + "application/rss+xml": "/feed.xml", + }, + }, + openGraph: { + type: "website", + siteName: config.title, + title: config.title, + description: config.description, + }, + twitter: { + card: "summary_large_image", + title: config.title, + description: config.description, + }, + ...(config.googleVerification && { + verification: { google: config.googleVerification }, + }), +}; + +const RootLayout = ({ children }: { children: React.ReactNode }) => { + return ( + + +