Skip to content

Commit 116248b

Browse files
committed
feat: implement article preview page and enhance image handling in article forms
1 parent ea45a93 commit 116248b

8 files changed

Lines changed: 376 additions & 43 deletions

File tree

app/admin/articles/[id]/page.tsx

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { Checkbox } from "@/components/ui/checkbox";
2222
import { useToast } from "@/hooks/use-toast";
2323
import { Breadcrumb } from "@/components/breadcrumb";
2424
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
25-
import { formatDate, sanitizeUrl, sanitizeText } from "@/lib/utils";
25+
import { formatDate, sanitizeUrl } from "@/lib/utils";
2626
import {
2727
Loader2,
2828
AlertTriangle,
@@ -67,6 +67,23 @@ export default function EditArticlePage() {
6767
const labelSuggestionsRef = useRef<HTMLDivElement>(null);
6868
const [copied, setCopied] = useState(false);
6969

70+
const trimmedImg = img.trim();
71+
const thumbnailPreviewSrc = (() => {
72+
if (!trimmedImg) return "";
73+
if (trimmedImg.startsWith("/")) return trimmedImg;
74+
75+
try {
76+
const parsed = new URL(trimmedImg);
77+
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
78+
return parsed.toString();
79+
}
80+
} catch {
81+
return "";
82+
}
83+
84+
return "";
85+
})();
86+
7087
const router = useRouter();
7188
const params = useParams();
7289
const id = Array.isArray(params.id) ? params.id[0] : params.id;
@@ -234,7 +251,7 @@ export default function EditArticlePage() {
234251
title,
235252
content,
236253
description,
237-
img,
254+
img: sanitizeUrl(trimmedImg),
238255
imgAlt,
239256
label,
240257
slug,
@@ -629,8 +646,8 @@ export default function EditArticlePage() {
629646
{img ? (
630647
<div className="relative">
631648
<img
632-
src={sanitizeUrl(img) || "/placeholder.svg"}
633-
alt={sanitizeText(imgAlt) || "Thumbnail Preview"}
649+
src={thumbnailPreviewSrc || "/placeholder.svg"}
650+
alt={imgAlt.trim() || "Thumbnail Preview"}
634651
className="max-h-64 object-contain border border-white/20 rounded-lg"
635652
onError={(e) => {
636653
e.currentTarget.src = "/placeholder.svg?height=200&width=400";
@@ -755,7 +772,7 @@ export default function EditArticlePage() {
755772
</div>
756773

757774
{/* Popularity */}
758-
<div className="mb-6 flex items-center space-x-2">
775+
{/* <div className="mb-6 flex items-center space-x-2">
759776
<Checkbox
760777
id="popularity"
761778
checked={popularity}
@@ -768,7 +785,7 @@ export default function EditArticlePage() {
768785
Mark as popular article
769786
</label>
770787
</div>
771-
788+
*/}
772789
{/* Read Time */}
773790
<div className="mb-6">
774791
<label className="block mb-2 font-medium">Read Time:</label>
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
"use client";
2+
3+
import { useEffect, useMemo, useState } from "react";
4+
import Link from "next/link";
5+
import { useParams } from "next/navigation";
6+
import { marked } from "marked";
7+
import DOMPurify from "dompurify";
8+
import {
9+
ArrowLeft,
10+
CalendarDays,
11+
Clock3,
12+
ExternalLink,
13+
FileText,
14+
Pencil,
15+
} from "lucide-react";
16+
import { Breadcrumb } from "@/components/breadcrumb";
17+
import { Button } from "@/components/ui/button";
18+
import { useDocument } from "@/hooks/use-firestore-query";
19+
import { formatDate, sanitizeUrl } from "@/lib/utils";
20+
21+
interface ArticlePreview {
22+
id: string;
23+
title?: string;
24+
description?: string;
25+
content?: string | Record<string, unknown>;
26+
img?: string;
27+
imgAlt?: string;
28+
label?: string;
29+
authorName?: string;
30+
slug?: string;
31+
publish?: boolean;
32+
createdAt?: any;
33+
date?: any;
34+
scheduledPublishDate?: any;
35+
read?: string;
36+
}
37+
38+
export default function AdminArticlePreviewPage() {
39+
const params = useParams();
40+
const articleId = Array.isArray(params.id) ? params.id[0] : params.id;
41+
const { data: article, isLoading } = useDocument<ArticlePreview>(
42+
"articles",
43+
articleId ?? null,
44+
);
45+
const [previewHtml, setPreviewHtml] = useState("");
46+
47+
const articleContent = useMemo(() => {
48+
if (!article?.content) return "";
49+
return typeof article.content === "string"
50+
? article.content
51+
: JSON.stringify(article.content, null, 2);
52+
}, [article?.content]);
53+
54+
const previewImage = useMemo(() => sanitizeUrl(article?.img), [article?.img]);
55+
const isPublished = Boolean(article?.publish);
56+
const liveHref =
57+
article?.slug && isPublished
58+
? `https://lap-docs.netlify.app/posts/${article.slug}`
59+
: null;
60+
61+
useEffect(() => {
62+
const generatePreview = async () => {
63+
if (!articleContent) {
64+
setPreviewHtml("<p>No article content yet.</p>");
65+
return;
66+
}
67+
68+
try {
69+
marked.setOptions({
70+
gfm: true,
71+
breaks: true,
72+
});
73+
74+
const rawHtml = await marked.parse(articleContent);
75+
const sanitized = DOMPurify.sanitize(rawHtml, {
76+
ADD_TAGS: ["iframe"],
77+
ADD_ATTR: [
78+
"allow",
79+
"allowfullscreen",
80+
"frameborder",
81+
"height",
82+
"scrolling",
83+
"src",
84+
"width",
85+
],
86+
});
87+
88+
setPreviewHtml(sanitized);
89+
} catch (error) {
90+
console.error("Error generating preview:", error);
91+
setPreviewHtml("<p>Error generating preview.</p>");
92+
}
93+
};
94+
95+
generatePreview();
96+
}, [articleContent]);
97+
98+
const breadcrumbItems = [
99+
{ label: "Dashboard", href: "/admin" },
100+
{ label: "Articles", href: "/admin/articles" },
101+
{ label: article?.title || "Preview" },
102+
];
103+
104+
if (isLoading) {
105+
return (
106+
<div className="min-h-screen px-4 py-8 text-white">
107+
<div className="flex items-center gap-3 text-white/70">
108+
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/20 border-t-white" />
109+
Loading article preview...
110+
</div>
111+
</div>
112+
);
113+
}
114+
115+
if (!article) {
116+
return (
117+
<div className="min-h-screen px-4 py-8 text-white">
118+
<div className="mb-6">
119+
<Breadcrumb items={breadcrumbItems} />
120+
</div>
121+
122+
<div className="max-w-3xl rounded-none border border-white/10 bg-white/5 p-8">
123+
<h1 className="text-2xl font-semibold">Article not found</h1>
124+
<p className="mt-3 text-white/70">
125+
This article could not be loaded for preview.
126+
</p>
127+
<Button asChild className="mt-6" variant="outline">
128+
<Link href="/admin/articles">
129+
<ArrowLeft className="mr-2 h-4 w-4" />
130+
Back to articles
131+
</Link>
132+
</Button>
133+
</div>
134+
</div>
135+
);
136+
}
137+
138+
return (
139+
<div className="min-h-screen px-4 pb-10 text-white">
140+
<div className="mb-4 mt-6 md:mt-0">
141+
<Breadcrumb items={breadcrumbItems} />
142+
</div>
143+
144+
<div className="mb-6 flex flex-col gap-4 border border-white/10 bg-white/5 p-5 lg:flex-row lg:items-start lg:justify-between">
145+
<div className="space-y-4">
146+
<div className="flex flex-wrap items-center gap-2">
147+
<span
148+
className={`inline-flex items-center rounded-none px-3 py-1 text-xs font-semibold uppercase tracking-wide ${
149+
isPublished
150+
? "bg-emerald-500/15 text-emerald-300"
151+
: article.scheduledPublishDate
152+
? "bg-orange-500/15 text-orange-300"
153+
: "bg-sky-500/15 text-sky-300"
154+
}`}
155+
>
156+
{isPublished
157+
? "Live on public site"
158+
: article.scheduledPublishDate
159+
? "Scheduled preview"
160+
: "Draft preview"}
161+
</span>
162+
{article.label ? (
163+
<span className="inline-flex items-center rounded-none border border-white/10 bg-white/5 px-3 py-1 text-xs text-white/70">
164+
{article.label}
165+
</span>
166+
) : null}
167+
</div>
168+
169+
<div>
170+
<h1 className="text-3xl font-semibold sm:text-4xl">
171+
{article.title || "Untitled article"}
172+
</h1>
173+
{article.description ? (
174+
<p className="mt-3 max-w-3xl text-base text-white/70 sm:text-lg">
175+
{article.description}
176+
</p>
177+
) : null}
178+
</div>
179+
180+
<div className="flex flex-wrap gap-4 text-sm text-white/60">
181+
<span className="inline-flex items-center gap-2">
182+
<FileText className="h-4 w-4" />
183+
{article.authorName || "Unknown author"}
184+
</span>
185+
{article.read ? (
186+
<span className="inline-flex items-center gap-2">
187+
<Clock3 className="h-4 w-4" />
188+
{article.read}
189+
</span>
190+
) : null}
191+
{article.createdAt ? (
192+
<span className="inline-flex items-center gap-2">
193+
<CalendarDays className="h-4 w-4" />
194+
Created {formatDate(article.createdAt.toDate?.())}
195+
</span>
196+
) : null}
197+
{article.scheduledPublishDate && !isPublished ? (
198+
<span className="inline-flex items-center gap-2 text-orange-300">
199+
<CalendarDays className="h-4 w-4" />
200+
Scheduled for{" "}
201+
{formatDate(article.scheduledPublishDate.toDate?.())}
202+
</span>
203+
) : null}
204+
{article.date && isPublished ? (
205+
<span className="inline-flex items-center gap-2 text-emerald-300">
206+
<CalendarDays className="h-4 w-4" />
207+
Published {formatDate(article.date.toDate?.() || article.date)}
208+
</span>
209+
) : null}
210+
</div>
211+
</div>
212+
213+
<div className="flex flex-wrap gap-3">
214+
<Button asChild variant="outline">
215+
<Link href={`/admin/articles/${article.id}`}>
216+
<Pencil className="mr-2 h-4 w-4" />
217+
Edit article
218+
</Link>
219+
</Button>
220+
{liveHref ? (
221+
<Button asChild>
222+
<Link href={liveHref} target="_blank" rel="noreferrer">
223+
<ExternalLink className="mr-2 h-4 w-4" />
224+
View live post
225+
</Link>
226+
</Button>
227+
) : null}
228+
</div>
229+
</div>
230+
231+
{previewImage ? (
232+
<div className="mb-8 overflow-hidden border border-white/10 bg-white/5">
233+
<img
234+
src={previewImage}
235+
alt={article.imgAlt?.trim() || article.title || "Article preview"}
236+
className="max-h-[28rem] w-full object-cover"
237+
onError={(event) => {
238+
event.currentTarget.src = "/placeholder.svg?height=480&width=1280";
239+
}}
240+
/>
241+
</div>
242+
) : null}
243+
244+
<article className="border border-white/10 bg-[#0d0d0d] p-6 sm:p-8">
245+
<div
246+
className="markdown-body mx-auto max-w-4xl"
247+
dangerouslySetInnerHTML={{ __html: previewHtml }}
248+
/>
249+
</article>
250+
</div>
251+
);
252+
}

app/admin/articles/new/page.tsx

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
2424
import { Loader2, ImageIcon, Save } from "lucide-react";
2525
import { marked } from "marked";
2626
import DOMPurify from "dompurify";
27-
import { generateSlugFromTitle, sanitizeUrl, sanitizeText } from "@/lib/utils";
27+
import { generateSlugFromTitle, sanitizeUrl } from "@/lib/utils";
2828
import { MarkdownToolbar } from "@/components/markdown-toolbar";
2929
import { AssetManager } from "@/components/admin/assets/asset-manager";
3030
import { convertImageToWebP } from "@/lib/image-utils";
@@ -72,6 +72,23 @@ export default function NewArticlePage() {
7272
const [showLabelSuggestions, setShowLabelSuggestions] = useState(false);
7373
const labelSuggestionsRef = useRef<HTMLDivElement>(null);
7474

75+
const trimmedImg = img.trim();
76+
const thumbnailPreviewSrc = (() => {
77+
if (!trimmedImg) return "";
78+
if (trimmedImg.startsWith("/")) return trimmedImg;
79+
80+
try {
81+
const parsed = new URL(trimmedImg);
82+
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
83+
return parsed.toString();
84+
}
85+
} catch {
86+
return "";
87+
}
88+
89+
return "";
90+
})();
91+
7592
const router = useRouter();
7693
const { toast } = useToast();
7794

@@ -319,7 +336,7 @@ export default function NewArticlePage() {
319336
title,
320337
content,
321338
description,
322-
img,
339+
img: sanitizeUrl(trimmedImg),
323340
imgAlt,
324341
label,
325342
popularity,
@@ -583,8 +600,8 @@ export default function NewArticlePage() {
583600
{img ? (
584601
<div className="relative">
585602
<img
586-
src={sanitizeUrl(img) || "/placeholder.svg"}
587-
alt={sanitizeText(imgAlt) || "Thumbnail Preview"}
603+
src={thumbnailPreviewSrc || "/placeholder.svg"}
604+
alt={imgAlt.trim() || "Thumbnail Preview"}
588605
className="max-h-64 object-contain border border-white/20 rounded-lg"
589606
onError={(e) => {
590607
e.currentTarget.src = "/placeholder.svg?height=200&width=400";
@@ -677,7 +694,7 @@ export default function NewArticlePage() {
677694
</div>
678695

679696
{/* Popularity */}
680-
<div className="mb-6 flex items-center space-x-2">
697+
{/*} <div className="mb-6 flex items-center space-x-2">
681698
<Checkbox
682699
id="popularity"
683700
checked={popularity}
@@ -689,7 +706,7 @@ export default function NewArticlePage() {
689706
>
690707
Mark as popular article
691708
</label>
692-
</div>
709+
</div> */}
693710

694711
{/* Read Time */}
695712
<div className="mb-6">

0 commit comments

Comments
 (0)