refactor: UX enhancements, SEO automation, Entity Singleton and Media URLs
This commit is contained in:
parent
8ab4915545
commit
ae4e242252
@ -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;
|
||||
|
||||
@ -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 || ""
|
||||
};
|
||||
|
||||
@ -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')) {
|
||||
|
||||
@ -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 || ""
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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 ↔
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user