mirror of
https://github.com/jakejarvis/jarv.is.git
synced 2026-06-05 19:15:30 -04:00
fix: don't pre-render view and comment count components
- Introduced a new PostStats component to handle view and comment counts, replacing the previous async implementation with a client-side approach. - Updated CommentCount component to use client-side state management for fetching comment counts. - Removed unnecessary caching logic from view and comment fetching functions. - Simplified date formatting by moving it inline, enhancing performance and readability.
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ActivityCalendar, type Activity } from "react-activity-calendar";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -46,7 +45,7 @@ const Calendar = ({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{block}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span className="text-[0.825rem] font-medium">{`${activity.count === 0 ? "No" : activity.count} ${noun}${activity.count === 1 ? "" : "s"} on ${formatDate(activity.date, "MMMM do")}`}</span>
|
||||
<span className="text-[0.825rem] font-medium">{`${activity.count === 0 ? "No" : activity.count} ${noun}${activity.count === 1 ? "" : "s"} on ${new Date(activity.date).toLocaleDateString("en-US", { month: "short", day: "numeric" })}`}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { codeToHtml } from "shiki";
|
||||
import { cacheLife } from "next/cache";
|
||||
import reactToText from "react-to-text";
|
||||
import CopyButton from "@/components/copy-button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn, getTextContent } from "@/lib/utils";
|
||||
|
||||
interface CodeBlockProps extends React.ComponentProps<"pre"> {
|
||||
showLineNumbers?: boolean;
|
||||
@@ -29,7 +28,7 @@ const CodeBlock = async ({ children, className, showLineNumbers = true, ...props
|
||||
}
|
||||
|
||||
const codeProps = children.props as React.ComponentProps<"code">;
|
||||
const codeString = reactToText(codeProps.children).trim();
|
||||
const codeString = getTextContent(codeProps.children).trim();
|
||||
const lang = codeProps.className?.split("language-")[1] ?? "text";
|
||||
|
||||
const html = await renderCode(codeString, lang);
|
||||
|
||||
@@ -1,8 +1,31 @@
|
||||
import { env } from "@/lib/env";
|
||||
import { getCommentCounts } from "@/lib/server/comments";
|
||||
"use client";
|
||||
|
||||
const CommentCount = async ({ slug }: { slug: string }) => {
|
||||
const count = await getCommentCounts(slug);
|
||||
import { useEffect, useState } from "react";
|
||||
import { env } from "@/lib/env";
|
||||
import { getCommentCount } from "@/lib/server/comments";
|
||||
|
||||
const CommentCount = ({ slug }: { slug: string }) => {
|
||||
const [count, setCount] = useState<number | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getCommentCount(slug)
|
||||
.then((result: number) => {
|
||||
setCount(result);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error("[comment-count] error:", err);
|
||||
setError(true);
|
||||
});
|
||||
}, [slug]);
|
||||
|
||||
if (error) {
|
||||
return <span title="Error getting comments">?</span>;
|
||||
}
|
||||
|
||||
if (count === null) {
|
||||
return <span className="motion-safe:animate-pulse">0</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
|
||||
+13
-12
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useState } from "react";
|
||||
import { useDebounce } from "react-use";
|
||||
import { useActionState, useState, useEffect } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { SendIcon, Loader2Icon, CheckIcon, XIcon } from "lucide-react";
|
||||
import Form from "next/form";
|
||||
import Input from "@/components/ui/input";
|
||||
@@ -35,14 +35,14 @@ const ContactForm = () => {
|
||||
// client-side validation using shared schema
|
||||
const [clientErrors, setClientErrors] = useState<Partial<Record<keyof ContactInput, string[]>>>({});
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
const result = ContactSchema.safeParse(formFields);
|
||||
setClientErrors(result.success ? {} : result.error.flatten().fieldErrors);
|
||||
},
|
||||
150,
|
||||
[formFields]
|
||||
);
|
||||
const debouncedValidate = useDebouncedCallback(() => {
|
||||
const result = ContactSchema.safeParse(formFields);
|
||||
setClientErrors(result.success ? {} : result.error.flatten().fieldErrors);
|
||||
}, 150);
|
||||
|
||||
useEffect(() => {
|
||||
debouncedValidate();
|
||||
}, [formFields, debouncedValidate]);
|
||||
|
||||
const hasClientErrors = Object.values(clientErrors).some((errs) => (errs?.length || 0) > 0);
|
||||
|
||||
@@ -59,7 +59,7 @@ const ContactForm = () => {
|
||||
|
||||
return (
|
||||
<Form action={formAction} className="my-6 space-y-4">
|
||||
<div>
|
||||
<div className="not-prose">
|
||||
<Input
|
||||
type="text"
|
||||
name="name"
|
||||
@@ -111,7 +111,8 @@ const ContactForm = () => {
|
||||
{messageError && <span className="text-destructive text-[0.8rem] font-semibold">{messageError}</span>}
|
||||
|
||||
<div className="text-foreground/85 my-2 text-[0.8rem] leading-relaxed">
|
||||
<MarkdownIcon className="mr-1.5 inline-block size-4 align-text-top" /> Basic{" "}
|
||||
<MarkdownIcon className="mr-1.5 inline-block size-4 align-text-top" />
|
||||
Basic{" "}
|
||||
<a
|
||||
href="https://commonmark.org/help/"
|
||||
target="_blank"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import reactToText from "react-to-text";
|
||||
import { LinkIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -14,7 +13,7 @@ const HeadingAnchor = ({ id, title, className }: { id: string; title: string; cl
|
||||
tabIndex={-1}
|
||||
>
|
||||
<LinkIcon className="inline-block size-[0.75em] align-baseline" />
|
||||
<span className="sr-only">Permalink to “{reactToText(title)}”</span>
|
||||
<span className="sr-only">Permalink to “{title}”</span>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { EyeIcon, MessagesSquareIcon } from "lucide-react";
|
||||
import { env } from "@/lib/env";
|
||||
import { getViewCount } from "@/lib/server/views";
|
||||
import { getCommentCount } from "@/lib/server/comments";
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE);
|
||||
|
||||
const PostStats = ({ slug }: { slug: string }) => {
|
||||
const [stats, setStats] = useState<{ views: number; comments: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([getViewCount(slug), getCommentCount(slug)])
|
||||
.then(([views, comments]) => {
|
||||
setStats({ views, comments });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[post-stats] error:", err);
|
||||
// Silently fail - just don't show stats
|
||||
});
|
||||
}, [slug]);
|
||||
|
||||
if (!stats) {
|
||||
return null; // No loading state - badges just appear when ready
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{stats.views > 0 && (
|
||||
<span className="bg-muted text-foreground/65 inline-flex h-5 flex-nowrap items-center gap-1 rounded-md px-1.5 align-text-top text-xs font-semibold text-nowrap shadow select-none">
|
||||
<EyeIcon className="inline-block size-4 shrink-0" aria-hidden="true" />
|
||||
<span className="inline-block leading-none tabular-nums">{numberFormatter.format(stats.views)}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{stats.comments > 0 && (
|
||||
<Link
|
||||
href={`/${slug}#comments`}
|
||||
title={`${numberFormatter.format(stats.comments)} ${stats.comments === 1 ? "comment" : "comments"}`}
|
||||
className="inline-flex hover:no-underline"
|
||||
>
|
||||
<span className="bg-muted text-foreground/65 inline-flex h-5 flex-nowrap items-center gap-1 rounded-md px-1.5 align-text-top text-xs font-semibold text-nowrap shadow select-none">
|
||||
<MessagesSquareIcon className="inline-block size-3 shrink-0" aria-hidden="true" />
|
||||
<span className="inline-block leading-none tabular-nums">{numberFormatter.format(stats.comments)}</span>
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostStats;
|
||||
@@ -1,11 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import TimeAgo from "react-timeago";
|
||||
import { makeIntlFormatter } from "react-timeago/defaultFormatter";
|
||||
|
||||
const intlFormatter = makeIntlFormatter({
|
||||
locale: "en",
|
||||
style: "long",
|
||||
numeric: "auto",
|
||||
});
|
||||
|
||||
const RelativeTime = ({ ...rest }: React.ComponentProps<typeof TimeAgo>) => {
|
||||
return (
|
||||
<span suppressHydrationWarning>
|
||||
<TimeAgo {...rest} />
|
||||
<TimeAgo formatter={intlFormatter} {...rest} />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -55,7 +55,7 @@ const DropdownMenuItem = ({
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
@@ -73,7 +73,7 @@ const DropdownMenuCheckboxItem = ({
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
@@ -102,7 +102,7 @@ const DropdownMenuRadioItem = ({
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
|
||||
Reference in New Issue
Block a user