initial commit: rocket.new export of broadcastbeat
This commit is contained in:
409
src/app/articles/[slug]/ArticleDetailClient.tsx
Normal file
409
src/app/articles/[slug]/ArticleDetailClient.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
"use client";
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import AppImage from "@/components/ui/AppImage";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import type { Article } from "@/lib/articles/sampleArticles";
|
||||
|
||||
// DO NOT OVERRIDE — ArticleDetailClient interface and session tracking
|
||||
interface ArticleDetailClientProps {
|
||||
article: Article;
|
||||
relatedArticles: Article[];
|
||||
}
|
||||
|
||||
function getSessionId(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
let sid = sessionStorage.getItem("bb_session_id");
|
||||
if (!sid) {
|
||||
sid = `sess_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
sessionStorage.setItem("bb_session_id", sid);
|
||||
}
|
||||
return sid;
|
||||
}
|
||||
|
||||
export default function ArticleDetailClient({ article, relatedArticles }: ArticleDetailClientProps) {
|
||||
const [isSaved, setIsSaved] = useState(false);
|
||||
const [savingState, setSavingState] = useState<"idle" | "saving" | "removing">("idle");
|
||||
const [saveToast, setSaveToast] = useState<string | null>(null);
|
||||
const [currentUser, setCurrentUser] = useState<{ id: string } | null>(null);
|
||||
const articleStartTime = useRef<number>(Date.now());
|
||||
const hasTrackedView = useRef(false);
|
||||
const supabase = createClient();
|
||||
|
||||
// Get current user
|
||||
useEffect(() => {
|
||||
supabase?.auth?.getUser().then(({ data }) => {
|
||||
setCurrentUser(data?.user ?? null);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Track article view on mount
|
||||
useEffect(() => {
|
||||
if (hasTrackedView.current || !article?.slug) return;
|
||||
hasTrackedView.current = true;
|
||||
articleStartTime.current = Date.now();
|
||||
|
||||
const trackView = async () => {
|
||||
try {
|
||||
const { data: userData } = await supabase?.auth?.getUser();
|
||||
await supabase?.from("article_analytics")?.insert({
|
||||
article_slug: article.slug,
|
||||
article_title: article.title,
|
||||
user_id: userData?.user?.id ?? null,
|
||||
session_id: getSessionId(),
|
||||
event_type: "view",
|
||||
read_time_seconds: 0,
|
||||
scroll_depth: 0,
|
||||
referrer: typeof document !== "undefined" ? document.referrer : "",
|
||||
});
|
||||
|
||||
// Track reading history and topic preferences for authenticated users
|
||||
if (userData?.user?.id) {
|
||||
// Upsert reading history
|
||||
await supabase?.from("reading_history")?.upsert(
|
||||
{
|
||||
user_id: userData.user.id,
|
||||
article_slug: article.slug,
|
||||
article_title: article.title,
|
||||
article_excerpt: article.excerpt ?? null,
|
||||
article_image: article.image ?? null,
|
||||
article_image_alt: article.alt ?? null,
|
||||
article_category: article.category ?? null,
|
||||
article_author: article.author ?? null,
|
||||
article_read_time: article.readTime ?? null,
|
||||
article_date: article.date ?? null,
|
||||
viewed_at: new Date().toISOString(),
|
||||
},
|
||||
{ onConflict: "user_id,article_slug" }
|
||||
);
|
||||
|
||||
// Upsert topic preference
|
||||
if (article.category) {
|
||||
const topic = article.category.toLowerCase().trim();
|
||||
const { data: existing } = await supabase
|
||||
?.from("user_topic_preferences")
|
||||
?.select("id, view_count")
|
||||
?.eq("user_id", userData.user.id)
|
||||
?.eq("topic", topic)
|
||||
?.maybeSingle();
|
||||
|
||||
if (existing) {
|
||||
await supabase
|
||||
?.from("user_topic_preferences")
|
||||
?.update({
|
||||
view_count: (existing.view_count || 0) + 1,
|
||||
last_viewed_at: new Date().toISOString(),
|
||||
})
|
||||
?.eq("id", existing.id);
|
||||
} else {
|
||||
await supabase?.from("user_topic_preferences")?.insert({
|
||||
user_id: userData.user.id,
|
||||
topic,
|
||||
view_count: 1,
|
||||
last_viewed_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silent fail — analytics should not break the page
|
||||
}
|
||||
};
|
||||
trackView();
|
||||
}, [article?.slug]);
|
||||
|
||||
// Track read completion on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (!article?.slug) return;
|
||||
const readSeconds = Math.round((Date.now() - articleStartTime.current) / 1000);
|
||||
if (readSeconds < 5) return;
|
||||
const trackRead = async () => {
|
||||
try {
|
||||
const { data: userData } = await supabase?.auth?.getUser();
|
||||
await supabase?.from("article_analytics")?.insert({
|
||||
article_slug: article.slug,
|
||||
article_title: article.title,
|
||||
user_id: userData?.user?.id ?? null,
|
||||
session_id: getSessionId(),
|
||||
event_type: "read",
|
||||
read_time_seconds: readSeconds,
|
||||
scroll_depth: 0,
|
||||
referrer: typeof document !== "undefined" ? document.referrer : "",
|
||||
});
|
||||
} catch {
|
||||
// Silent fail
|
||||
}
|
||||
};
|
||||
trackRead();
|
||||
};
|
||||
}, [article?.slug]);
|
||||
|
||||
// Check if article is already saved
|
||||
useEffect(() => {
|
||||
if (!currentUser || !article?.slug) return;
|
||||
const checkSaved = async () => {
|
||||
try {
|
||||
const { data } = await supabase
|
||||
?.from("reading_list")
|
||||
?.select("id")
|
||||
?.eq("user_id", currentUser.id)
|
||||
?.eq("article_slug", article.slug)
|
||||
?.maybeSingle();
|
||||
setIsSaved(!!data);
|
||||
} catch {
|
||||
// Silent fail
|
||||
}
|
||||
};
|
||||
checkSaved();
|
||||
}, [currentUser, article?.slug]);
|
||||
|
||||
const handleSaveToReadingList = useCallback(async () => {
|
||||
if (!currentUser) {
|
||||
setSaveToast("Sign in to save articles to your reading list");
|
||||
setTimeout(() => setSaveToast(null), 3000);
|
||||
return;
|
||||
}
|
||||
if (isSaved) {
|
||||
setSavingState("removing");
|
||||
try {
|
||||
await supabase
|
||||
?.from("reading_list")
|
||||
?.delete()
|
||||
?.eq("user_id", currentUser.id)
|
||||
?.eq("article_slug", article.slug);
|
||||
setIsSaved(false);
|
||||
setSaveToast("Removed from reading list");
|
||||
} catch {
|
||||
setSaveToast("Failed to remove article");
|
||||
} finally {
|
||||
setSavingState("idle");
|
||||
setTimeout(() => setSaveToast(null), 2500);
|
||||
}
|
||||
} else {
|
||||
setSavingState("saving");
|
||||
try {
|
||||
await supabase?.from("reading_list")?.insert({
|
||||
user_id: currentUser.id,
|
||||
article_slug: article.slug,
|
||||
article_title: article.title,
|
||||
article_excerpt: article.excerpt,
|
||||
article_image: article.image,
|
||||
article_image_alt: article.alt,
|
||||
article_category: article.category,
|
||||
article_author: article.author,
|
||||
article_read_time: article.readTime,
|
||||
article_date: article.date,
|
||||
});
|
||||
setIsSaved(true);
|
||||
setSaveToast("Saved to reading list");
|
||||
} catch {
|
||||
setSaveToast("Failed to save article");
|
||||
} finally {
|
||||
setSavingState("idle");
|
||||
setTimeout(() => setSaveToast(null), 2500);
|
||||
}
|
||||
}
|
||||
}, [currentUser, isSaved, article]);
|
||||
|
||||
// Breadcrumb section label
|
||||
const sectionLabel = article.section === "gear" ? "Gear & Reviews"
|
||||
: article.section === "show-coverage" ? "Show Coverage"
|
||||
: article.section === "technology"? "Technology" :"News";
|
||||
const sectionHref = article.section === "gear" ? "/gear"
|
||||
: article.section === "show-coverage" ? "/show-coverage"
|
||||
: article.section === "technology"? "/technology" :"/news";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
|
||||
{/* Breadcrumb navigation */}
|
||||
{/* DO NOT OVERRIDE — article breadcrumb and back navigation */}
|
||||
<div className="bg-[#0d0d0d] border-b border-[#1a1a1a]">
|
||||
<div className="max-w-container mx-auto px-4 py-2.5 flex items-center gap-2 text-xs font-body text-[#555]">
|
||||
<Link href="/home-page" className="hover:text-[#3b82f6] transition-colors">Home</Link>
|
||||
<span>/</span>
|
||||
<Link href={sectionHref} className="hover:text-[#3b82f6] transition-colors">{sectionLabel}</Link>
|
||||
<span>/</span>
|
||||
<span className="text-[#888] truncate max-w-[300px]">{article.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Article Content */}
|
||||
<article className="max-w-container mx-auto px-4 py-8 md:py-12">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-8">
|
||||
{/* Article Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Link href={sectionHref} className="text-xs font-bold uppercase tracking-widest text-[#3b82f6] hover:underline">
|
||||
{article.category}
|
||||
</Link>
|
||||
<span className="text-xs text-[#777]">{article.date}</span>
|
||||
<span className="text-xs text-[#555]">·</span>
|
||||
<span className="text-xs text-[#777]">{article.readTime}</span>
|
||||
</div>
|
||||
<h1 className="font-heading text-3xl md:text-4xl font-bold mb-4 leading-tight text-[#f0f0f0]">
|
||||
{article.title}
|
||||
</h1>
|
||||
<p className="font-body text-lg text-[#999] mb-6 leading-relaxed">{article.excerpt}</p>
|
||||
|
||||
{/* Article Meta */}
|
||||
<div className="flex items-center justify-between py-4 border-t border-b border-[#222]">
|
||||
<div className="flex items-center gap-3">
|
||||
<AppImage
|
||||
src={article.authorAvatar}
|
||||
alt={`${article.author} — ${article.authorTitle}`}
|
||||
width={40}
|
||||
height={40}
|
||||
className="w-10 h-10 rounded-full object-cover"
|
||||
/>
|
||||
<div>
|
||||
<Link
|
||||
href={`/authors/${article.authorSlug}`}
|
||||
className="font-heading font-bold text-sm hover:text-[#3b82f6] transition-colors text-[#e0e0e0]">
|
||||
{article.author}
|
||||
</Link>
|
||||
<p className="font-body text-xs text-[#777]">{article.authorTitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSaveToReadingList}
|
||||
disabled={savingState !== "idle"}
|
||||
aria-label={isSaved ? "Remove from reading list" : "Save to reading list"}
|
||||
className="text-xs font-bold uppercase tracking-widest text-[#3b82f6] hover:text-blue-300 disabled:opacity-50 transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6]">
|
||||
{savingState === "saving" ? "Saving..." : savingState === "removing" ? "Removing..." : isSaved ? "✓ Saved" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Featured Image */}
|
||||
<div className="mb-8 overflow-hidden rounded-sm">
|
||||
<AppImage
|
||||
src={article.image}
|
||||
alt={article.alt}
|
||||
width={800}
|
||||
height={450}
|
||||
className="w-full h-auto object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Article Body */}
|
||||
{/* DO NOT OVERRIDE — article body prose styles */}
|
||||
<div
|
||||
className="prose prose-invert max-w-none mb-8 font-body text-[#aaa] leading-relaxed
|
||||
[&_h2]:font-heading [&_h2]:text-[#e0e0e0] [&_h2]:text-xl [&_h2]:font-bold [&_h2]:mt-8 [&_h2]:mb-4 [&_h2]:border-b [&_h2]:border-[#222] [&_h2]:pb-2
|
||||
[&_p]:mb-4 [&_p]:leading-relaxed
|
||||
[&_ul]:mb-4 [&_ul]:pl-5 [&_ul]:space-y-2
|
||||
[&_li]:text-[#aaa]
|
||||
[&_strong]:text-[#e0e0e0] [&_strong]:font-bold
|
||||
[&_a]:text-[#3b82f6] [&_a]:hover:underline"
|
||||
dangerouslySetInnerHTML={{ __html: article.content }}
|
||||
/>
|
||||
|
||||
{/* Tags */}
|
||||
{article.tags && article.tags.length > 0 && (
|
||||
<div className="py-6 border-t border-[#222]">
|
||||
<p className="font-body text-xs text-[#555] uppercase tracking-wider mb-3">Tags</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{article.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-3 py-1 text-xs font-body bg-[#1a1a1a] border border-[#2a2a2a] text-[#888] hover:border-[#3b82f6] hover:text-[#3b82f6] transition-colors rounded-sm cursor-default">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Back navigation */}
|
||||
<div className="pt-6 border-t border-[#222]">
|
||||
<Link
|
||||
href={sectionHref}
|
||||
className="inline-flex items-center gap-2 text-[#3b82f6] font-body text-sm hover:underline focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6]">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
Back to {sectionLabel}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className="lg:col-span-4 space-y-6">
|
||||
{/* Related Articles */}
|
||||
{/* DO NOT OVERRIDE — related articles sidebar */}
|
||||
<div className="bg-[#111] border border-[#222] p-5">
|
||||
<h3 className="font-body font-bold text-xs text-[#3b82f6] uppercase tracking-widest mb-4 pb-2 border-b border-[#222]">
|
||||
Related Articles
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{relatedArticles.map((related) => (
|
||||
<Link
|
||||
key={related.slug}
|
||||
href={`/articles/${related.slug}`}
|
||||
className="flex gap-3 group">
|
||||
<div className="flex-shrink-0 w-[80px] h-[55px] relative overflow-hidden rounded-sm">
|
||||
<AppImage
|
||||
src={related.image}
|
||||
alt={related.alt}
|
||||
fill
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
sizes="80px"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[#3b82f6] font-body text-[9px] font-bold uppercase tracking-wider mb-0.5">
|
||||
{related.category}
|
||||
</p>
|
||||
<p className="font-heading text-[#e0e0e0] text-xs font-bold group-hover:text-[#3b82f6] transition-colors line-clamp-2 leading-snug">
|
||||
{related.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Newsletter Signup */}
|
||||
<div className="bg-[#0d1520] border border-[#1e3a5f] p-5">
|
||||
<h3 className="font-heading text-[#e0e0e0] font-bold mb-2">Stay Updated</h3>
|
||||
<p className="font-body text-[#777] text-xs mb-4">
|
||||
Get the latest broadcast engineering news delivered to your inbox.
|
||||
</p>
|
||||
<Link href="/home-page#newsletter" className="btn-subscribe text-xs py-2 px-4 inline-block w-full text-center">
|
||||
Subscribe Free
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Ad placeholder */}
|
||||
<div className="ad-placeholder w-full h-[250px] flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-[#444] text-xs font-body">Advertisement</p>
|
||||
<p className="text-[#333] text-[10px] font-body mt-1">300×250</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{/* Toast Notification */}
|
||||
{saveToast && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="fixed bottom-4 right-4 bg-[#1a1a1a] border border-[#3b82f6] text-white px-4 py-3 rounded-sm text-sm font-body shadow-lg z-50">
|
||||
{saveToast}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
249
src/app/articles/[slug]/components/CommentsSection.tsx
Normal file
249
src/app/articles/[slug]/components/CommentsSection.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
|
||||
interface Comment {
|
||||
id: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
user_id: string;
|
||||
user_profiles: {
|
||||
full_name: string;
|
||||
avatar_url: string | null;
|
||||
email: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface CommentsSectionProps {
|
||||
articleSlug: string;
|
||||
}
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return "just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
return new Date(dateStr).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
}
|
||||
|
||||
export default function CommentsSection({ articleSlug }: CommentsSectionProps) {
|
||||
const supabase = createClient();
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [user, setUser] = useState<any>(null);
|
||||
const [newComment, setNewComment] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const fetchComments = useCallback(async () => {
|
||||
try {
|
||||
const { data, error: fetchError } = await supabase
|
||||
.from("article_comments")
|
||||
.select(`
|
||||
id,
|
||||
content,
|
||||
created_at,
|
||||
user_id,
|
||||
user_profiles (
|
||||
full_name,
|
||||
avatar_url,
|
||||
email
|
||||
)
|
||||
`)
|
||||
.eq("article_slug", articleSlug)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (fetchError) throw fetchError;
|
||||
setComments((data as unknown as Comment[]) || []);
|
||||
} catch {
|
||||
// Silent fail
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [articleSlug]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchComments();
|
||||
supabase.auth.getUser().then(({ data }) => {
|
||||
setUser(data?.user ?? null);
|
||||
});
|
||||
}, [fetchComments]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user || !newComment.trim()) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { error: insertError } = await supabase.from("article_comments").insert({
|
||||
article_slug: articleSlug,
|
||||
user_id: user.id,
|
||||
content: newComment.trim(),
|
||||
});
|
||||
if (insertError) throw insertError;
|
||||
setNewComment("");
|
||||
await fetchComments();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to post comment. Please try again.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (commentId: string) => {
|
||||
setDeletingId(commentId);
|
||||
try {
|
||||
await supabase.from("article_comments").delete().eq("id", commentId);
|
||||
setComments((prev) => prev.filter((c) => c.id !== commentId));
|
||||
} catch {
|
||||
// Silent fail
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="mt-10 pt-8 border-t border-[#2a2a2a]">
|
||||
{/* Section Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<span className="section-label">Comments</span>
|
||||
{!loading && (
|
||||
<span className="font-body text-xs text-[#555]">
|
||||
{comments.length} {comments.length === 1 ? "comment" : "comments"}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1 h-px bg-[#2a2a2a]" />
|
||||
</div>
|
||||
|
||||
{/* Comment Form */}
|
||||
{user ? (
|
||||
<form onSubmit={handleSubmit} className="mb-8">
|
||||
<div className="flex gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-[#1e3a5f] border border-[#2a3a50] flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<span className="font-body text-[10px] font-bold text-[#3b82f6]">
|
||||
{getInitials(user.user_metadata?.full_name || user.email || "U")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<textarea
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
placeholder="Share your thoughts on this article..."
|
||||
rows={3}
|
||||
maxLength={1000}
|
||||
className="w-full bg-[#1a1a1a] border border-[#2a2a2a] text-[#e0e0e0] font-body text-sm px-3 py-2.5 rounded-sm focus:outline-none focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6] transition-colors placeholder-[#444] resize-none"
|
||||
/>
|
||||
{error && (
|
||||
<p className="font-body text-xs text-[#cc0000] mt-1.5">{error}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="font-body text-[11px] text-[#555]">
|
||||
{newComment.length}/1000
|
||||
</span>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !newComment.trim()}
|
||||
className="bg-[#3b82f6] hover:bg-[#2563eb] text-white font-body text-xs font-bold uppercase tracking-wide px-5 py-2 rounded-sm transition-colors disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6]">
|
||||
{submitting ? "Posting..." : "Post Comment"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="mb-8 bg-[#0d1520] border border-[#1e3a5f] rounded-sm p-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-body text-sm font-bold text-[#e0e0e0] mb-0.5">Join the conversation</p>
|
||||
<p className="font-body text-xs text-[#666]">Sign in to post a comment on this article.</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex-shrink-0 bg-[#3b82f6] hover:bg-[#2563eb] text-white font-body text-xs font-bold uppercase tracking-wide px-5 py-2 rounded-sm transition-colors">
|
||||
Sign In
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comments List */}
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="flex gap-3">
|
||||
<div className="skeleton w-8 h-8 rounded-full flex-shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="skeleton h-3 w-32 rounded-sm" />
|
||||
<div className="skeleton h-4 w-full rounded-sm" />
|
||||
<div className="skeleton h-4 w-3/4 rounded-sm" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : comments.length === 0 ? (
|
||||
<div className="empty-state py-10">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="empty-state-icon" aria-hidden="true">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
<p className="empty-state-title">No comments yet</p>
|
||||
<p className="empty-state-desc">Be the first to share your thoughts on this article.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{comments.map((comment) => {
|
||||
const name = comment.user_profiles?.full_name || comment.user_profiles?.email?.split("@")[0] || "Anonymous";
|
||||
const isOwn = user?.id === comment.user_id;
|
||||
return (
|
||||
<div key={comment.id} className="flex gap-3 group">
|
||||
<div className="w-8 h-8 rounded-full bg-[#1a2535] border border-[#2a3a50] flex items-center justify-center flex-shrink-0 mt-0.5 overflow-hidden">
|
||||
{comment.user_profiles?.avatar_url ? (
|
||||
<img
|
||||
src={comment.user_profiles.avatar_url}
|
||||
alt={`${name} avatar`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="font-body text-[10px] font-bold text-[#3b82f6]">
|
||||
{getInitials(name)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-body text-xs font-bold text-[#e0e0e0]">{name}</span>
|
||||
<span className="font-body text-[11px] text-[#555]">{timeAgo(comment.created_at)}</span>
|
||||
{isOwn && (
|
||||
<button
|
||||
onClick={() => handleDelete(comment.id)}
|
||||
disabled={deletingId === comment.id}
|
||||
aria-label="Delete comment"
|
||||
className="ml-auto opacity-0 group-hover:opacity-100 font-body text-[11px] text-[#555] hover:text-[#cc0000] transition-all disabled:opacity-30">
|
||||
{deletingId === comment.id ? "Deleting..." : "Delete"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="font-body text-sm text-[#cccccc] leading-relaxed whitespace-pre-wrap break-words">
|
||||
{comment.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
105
src/app/articles/[slug]/page.tsx
Normal file
105
src/app/articles/[slug]/page.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import ArticleDetailPage from './ArticleDetailClient';
|
||||
import { sampleArticles, getRelatedArticles } from '@/lib/articles/sampleArticles';
|
||||
|
||||
// DO NOT OVERRIDE — article slug generation and metadata
|
||||
interface PageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const article = sampleArticles.find((a) => a.slug === slug);
|
||||
|
||||
if (!article) {
|
||||
return {
|
||||
title: 'Article Not Found',
|
||||
description: 'The article you are looking for does not exist.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: article.title,
|
||||
description: article.excerpt,
|
||||
alternates: {
|
||||
canonical: `/articles/${article.slug}`,
|
||||
},
|
||||
openGraph: {
|
||||
type: 'article',
|
||||
title: article.title,
|
||||
description: article.excerpt,
|
||||
url: `/articles/${article.slug}`,
|
||||
images: [
|
||||
{
|
||||
url: article.image,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: article.alt,
|
||||
type: 'image/jpeg',
|
||||
},
|
||||
],
|
||||
authors: [article.author],
|
||||
publishedTime: article.date,
|
||||
tags: article.tags,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: article.title,
|
||||
description: article.excerpt,
|
||||
images: [article.image],
|
||||
creator: '@BroadcastBeat',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return sampleArticles.map((article) => ({
|
||||
slug: article.slug,
|
||||
}));
|
||||
}
|
||||
|
||||
export default async function ArticlePage({ params }: PageProps) {
|
||||
const { slug } = await params;
|
||||
const article = sampleArticles.find((a) => a.slug === slug);
|
||||
|
||||
if (!article) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const related = getRelatedArticles(slug, 3);
|
||||
|
||||
const articleSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: article.title,
|
||||
description: article.excerpt,
|
||||
image: article.image,
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: article.author,
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'BroadcastBeat',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: 'https://broadcastb5322.builtwithrocket.new/assets/images/logo.png',
|
||||
},
|
||||
},
|
||||
datePublished: article.date,
|
||||
dateModified: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(articleSchema),
|
||||
}}
|
||||
/>
|
||||
<ArticleDetailPage article={article} relatedArticles={related} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user