cambios
This commit is contained in:
1 parent
794a659369
commit
5f99b65611
349 files changed
+32252
-2180
No files matched your search
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import Loader from '@/app/(landing)/components/Loader';
|
||||
import { useBlogPosts } from '@/app/(landing)/blog/services/blog';
|
||||
|
||||
const fadeUp = {
|
||||
hidden: { opacity: 0, y: 28 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] as const },
|
||||
},
|
||||
};
|
||||
|
||||
function BlogContent() {
|
||||
const { data, isLoading } = useBlogPosts();
|
||||
useEffect(() => {
|
||||
document.title = "Blog | Rynext Technologies";
|
||||
}, []);
|
||||
|
||||
const posts = (data ?? []).map((p) => ({
|
||||
id: p.id,
|
||||
category: p.category.toUpperCase(),
|
||||
title: p.title,
|
||||
summary: p.summary,
|
||||
publishedAt: p.publishedAt,
|
||||
imageUrl: p.imageUrl,
|
||||
slug: p.slug,
|
||||
}));
|
||||
|
||||
return (
|
||||
<main id="main-content">
|
||||
<section
|
||||
style={{
|
||||
background: "var(--background)",
|
||||
paddingTop: "120px",
|
||||
paddingBottom: "16px",
|
||||
}}
|
||||
className="px-8 md:px-14"
|
||||
>
|
||||
<div className="mx-auto max-w-7xl text-center">
|
||||
<motion.h1
|
||||
className="text-2xl md:text-3xl font-bold"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
variants={fadeUp}
|
||||
>
|
||||
Artículos
|
||||
</motion.h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
style={{
|
||||
background: "var(--background)",
|
||||
padding: "0 0 100px",
|
||||
minHeight: "60vh",
|
||||
}}
|
||||
className="px-8 md:px-14"
|
||||
>
|
||||
<div className="mx-auto max-w-7xl">
|
||||
{isLoading ? (
|
||||
<Loader className="py-20" />
|
||||
) : posts.length > 0 ? (
|
||||
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
{posts.map((art, i) => (
|
||||
<motion.article
|
||||
key={art.id}
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
whileHover={{ y: -4 }}
|
||||
viewport={{ once: true, margin: "-40px" }}
|
||||
transition={{
|
||||
duration: 0.55,
|
||||
delay: i * 0.08,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: "1px solid var(--border)",
|
||||
background: "var(--card)",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
opacity: art.slug ? 1 : 0.82,
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={art.slug ? `/blog/${art.slug}` : "#"}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flex: 1,
|
||||
textDecoration: "none",
|
||||
color: "inherit",
|
||||
pointerEvents: art.slug ? "auto" : "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
aspectRatio: "16/9",
|
||||
overflow: "hidden",
|
||||
background: "rgba(0,0,0,0.03)",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{art.imageUrl ? (
|
||||
<img
|
||||
src={art.imageUrl}
|
||||
alt={art.title}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="var(--muted-foreground)" strokeWidth="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<path d="m21 15-5-5L5 21" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: "24px 24px 28px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{art.category && (
|
||||
<span
|
||||
className="text-xs font-semibold uppercase tracking-wider mb-2 block"
|
||||
style={{ color: "#3b82f6" }}
|
||||
>
|
||||
{art.category}
|
||||
</span>
|
||||
)}
|
||||
<h2
|
||||
className="font-display font-bold mb-3"
|
||||
style={{ fontSize: "1.1rem", color: "var(--foreground)", letterSpacing: "-0.01em" }}
|
||||
>
|
||||
{art.title}
|
||||
</h2>
|
||||
<p
|
||||
className="text-sm leading-relaxed mb-5"
|
||||
style={{ color: "var(--muted-foreground)", flex: 1 }}
|
||||
>
|
||||
{art.summary}
|
||||
</p>
|
||||
<p
|
||||
className="text-xs mt-auto"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
>
|
||||
{art.publishedAt
|
||||
? new Date(art.publishedAt).toLocaleDateString(
|
||||
"es-MX",
|
||||
{ day: "2-digit", month: "long", year: "numeric" },
|
||||
)
|
||||
: "Próximamente"}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
|
||||
Sin artículos publicados.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function BlogPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
if (!mounted) return null;
|
||||
return <BlogContent />;
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
"use client";
|
||||
import { useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import { useBlogPost } from '@/app/(landing)/blog/services/blog';
|
||||
import { assetUrl } from '@/app/(landing)/services/api';
|
||||
import Loader from '@/app/(landing)/components/Loader';
|
||||
|
||||
function renderContent(content: string) {
|
||||
return content.split("\n").map((line, i) => {
|
||||
if (line.startsWith("# "))
|
||||
return (
|
||||
<h2
|
||||
key={i}
|
||||
style={{
|
||||
fontSize: "1.5rem",
|
||||
fontWeight: 700,
|
||||
color: "var(--foreground)",
|
||||
marginTop: "2rem",
|
||||
marginBottom: "0.5rem",
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{line.slice(2)}
|
||||
</h2>
|
||||
);
|
||||
if (line.startsWith("## "))
|
||||
return (
|
||||
<h3
|
||||
key={i}
|
||||
style={{
|
||||
fontSize: "1.15rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--foreground)",
|
||||
marginTop: "1.5rem",
|
||||
marginBottom: "0.4rem",
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default function ArticlePage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const slug =
|
||||
typeof params.slug === "string" ? params.slug : (params.slug?.[0] ?? "");
|
||||
|
||||
const { data: post, isLoading, isError } = useBlogPost(slug);
|
||||
|
||||
useEffect(() => {
|
||||
if (post) document.title = `${post.title} | Blog`;
|
||||
}, [post]);
|
||||
|
||||
if (isLoading) return <Loader className="py-40" />;
|
||||
if (isError || !post) {
|
||||
return (
|
||||
<main
|
||||
style={{
|
||||
minHeight: "80vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
color: "var(--muted-foreground)",
|
||||
fontSize: 14,
|
||||
fontFamily: "monospace",
|
||||
letterSpacing: "0.1em",
|
||||
}}
|
||||
>
|
||||
ARTÍCULO NO ENCONTRADO
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push("/blog")}
|
||||
style={{
|
||||
color: "#3b82f6",
|
||||
fontSize: 14,
|
||||
textDecoration: "underline",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Volver al blog
|
||||
</button>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const imageUrl = assetUrl(post.imageUrl);
|
||||
const publishDate = post.publishedAt
|
||||
? new Date(post.publishedAt).toLocaleDateString("es-MX", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main id="main-content">
|
||||
{/* Hero */}
|
||||
<section
|
||||
style={{
|
||||
background: "var(--background)",
|
||||
paddingTop: "140px",
|
||||
paddingBottom: "60px",
|
||||
}}
|
||||
className="px-8 md:px-14"
|
||||
>
|
||||
<div className="mx-auto" style={{ maxWidth: "780px" }}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{/* Volver */}
|
||||
<Link
|
||||
href="/blog"
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: 8, marginBottom: 20, fontFamily: "monospace", fontSize: 11, letterSpacing: "0.14em", color: "#3b82f6", textDecoration: "none" }}
|
||||
>
|
||||
← VOLVER AL BLOG
|
||||
</Link>
|
||||
|
||||
{post.category && (
|
||||
<div style={{ marginBottom: 20, fontFamily: "monospace", fontSize: 11, letterSpacing: "0.14em", color: "var(--muted-foreground)" }}>
|
||||
{post.category.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Título */}
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "clamp(1.8rem, 4vw, 2.8rem)",
|
||||
fontWeight: 700,
|
||||
color: "var(--foreground)",
|
||||
lineHeight: 1.2,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
{post.title}
|
||||
</h1>
|
||||
|
||||
{/* Resumen */}
|
||||
{post.summary && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: "1.1rem",
|
||||
color: "var(--muted-foreground)",
|
||||
lineHeight: 1.7,
|
||||
marginBottom: 28,
|
||||
borderLeft: "3px solid var(--border)",
|
||||
paddingLeft: 16,
|
||||
}}
|
||||
>
|
||||
{post.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Meta */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
paddingTop: 20,
|
||||
borderTop: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{post.authorName && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: "50%",
|
||||
background: "linear-gradient(135deg,#3b82f6,#6366f1)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{post.authorName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
color: "var(--foreground)",
|
||||
}}
|
||||
>
|
||||
{post.authorName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{publishDate && (
|
||||
<>
|
||||
{post.authorName && (
|
||||
<span style={{ color: "var(--border)" }}>·</span>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "monospace",
|
||||
fontSize: 11,
|
||||
color: "var(--muted-foreground)",
|
||||
letterSpacing: "0.1em",
|
||||
}}
|
||||
>
|
||||
{publishDate.toUpperCase()}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Imagen de portada */}
|
||||
{imageUrl && (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--muted)",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="mx-auto"
|
||||
style={{ maxWidth: "1100px", padding: "0 2rem" }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.6, delay: 0.15 }}
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
borderRadius: 4,
|
||||
aspectRatio: "16/7",
|
||||
marginTop: -1,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={post.title}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contenido */}
|
||||
<section
|
||||
style={{ background: "var(--background)", padding: "80px 0" }}
|
||||
className="px-8 md:px-14"
|
||||
>
|
||||
<motion.div
|
||||
className="mx-auto"
|
||||
style={{ maxWidth: "780px" }}
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 4,
|
||||
padding: "48px 52px",
|
||||
fontSize: "1rem",
|
||||
lineHeight: 1.85,
|
||||
}}
|
||||
>
|
||||
{renderContent(post.content)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import ArticlePage from "./ArticlePage";
|
||||
import { API_URL, assetUrl } from '@/app/(landing)/services/api';
|
||||
import type { BlogPost } from '@/app/(landing)/blog/services/blog';
|
||||
|
||||
const BASE_URL = "https://rynext.com.mx";
|
||||
|
||||
/**
|
||||
* null = la API respondió que el artículo no existe o no está publicado (404 real).
|
||||
* undefined = no se pudo consultar la API (red, TLS local, etc.) — no es un 404,
|
||||
* se deja que el cliente (ArticlePage) resuelva con su propio fetch.
|
||||
*/
|
||||
async function getPost(slug: string): Promise<BlogPost | null | undefined> {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/blog/${slug}`, {
|
||||
next: { revalidate: 3600 },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const post = (await res.json()) as BlogPost;
|
||||
return post.isPublished ? post : null;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
type Props = { params: Promise<{ slug: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const post = await getPost(slug);
|
||||
|
||||
if (!post) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const url = `${BASE_URL}/blog/${post.slug}`;
|
||||
const image = assetUrl(post.imageUrl);
|
||||
|
||||
return {
|
||||
title: `${post.title} | Blog | Rynext Technologies`,
|
||||
description: post.summary,
|
||||
alternates: { canonical: url },
|
||||
openGraph: {
|
||||
title: post.title,
|
||||
description: post.summary,
|
||||
type: "article",
|
||||
url,
|
||||
publishedTime: post.publishedAt,
|
||||
images: image
|
||||
? [{ url: image, width: 1200, height: 630, alt: post.title }]
|
||||
: undefined,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: post.title,
|
||||
description: post.summary,
|
||||
images: image ? [image] : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({ params }: Props) {
|
||||
const { slug } = await params;
|
||||
const post = await getPost(slug);
|
||||
|
||||
// Solo 404 cuando confirmamos que no existe/no está publicado; si la API
|
||||
// no respondió, dejamos que ArticlePage lo resuelva del lado del cliente.
|
||||
if (post === null) notFound();
|
||||
|
||||
return <ArticlePage />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import Loader from '@/app/(landing)/components/Loader';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center" style={{ background: 'var(--background)' }}>
|
||||
<Loader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { BlogPage } from './BlogPage';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Blog | Rynext Technologies',
|
||||
description: 'Artículos sobre arquitectura de software, sistemas empresariales y tecnología. Perspectivas del equipo de Rynext Technologies.',
|
||||
alternates: { canonical: 'https://rynext.com.mx/blog' },
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return <BlogPage />;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiFetch } from '@/app/(landing)/services/api';
|
||||
|
||||
export interface BlogPost {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
summary: string;
|
||||
content: string;
|
||||
category: string;
|
||||
imageUrl?: string;
|
||||
authorName?: string;
|
||||
isPublished: boolean;
|
||||
publishedAt?: string;
|
||||
}
|
||||
|
||||
export function useBlogPosts() {
|
||||
return useQuery<BlogPost[]>({
|
||||
queryKey: ["blog"],
|
||||
queryFn: () => apiFetch<BlogPost[]>("/api/blog"),
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
}
|
||||
|
||||
export function useBlogPost(slug: string) {
|
||||
return useQuery<BlogPost>({
|
||||
queryKey: ["blog", slug],
|
||||
queryFn: () => apiFetch<BlogPost>(`/api/blog/${slug}`),
|
||||
staleTime: 1000 * 60 * 5,
|
||||
enabled: !!slug,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface BlogPost {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
summary: string;
|
||||
content: string;
|
||||
category: string;
|
||||
imageUrl?: string;
|
||||
authorName?: string;
|
||||
isPublished: boolean;
|
||||
publishedAt?: string;
|
||||
}
|
||||
Reference in new issue
Block a user