refactor: UX enhancements, SEO automation, Entity Singleton and Media URLs

This commit is contained in:
Carlos (Almeira) 2026-03-28 17:09:35 -05:00
parent 8ab4915545
commit ae4e242252
9 changed files with 127 additions and 36 deletions

View File

@ -44,17 +44,29 @@ export async function updateEntityConfig(data: any) {
const databases = await getAdminClient();
const existing = await getEntityConfig();
if (!data || Object.keys(data).length === 0) {
let parsedData = data;
if (typeof FormData !== 'undefined' && data instanceof FormData) {
parsedData = {};
data.forEach((value, key) => {
if (key !== '$id' && key !== '$collectionId' && key !== '$databaseId') {
parsedData[key] = value;
}
});
}
if (!parsedData || Object.keys(parsedData).length === 0) {
console.warn('updateEntityConfig: Data is empty, skipping update.');
return existing;
}
const SINGLETON_ID = existing?.$id || 'main_profile_singleton';
if (existing) {
const result = await databases.updateDocument(
DATABASE_ID,
COLLECTION_ID,
existing.$id,
data
parsedData
);
revalidatePath('/');
return result;
@ -62,8 +74,8 @@ export async function updateEntityConfig(data: any) {
const result = await databases.createDocument(
DATABASE_ID,
COLLECTION_ID,
ID.unique(),
data
SINGLETON_ID,
parsedData
);
revalidatePath('/');
return result;

View File

@ -23,7 +23,7 @@ async function fetchClasificadosData() {
const postsCount = settings.feed_news_count || 12;
const cardSettings = {
showCategory: settings.news_card_show_category ?? false,
showFooterDate: settings.show_news_date ?? false,
showFooterDate: String(settings.show_news_date) === 'true',
showFooterButton: settings.news_card_show_footer_button ?? true,
footerText: settings.news_card_footer_text || ""
};

View File

@ -34,11 +34,12 @@ export default async function SettingsPage() {
const sessionClient = await createSessionClient();
if (!sessionClient) throw new Error("Acceso denegado");
const showNewsDateVal = formData.get('show_news_date');
const updateData = {
ticker_count: parseInt(formData.get('ticker_count') as string),
feed_news_count: parseInt(formData.get('feed_news_count') as string),
rotation_seconds: parseInt(formData.get('rotation_seconds') as string),
show_news_date: formData.get('show_news_date') === 'true'
show_news_date: showNewsDateVal === 'true' || showNewsDateVal === 'on'
};
if (formData.get('docId')) {

View File

@ -23,7 +23,7 @@ async function fetchEventosData() {
const postsCount = settings.feed_news_count || 12;
const cardSettings = {
showCategory: settings.news_card_show_category ?? false,
showFooterDate: settings.show_news_date ?? false,
showFooterDate: String(settings.show_news_date) === 'true',
showFooterButton: settings.news_card_show_footer_button ?? true,
footerText: settings.news_card_footer_text || ""
};

View File

@ -1,5 +1,5 @@
import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite';
import { createAdminClient } from '@/lib/appwrite.server';
import { createAdminClient, getLoggedInUser } from '@/lib/appwrite.server';
import { Query } from 'node-appwrite';
import { NewsCard } from '@/components/NewsCard';
import { GlobalSidebar, hasSidebarContent } from '@/components/AdWrappers';
@ -25,7 +25,7 @@ async function fetchHomeData() {
const postsCount = settings.feed_news_count || 12;
const cardSettings = {
showCategory: settings.news_card_show_category ?? false,
showFooterDate: settings.show_news_date ?? false,
showFooterDate: String(settings.show_news_date) === 'true',
showFooterButton: settings.news_card_show_footer_button ?? true,
footerText: settings.news_card_footer_text || ""
};
@ -72,6 +72,9 @@ async function fetchHomeData() {
export default async function Home() {
const { posts, cardSettings } = await fetchHomeData();
const hasSidebar = await hasSidebarContent();
const user = await getLoggedInUser();
const isAdmin = (user as any)?.labels?.includes('admin') || (user as any)?.labels?.includes('editor') || user?.$id === 'admin-emergency';
return (
<main className="animate-fade-in relative">
@ -150,19 +153,20 @@ export default async function Home() {
return (
<NewsCard
key={post.$id}
id={post.$id}
slug={post.slug || post.$id}
titulo={decodeHtmlEntities(post.titulo)}
resumen={decodeHtmlEntities(post.resumen_redes || post.contenido?.replace(/<[^>]*>?/gm, '').substring(0, 150) + '...')}
tipo="Noticia"
fecha={post.fecha_publicacion || post.$createdAt}
imagenUrl={imagenUrl}
showCategory={cardSettings.showCategory}
showFooterDate={cardSettings.showFooterDate}
showFooterButton={cardSettings.showFooterButton}
footerText={cardSettings.footerText}
/>
key={`post-${post.$id}`}
id={post.$id}
slug={post.slug || post.$id}
titulo={post.titulo}
resumen={post.resumen_redes || post.contenido || ''}
tipo={typeof post.tipo_publicacion_id === 'object' ? post.tipo_publicacion_id.descripcion : ''}
fecha={post.fecha_publicacion || post.$createdAt}
imagenUrl={imagenUrl}
showCategory={cardSettings.showCategory}
showFooterDate={cardSettings.showFooterDate}
showFooterButton={cardSettings.showFooterButton}
footerText={cardSettings.footerText}
isAdmin={isAdmin}
/>
)
})}
</div>

View File

@ -14,6 +14,7 @@ interface NewsCardProps {
showFooterDate?: boolean;
showFooterButton?: boolean;
footerText?: string;
isAdmin?: boolean;
}
export const NewsCard = ({
@ -27,7 +28,8 @@ export const NewsCard = ({
showCategory = false,
showFooterDate = false,
showFooterButton = false,
footerText
footerText,
isAdmin = false
}: NewsCardProps) => {
const titulo = decodeHtmlEntities(rawTitulo);
const resumen = decodeHtmlEntities(rawResumen);
@ -57,6 +59,20 @@ export const NewsCard = ({
</span>
</div>
)}
{/* Edit Button for Admins */}
{isAdmin && (
<div className="absolute top-4 right-4 z-30 opacity-0 group-hover:opacity-100 transition-opacity">
<Link
href={`/dashboard/posts/${id}`}
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-2 px-3 py-1.5 rounded-full text-[10px] font-black uppercase tracking-wider bg-slate-950/80 backdrop-blur border border-cyan-500/50 text-cyan-400 hover:bg-cyan-500 hover:text-slate-950 transition-all shadow-lg"
title="Editar en Dashboard"
>
<span className="hidden sm:inline">Editar</span>
</Link>
</div>
)}
</div>
{/* Contenido */}

View File

@ -59,6 +59,20 @@ export const MediaGridSelector = ({
}
};
useEffect(() => {
// If we have a value but haven't loaded its document, fetch just that one
if (value && mode === 'field' && !mediaItems.some(m => m.$id === value)) {
databases.getDocument(APPWRITE_ALMEIRA_CONFIG.databaseId, 'media', value)
.then(doc => {
setMediaItems(prev => {
// Protect against race conditions duplicating the doc
if (prev.some(m => m.$id === value)) return prev;
return [...prev, doc];
});
}).catch(err => console.error("Error resolver media thumbnail:", err));
}
}, [value, mode]);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
@ -80,7 +94,7 @@ export const MediaGridSelector = ({
// 3. Update state
setMediaItems(prev => [doc, ...prev]);
const url = getAppwriteImageUrl(uploadedFile.$id, 1200, 800);
onChange(uploadedFile.$id, url);
onChange(doc.$id, url);
closeModal();
} catch (error) {
console.error("Error upload:", error);
@ -162,7 +176,7 @@ export const MediaGridSelector = ({
<div
key={item.$id}
onClick={() => {
onChange(item.file_id, getAppwriteImageUrl(item.file_id, 1200, 800));
onChange(item.$id, getAppwriteImageUrl(item.file_id, 1200, 800));
if (mode !== 'modal') setIsOpen(false);
closeModal();
}}
@ -172,7 +186,7 @@ export const MediaGridSelector = ({
>
<img src={url} alt={item.titulo} className="w-full h-full object-cover transition-transform group-hover:scale-110" loading="lazy" />
<div className="absolute inset-0 bg-gradient-to-t from-cyan-500/20 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
{value === item.file_id && (
{value === item.$id && (
<div className="absolute top-2 right-2 bg-cyan-500 text-white rounded-full p-1 shadow-lg">
<CheckCircle2 size={16} />
</div>
@ -224,7 +238,17 @@ export const MediaGridSelector = ({
>
{value ? (
<>
<img src={getAppwriteImageUrl(value, 800, 450)} alt="Selected Preview" className="w-full h-full object-cover" />
{(() => {
// Si value existe, buscamos su documento para sacar el file_id
const selectedDoc = mediaItems.find(m => m.$id === value);
// Si aún no está en la lista (cargando), mostramos loader genérico
if (!selectedDoc && mediaItems.length > 0) return null; // No está en la DB o se paginó, idealmente pasamos un initialUrl.
const fileIdToRender = selectedDoc ? selectedDoc.file_id : value; // Fallback al propio ID por si es legacy.
return (
<img src={getAppwriteImageUrl(fileIdToRender, 800, 450)} alt="Selected Preview" className="w-full h-full object-cover" />
);
})()}
<div className="absolute inset-0 bg-slate-900/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<span className="bg-slate-950/80 px-4 py-2 rounded-full text-[10px] font-black uppercase tracking-widest text-cyan-400 border border-cyan-500/30">
Cambiar Imagen ↔

View File

@ -96,11 +96,35 @@ export const PublicationLayout: React.FC<PublicationLayoutProps> = ({
const [slug, setSlug] = useState(initialData?.slug || '');
const [isAutoSlug, setIsAutoSlug] = useState(!initialData?.slug);
const [contenido, setContenido] = useState(initialData?.contenido || '');
const [imagenDestacadaId, setImagenDestacadaId] = useState(
const [resumen_redes, setResumenRedes] = useState(initialData?.resumen_redes || '');
const [imagenDestacadaId, setImagenDestacadaId] = useState<string>(
typeof initialData?.imagen_destacada_id === 'object'
? initialData.imagen_destacada_id.$id
? initialData?.imagen_destacada_id?.$id
: initialData?.imagen_destacada_id || ''
);
// SEO Smart Autofill States
const [seoTitulo, setSeoTitulo] = useState(initialData?.seo_titulo || '');
const [seoDescripcion, setSeoDescripcion] = useState(initialData?.seo_descripcion || '');
const [seoDirty, setSeoDirty] = useState({ titulo: !!initialData?.seo_titulo, descripcion: !!initialData?.seo_descripcion });
useEffect(() => {
if (!seoDirty.titulo && titulo) {
setSeoTitulo(titulo.substring(0, 70));
}
}, [titulo, seoDirty.titulo]);
useEffect(() => {
if (!seoDirty.descripcion) {
// Strip HTML if it's the rich text
const richTextSnippet = contenido?.replace(/<[^>]*>?/gm, '').substring(0, 160) || '';
const sourceMeta = resumen_redes || richTextSnippet;
if (sourceMeta) {
setSeoDescripcion(sourceMeta.substring(0, 160));
}
}
}, [contenido, resumen_redes, seoDirty.descripcion]);
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
// Populate relation cache in the store on mount
@ -263,7 +287,8 @@ export const PublicationLayout: React.FC<PublicationLayoutProps> = ({
>
<textarea
name="resumen_redes"
defaultValue={initialData?.resumen_redes || ''}
value={resumen_redes}
onChange={(e) => setResumenRedes(e.target.value)}
placeholder="Resumen corto para compartir en redes sociales..."
maxLength={500}
className={`${textareaClass} custom-scrollbar resize-none`}
@ -297,7 +322,11 @@ export const PublicationLayout: React.FC<PublicationLayoutProps> = ({
<input
type="text"
name="seo_titulo"
defaultValue={initialData?.seo_titulo || ''}
value={seoTitulo}
onChange={(e) => {
setSeoTitulo(e.target.value);
setSeoDirty(prev => ({ ...prev, titulo: true }));
}}
maxLength={70}
placeholder="Título para buscadores..."
className={inputClass}
@ -311,7 +340,11 @@ export const PublicationLayout: React.FC<PublicationLayoutProps> = ({
<input
type="text"
name="seo_descripcion"
defaultValue={initialData?.seo_descripcion || ''}
value={seoDescripcion}
onChange={(e) => {
setSeoDescripcion(e.target.value);
setSeoDirty(prev => ({ ...prev, descripcion: true }));
}}
maxLength={160}
placeholder="Descripción para resultados de búsqueda..."
className={inputClass}

View File

@ -101,9 +101,10 @@ export const PublicationsList: React.FC<PublicationsListProps> = ({
// Sort
result.sort((a, b) => {
if (sortBy === 'titulo') return a.titulo.localeCompare(b.titulo);
const aDate = new Date(a[sortBy] || a.$createdAt).getTime();
const bDate = new Date(b[sortBy] || b.$createdAt).getTime();
return bDate - aDate; // Newest first
if (sortBy === 'estado') return (a.estado || '').localeCompare(b.estado || '');
const aDate = new Date(a[sortBy] || a.fecha_publicacion || a.$createdAt || 0).getTime();
const bDate = new Date(b[sortBy] || b.fecha_publicacion || b.$createdAt || 0).getTime();
return bDate - aDate; // Strictly Newest first
});
return result;
@ -245,7 +246,7 @@ export const PublicationsList: React.FC<PublicationsListProps> = ({
)}
{filteredAndSortedPosts.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
{filteredAndSortedPosts.map((post) => (
<PostCard
key={post.$id}