This commit is contained in:
Emmanuel Rojas committed 2026-10-06 02:00:11 -05:00
1 parent 794a659369
commit 5f99b65611
349 files changed
+32252 -2180

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
"use client";
import { useParams } from "next/navigation";
import BlogEditorPage from "../../_components/BlogEditorPage";
export default function EditBlogPage() {
const params = useParams();
const id = typeof params.id === "string" ? params.id : params.id?.[0];
return <BlogEditorPage postId={id} />;
}
+8
View File
@@ -0,0 +1,8 @@
import type { Metadata } from "next";
import BlogEditor from "./_page";
export const metadata: Metadata = { title: "Editar artículo" };
export default function Page() {
return <BlogEditor />;
}
@@ -0,0 +1,558 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { z } from "zod";
import { Icon } from "@iconify/react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Button } from "@/app/admin/components/ui/Button";
import { Input } from "@/app/admin/components/ui/Input";
import { Textarea } from "@/app/admin/components/ui/Textarea";
import Toggle from "@/app/admin/components/Toggle";
import {
useBlogPost,
useCreateBlogPost,
useUpdateBlogPost,
useUploadBlogImage,
type CreateBlogPostRequest,
} from "@/app/admin/blog/services/blog.service";
import { assetUrl } from "@/app/admin/services/http";
import { ERR_MSG } from "@/app/admin/lib/errorMessage";
const blogPostSchema = z.object({
title: z.string().trim().min(1, "El título es requerido").max(200, "Máximo 200 caracteres"),
category: z.string().trim().min(1, "La categoría es requerida").max(80, "Máximo 80 caracteres"),
});
type BlogPostFormErrors = Partial<Record<keyof z.infer<typeof blogPostSchema>, string>>;
function toSlug(text: string) {
return text
.toLowerCase()
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9\s-]/g, "")
.trim()
.replace(/\s+/g, "-");
}
// Replica el markdown básico tal cual lo renderiza el artículo público
// (src/app/(landing)/blog/[slug]/ArticlePage.tsx) para que el preview del
// editor no diverja del resultado real en la landing.
function renderPreviewContent(content: string) {
return content.split("\n").map((line, i) => {
if (line.startsWith("# "))
return (
<h2
key={i}
style={{
fontSize: "1.3rem",
fontWeight: 700,
color: "var(--foreground)",
marginTop: "1.5rem",
marginBottom: "0.4rem",
lineHeight: 1.3,
}}
>
{line.slice(2)}
</h2>
);
if (line.startsWith("## "))
return (
<h3
key={i}
style={{
fontSize: "1.05rem",
fontWeight: 600,
color: "var(--foreground)",
marginTop: "1.1rem",
marginBottom: "0.3rem",
}}
>
{line.slice(3)}
</h3>
);
if (line.startsWith("- "))
return (
<li
key={i}
style={{
color: "var(--muted-foreground)",
lineHeight: 1.8,
marginLeft: "1.2rem",
listStyleType: "disc",
}}
>
{line.slice(2)}
</li>
);
if (line.trim() === "") return <br key={i} />;
return (
<p
key={i}
style={{
color: "var(--muted-foreground)",
lineHeight: 1.85,
marginBottom: "0.25rem",
}}
>
{line}
</p>
);
});
}
function BlogPreview({
title,
summary,
content,
category,
authorName,
imagePreview,
isPublished,
}: {
title: string;
summary: string;
content: string;
category: string;
authorName: string;
imagePreview: string | null;
isPublished: boolean;
}) {
const today = new Date()
.toLocaleDateString("es-MX", { day: "2-digit", month: "long", year: "numeric" })
.toUpperCase();
return (
<div
style={{
position: "relative",
borderRadius: 12,
overflow: "hidden",
border: "1px solid var(--border)",
background: "var(--background)",
}}
>
<span
style={{
position: "absolute",
top: 10,
left: 10,
zIndex: 1,
display: "inline-block",
background: isPublished ? "rgba(16,185,129,0.15)" : "var(--muted)",
color: isPublished ? "#10b981" : "var(--muted-foreground)",
fontSize: 10,
letterSpacing: "0.1em",
padding: "2px 8px",
borderRadius: 4,
}}
>
{isPublished ? "PUBLICADO" : "BORRADOR"}
</span>
{/* Hero — mismo layout que ArticlePage.tsx, a escala reducida */}
<section style={{ padding: "28px 28px 24px" }}>
<div style={{ display: "inline-flex", alignItems: "center", gap: 8, marginBottom: 16, fontFamily: "monospace", fontSize: 10, letterSpacing: "0.14em", color: "#3b82f6" }}>
← VOLVER AL BLOG
</div>
<div style={{ marginBottom: 14, fontFamily: "monospace", fontSize: 10, letterSpacing: "0.14em", color: "var(--muted-foreground)" }}>
{category ? category.toUpperCase() : "CATEGORÍA"}
</div>
<h1
style={{
fontSize: "1.6rem",
fontWeight: 700,
color: title ? "var(--foreground)" : "var(--muted-foreground)",
lineHeight: 1.2,
marginBottom: 14,
}}
>
{title || "Título del artículo"}
</h1>
{summary && (
<p
style={{
fontSize: "0.9rem",
color: "var(--muted-foreground)",
lineHeight: 1.7,
marginBottom: 20,
borderLeft: "3px solid var(--border)",
paddingLeft: 14,
}}
>
{summary}
</p>
)}
<div style={{ display: "flex", alignItems: "center", gap: 12, paddingTop: 14, borderTop: "1px solid var(--border)" }}>
{authorName && (
<>
<div
style={{
width: 26, height: 26, borderRadius: "50%",
background: "linear-gradient(135deg,#3b82f6,#6366f1)",
display: "flex", alignItems: "center", justifyContent: "center",
color: "#fff", fontSize: 11, fontWeight: 700,
}}
>
{authorName.charAt(0).toUpperCase()}
</div>
<span style={{ fontSize: 12, fontWeight: 500, color: "var(--foreground)" }}>{authorName}</span>
<span style={{ color: "var(--border)" }}>·</span>
</>
)}
<span style={{ fontFamily: "monospace", fontSize: 10, color: "var(--muted-foreground)", letterSpacing: "0.1em" }}>
{today}
</span>
</div>
</section>
{/* Imagen de portada */}
{imagePreview && (
<div style={{ background: "var(--muted)", borderBottom: "1px solid var(--border)" }}>
<div style={{ padding: "0 16px" }}>
<div style={{ overflow: "hidden", borderRadius: 4, aspectRatio: "16/7", marginTop: -1 }}>
<img src={imagePreview} alt="Portada" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
</div>
</div>
</div>
)}
{/* Contenido */}
<section style={{ padding: "28px" }}>
<div
style={{
background: "var(--card)",
border: "1px solid var(--border)",
borderRadius: 4,
padding: "24px 26px",
fontSize: "0.9rem",
lineHeight: 1.85,
}}
>
{content ? (
renderPreviewContent(content)
) : (
<p style={{ color: "var(--muted-foreground)", fontStyle: "italic", fontSize: 11, letterSpacing: "0.08em" }}>
EL CONTENIDO APARECERÁ AQUÍ...
</p>
)}
</div>
</section>
</div>
);
}
export default function BlogEditorPage({ postId }: { postId?: string }) {
const router = useRouter();
const { data: existingPost } = useBlogPost(postId ?? "");
const createPost = useCreateBlogPost();
const updatePost = useUpdateBlogPost(postId ?? "");
const uploadImage = useUploadBlogImage();
const isPending = createPost.isPending || updatePost.isPending;
const [title, setTitle] = useState("");
const [slug, setSlug] = useState("");
const [summary, setSummary] = useState("");
const [content, setContent] = useState("");
const [category, setCategory] = useState("");
const [authorName, setAuthorName] = useState("");
const [isPublished, setIsPublished] = useState(false);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [slugTouched, setSlugTouched] = useState(false);
const [errors, setErrors] = useState<BlogPostFormErrors>({});
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!existingPost) return;
setTitle(existingPost.title);
setSlug(existingPost.slug);
setSummary(existingPost.summary);
setContent(existingPost.content);
setCategory(existingPost.category);
setAuthorName(existingPost.authorName ?? "");
setIsPublished(existingPost.isPublished);
setImagePreview(assetUrl(existingPost.imageUrl) ?? null);
setSlugTouched(true);
}, [existingPost]);
function handleTitleChange(e: React.ChangeEvent<HTMLInputElement>) {
const v = e.target.value;
setTitle(v);
if (!slugTouched) setSlug(toSlug(v));
}
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setPendingFile(file);
setImagePreview(URL.createObjectURL(file));
e.target.value = "";
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const result = blogPostSchema.safeParse({ title, category });
if (!result.success) {
const fieldErrors: BlogPostFormErrors = {};
for (const issue of result.error.issues) {
const key = issue.path[0] as keyof BlogPostFormErrors;
if (!fieldErrors[key]) fieldErrors[key] = issue.message;
}
setErrors(fieldErrors);
toast.error("Revisa los campos marcados en rojo");
return;
}
setErrors({});
const payload: CreateBlogPostRequest = {
title,
slug,
summary,
content,
category,
authorName: authorName || undefined,
isPublished,
};
if (postId) {
updatePost.mutate(payload, {
onSuccess: async () => {
if (pendingFile) {
await uploadImage
.mutateAsync({ postId, file: pendingFile })
.catch(() => toast.error(ERR_MSG));
}
toast.success("Artículo actualizado");
router.push("/admin/blog");
},
onError: () => toast.error(ERR_MSG),
});
} else {
createPost.mutate(payload, {
onSuccess: async (created) => {
if (pendingFile && created?.id) {
await uploadImage
.mutateAsync({ postId: created.id, file: pendingFile })
.catch(() => toast.error(ERR_MSG));
}
toast.success("Artículo creado");
router.push("/admin/blog");
},
onError: () => toast.error(ERR_MSG),
});
}
}
return (
<div className="p-6 space-y-6">
{/* Encabezado */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => router.push("/admin/blog")}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<Icon icon="ph:arrow-left" className="w-4 h-4"/>
Blog
</button>
<span className="text-muted-foreground">/</span>
<h1 className="text-xl font-semibold text-foreground">
{postId ? "Editar artículo" : "Nuevo artículo"}
</h1>
</div>
<div className="flex items-center gap-3">
<Toggle
value={isPublished}
onChange={setIsPublished}
label="Publicar"
description=""
/>
<Button
type="submit"
form="blog-editor-form"
disabled={isPending}
loading={isPending}
>
<Icon icon="ph:check" className="w-4 h-4 mr-1.5"/>
{postId ? "Guardar cambios" : "Crear artículo"}
</Button>
</div>
</div>
{/* Layout: editor + preview */}
<form id="blog-editor-form" onSubmit={handleSubmit}>
<div className="grid grid-cols-2 gap-6 items-start">
{/* Panel izquierdo — formulario */}
<div className="rounded-2xl border border-border bg-card p-6 space-y-5">
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
{/* Imagen de portada */}
<div>
<label className="block text-xs font-semibold text-muted-foreground mb-1.5 uppercase tracking-wide">
Imagen de portada
</label>
{imagePreview ? (
<div
className="relative overflow-hidden rounded-xl border border-border"
style={{ aspectRatio: "16/9" }}
>
<img
src={imagePreview}
alt="Preview"
className="w-full h-full object-cover"
/>
<button
type="button"
onClick={() => {
setImagePreview(null);
setPendingFile(null);
}}
className="absolute top-2 right-2 flex h-7 w-7 items-center justify-center rounded-full bg-white/90 text-muted-foreground shadow hover:bg-white"
>
<Icon icon="ph:x" width={14}/>
</button>
<button
type="button"
onClick={() => fileRef.current?.click()}
className="absolute bottom-2 right-2 flex items-center gap-1 rounded-lg bg-white/90 px-2.5 py-1.5 text-xs font-semibold text-foreground shadow hover:bg-white"
>
<Icon icon="ph:upload-simple" width={12}/> Cambiar
</button>
</div>
) : (
<button
type="button"
onClick={() => fileRef.current?.click()}
className="flex w-full flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-border text-muted-foreground hover:border-blue-400 hover:text-blue-500 transition-colors"
style={{ aspectRatio: "16/9" }}
>
<Icon icon="ph:upload-simple" width={22}/>
<span className="text-xs font-medium">
Subir imagen de portada
</span>
<span className="text-xs text-muted-foreground">
16:9 recomendado
</span>
</button>
)}
</div>
{/* Título */}
<div>
<label className="block text-xs font-semibold text-muted-foreground mb-1 uppercase tracking-wide">
Título *
</label>
<Input
value={title}
onChange={handleTitleChange}
placeholder="Título del artículo"
className={errors.title ? "text-base font-medium border-red-500 focus:ring-red-500/30" : "text-base font-medium"}
/>
{errors.title && <p className="mt-1 text-xs text-red-500">{errors.title}</p>}
</div>
{/* Slug */}
<div>
<label className="block text-xs font-semibold text-muted-foreground mb-1 uppercase tracking-wide">
Slug
</label>
<Input
value={slug}
onChange={(e) => {
setSlug(e.target.value);
setSlugTouched(true);
}}
placeholder="url-del-articulo"
className="font-mono text-sm"
/>
</div>
{/* Categoría + Autor */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-muted-foreground mb-1 uppercase tracking-wide">
Categoría *
</label>
<Input
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder="ej. Tecnología"
className={errors.category ? "border-red-500 focus:ring-red-500/30" : ""}
/>
{errors.category && <p className="mt-1 text-xs text-red-500">{errors.category}</p>}
</div>
<div>
<label className="block text-xs font-semibold text-muted-foreground mb-1 uppercase tracking-wide">
Autor
</label>
<Input
value={authorName}
onChange={(e) => setAuthorName(e.target.value)}
placeholder="Nombre del autor"
/>
</div>
</div>
{/* Resumen */}
<div>
<label className="block text-xs font-semibold text-muted-foreground mb-1 uppercase tracking-wide">
Resumen
</label>
<Textarea
rows={2}
value={summary}
onChange={(e) => setSummary(e.target.value)}
placeholder="Breve descripción visible en la lista de artículos"
/>
</div>
{/* Contenido */}
<div>
<label className="block text-xs font-semibold text-muted-foreground mb-1 uppercase tracking-wide">
Contenido
</label>
<p className="text-[11px] text-muted-foreground mb-1.5">
Soporta Markdown básico: # Título, ## Subtítulo, - Lista
</p>
<Textarea
rows={18}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Escribe el contenido del artículo..."
className="font-mono text-sm"
/>
</div>
</div>
{/* Panel derecho — preview */}
<div className="space-y-3">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Preview
</p>
<BlogPreview
title={title}
summary={summary}
content={content}
category={category}
authorName={authorName}
imagePreview={imagePreview}
isPublished={isPublished}
/>
</div>
</div>
</form>
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { useRouter } from "next/navigation";
import { Icon } from "@iconify/react";
import { toast } from "sonner";
import { Table } from "@/app/admin/components/Table";
import type { TableColumn } from "@/app/admin/components/Table";
import { Button } from "@/app/admin/components/ui/Button";
import {
useBlogPosts,
useDeleteBlogPost,
type BlogPost,
} from "@/app/admin/blog/services/blog.service";
import { ERR_MSG } from "@/app/admin/lib/errorMessage";
export default function BlogPosts() {
const router = useRouter();
const { data: posts = [], isLoading } = useBlogPosts();
const deletePost = useDeleteBlogPost();
const columns: TableColumn<BlogPost>[] = [
{
key: "title", header: "Título",
cell: (p) => (
<div>
<p className="font-medium text-foreground dark:text-slate-100">{p.title}</p>
<p className="text-xs text-slate-400">{p.slug}</p>
</div>
),
},
{
key: "category", header: "Categoría",
cell: (p) => <span className="text-muted-foreground dark:text-slate-300">{p.category}</span>,
},
{
key: "isPublished", header: "Estado",
cell: (p) => p.isPublished ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-50 text-emerald-700">
<Icon icon="ph:eye" className="w-3 h-3"/> Publicado
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-muted-foreground">
<Icon icon="ph:eye-slash" className="w-3 h-3"/> Borrador
</span>
),
},
{
key: "createdAt", header: "Creado",
cell: (p) => (
<span className="text-muted-foreground text-sm">
{new Date(p.createdAt).toLocaleDateString("es-MX", { day: "2-digit", month: "short", year: "numeric" })}
</span>
),
},
{
key: "actions", header: "",
cell: (p) => (
<div className="flex items-center gap-2 justify-end">
<button onClick={() => router.push(`/admin/blog/${p.id}/edit`)}
className="rounded-lg p-1.5 text-muted-foreground hover:bg-indigo-500/10 hover:text-indigo-500 transition-colors">
<Icon icon="ph:pencil-simple" className="w-4 h-4"/>
</button>
<button onClick={() => deletePost.mutate(p.id, {
onSuccess: () => toast.success("Eliminado"),
onError: () => toast.error(ERR_MSG),
})} className="rounded-lg p-1.5 text-muted-foreground hover:bg-rose-500/10 hover:text-rose-500 transition-colors">
<Icon icon="ph:trash" className="w-4 h-4"/>
</button>
</div>
),
},
];
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Blog</h1>
</div>
<Button onClick={() => router.push("/admin/blog/new")} className="flex items-center gap-2">
<Icon icon="ph:plus" className="w-4 h-4"/> Nuevo artículo
</Button>
</div>
<Table columns={columns} data={posts} rowKey={(p) => p.id} loading={isLoading} emptyText="Sin artículos" />
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
"use client";
import BlogEditorPage from "../_components/BlogEditorPage";
export default function NewBlogPage() {
return <BlogEditorPage />;
}
+8
View File
@@ -0,0 +1,8 @@
import type { Metadata } from "next";
import BlogEditor from "./_page";
export const metadata: Metadata = { title: "Nuevo artículo" };
export default function Page() {
return <BlogEditor />;
}
+11
View File
@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import BlogPosts from "./_page";
export const metadata: Metadata = {
title: "Blog",
description: "Artículos y noticias del equipo",
};
export default function Page() {
return <BlogPosts />;
}
+100
View File
@@ -0,0 +1,100 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import http from "@/app/admin/services/http";
export interface BlogPost {
id: string;
title: string;
slug: string;
summary: string;
content: string;
category: string;
imageUrl?: string;
authorName?: string;
isPublished: boolean;
publishedAt?: string;
createdAt: string;
updatedAt?: string;
}
export interface CreateBlogPostRequest {
title: string;
slug: string;
summary: string;
content: string;
category: string;
authorName?: string;
isPublished?: boolean;
}
export interface UpdateBlogPostRequest extends CreateBlogPostRequest {}
export const blogKeys = {
all: ["blog"] as const,
detail: (id: string) => ["blog", id] as const,
};
export function useBlogPosts() {
return useQuery({
queryKey: blogKeys.all,
queryFn: async () => {
const res = await http.get<BlogPost[]>("/api/blog/admin/all");
return res.data;
},
});
}
export function useBlogPost(id: string) {
return useQuery({
queryKey: blogKeys.detail(id),
queryFn: async () => {
// No existe un GET por id en el backend; se reutiliza el listado
// admin y se busca el artículo por id en el cliente.
const res = await http.get<BlogPost[]>("/api/blog/admin/all");
return res.data.find((p) => p.id === id);
},
enabled: !!id,
});
}
export function useCreateBlogPost() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: CreateBlogPostRequest) => {
const res = await http.post<BlogPost>("/api/blog", data);
return res.data;
},
onSuccess: () => qc.invalidateQueries({ queryKey: blogKeys.all }),
});
}
export function useUpdateBlogPost(id: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: UpdateBlogPostRequest) => {
const res = await http.put<BlogPost>(`/api/blog/${id}`, data);
return res.data;
},
onSuccess: () => qc.invalidateQueries({ queryKey: blogKeys.all }),
});
}
export function useDeleteBlogPost() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) => http.delete(`/api/blog/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: blogKeys.all }),
});
}
export function useUploadBlogImage() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ postId, file }: { postId: string; file: File }) => {
const form = new FormData();
form.append("file", file);
const res = await http.post<BlogPost>(`/api/blog/${postId}/image`, form);
return res.data;
},
onSuccess: () => qc.invalidateQueries({ queryKey: blogKeys.all }),
});
}