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 databases = await getAdminClient();
|
||||||
const existing = await getEntityConfig();
|
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.');
|
console.warn('updateEntityConfig: Data is empty, skipping update.');
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SINGLETON_ID = existing?.$id || 'main_profile_singleton';
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const result = await databases.updateDocument(
|
const result = await databases.updateDocument(
|
||||||
DATABASE_ID,
|
DATABASE_ID,
|
||||||
COLLECTION_ID,
|
COLLECTION_ID,
|
||||||
existing.$id,
|
existing.$id,
|
||||||
data
|
parsedData
|
||||||
);
|
);
|
||||||
revalidatePath('/');
|
revalidatePath('/');
|
||||||
return result;
|
return result;
|
||||||
@ -62,8 +74,8 @@ export async function updateEntityConfig(data: any) {
|
|||||||
const result = await databases.createDocument(
|
const result = await databases.createDocument(
|
||||||
DATABASE_ID,
|
DATABASE_ID,
|
||||||
COLLECTION_ID,
|
COLLECTION_ID,
|
||||||
ID.unique(),
|
SINGLETON_ID,
|
||||||
data
|
parsedData
|
||||||
);
|
);
|
||||||
revalidatePath('/');
|
revalidatePath('/');
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@ -23,7 +23,7 @@ async function fetchClasificadosData() {
|
|||||||
const postsCount = settings.feed_news_count || 12;
|
const postsCount = settings.feed_news_count || 12;
|
||||||
const cardSettings = {
|
const cardSettings = {
|
||||||
showCategory: settings.news_card_show_category ?? false,
|
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,
|
showFooterButton: settings.news_card_show_footer_button ?? true,
|
||||||
footerText: settings.news_card_footer_text || ""
|
footerText: settings.news_card_footer_text || ""
|
||||||
};
|
};
|
||||||
|
|||||||
@ -34,11 +34,12 @@ export default async function SettingsPage() {
|
|||||||
const sessionClient = await createSessionClient();
|
const sessionClient = await createSessionClient();
|
||||||
if (!sessionClient) throw new Error("Acceso denegado");
|
if (!sessionClient) throw new Error("Acceso denegado");
|
||||||
|
|
||||||
|
const showNewsDateVal = formData.get('show_news_date');
|
||||||
const updateData = {
|
const updateData = {
|
||||||
ticker_count: parseInt(formData.get('ticker_count') as string),
|
ticker_count: parseInt(formData.get('ticker_count') as string),
|
||||||
feed_news_count: parseInt(formData.get('feed_news_count') as string),
|
feed_news_count: parseInt(formData.get('feed_news_count') as string),
|
||||||
rotation_seconds: parseInt(formData.get('rotation_seconds') 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')) {
|
if (formData.get('docId')) {
|
||||||
|
|||||||
@ -23,7 +23,7 @@ async function fetchEventosData() {
|
|||||||
const postsCount = settings.feed_news_count || 12;
|
const postsCount = settings.feed_news_count || 12;
|
||||||
const cardSettings = {
|
const cardSettings = {
|
||||||
showCategory: settings.news_card_show_category ?? false,
|
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,
|
showFooterButton: settings.news_card_show_footer_button ?? true,
|
||||||
footerText: settings.news_card_footer_text || ""
|
footerText: settings.news_card_footer_text || ""
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite';
|
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 { Query } from 'node-appwrite';
|
||||||
import { NewsCard } from '@/components/NewsCard';
|
import { NewsCard } from '@/components/NewsCard';
|
||||||
import { GlobalSidebar, hasSidebarContent } from '@/components/AdWrappers';
|
import { GlobalSidebar, hasSidebarContent } from '@/components/AdWrappers';
|
||||||
@ -25,7 +25,7 @@ async function fetchHomeData() {
|
|||||||
const postsCount = settings.feed_news_count || 12;
|
const postsCount = settings.feed_news_count || 12;
|
||||||
const cardSettings = {
|
const cardSettings = {
|
||||||
showCategory: settings.news_card_show_category ?? false,
|
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,
|
showFooterButton: settings.news_card_show_footer_button ?? true,
|
||||||
footerText: settings.news_card_footer_text || ""
|
footerText: settings.news_card_footer_text || ""
|
||||||
};
|
};
|
||||||
@ -73,6 +73,9 @@ export default async function Home() {
|
|||||||
const { posts, cardSettings } = await fetchHomeData();
|
const { posts, cardSettings } = await fetchHomeData();
|
||||||
const hasSidebar = await hasSidebarContent();
|
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 (
|
return (
|
||||||
<main className="animate-fade-in relative">
|
<main className="animate-fade-in relative">
|
||||||
{/* Sci-Fi Hero Section */}
|
{/* Sci-Fi Hero Section */}
|
||||||
@ -150,19 +153,20 @@ export default async function Home() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<NewsCard
|
<NewsCard
|
||||||
key={post.$id}
|
key={`post-${post.$id}`}
|
||||||
id={post.$id}
|
id={post.$id}
|
||||||
slug={post.slug || post.$id}
|
slug={post.slug || post.$id}
|
||||||
titulo={decodeHtmlEntities(post.titulo)}
|
titulo={post.titulo}
|
||||||
resumen={decodeHtmlEntities(post.resumen_redes || post.contenido?.replace(/<[^>]*>?/gm, '').substring(0, 150) + '...')}
|
resumen={post.resumen_redes || post.contenido || ''}
|
||||||
tipo="Noticia"
|
tipo={typeof post.tipo_publicacion_id === 'object' ? post.tipo_publicacion_id.descripcion : ''}
|
||||||
fecha={post.fecha_publicacion || post.$createdAt}
|
fecha={post.fecha_publicacion || post.$createdAt}
|
||||||
imagenUrl={imagenUrl}
|
imagenUrl={imagenUrl}
|
||||||
showCategory={cardSettings.showCategory}
|
showCategory={cardSettings.showCategory}
|
||||||
showFooterDate={cardSettings.showFooterDate}
|
showFooterDate={cardSettings.showFooterDate}
|
||||||
showFooterButton={cardSettings.showFooterButton}
|
showFooterButton={cardSettings.showFooterButton}
|
||||||
footerText={cardSettings.footerText}
|
footerText={cardSettings.footerText}
|
||||||
/>
|
isAdmin={isAdmin}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -14,6 +14,7 @@ interface NewsCardProps {
|
|||||||
showFooterDate?: boolean;
|
showFooterDate?: boolean;
|
||||||
showFooterButton?: boolean;
|
showFooterButton?: boolean;
|
||||||
footerText?: string;
|
footerText?: string;
|
||||||
|
isAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NewsCard = ({
|
export const NewsCard = ({
|
||||||
@ -27,7 +28,8 @@ export const NewsCard = ({
|
|||||||
showCategory = false,
|
showCategory = false,
|
||||||
showFooterDate = false,
|
showFooterDate = false,
|
||||||
showFooterButton = false,
|
showFooterButton = false,
|
||||||
footerText
|
footerText,
|
||||||
|
isAdmin = false
|
||||||
}: NewsCardProps) => {
|
}: NewsCardProps) => {
|
||||||
const titulo = decodeHtmlEntities(rawTitulo);
|
const titulo = decodeHtmlEntities(rawTitulo);
|
||||||
const resumen = decodeHtmlEntities(rawResumen);
|
const resumen = decodeHtmlEntities(rawResumen);
|
||||||
@ -57,6 +59,20 @@ export const NewsCard = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Contenido */}
|
{/* 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 handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@ -80,7 +94,7 @@ export const MediaGridSelector = ({
|
|||||||
// 3. Update state
|
// 3. Update state
|
||||||
setMediaItems(prev => [doc, ...prev]);
|
setMediaItems(prev => [doc, ...prev]);
|
||||||
const url = getAppwriteImageUrl(uploadedFile.$id, 1200, 800);
|
const url = getAppwriteImageUrl(uploadedFile.$id, 1200, 800);
|
||||||
onChange(uploadedFile.$id, url);
|
onChange(doc.$id, url);
|
||||||
closeModal();
|
closeModal();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error upload:", error);
|
console.error("Error upload:", error);
|
||||||
@ -162,7 +176,7 @@ export const MediaGridSelector = ({
|
|||||||
<div
|
<div
|
||||||
key={item.$id}
|
key={item.$id}
|
||||||
onClick={() => {
|
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);
|
if (mode !== 'modal') setIsOpen(false);
|
||||||
closeModal();
|
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" />
|
<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" />
|
<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">
|
<div className="absolute top-2 right-2 bg-cyan-500 text-white rounded-full p-1 shadow-lg">
|
||||||
<CheckCircle2 size={16} />
|
<CheckCircle2 size={16} />
|
||||||
</div>
|
</div>
|
||||||
@ -224,7 +238,17 @@ export const MediaGridSelector = ({
|
|||||||
>
|
>
|
||||||
{value ? (
|
{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">
|
<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">
|
<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 ↔
|
Cambiar Imagen ↔
|
||||||
|
|||||||
@ -96,11 +96,35 @@ export const PublicationLayout: React.FC<PublicationLayoutProps> = ({
|
|||||||
const [slug, setSlug] = useState(initialData?.slug || '');
|
const [slug, setSlug] = useState(initialData?.slug || '');
|
||||||
const [isAutoSlug, setIsAutoSlug] = useState(!initialData?.slug);
|
const [isAutoSlug, setIsAutoSlug] = useState(!initialData?.slug);
|
||||||
const [contenido, setContenido] = useState(initialData?.contenido || '');
|
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'
|
typeof initialData?.imagen_destacada_id === 'object'
|
||||||
? initialData.imagen_destacada_id.$id
|
? initialData?.imagen_destacada_id?.$id
|
||||||
: initialData?.imagen_destacada_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>>({});
|
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
// Populate relation cache in the store on mount
|
// Populate relation cache in the store on mount
|
||||||
@ -263,7 +287,8 @@ export const PublicationLayout: React.FC<PublicationLayoutProps> = ({
|
|||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
name="resumen_redes"
|
name="resumen_redes"
|
||||||
defaultValue={initialData?.resumen_redes || ''}
|
value={resumen_redes}
|
||||||
|
onChange={(e) => setResumenRedes(e.target.value)}
|
||||||
placeholder="Resumen corto para compartir en redes sociales..."
|
placeholder="Resumen corto para compartir en redes sociales..."
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
className={`${textareaClass} custom-scrollbar resize-none`}
|
className={`${textareaClass} custom-scrollbar resize-none`}
|
||||||
@ -297,7 +322,11 @@ export const PublicationLayout: React.FC<PublicationLayoutProps> = ({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="seo_titulo"
|
name="seo_titulo"
|
||||||
defaultValue={initialData?.seo_titulo || ''}
|
value={seoTitulo}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSeoTitulo(e.target.value);
|
||||||
|
setSeoDirty(prev => ({ ...prev, titulo: true }));
|
||||||
|
}}
|
||||||
maxLength={70}
|
maxLength={70}
|
||||||
placeholder="Título para buscadores..."
|
placeholder="Título para buscadores..."
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
@ -311,7 +340,11 @@ export const PublicationLayout: React.FC<PublicationLayoutProps> = ({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="seo_descripcion"
|
name="seo_descripcion"
|
||||||
defaultValue={initialData?.seo_descripcion || ''}
|
value={seoDescripcion}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSeoDescripcion(e.target.value);
|
||||||
|
setSeoDirty(prev => ({ ...prev, descripcion: true }));
|
||||||
|
}}
|
||||||
maxLength={160}
|
maxLength={160}
|
||||||
placeholder="Descripción para resultados de búsqueda..."
|
placeholder="Descripción para resultados de búsqueda..."
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
|
|||||||
@ -101,9 +101,10 @@ export const PublicationsList: React.FC<PublicationsListProps> = ({
|
|||||||
// Sort
|
// Sort
|
||||||
result.sort((a, b) => {
|
result.sort((a, b) => {
|
||||||
if (sortBy === 'titulo') return a.titulo.localeCompare(b.titulo);
|
if (sortBy === 'titulo') return a.titulo.localeCompare(b.titulo);
|
||||||
const aDate = new Date(a[sortBy] || a.$createdAt).getTime();
|
if (sortBy === 'estado') return (a.estado || '').localeCompare(b.estado || '');
|
||||||
const bDate = new Date(b[sortBy] || b.$createdAt).getTime();
|
const aDate = new Date(a[sortBy] || a.fecha_publicacion || a.$createdAt || 0).getTime();
|
||||||
return bDate - aDate; // Newest first
|
const bDate = new Date(b[sortBy] || b.fecha_publicacion || b.$createdAt || 0).getTime();
|
||||||
|
return bDate - aDate; // Strictly Newest first
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@ -245,7 +246,7 @@ export const PublicationsList: React.FC<PublicationsListProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{filteredAndSortedPosts.length > 0 ? (
|
{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) => (
|
{filteredAndSortedPosts.map((post) => (
|
||||||
<PostCard
|
<PostCard
|
||||||
key={post.$id}
|
key={post.$id}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user