cambios
This commit is contained in:
1 parent
794a659369
commit
5f99b65611
349 files changed
+32252
-2180
No files matched your search
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"use client";
|
||||
import BlogEditorPage from "../_components/BlogEditorPage";
|
||||
|
||||
export default function NewBlogPage() {
|
||||
return <BlogEditorPage />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user