feat: implement robust publications module with real-time validation and state management

- Refactor dashboard entity pages to support dynamic publications management.
- Add Publications Module with Atomic Design components (PostCard, PublicationsList, FormSection).
- Implement Zod-based real-time validation for publication forms.
- Manage state using Zustand for optimistic UI and in-place relations.
- Add RBAC protection with PermissionGuard and server-side API validation.
- Fix: Resolve TypeScript type error in AdWrappers.tsx to ensure successful build.
- Add ToastProvider and ErrorBoundary for improved user experience.
- Update dependencies for publications logic.
This commit is contained in:
Carlos (Almeira) 2026-03-28 08:47:40 -05:00
parent 7659019ca3
commit dda9ae0f4a
20 changed files with 2846 additions and 107 deletions

28
package-lock.json generated
View File

@ -17,10 +17,12 @@
"@tiptap/extension-underline": "^3.21.0",
"@tiptap/react": "^3.21.0",
"@tiptap/starter-kit": "^3.21.0",
"@types/dompurify": "^3.0.5",
"@types/video.js": "^7.3.58",
"appwrite": "^23.0.0",
"axios": "^1.13.6",
"clsx": "^2.1.1",
"dompurify": "^3.3.3",
"lucide-react": "^0.577.0",
"next": "16.1.6",
"node-appwrite": "^22.1.3",
@ -28,6 +30,7 @@
"react-dom": "19.2.3",
"tailwind-merge": "^3.5.0",
"video.js": "^8.23.7",
"zod": "^4.3.6",
"zustand": "^5.0.12"
},
"devDependencies": {
@ -2086,6 +2089,15 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"license": "MIT",
"dependencies": {
"@types/trusted-types": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -2157,6 +2169,12 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@ -3579,6 +3597,15 @@
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
},
"node_modules/dompurify": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -7893,7 +7920,6 @@
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"

View File

@ -19,10 +19,12 @@
"@tiptap/extension-underline": "^3.21.0",
"@tiptap/react": "^3.21.0",
"@tiptap/starter-kit": "^3.21.0",
"@types/dompurify": "^3.0.5",
"@types/video.js": "^7.3.58",
"appwrite": "^23.0.0",
"axios": "^1.13.6",
"clsx": "^2.1.1",
"dompurify": "^3.3.3",
"lucide-react": "^0.577.0",
"next": "16.1.6",
"node-appwrite": "^22.1.3",
@ -30,6 +32,7 @@
"react-dom": "19.2.3",
"tailwind-merge": "^3.5.0",
"video.js": "^8.23.7",
"zod": "^4.3.6",
"zustand": "^5.0.12"
},
"devDependencies": {

View File

@ -0,0 +1,61 @@
/**
* @file app/api/publications/quick-create/route.ts
* @description Quick-create API endpoint invoked by RelationSelectField's portal modal.
* Validates session and creates a new document in any allowed relation collection.
*/
import { NextRequest, NextResponse } from 'next/server';
import { ID } from 'node-appwrite';
import { createSessionClient } from '@/lib/appwrite.server';
import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite';
import { QuickCreateSchema } from '@/lib/validators/publications';
import { collectionMap } from '@/lib/schema';
import type { ApiResponse } from '@/types/publications';
const DB = APPWRITE_ALMEIRA_CONFIG.databaseId;
/** Collections that can be created via Quick Create */
const ALLOWED_QUICK_CREATE = ['publication_types', 'clients', 'categories', 'cities', 'departments'];
export async function POST(request: NextRequest) {
// Auth
const session = await createSessionClient();
if (!session) {
return NextResponse.json<ApiResponse>({ success: false, error: 'No autenticado.' }, { status: 401 });
}
// Parse & validate
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json<ApiResponse>({ success: false, error: 'Cuerpo inválido.' }, { status: 400 });
}
const parseResult = QuickCreateSchema.safeParse(body);
if (!parseResult.success) {
return NextResponse.json<ApiResponse>({ success: false, error: 'Datos inválidos.' }, { status: 422 });
}
const { collection, data } = parseResult.data;
// Only allow whitelisted collections
if (!ALLOWED_QUICK_CREATE.includes(collection)) {
return NextResponse.json<ApiResponse>(
{ success: false, error: `Creación rápida no permitida para la colección: ${collection}` },
{ status: 403 }
);
}
const collectionId = collectionMap[collection];
if (!collectionId) {
return NextResponse.json<ApiResponse>({ success: false, error: 'Colección no encontrada.' }, { status: 404 });
}
try {
const document = await session.databases.createDocument(DB, collectionId, ID.unique(), data as Record<string, unknown>);
return NextResponse.json<ApiResponse>({ success: true, data: document }, { status: 201 });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Error al crear el registro.';
return NextResponse.json<ApiResponse>({ success: false, error: message }, { status: 500 });
}
}

View File

@ -0,0 +1,223 @@
/**
* @file app/api/publications/route.ts
* @description Secure API Route for Publications CRUD.
*
* Security layers:
* 1. Session validation via createSessionClient()
* 2. Role-based authorization (editor/admin for mutations)
* 3. Zod server-side validation before any DB write
* 4. Returns standardized ApiResponse<T>
*/
import { NextRequest, NextResponse } from 'next/server';
import { ID, Query } from 'node-appwrite';
import { createSessionClient, createAdminClient } from '@/lib/appwrite.server';
import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite';
import { PostSchema, PostPatchSchema } from '@/lib/validators/publications';
import type { ApiResponse } from '@/types/publications';
const DB = APPWRITE_ALMEIRA_CONFIG.databaseId;
const COLLECTION = 'posts';
// ─────────────────────────────────────────────
// HELPERS
// ─────────────────────────────────────────────
/** Validates the session and returns the user, or throws a 401 response */
async function requireAuth() {
const session = await createSessionClient();
if (!session) {
return { user: null, error: NextResponse.json<ApiResponse>(
{ success: false, error: 'No autenticado. Inicia sesión para continuar.' },
{ status: 401 }
)};
}
try {
const user = await session.account.get();
return { user, session, error: null };
} catch {
return { user: null, error: NextResponse.json<ApiResponse>(
{ success: false, error: 'Sesión inválida o expirada.' },
{ status: 401 }
)};
}
}
/** Checks if user has the required role */
function requireRole(user: any, allowedRoles: string[]): boolean {
const role: string = user?.prefs?.role || 'viewer';
return allowedRoles.includes(role);
}
// ─────────────────────────────────────────────
// GET — List Publications (public, cached)
// ─────────────────────────────────────────────
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const estado = searchParams.get('estado') || 'Publicado';
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 100);
const offset = parseInt(searchParams.get('offset') || '0');
const client = await createAdminClient();
const response = await client.databases.listDocuments(DB, COLLECTION, [
Query.equal('estado', estado),
Query.orderDesc('fecha_publicacion'),
Query.limit(limit),
Query.offset(offset),
]);
return NextResponse.json<ApiResponse>(
{ success: true, data: response },
{
status: 200,
headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300',
},
}
);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Error al obtener publicaciones.';
return NextResponse.json<ApiResponse>({ success: false, error: message }, { status: 500 });
}
}
// ─────────────────────────────────────────────
// POST — Create Publication (editor/admin only)
// ─────────────────────────────────────────────
export async function POST(request: NextRequest) {
// 1. Auth check
const { user, session, error: authError } = await requireAuth();
if (authError || !user || !session) return authError;
// 2. Role check
if (!requireRole(user, ['editor', 'admin'])) {
return NextResponse.json<ApiResponse>(
{ success: false, error: 'Acceso denegado. Se requiere rol de Editor o Administrador.' },
{ status: 403 }
);
}
// 3. Parse body
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json<ApiResponse>({ success: false, error: 'Cuerpo de la solicitud inválido.' }, { status: 400 });
}
// 4. Zod validation
const parseResult = PostSchema.safeParse(body);
if (!parseResult.success) {
const errors: Record<string, string[]> = {};
parseResult.error.errors.forEach((err) => {
const key = String(err.path[0]);
if (!errors[key]) errors[key] = [];
errors[key].push(err.message);
});
return NextResponse.json<ApiResponse>(
{ success: false, error: 'Datos inválidos.', validationErrors: errors },
{ status: 422 }
);
}
// 5. Create document
try {
const document = await session.databases.createDocument(
DB,
COLLECTION,
ID.unique(),
parseResult.data
);
return NextResponse.json<ApiResponse>({ success: true, data: document }, { status: 201 });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Error al crear la publicación.';
return NextResponse.json<ApiResponse>({ success: false, error: message }, { status: 500 });
}
}
// ─────────────────────────────────────────────
// PUT — Update Publication (editor/admin only)
// ─────────────────────────────────────────────
export async function PUT(request: NextRequest) {
// 1. Auth check
const { user, session, error: authError } = await requireAuth();
if (authError || !user || !session) return authError;
// 2. Role check
if (!requireRole(user, ['editor', 'admin'])) {
return NextResponse.json<ApiResponse>(
{ success: false, error: 'Acceso denegado. Se requiere rol de Editor o Administrador.' },
{ status: 403 }
);
}
// 3. Parse body
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json<ApiResponse>({ success: false, error: 'Cuerpo de la solicitud inválido.' }, { status: 400 });
}
// 4. Zod validation (partial update schema)
const parseResult = PostPatchSchema.safeParse(body);
if (!parseResult.success) {
const errors: Record<string, string[]> = {};
parseResult.error.errors.forEach((err) => {
const key = String(err.path[0]);
if (!errors[key]) errors[key] = [];
errors[key].push(err.message);
});
return NextResponse.json<ApiResponse>(
{ success: false, error: 'Datos inválidos.', validationErrors: errors },
{ status: 422 }
);
}
const { $id, ...updateData } = parseResult.data;
// 5. Update document
try {
const document = await session.databases.updateDocument(DB, COLLECTION, $id!, updateData);
return NextResponse.json<ApiResponse>({ success: true, data: document }, { status: 200 });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Error al actualizar la publicación.';
return NextResponse.json<ApiResponse>({ success: false, error: message }, { status: 500 });
}
}
// ─────────────────────────────────────────────
// DELETE — Delete Publication (admin only)
// ─────────────────────────────────────────────
export async function DELETE(request: NextRequest) {
// 1. Auth check
const { user, session, error: authError } = await requireAuth();
if (authError || !user || !session) return authError;
// 2. Role check — only admins can delete
if (!requireRole(user, ['admin'])) {
return NextResponse.json<ApiResponse>(
{ success: false, error: 'Acceso denegado. Solo los Administradores pueden eliminar publicaciones.' },
{ status: 403 }
);
}
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json<ApiResponse>({ success: false, error: 'ID de publicación requerido.' }, { status: 400 });
}
try {
await session.databases.deleteDocument(DB, COLLECTION, id);
return NextResponse.json<ApiResponse>({ success: true }, { status: 200 });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Error al eliminar la publicación.';
return NextResponse.json<ApiResponse>({ success: false, error: message }, { status: 500 });
}
}

View File

@ -1,57 +1,102 @@
import { APPWRITE_ALMEIRA_CONFIG, projectId, endpoint } from '@/lib/appwrite';
import { createSessionClient } from '@/lib/appwrite.server';
import { createAdminClient, getLoggedInUser } from '@/lib/appwrite.server';
import { Query } from 'node-appwrite';
import { collectionMap, SCHEMA_MAP } from '@/lib/schema';
import { createDocumentServer } from '@/app/actions/appwrite';
import { saveEntityAction } from '@/app/actions/entities';
import { redirect } from 'next/navigation';
import { EntityForm } from '@/components/dashboard/EntityForm';
import type { AuthUser } from '@/types/publications';
export default async function NewEntityPage({ params }: { params: Promise<{ entity: string }> }) {
/**
* New entity page — uses the specialized PublicationLayout for 'posts',
* and the generic EntityForm for all other entity types.
*/
export default async function NewEntityPage({
params,
}: {
params: Promise<{ entity: string }>;
}) {
const { entity } = await params;
const collectionId = collectionMap[entity];
const fields = SCHEMA_MAP[entity] || [];
const entityNames: Record<string, string> = {
posts: 'Publicaciones', categories: 'Categorías', clients: 'Clientes', client_types: 'Tipos de Cliente',
countries: 'Países', departments: 'Departamentos', cities: 'Ciudades', contracts: 'Contratos',
payment_methods: 'Formas de Pago', publication_types: 'Tipos de Publicación'
};
const entityName = entityNames[entity] || entity.replace(/_/g, ' ');
if (!collectionId || fields.length === 0) {
return (
<div className="p-8"><h1 className="text-red-500 font-bold">Configuración no encontrada para {entity}</h1></div>
<div className="p-8">
<h1 className="text-red-500 font-bold text-xl">Configuración no encontrada para: {entity}</h1>
</div>
);
}
const sessionClient = await createSessionClient();
if (!sessionClient) {
throw new Error("No sesión activa");
// Auth check
const user = await getLoggedInUser();
if (!user) redirect('/login');
// Pre-load all relation data server-side
const relationData: Record<string, Array<{ $id: string; [key: string]: unknown }>> = {};
try {
const client = await createAdminClient();
await Promise.all(
fields
.filter((f) => f.type === 'relation' && f.relationCollection)
.map(async (field) => {
const relKey = field.relationCollection as string;
const relColId = collectionMap[relKey];
if (!relColId) return;
try {
const res = await client.databases.listDocuments(
APPWRITE_ALMEIRA_CONFIG.databaseId,
relColId,
[Query.limit(100)]
);
relationData[field.name] = res.documents as Array<{ $id: string; [key: string]: unknown }>;
} catch {
// Silent fail for individual relations
}
})
);
} catch (error) {
console.error('Error loading relation data:', error);
}
const relationData: Record<string, any[]> = {};
await Promise.all(
fields.filter(f => f.type === 'relation' && f.relationCollection).map(async (field) => {
try {
const relKey = field.relationCollection as string;
if(!collectionMap[relKey]) return;
const res = await sessionClient.databases.listDocuments(APPWRITE_ALMEIRA_CONFIG.databaseId, collectionMap[relKey], []);
relationData[field.name] = res.documents;
} catch(e) { console.error('Error obteniendo relaciones de', field.name, e); }
})
);
const boundSave = saveEntityAction.bind(null, entity);
const appwriteConfig = {
endpoint,
projectId,
databaseId: APPWRITE_ALMEIRA_CONFIG.databaseId,
};
return (
<EntityForm
entity={entity}
entityName={entityName}
fields={fields}
// ── POSTS: Use the new PublicationLayout ──────────────
if (entity === 'posts') {
const { default: PublicationLayout } = await import('@/components/publications/PublicationLayout');
return (
<PublicationLayout
user={user as AuthUser}
relationData={relationData}
submitAction={boundSave}
appwriteConfig={{ endpoint, projectId }}
appwriteConfig={appwriteConfig}
/>
);
}
// ── OTHER ENTITIES: Use the generic EntityForm ────────
const { EntityForm } = await import('@/components/dashboard/EntityForm');
const entityNames: Record<string, string> = {
categories: 'Categorías', clients: 'Clientes', client_types: 'Tipos de Cliente',
countries: 'Países', departments: 'Departamentos', cities: 'Ciudades',
contracts: 'Contratos', payment_methods: 'Formas de Pago', publication_types: 'Tipos de Publicación',
};
return (
<EntityForm
entity={entity}
entityName={entityNames[entity] || entity.replace(/_/g, ' ')}
fields={fields}
relationData={relationData}
submitAction={boundSave}
appwriteConfig={{ endpoint, projectId }}
/>
);
}

View File

@ -1,34 +1,134 @@
import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite';
import { createSessionClient, getLoggedInUser } from '@/lib/appwrite.server';
import { APPWRITE_ALMEIRA_CONFIG, endpoint, projectId } from '@/lib/appwrite';
import { createAdminClient, createSessionClient, getLoggedInUser } from '@/lib/appwrite.server';
import { Query } from 'node-appwrite';
import Link from 'next/link';
import { collectionMap, SCHEMA_MAP } from '@/lib/schema';
import { DataTable } from '@/components/dashboard/DataTable';
import { getUserPreferences } from '@/lib/preferences';
import { ErrorBoundary } from '@/components/publications/ErrorBoundary';
import { saveEntityAction } from '@/app/actions/entities';
import type { Post, AuthUser } from '@/types/publications';
export default async function EntityPage({
// ─────────────────────────────────────────────
// POSTS‑SPECIFIC LAYOUT (card grid + drawer)
// ─────────────────────────────────────────────
/**
* Renders the publications module with PublicationsList (cards + drawer)
* instead of the generic DataTable, when entity === 'posts'.
*/
async function PostsPage({
entity,
user,
}: {
entity: string;
user: AuthUser;
}) {
// Lazy import server-side only
const { PublicationsList } = await import('@/components/publications/PublicationsList');
const collectionId = collectionMap[entity];
let posts: Post[] = [];
let relationData: Record<string, Array<{ $id: string; [key: string]: unknown }>> = {};
try {
const client = await createAdminClient();
// Fetch all published + draft + pending posts
const res = await client.databases.listDocuments(
APPWRITE_ALMEIRA_CONFIG.databaseId,
collectionId,
[Query.orderDesc('$createdAt'), Query.limit(100)]
);
posts = res.documents as unknown as Post[];
// Pre-load relation data for the drawer/form
const fields = SCHEMA_MAP[entity] || [];
await Promise.all(
fields
.filter((f) => f.type === 'relation' && f.relationCollection)
.map(async (field) => {
const relKey = field.relationCollection as string;
const relColId = collectionMap[relKey];
if (!relColId) return;
try {
const relRes = await client.databases.listDocuments(
APPWRITE_ALMEIRA_CONFIG.databaseId,
relColId,
[Query.limit(100)]
);
relationData[field.name] = relRes.documents as Array<{ $id: string; [key: string]: unknown }>;
} catch {
// Fail silently for individual relation fetches
}
})
);
} catch (error) {
console.error('PostsPage fetch error:', error);
}
const boundEditAction = saveEntityAction.bind(null, entity);
return (
<div className="space-y-6 animate-fade-in pb-10">
{/* Page Header */}
<div className="flex items-center gap-4 bg-slate-900/60 border border-white/8 p-6 rounded-2xl">
<div>
<h1 className="text-3xl font-black font-outfit uppercase tracking-tighter text-white">
Publicaciones
</h1>
<p className="text-sm font-bold text-cyan-500 uppercase tracking-widest mt-1">
{posts.length} registros en el sistema
</p>
</div>
</div>
<ErrorBoundary>
<PublicationsList
initialPosts={posts}
user={user}
relationData={relationData}
submitEditAction={boundEditAction}
appwriteConfig={{
endpoint,
projectId,
databaseId: APPWRITE_ALMEIRA_CONFIG.databaseId,
}}
entity={entity}
/>
</ErrorBoundary>
</div>
);
}
// ─────────────────────────────────────────────
// GENERIC ENTITY PAGE (DataTable)
// ─────────────────────────────────────────────
export default async function EntityPage({
params,
searchParams
}: {
params: Promise<{ entity: string }>,
searchParams: Promise<{ q?: string, page?: string, sort?: string, dir?: string }>
searchParams,
}: {
params: Promise<{ entity: string }>;
searchParams: Promise<{ q?: string; page?: string; sort?: string; dir?: string }>;
}) {
const { entity } = await params;
const sParams = await searchParams;
const q = sParams.q || '';
const page = Number(sParams.page) || 1;
const sort = sParams.sort || '$createdAt';
const dir = (sParams.dir === 'asc' || sParams.dir === 'desc') ? sParams.dir : 'desc';
const dir = sParams.dir === 'asc' ? 'asc' : 'desc';
const limit = 15;
const offset = (page - 1) * limit;
const collectionId = collectionMap[entity];
const fieldsConfig = SCHEMA_MAP[entity] || [];
const entityNames: Record<string, string> = {
posts: 'Publicaciones', categories: 'Categorías', clients: 'Clientes', client_types: 'Tipos de Cliente',
countries: 'Países', departments: 'Departamentos', cities: 'Ciudades', contracts: 'Contratos',
payment_methods: 'Formas de Pago', publication_types: 'Tipos de Publicación'
posts: 'Publicaciones', categories: 'Categorías', clients: 'Clientes',
client_types: 'Tipos de Cliente', countries: 'Países', departments: 'Departamentos',
cities: 'Ciudades', contracts: 'Contratos', payment_methods: 'Formas de Pago',
publication_types: 'Tipos de Publicación',
};
const entityName = entityNames[entity] || entity.replace(/_/g, ' ');
@ -42,48 +142,48 @@ export default async function EntityPage({
);
}
// Fetch user and preferences
const user = await getLoggedInUser();
const prefs = await getUserPreferences();
const hiddenColumns = prefs.hiddenColumns?.[entity] || [];
let documents: any[] = [];
// Redirect the 'posts' entity to the specialized card view
if (entity === 'posts') {
return <PostsPage entity={entity} user={user as AuthUser} />;
}
// Generic DataTable for all other entities
let documents: Array<Record<string, unknown>> = [];
let total = 0;
try {
const sessionClient = await createSessionClient();
if (!sessionClient) {
throw new Error("No sesión activa");
}
if (!sessionClient) throw new Error('No sesión activa');
const queries = [
Query.limit(limit),
Query.offset(offset),
];
const queries = [Query.limit(limit), Query.offset(offset)];
// Apply Sorting
if (dir === 'asc') {
queries.push(Query.orderAsc(sort));
queries.push(Query.orderAsc(sort));
} else {
queries.push(Query.orderDesc(sort));
queries.push(Query.orderDesc(sort));
}
if (q) {
const searchableFields = fieldsConfig
.filter(f => f.type === 'text' || f.type === 'textarea')
.map(f => f.name)
.slice(0, 2);
if (searchableFields.length > 0) {
queries.push(Query.contains(searchableFields[0], q));
}
const searchableFields = fieldsConfig
.filter((f) => f.type === 'text' || f.type === 'textarea')
.map((f) => f.name)
.slice(0, 2);
if (searchableFields.length > 0) {
queries.push(Query.contains(searchableFields[0], q));
}
}
const response = await sessionClient.databases.listDocuments(
APPWRITE_ALMEIRA_CONFIG.databaseId,
collectionId,
APPWRITE_ALMEIRA_CONFIG.databaseId,
collectionId,
queries
);
documents = response.documents;
documents = response.documents as Array<Record<string, unknown>>;
total = response.total;
} catch (error) {
console.error(`Error fetching from ${collectionId}:`, error);
@ -102,24 +202,27 @@ export default async function EntityPage({
</div>
<form className="flex-1 max-w-md w-full relative group">
<input
type="text"
name="q"
defaultValue={q}
placeholder={`Buscar en ${entityName}...`}
className="w-full bg-slate-950 border border-slate-800 rounded-full px-6 py-3 text-slate-200 focus:outline-none focus:border-cyan-500 transition-all font-medium placeholder:text-slate-600"
/>
<button type="submit" className="absolute right-2 top-2 bg-slate-800 p-1.5 rounded-full hover:bg-slate-700 transition-colors text-slate-400">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
</button>
<input
type="text"
name="q"
defaultValue={q}
placeholder={`Buscar en ${entityName}...`}
className="w-full bg-slate-950 border border-slate-800 rounded-full px-6 py-3 text-slate-200 focus:outline-none focus:border-cyan-500 transition-all font-medium placeholder:text-slate-600"
/>
<button type="submit" className="absolute right-2 top-2 bg-slate-800 p-1.5 rounded-full hover:bg-slate-700 transition-colors text-slate-400">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
</button>
</form>
<Link href={`/dashboard/${entity}/new`} className="group flex items-center gap-2 bg-gradient-to-r from-blue-600 to-cyan-500 hover:from-blue-500 hover:to-cyan-400 text-white text-sm font-bold px-6 py-3 rounded-full shadow-[0_0_20px_rgba(6,182,212,0.3)] hover:shadow-[0_0_30px_rgba(6,182,212,0.5)] transition-all hover:scale-105">
<Link
href={`/dashboard/${entity}/new`}
className="group flex items-center gap-2 bg-gradient-to-r from-blue-600 to-cyan-500 hover:from-blue-500 hover:to-cyan-400 text-white text-sm font-bold px-6 py-3 rounded-full shadow-[0_0_20px_rgba(6,182,212,0.3)] hover:shadow-[0_0_30px_rgba(6,182,212,0.5)] transition-all hover:scale-105"
>
<span className="text-xl leading-none -mt-1">+</span> Nuevo
</Link>
</div>
<DataTable
<DataTable
entity={entity}
collectionId={collectionId}
documents={documents}
@ -133,21 +236,21 @@ export default async function EntityPage({
{/* Pagination */}
{total > limit && (
<div className="flex justify-center items-center gap-4 mt-8">
<Link
href={`/dashboard/${entity}?page=${page - 1}${q ? `&q=${q}` : ''}${sort ? `&sort=${sort}` : ''}${dir ? `&dir=${dir}` : ''}`}
className={`px-6 py-2 rounded-full font-bold text-sm transition-all border border-slate-800 ${page <= 1 ? 'opacity-30 pointer-events-none bg-slate-900' : 'bg-slate-900 text-white hover:border-cyan-500'}`}
>
ANTERIOR
</Link>
<span className="text-slate-400 font-bold tracking-widest text-xs uppercase">
Página {page} de {Math.ceil(total / limit)}
</span>
<Link
href={`/dashboard/${entity}?page=${page + 1}${q ? `&q=${q}` : ''}${sort ? `&sort=${sort}` : ''}${dir ? `&dir=${dir}` : ''}`}
className={`px-6 py-2 rounded-full font-bold text-sm transition-all border border-slate-800 ${page >= Math.ceil(total / limit) ? 'opacity-30 pointer-events-none bg-slate-900' : 'bg-slate-900 text-white hover:border-cyan-500'}`}
>
SIGUIENTE
</Link>
<Link
href={`/dashboard/${entity}?page=${page - 1}${q ? `&q=${q}` : ''}${sort ? `&sort=${sort}` : ''}${dir ? `&dir=${dir}` : ''}`}
className={`px-6 py-2 rounded-full font-bold text-sm transition-all border border-slate-800 ${page <= 1 ? 'opacity-30 pointer-events-none bg-slate-900' : 'bg-slate-900 text-white hover:border-cyan-500'}`}
>
ANTERIOR
</Link>
<span className="text-slate-400 font-bold tracking-widest text-xs uppercase">
Página {page} de {Math.ceil(total / limit)}
</span>
<Link
href={`/dashboard/${entity}?page=${page + 1}${q ? `&q=${q}` : ''}${sort ? `&sort=${sort}` : ''}${dir ? `&dir=${dir}` : ''}`}
className={`px-6 py-2 rounded-full font-bold text-sm transition-all border border-slate-800 ${page >= Math.ceil(total / limit) ? 'opacity-30 pointer-events-none bg-slate-900' : 'bg-slate-900 text-white hover:border-cyan-500'}`}
>
SIGUIENTE
</Link>
</div>
)}
</div>

View File

@ -1,6 +1,8 @@
import { Sidebar } from '@/components/Sidebar';
import { getLoggedInUser } from '@/lib/appwrite.server';
import { redirect } from 'next/navigation';
import { ToastProvider } from '@/components/publications/ToastProvider';
import { ErrorBoundary } from '@/components/publications/ErrorBoundary';
export default async function DashboardLayout({
children,
@ -13,12 +15,16 @@ export default async function DashboardLayout({
}
return (
<div className="flex h-screen bg-[#0f172a] text-white pt-[80px]">
<Sidebar user={user} />
{/* Main content */}
<main className="flex-1 overflow-y-auto bg-[#020617] p-8 md:p-12 shadow-inner border-l border-slate-800">
{children}
</main>
</div>
<ToastProvider>
<div className="flex h-screen bg-[#0f172a] text-white pt-[80px]">
<Sidebar user={user} />
{/* Main content */}
<main className="flex-1 overflow-y-auto bg-[#020617] p-8 md:p-12 shadow-inner border-l border-slate-800">
<ErrorBoundary>
{children}
</ErrorBoundary>
</main>
</div>
</ToastProvider>
);
}

View File

@ -3,11 +3,17 @@ import { createAdminClient } from '@/lib/appwrite.server';
import TopBanner from './TopBanner';
import ClientSidebars from './ClientSidebars';
async function fetchSettings() {
interface Settings {
rotation_seconds: number;
ticker_count: number;
[key: string]: any;
}
async function fetchSettings(): Promise<Settings> {
try {
const client = await createAdminClient();
const res = await client.databases.listDocuments(APPWRITE_ALMEIRA_CONFIG.databaseId, 'settings', [Query.limit(1)]);
if (res.documents.length > 0) return res.documents[0];
if (res.documents.length > 0) return res.documents[0] as unknown as Settings;
} catch(e) {
console.error("fetchSettings error:", e);
}
@ -51,7 +57,7 @@ export async function GlobalTopBanner() {
}
export async function GlobalSidebar() {
let settings = { rotation_seconds: 5 };
let settings = { rotation_seconds: 5 } as Settings;
let groupedAds: Record<string, any[]> = {};
try {

View File

@ -0,0 +1,79 @@
'use client';
/**
* @file ErrorBoundary.tsx
* @description React Error Boundary for the Publications module.
* Catches runtime errors in any child component and displays a graceful fallback.
*
* Note: Error Boundaries must be class components per React spec.
*/
import React, { Component, type ErrorInfo, type ReactNode } from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react';
interface Props {
children: ReactNode;
/** Custom fallback UI. Defaults to a generic error card. */
fallback?: ReactNode;
/** Called when an error is caught — use to send to error reporting */
onError?: (error: Error, info: ErrorInfo) => void;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error('[ErrorBoundary] Caught error:', error, info);
this.props.onError?.(error, info);
}
handleReset = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback;
return (
<div className="flex flex-col items-center justify-center gap-6 p-12 bg-slate-900/60 border border-red-500/20 rounded-2xl text-center">
<div className="p-4 bg-red-500/10 rounded-full text-red-400">
<AlertTriangle size={32} />
</div>
<div>
<h3 className="text-lg font-black text-white uppercase tracking-tight mb-2">
Algo salió mal
</h3>
<p className="text-slate-400 text-sm max-w-xs mx-auto">
{this.state.error?.message || 'Error inesperado en el módulo de publicaciones.'}
</p>
</div>
<button
type="button"
onClick={this.handleReset}
className="flex items-center gap-2 px-6 py-2.5 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-xl text-white text-sm font-bold transition-all"
>
<RefreshCw size={14} />
Reintentar
</button>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@ -0,0 +1,62 @@
'use client';
/**
* @file FormSection.tsx
* @description Reusable form section wrapper with title, icon, and internal grid.
* Groups related form fields visually and logically.
*
* @example
* <FormSection title="Datos Básicos" icon={<FileText size={18} />} columns={2}>
* <input name="titulo" />
* <input name="slug" />
* </FormSection>
*/
import React from 'react';
import { FormSectionProps } from '@/types/publications';
/** Maps column count to Tailwind grid class */
const gridColsMap: Record<number, string> = {
1: 'grid-cols-1',
2: 'grid-cols-1 md:grid-cols-2',
3: 'grid-cols-1 md:grid-cols-3',
4: 'grid-cols-2 md:grid-cols-4',
6: 'grid-cols-2 md:grid-cols-6',
12: 'grid-cols-12',
};
export const FormSection: React.FC<FormSectionProps> = ({
title,
icon,
columns = 2,
children,
className = '',
}) => {
const gridClass = gridColsMap[columns] ?? 'grid-cols-1 md:grid-cols-2';
return (
<fieldset className={`border-0 p-0 m-0 ${className}`}>
{/* Section Header */}
<div className="flex items-center gap-3 mb-5">
{icon && (
<div className="p-2 bg-cyan-500/10 rounded-lg text-cyan-400 shrink-0">
{icon}
</div>
)}
<div className="flex-1">
<legend className="text-sm font-black text-white uppercase tracking-widest">
{title}
</legend>
<div className="mt-1.5 h-px bg-gradient-to-r from-cyan-500/40 via-slate-700/40 to-transparent" />
</div>
</div>
{/* Fields Grid */}
<div className={`grid ${gridClass} gap-5`}>
{children}
</div>
</fieldset>
);
};
export default FormSection;

View File

@ -0,0 +1,53 @@
'use client';
/**
* @file PermissionGuard.tsx
* @description Role-Based Access Control (RBAC) HOC/Wrapper component.
* Only renders children if the user's role is in the allowedRoles list.
* Use this to protect any UI element that requires authorization.
*
* @example
* // Only admins and editors can see the edit button
* <PermissionGuard allowedRoles={['admin', 'editor']} userRole={user.prefs.role}>
* <button onClick={handleEdit}>Editar</button>
* </PermissionGuard>
*/
import React from 'react';
import { PermissionGuardProps, UserRole } from '@/types/publications';
/** Priority order of roles (higher index = more permissions) */
const ROLE_HIERARCHY: Record<UserRole, number> = {
guest: 0,
viewer: 1,
editor: 2,
admin: 3,
};
/**
* Checks if a given role meets the minimum required role.
*/
export function hasPermission(userRole: UserRole, allowedRoles: UserRole[]): boolean {
return allowedRoles.some(
(allowed) => ROLE_HIERARCHY[userRole] >= ROLE_HIERARCHY[allowed]
);
}
/**
* PermissionGuard — Renders children only if userRole is in allowedRoles.
* Renders `fallback` (or null) otherwise.
*/
export const PermissionGuard: React.FC<PermissionGuardProps> = ({
allowedRoles,
userRole,
children,
fallback = null,
}) => {
if (!hasPermission(userRole, allowedRoles)) {
return <>{fallback}</>;
}
return <>{children}</>;
};
export default PermissionGuard;

View File

@ -0,0 +1,195 @@
'use client';
/**
* @file PostCard.tsx
* @description Pure presentation component for displaying a Post in the dashboard.
*
* - Receives all data via props (no internal data fetching).
* - Uses `PermissionGuard` to conditionally show edit/delete controls.
* - XSS-safe: all text content uses textContent rendering (no dangerouslySetInnerHTML).
* - The `onEdit` callback is injected by the parent — this component stays pure.
*/
import React from 'react';
import Link from 'next/link';
import { Edit2, Trash2, Eye, Calendar, Tag } from 'lucide-react';
import { Post, PostCardProps, UserRole } from '@/types/publications';
import PermissionGuard from './PermissionGuard';
import { decodeHtmlEntities } from '@/lib/utils';
/** Derives a plain text excerpt from raw HTML content — XSS safe */
function getPlainTextExcerpt(html: string, maxLength = 120): string {
// Remove all HTML tags using regex (safe for server, no DOM parsing needed)
const plain = html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
return plain.length > maxLength ? plain.substring(0, maxLength) + '…' : plain;
}
/** Resolves the image URL from a Post's imagen_destacada_id (string or MediaItem object) */
function resolveImageUrl(
post: Post,
endpoint: string,
projectId: string
): string | null {
if (post.og_image_url) return post.og_image_url;
if (!post.imagen_destacada_id) return null;
if (typeof post.imagen_destacada_id === 'object') {
const { bucket_id, file_id } = post.imagen_destacada_id;
return `${endpoint}/storage/buckets/${bucket_id}/files/${file_id}/preview?project=${projectId}&width=600&height=400&quality=80`;
}
// Fallback: just an ID, use default 'media' bucket
return `${endpoint}/storage/buckets/media/files/${post.imagen_destacada_id}/preview?project=${projectId}&width=600&height=400&quality=80`;
}
/** Maps PostStatus to a visual badge style */
const statusBadge: Record<string, { label: string; className: string }> = {
Publicado: { label: 'Publicado', className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30' },
Borrador: { label: 'Borrador', className: 'bg-slate-500/10 text-slate-400 border-slate-500/30' },
Pendiente: { label: 'Pendiente', className: 'bg-amber-500/10 text-amber-400 border-amber-500/30' },
Archivado: { label: 'Archivado', className: 'bg-red-500/10 text-red-400 border-red-500/30' },
};
/**
* PostCard — Dashboard card for a single Post.
*/
export const PostCard: React.FC<PostCardProps> = ({
post,
userRole,
onEdit,
onDelete,
appwriteEndpoint,
appwriteProjectId,
}) => {
const imageUrl = resolveImageUrl(post, appwriteEndpoint, appwriteProjectId);
// Safe text rendering — no raw HTML
const safeTitle = decodeHtmlEntities(post.titulo);
const safeExcerpt = getPlainTextExcerpt(post.resumen_redes || post.contenido || '');
const badge = statusBadge[post.estado] ?? statusBadge['Borrador'];
const fechaFormatted = new Date(post.fecha_publicacion || post.$createdAt).toLocaleDateString(
'es-CO',
{ day: '2-digit', month: 'short', year: 'numeric' }
);
const postType =
typeof post.tipo_publicacion_id === 'object'
? post.tipo_publicacion_id.descripcion
: post.tipo_publicacion_id;
return (
<article
className="group relative flex flex-col bg-slate-900/60 border border-white/8 rounded-2xl overflow-hidden transition-all duration-300 hover:border-cyan-500/40 hover:shadow-[0_8px_30px_rgba(6,182,212,0.1)] hover:-translate-y-1"
aria-label={`Publicación: ${safeTitle}`}
>
{/* Imagen Destacada */}
<div className="relative aspect-[16/9] bg-slate-800 overflow-hidden">
{imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={imageUrl}
alt={safeTitle}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-slate-800 to-slate-900">
<Tag size={32} className="text-slate-700" />
</div>
)}
{/* Estado Badge */}
<div className="absolute top-3 left-3">
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-[10px] font-black uppercase tracking-widest border ${badge.className}`}>
{badge.label}
</span>
</div>
{/* Edit Overlay — solo visible si el usuario tiene permisos */}
<PermissionGuard allowedRoles={['editor', 'admin']} userRole={userRole}>
<div
className="absolute inset-0 bg-slate-950/70 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center gap-3 cursor-pointer"
onClick={() => onEdit?.(post)}
role="button"
aria-label={`Editar: ${safeTitle}`}
>
<span className="flex items-center gap-2 bg-cyan-500 text-slate-950 font-black text-xs px-4 py-2 rounded-full uppercase tracking-widest shadow-lg">
<Edit2 size={14} />
Editar
</span>
</div>
</PermissionGuard>
</div>
{/* Contenido */}
<div className="flex flex-col flex-1 p-5">
{/* Tipo de publicación */}
{postType && (
<span className="text-[10px] font-black uppercase tracking-widest text-cyan-500 mb-2">
{postType}
</span>
)}
{/* Título — safe text */}
<h3 className="text-base font-bold text-white leading-snug mb-2 line-clamp-2">
{safeTitle}
</h3>
{/* Resumen — safe, no HTML */}
<p className="text-slate-400 text-sm leading-relaxed line-clamp-3 flex-1">
{safeExcerpt}
</p>
{/* Footer */}
<div className="mt-4 pt-4 border-t border-white/8 flex items-center justify-between gap-2">
{/* Fecha */}
<div className="flex items-center gap-1.5 text-slate-500 text-xs font-medium">
<Calendar size={12} />
<span>{fechaFormatted}</span>
</div>
{/* Acciones */}
<div className="flex items-center gap-1">
{/* Ver publicación (siempre visible) */}
<Link
href={`/${post.slug || post.$id}`}
target="_blank"
className="p-2 rounded-lg text-slate-500 hover:text-cyan-400 hover:bg-cyan-500/10 transition-all"
aria-label="Ver publicación"
>
<Eye size={15} />
</Link>
{/* Editar — solo para editor/admin */}
<PermissionGuard allowedRoles={['editor', 'admin']} userRole={userRole}>
<button
type="button"
onClick={() => onEdit?.(post)}
className="p-2 rounded-lg text-slate-500 hover:text-cyan-400 hover:bg-cyan-500/10 transition-all"
aria-label="Editar publicación"
>
<Edit2 size={15} />
</button>
</PermissionGuard>
{/* Eliminar — solo para admin */}
<PermissionGuard allowedRoles={['admin']} userRole={userRole}>
<button
type="button"
onClick={() => onDelete?.(post.$id)}
className="p-2 rounded-lg text-slate-500 hover:text-red-400 hover:bg-red-500/10 transition-all"
aria-label="Eliminar publicación"
>
<Trash2 size={15} />
</button>
</PermissionGuard>
</div>
</div>
</div>
</article>
);
};
export default PostCard;

View File

@ -0,0 +1,168 @@
'use client';
/**
* @file PublicationDrawer.tsx
* @description Slide-in SideDrawer for inline post editing.
* Opens when a user clicks a PostCard (gated by RBAC).
* Renders entirely in a Portal, focus-trapped, Escape-closeable.
*/
import React, { useEffect, useRef, useCallback, useState } from 'react';
import { createPortal } from 'react-dom';
import { X, Edit2, Loader2 } from 'lucide-react';
import type { Post, AuthUser, PublicationLayoutProps } from '@/types/publications';
export interface PublicationDrawerProps {
isOpen: boolean;
post: Post | null;
user: AuthUser;
relationData: Record<string, Array<{ $id: string; [key: string]: unknown }>>;
submitAction: (formData: FormData) => Promise<{ success: boolean; error?: string; data?: unknown }>;
appwriteConfig: {
endpoint: string;
projectId: string;
databaseId: string;
};
onClose: () => void;
onSuccess?: () => void;
}
export const PublicationDrawer: React.FC<PublicationDrawerProps> = ({
isOpen,
post,
user,
relationData,
submitAction,
appwriteConfig,
onClose,
onSuccess,
}) => {
const closeButtonRef = useRef<HTMLButtonElement>(null);
// Focus management
useEffect(() => {
if (isOpen) {
const timer = setTimeout(() => closeButtonRef.current?.focus(), 150);
return () => clearTimeout(timer);
}
}, [isOpen]);
// Keyboard handler
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
}, [onClose]);
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
};
}, [isOpen, handleKeyDown]);
const wrappedSubmit = useCallback(async (formData: FormData) => {
const result = await submitAction(formData);
if (result?.success) {
onSuccess?.();
}
return result;
}, [submitAction, onSuccess]);
if (typeof document === 'undefined') return null;
return createPortal(
<div
className={`fixed inset-0 z-[1000] flex justify-end transition-visibility duration-300 ${isOpen ? 'visible' : 'invisible pointer-events-none'}`}
aria-modal="true"
role="dialog"
aria-label={post ? `Editando: ${post.titulo}` : 'Editor de publicación'}
>
{/* Backdrop */}
<div
className={`absolute inset-0 bg-slate-950/75 backdrop-blur-sm transition-opacity duration-300 ${isOpen ? 'opacity-100' : 'opacity-0'}`}
onClick={onClose}
aria-hidden="true"
/>
{/* Drawer Panel */}
<div
className={`relative z-10 w-full max-w-3xl h-full bg-[#060f1e] border-l border-slate-800 shadow-2xl shadow-cyan-500/5
overflow-y-auto flex flex-col
transition-transform duration-300 ease-out
${isOpen ? 'translate-x-0' : 'translate-x-full'}`}
>
{/* Header */}
<div className="sticky top-0 z-10 flex items-center justify-between px-6 py-4 bg-[#060f1e]/95 backdrop-blur-sm border-b border-slate-800">
<div className="flex items-center gap-3">
<div className="p-2 bg-cyan-500/10 rounded-lg text-cyan-400">
<Edit2 size={16} />
</div>
<div>
<h2 className="text-sm font-black text-white uppercase tracking-widest">Editar Publicación</h2>
{post && (
<p className="text-[10px] text-slate-500 font-medium mt-0.5 truncate max-w-[320px]">
{post.titulo}
</p>
)}
</div>
</div>
<button
ref={closeButtonRef}
type="button"
onClick={onClose}
className="p-2 rounded-xl border border-slate-800 text-slate-400 hover:text-white hover:border-slate-600 transition-all"
aria-label="Cerrar panel de edición"
>
<X size={18} />
</button>
</div>
{/* Body */}
<div className="flex-1 p-6">
{post && isOpen ? (
<LazyPublicationLayout
initialData={post}
user={user}
relationData={relationData}
submitAction={wrappedSubmit}
appwriteConfig={appwriteConfig}
/>
) : (
<div className="flex items-center justify-center h-32 gap-3 text-slate-600 text-sm">
<Loader2 size={16} className="animate-spin" />
Cargando editor…
</div>
)}
</div>
</div>
</div>,
document.body
);
};
/** Lazy-loads PublicationLayout to code-split it from the list page */
function LazyPublicationLayout(props: PublicationLayoutProps) {
const [Layout, setLayout] = useState<React.ComponentType<PublicationLayoutProps> | null>(null);
useEffect(() => {
import('./PublicationLayout').then((mod) => {
setLayout(() => mod.default as React.ComponentType<PublicationLayoutProps>);
});
}, []);
if (!Layout) {
return (
<div className="flex items-center justify-center gap-3 h-32 text-slate-500">
<Loader2 size={18} className="animate-spin" />
Cargando formulario…
</div>
);
}
return <Layout {...props} />;
}
export default PublicationDrawer;

View File

@ -0,0 +1,496 @@
'use client';
/**
* @file PublicationLayout.tsx
* @description 12-column grid layout for the Post creation/edit form.
* Organizes fields into logical sections: Datos Básicos, Contenido,
* Multimedia, SEO, Configuración, and Comercial.
*
* Integrates:
* - `FormSection` for visual grouping
* - `RelationSelectField` for relation fields with quick-create
* - `RichTextEditor` for contenido field
* - `usePublicationStore` for optimistic UI and state
* - Zod validation (client-side, before submission)
* - `PermissionGuard` to ensure only editor/admin can submit
*/
import React, { useState, useTransition, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import {
FileText, Image as ImageIcon, Search, Settings, Briefcase,
Save, ArrowLeft, AlertCircle, CheckCircle, Loader2, Eye
} from 'lucide-react';
import Link from 'next/link';
import { PublicationLayoutProps } from '@/types/publications';
import { PostSchema, generateSlug } from '@/lib/validators/publications';
import { usePublicationStore } from '@/store/usePublicationStore';
import FormSection from './FormSection';
import RelationSelectField from './RelationSelectField';
import PermissionGuard from './PermissionGuard';
// ─────────────────────────────────────────────
// FIELD COMPONENTS
// ─────────────────────────────────────────────
function FieldWrapper({
label,
required,
error,
children,
hint,
colSpan = 1,
}: {
label: string;
required?: boolean;
error?: string;
children: React.ReactNode;
hint?: string;
colSpan?: number;
}) {
const spanClass = colSpan === 2 ? 'md:col-span-2' : '';
return (
<div className={`flex flex-col gap-1.5 ${spanClass}`}>
<label className="text-[10px] font-black uppercase tracking-widest text-slate-500">
{label}
{required && <span className="text-cyan-400 ml-1">*</span>}
</label>
{children}
{hint && !error && <p className="text-[10px] text-slate-600">{hint}</p>}
{error && (
<p className="text-[10px] text-red-400 flex items-center gap-1 font-bold">
<AlertCircle size={10} /> {error}
</p>
)}
</div>
);
}
const inputClass =
'w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-2.5 text-white text-sm ' +
'focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500/30 ' +
'hover:border-slate-700 transition-all placeholder:text-slate-700';
const textareaClass = inputClass + ' min-h-[100px] resize-y';
// ─────────────────────────────────────────────
// MAIN COMPONENT
// ─────────────────────────────────────────────
export const PublicationLayout: React.FC<PublicationLayoutProps> = ({
initialData,
relationData,
submitAction,
user,
appwriteConfig,
}) => {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const { setOptimisticState, optimisticState, errorMessage, reset, setRelationCache } = usePublicationStore();
// Form state
const [titulo, setTitulo] = useState(initialData?.titulo || '');
const [slug, setSlug] = useState(initialData?.slug || '');
const [isAutoSlug, setIsAutoSlug] = useState(!initialData?.slug);
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
// Populate relation cache in the store on mount
useEffect(() => {
Object.entries(relationData).forEach(([collection, data]) => {
setRelationCache(collection, data);
});
return () => reset();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Auto-generate slug from titulo
useEffect(() => {
if (isAutoSlug && titulo) {
setSlug(generateSlug(titulo));
}
}, [titulo, isAutoSlug]);
const userRole = (user?.prefs?.role as any) || 'viewer';
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
// Client-side Zod validation
const parseResult = PostSchema.safeParse(Object.fromEntries(formData.entries()));
if (!parseResult.success) {
const errors: Record<string, string> = {};
parseResult.error.errors.forEach((err) => {
const field = err.path[0] as string;
errors[field] = err.message;
});
setValidationErrors(errors);
return;
}
setValidationErrors({});
setOptimisticState('saving');
startTransition(async () => {
try {
const result = await submitAction(formData);
if (result?.success) {
setOptimisticState('saved');
setTimeout(() => router.push('/dashboard/posts'), 800);
} else {
setOptimisticState('error', result?.error || 'Error al guardar.');
}
} catch (err: unknown) {
setOptimisticState('error', err instanceof Error ? err.message : 'Error desconocido.');
}
});
};
const isLoading = isPending || optimisticState === 'saving';
const isSaved = optimisticState === 'saved';
return (
<div className="max-w-7xl mx-auto pb-16 animate-fade-in">
{/* Page Header */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
<div className="flex items-center gap-4">
<Link
href="/dashboard/posts"
className="p-2 rounded-xl border border-slate-800 text-slate-400 hover:text-white hover:border-slate-700 transition-all"
>
<ArrowLeft size={18} />
</Link>
<div>
<h1 className="text-2xl font-black font-outfit text-white uppercase tracking-tighter">
{initialData?.$id ? 'Editar Publicación' : 'Nueva Publicación'}
</h1>
<p className="text-slate-500 text-sm font-medium">
{initialData?.$id ? `ID: ${initialData.$id}` : 'Completa todos los campos requeridos.'}
</p>
</div>
</div>
{/* Status indicator */}
<div className="flex items-center gap-3">
{isSaved && (
<span className="flex items-center gap-2 text-emerald-400 text-sm font-bold bg-emerald-500/10 border border-emerald-500/30 px-4 py-2 rounded-xl">
<CheckCircle size={16} /> Guardado
</span>
)}
{optimisticState === 'error' && (
<span className="flex items-center gap-2 text-red-400 text-sm font-bold bg-red-500/10 border border-red-500/30 px-4 py-2 rounded-xl">
<AlertCircle size={16} /> {errorMessage}
</span>
)}
</div>
</div>
<form onSubmit={handleSubmit}>
{/* 12-column grid layout: left=8cols content, right=4cols sidebar */}
<div className="grid grid-cols-1 xl:grid-cols-12 gap-8">
{/* ── LEFT COLUMN (8/12) ─────────────────────── */}
<div className="xl:col-span-8 space-y-8">
{/* SECCIÓN: Datos Básicos */}
<div className="bg-slate-900/60 border border-white/8 rounded-2xl p-6">
<FormSection title="Datos Básicos" icon={<FileText size={16} />} columns={1}>
{/* Título */}
<FieldWrapper label="Título" required error={validationErrors.titulo}>
<input
type="text"
name="titulo"
value={titulo}
onChange={(e) => setTitulo(e.target.value)}
placeholder="Escribe un título llamativo..."
required
className={inputClass}
/>
</FieldWrapper>
{/* Slug */}
<FieldWrapper
label="URL Amigable (Slug)"
required
error={validationErrors.slug}
hint="Se genera automáticamente. Edita para personalizar."
>
<div className="flex items-center gap-2">
<input
type="text"
name="slug"
value={slug}
onChange={(e) => { setSlug(e.target.value); setIsAutoSlug(false); }}
placeholder="url-de-la-publicacion"
required
className={`${inputClass} font-mono`}
/>
<button
type="button"
onClick={() => { setIsAutoSlug(true); setSlug(generateSlug(titulo)); }}
className="shrink-0 px-3 py-2.5 rounded-xl border border-slate-700 text-slate-400 text-xs font-bold hover:border-cyan-500 hover:text-cyan-400 transition-all"
title="Regenerar slug desde el título"
>
↺
</button>
</div>
</FieldWrapper>
{/* Resumen */}
<FieldWrapper
label="Resumen para Redes"
error={validationErrors.resumen_redes}
hint="Máximo 500 caracteres. Se usa en Twitter/Facebook cards."
>
<textarea
name="resumen_redes"
defaultValue={initialData?.resumen_redes || ''}
placeholder="Resumen corto para compartir en redes sociales..."
maxLength={500}
className={textareaClass}
style={{ minHeight: '80px' }}
/>
</FieldWrapper>
</FormSection>
</div>
{/* SECCIÓN: Contenido */}
<div className="bg-slate-900/60 border border-white/8 rounded-2xl p-6">
<FormSection title="Contenido Principal" icon={<Eye size={16} />} columns={1}>
<FieldWrapper label="Contenido Completo" required error={validationErrors.contenido}>
<textarea
name="contenido"
defaultValue={initialData?.contenido || ''}
placeholder="Escribe el contenido completo de la publicación..."
required
className={`${textareaClass}`}
style={{ minHeight: '250px' }}
/>
</FieldWrapper>
</FormSection>
</div>
{/* SECCIÓN: SEO */}
<div className="bg-slate-900/60 border border-white/8 rounded-2xl p-6">
<FormSection title="SEO & Metadatos" icon={<Search size={16} />} columns={2}>
<FieldWrapper
label="SEO Título"
error={validationErrors.seo_titulo}
hint="Máximo 70 caracteres."
>
<input
type="text"
name="seo_titulo"
defaultValue={initialData?.seo_titulo || ''}
maxLength={70}
placeholder="Título para buscadores..."
className={inputClass}
/>
</FieldWrapper>
<FieldWrapper
label="SEO Descripción"
error={validationErrors.seo_descripcion}
hint="Máximo 160 caracteres."
>
<input
type="text"
name="seo_descripcion"
defaultValue={initialData?.seo_descripcion || ''}
maxLength={160}
placeholder="Descripción para resultados de búsqueda..."
className={inputClass}
/>
</FieldWrapper>
<FieldWrapper label="OG Image URL" error={validationErrors.og_image_url}>
<input
type="url"
name="og_image_url"
defaultValue={initialData?.og_image_url || ''}
placeholder="https://..."
className={inputClass}
/>
</FieldWrapper>
</FormSection>
</div>
</div>
{/* ── RIGHT COLUMN (4/12) ─────────────────────── */}
<div className="xl:col-span-4 space-y-6">
{/* SECCIÓN: Configuración */}
<div className="bg-slate-900/60 border border-white/8 rounded-2xl p-6">
<FormSection title="Configuración" icon={<Settings size={16} />} columns={1}>
{/* Tipo de Publicación */}
<RelationSelectField
name="tipo_publicacion_id"
label="Tipo de Publicación"
options={relationData.tipo_publicacion_id || []}
labelKey="descripcion"
value={
typeof initialData?.tipo_publicacion_id === 'object'
? initialData.tipo_publicacion_id.$id
: initialData?.tipo_publicacion_id || ''
}
relationCollection="publication_types"
required
/>
{/* Estado */}
<FieldWrapper label="Estado" required error={validationErrors.estado}>
<select
name="estado"
defaultValue={initialData?.estado || 'Borrador'}
required
className={inputClass}
>
<option value="Borrador">Borrador</option>
<option value="Pendiente">Pendiente revisión</option>
<option value="Publicado">Publicado</option>
<option value="Archivado">Archivado</option>
</select>
</FieldWrapper>
{/* Fecha Publicación */}
<FieldWrapper label="Fecha de Publicación" required error={validationErrors.fecha_publicacion}>
<input
type="datetime-local"
name="fecha_publicacion"
defaultValue={
initialData?.fecha_publicacion
? new Date(initialData.fecha_publicacion).toISOString().slice(0, 16)
: new Date().toISOString().slice(0, 16)
}
required
className={inputClass}
/>
</FieldWrapper>
{/* Fecha Expiración */}
<FieldWrapper label="Fecha de Expiración" hint="Opcional. Para banners con caducidad.">
<input
type="datetime-local"
name="fecha_finalizacion"
defaultValue={
initialData?.fecha_finalizacion
? new Date(initialData.fecha_finalizacion).toISOString().slice(0, 16)
: ''
}
className={inputClass}
/>
</FieldWrapper>
</FormSection>
</div>
{/* SECCIÓN: Multimedia */}
<div className="bg-slate-900/60 border border-white/8 rounded-2xl p-6">
<FormSection title="Multimedia" icon={<ImageIcon size={16} />} columns={1}>
<RelationSelectField
name="imagen_destacada_id"
label="Imagen Destacada"
options={relationData.imagen_destacada_id || []}
labelKey="titulo"
value={
typeof initialData?.imagen_destacada_id === 'object'
? initialData.imagen_destacada_id.$id
: initialData?.imagen_destacada_id || ''
}
relationCollection="media"
/>
<FieldWrapper label="URL de Video (YouTube/Vimeo)" error={validationErrors.video_url}>
<input
type="url"
name="video_url"
defaultValue={initialData?.video_url || ''}
placeholder="https://youtube.com/watch?v=..."
className={inputClass}
/>
</FieldWrapper>
</FormSection>
</div>
{/* SECCIÓN: Comercial */}
<div className="bg-slate-900/60 border border-white/8 rounded-2xl p-6">
<FormSection title="Comercial" icon={<Briefcase size={16} />} columns={1}>
<RelationSelectField
name="cliente_id"
label="Patrocinador / Cliente"
options={relationData.cliente_id || []}
labelKey="nombre_entidad"
value={
typeof initialData?.cliente_id === 'object'
? initialData.cliente_id.$id
: initialData?.cliente_id || ''
}
relationCollection="clients"
/>
<FieldWrapper label="URL Destino (Click del banner)" error={validationErrors.url_destino}>
<input
type="url"
name="url_destino"
defaultValue={initialData?.url_destino || ''}
placeholder="https://cliente.com/landing"
className={inputClass}
/>
</FieldWrapper>
</FormSection>
</div>
{/* Submit Button */}
<PermissionGuard allowedRoles={['editor', 'admin']} userRole={userRole}>
<button
type="submit"
disabled={isLoading || isSaved}
className="w-full relative flex items-center justify-center gap-3 py-4 px-6 rounded-2xl
font-black uppercase tracking-widest text-sm overflow-hidden
bg-gradient-to-r from-cyan-500 to-blue-600
hover:from-cyan-400 hover:to-blue-500
text-slate-950 shadow-[0_0_30px_rgba(6,182,212,0.3)]
transition-all disabled:opacity-60 disabled:cursor-not-allowed
active:scale-98"
>
{isLoading ? (
<>
<Loader2 size={18} className="animate-spin" />
Guardando…
</>
) : isSaved ? (
<>
<CheckCircle size={18} />
¡Guardado!
</>
) : (
<>
<Save size={18} />
{initialData?.$id ? 'Guardar Cambios' : 'Crear Publicación'}
</>
)}
</button>
</PermissionGuard>
{/* No permission notice */}
<PermissionGuard
allowedRoles={['admin']}
userRole={userRole}
fallback={
userRole !== 'editor' && userRole !== 'admin' ? (
<div className="p-4 bg-amber-500/10 border border-amber-500/20 rounded-xl text-amber-400 text-xs font-bold text-center">
Solo los editores y administradores pueden crear o editar publicaciones.
</div>
) : null
}
>
<></>
</PermissionGuard>
</div>
</div>
</form>
</div>
);
};
export default PublicationLayout;

View File

@ -0,0 +1,299 @@
'use client';
/**
* @file PublicationsList.tsx
* @description Optimized, RBAC-aware publications list for the dashboard.
*
* Architecture:
* - Uses React.memo on PostCard to avoid re-renders on unrelated state changes
* - useCallback for all event handlers
* - useMemo for expensive derived state (filtered/sorted posts)
* - Integrates PublicationDrawer for inline editing
* - Integrates useToast for user feedback
* - Wrapped by ErrorBoundary at usage site
*
* RBAC:
* - Editor/Admin: can open drawer to edit posts on click
* - Admin only: can delete posts
* - Viewer: read-only list
*/
import React, { useState, useCallback, useMemo, useTransition } from 'react';
import { Search, Plus, Filter, Loader2, FileText } from 'lucide-react';
import Link from 'next/link';
import type { Post, AuthUser, UserRole } from '@/types/publications';
import PostCard from './PostCard';
import PermissionGuard from './PermissionGuard';
import { useToast } from './ToastProvider';
// ─────────────────────────────────────────────
// TYPES
// ─────────────────────────────────────────────
interface PublicationsListProps {
initialPosts: Post[];
user: AuthUser;
relationData: Record<string, Array<{ $id: string; [key: string]: unknown }>>;
submitEditAction: (formData: FormData) => Promise<{ success: boolean; error?: string; data?: unknown }>;
appwriteConfig: {
endpoint: string;
projectId: string;
databaseId: string;
};
entity: string;
}
type SortKey = 'fecha_publicacion' | '$createdAt' | 'titulo' | 'estado';
// ─────────────────────────────────────────────
// COMPONENT
// ─────────────────────────────────────────────
export const PublicationsList: React.FC<PublicationsListProps> = ({
initialPosts,
user,
relationData,
submitEditAction,
appwriteConfig,
entity,
}) => {
const toast = useToast();
const [isPending, startTransition] = useTransition();
// Local state
const [posts, setPosts] = useState<Post[]>(initialPosts);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [sortBy, setSortBy] = useState<SortKey>('fecha_publicacion');
const [drawerPost, setDrawerPost] = useState<Post | null>(null);
// Lazy-load Drawer to avoid shipping it unless needed
const [DrawerComponent, setDrawerComponent] = useState<React.ComponentType<import('./PublicationDrawer').PublicationDrawerProps> | null>(null);
const userRole = (user?.prefs?.role as UserRole) ?? 'viewer';
// ── DERIVED STATE ────────────────────────────
const filteredAndSortedPosts = useMemo(() => {
let result = [...posts];
// Filter by search
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(
(p) =>
p.titulo.toLowerCase().includes(q) ||
p.slug.toLowerCase().includes(q) ||
(p.resumen_redes || '').toLowerCase().includes(q)
);
}
// Filter by status
if (statusFilter !== 'all') {
result = result.filter((p) => p.estado === statusFilter);
}
// 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
});
return result;
}, [posts, searchQuery, statusFilter, sortBy]);
const stats = useMemo(() => ({
total: posts.length,
published: posts.filter((p) => p.estado === 'Publicado').length,
draft: posts.filter((p) => p.estado === 'Borrador').length,
pending: posts.filter((p) => p.estado === 'Pendiente').length,
}), [posts]);
// ── HANDLERS ────────────────────────────────
const handleEdit = useCallback(async (post: Post) => {
// Lazy-load the drawer component
if (!DrawerComponent) {
const mod = await import('./PublicationDrawer');
setDrawerComponent(() => mod.PublicationDrawer);
}
setDrawerPost(post);
}, [DrawerComponent]);
const handleCloseDrawer = useCallback(() => {
setDrawerPost(null);
}, []);
const handleDelete = useCallback((postId: string) => {
if (!confirm('¿Eliminar esta publicación permanentemente?')) return;
startTransition(async () => {
try {
const res = await fetch(`/api/publications?id=${postId}`, { method: 'DELETE' });
const json = await res.json() as { success: boolean; error?: string };
if (json.success) {
// Optimistic UI: remove from local state immediately
setPosts((prev) => prev.filter((p) => p.$id !== postId));
toast.success('Publicación eliminada correctamente.');
} else {
toast.error(json.error || 'Error al eliminar la publicación.');
}
} catch {
toast.error('Error de red. Intenta de nuevo.');
}
});
}, [toast]);
const handleEditSuccess = useCallback(() => {
setDrawerPost(null);
toast.success('Publicación actualizada correctamente.');
// Note: For a full refresh of the list, the parent Server Component should
// call revalidatePath. Here we rely on Next.js router refresh.
window.location.reload();
}, [toast]);
// ── RENDER ──────────────────────────────────
return (
<div className="space-y-6 animate-fade-in">
{/* Stats Row */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ label: 'Total', value: stats.total, color: 'text-white' },
{ label: 'Publicados', value: stats.published, color: 'text-emerald-400' },
{ label: 'Borradores', value: stats.draft, color: 'text-slate-400' },
{ label: 'Pendientes', value: stats.pending, color: 'text-amber-400' },
].map((s) => (
<div key={s.label} className="bg-slate-900/60 border border-white/8 rounded-2xl p-4">
<p className="text-[10px] font-black text-slate-500 uppercase tracking-widest mb-1">{s.label}</p>
<p className={`text-3xl font-black ${s.color}`}>{s.value}</p>
</div>
))}
</div>
{/* Toolbar */}
<div className="flex flex-col md:flex-row gap-3">
{/* Search */}
<div className="relative flex-1">
<Search size={16} className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500" />
<input
type="search"
placeholder="Buscar publicaciones..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-slate-900/60 border border-white/8 rounded-xl pl-10 pr-4 py-2.5 text-white text-sm
focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500/30 transition-all"
/>
</div>
{/* Status Filter */}
<div className="flex items-center gap-2">
<Filter size={14} className="text-slate-500" />
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="bg-slate-900/60 border border-white/8 rounded-xl px-3 py-2.5 text-white text-sm
focus:border-cyan-500 focus:outline-none transition-all"
>
<option value="all">Todos los estados</option>
<option value="Publicado">Publicados</option>
<option value="Borrador">Borradores</option>
<option value="Pendiente">Pendientes</option>
<option value="Archivado">Archivados</option>
</select>
</div>
{/* Sort */}
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortKey)}
className="bg-slate-900/60 border border-white/8 rounded-xl px-3 py-2.5 text-white text-sm
focus:border-cyan-500 focus:outline-none transition-all"
>
<option value="fecha_publicacion">Más recientes</option>
<option value="titulo">Título A-Z</option>
<option value="estado">Estado</option>
</select>
{/* New post button */}
<PermissionGuard allowedRoles={['editor', 'admin']} userRole={userRole}>
<Link
href={`/dashboard/${entity}/new`}
className="flex items-center gap-2 px-5 py-2.5 bg-cyan-500 hover:bg-cyan-400
text-slate-950 font-black text-sm rounded-xl transition-all
shadow-[0_0_20px_rgba(6,182,212,0.25)] whitespace-nowrap"
>
<Plus size={16} />
Nueva publicación
</Link>
</PermissionGuard>
</div>
{/* Grid de publicaciones */}
{isPending && (
<div className="flex items-center gap-2 text-slate-400 text-sm py-2">
<Loader2 size={14} className="animate-spin" />
Procesando…
</div>
)}
{filteredAndSortedPosts.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredAndSortedPosts.map((post) => (
<PostCard
key={post.$id}
post={post}
userRole={userRole}
onEdit={handleEdit}
onDelete={handleDelete}
appwriteEndpoint={appwriteConfig.endpoint}
appwriteProjectId={appwriteConfig.projectId}
/>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center gap-4 py-20 border-2 border-dashed border-slate-800 rounded-2xl">
<div className="p-4 bg-slate-800/50 rounded-full">
<FileText size={32} className="text-slate-600" />
</div>
<div className="text-center">
<h3 className="text-white font-bold mb-1">Sin resultados</h3>
<p className="text-slate-500 text-sm">
{searchQuery ? 'No hay publicaciones que coincidan con tu búsqueda.' : 'Aún no hay publicaciones. ¡Crea la primera!'}
</p>
</div>
<PermissionGuard allowedRoles={['editor', 'admin']} userRole={userRole}>
<Link
href={`/dashboard/${entity}/new`}
className="flex items-center gap-2 px-5 py-2.5 bg-slate-800 hover:bg-slate-700
border border-slate-700 text-white text-sm font-bold rounded-xl transition-all"
>
<Plus size={14} />
Crear publicación
</Link>
</PermissionGuard>
</div>
)}
{/* SideDrawer — lazy loaded */}
{DrawerComponent && (
<DrawerComponent
isOpen={!!drawerPost}
post={drawerPost}
user={user}
relationData={relationData}
submitAction={submitEditAction}
appwriteConfig={appwriteConfig}
onClose={handleCloseDrawer}
onSuccess={handleEditSuccess}
/>
)}
</div>
);
};
export default PublicationsList;

View File

@ -0,0 +1,297 @@
'use client';
/**
* @file RelationSelectField.tsx
* @description A styled select field for relation fields that supports:
* - Standard single-click selection
* - Double-click to open QuickCreateModal for in-place record creation
* - Auto-update when a new record is injected via usePublicationStore
*
* Usage:
* ```tsx
* <RelationSelectField
* name="tipo_publicacion_id"
* label="Tipo de Publicación"
* options={publicationTypes}
* labelKey="descripcion"
* relationCollection="publication_types"
* required
* />
* ```
*/
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { PlusCircle, ChevronDown, MousePointerClick } from 'lucide-react';
import { RelationSelectFieldProps } from '@/types/publications';
import { usePublicationStore } from '@/store/usePublicationStore';
import { collectionMap } from '@/lib/schema';
import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite';
// ─────────────────────────────────────────────
// QUICK CREATE MODAL (Portal-based)
// ─────────────────────────────────────────────
interface QuickCreatePortalProps {
collection: string;
fieldName: string;
onClose: () => void;
}
function QuickCreatePortal({ collection, fieldName, onClose }: QuickCreatePortalProps) {
const { injectRelationValue } = usePublicationStore();
const [fields, setFields] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setError(null);
try {
const res = await fetch('/api/publications/quick-create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ collection, data: fields }),
});
const json = await res.json();
if (!res.ok || !json.success) {
throw new Error(json.error || 'Error al crear el registro.');
}
// Inject the new record into the store so the parent select updates
injectRelationValue(fieldName, collection, json.data);
onClose();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Error desconocido.');
} finally {
setSaving(false);
}
};
// Determine simple fields to show for quick creation
const quickFields: { name: string; label: string }[] = (() => {
const schemaMap: Record<string, Array<{ name: string; label: string; type: string }>> = {
publication_types: [
{ name: 'codigo', label: 'Código', type: 'text' },
{ name: 'descripcion', label: 'Descripción', type: 'text' },
],
clients: [
{ name: 'nombre_entidad', label: 'Nombre de la Empresa', type: 'text' },
{ name: 'numero_whatsapp', label: 'WhatsApp', type: 'text' },
{ name: 'slug', label: 'Slug', type: 'text' },
],
categories: [
{ name: 'nombre', label: 'Nombre', type: 'text' },
{ name: 'slug', label: 'Slug', type: 'text' },
],
};
return schemaMap[collection] ?? [{ name: 'descripcion', label: 'Descripción', type: 'text' }];
})();
const modal = (
<div
className="fixed inset-0 z-[9999] flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-label="Crear registro rápido"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-slate-950/80 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
{/* Modal Card */}
<div className="relative z-10 w-full max-w-md bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200">
{/* Header */}
<div className="px-6 py-5 border-b border-slate-800 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-cyan-500/10 rounded-lg text-cyan-400">
<PlusCircle size={18} />
</div>
<div>
<h3 className="text-sm font-black text-white uppercase tracking-widest">Crear Nuevo</h3>
<p className="text-[10px] text-slate-500 font-medium mt-0.5 capitalize">{collection.replace(/_/g, ' ')}</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="text-slate-500 hover:text-white transition-colors text-xl leading-none"
aria-label="Cerrar modal"
>
×
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{quickFields.map((field) => (
<div key={field.name}>
<label className="text-[10px] font-black uppercase tracking-widest text-slate-500 block mb-1.5">
{field.label}
</label>
<input
type="text"
required
value={fields[field.name] || ''}
onChange={(e) => setFields((prev) => ({ ...prev, [field.name]: e.target.value }))}
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-2.5 text-white text-sm focus:border-cyan-500 focus:outline-none transition-colors"
/>
</div>
))}
{error && (
<p className="text-red-400 text-xs font-bold bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-2">
{error}
</p>
)}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 py-2.5 rounded-xl border border-slate-700 text-slate-400 text-sm font-bold hover:border-slate-600 hover:text-white transition-all"
>
Cancelar
</button>
<button
type="submit"
disabled={saving}
className="flex-1 py-2.5 rounded-xl bg-cyan-500 text-slate-950 text-sm font-black hover:bg-cyan-400 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? 'Guardando…' : 'Crear'}
</button>
</div>
</form>
</div>
</div>
);
// Use a Portal to render outside the form DOM tree
return createPortal(modal, document.body);
}
// ─────────────────────────────────────────────
// RELATION SELECT FIELD
// ─────────────────────────────────────────────
export const RelationSelectField: React.FC<RelationSelectFieldProps> = ({
name,
label,
options: initialOptions,
labelKey,
value: externalValue,
onChange,
relationCollection,
required = false,
disabled = false,
}) => {
const {
relationCache,
pendingRelationField,
openQuickCreate,
closeQuickCreate,
} = usePublicationStore();
// Merge initial options with any newly injected ones from the store
const cachedOptions = relationCache[name] || relationCache[relationCollection] || [];
const mergedOptions = cachedOptions.length > 0 ? cachedOptions : initialOptions;
const [selectedValue, setSelectedValue] = useState(externalValue || '');
const [isModalOpen, setIsModalOpen] = useState(false);
const doubleClickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Auto-select the newly injected option
useEffect(() => {
if (pendingRelationField === name && cachedOptions.length > 0) {
const newest = cachedOptions[0];
if (newest) {
setSelectedValue(newest.$id);
onChange?.(newest.$id);
}
}
}, [pendingRelationField, cachedOptions, name, onChange]);
const handleChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
const val = e.target.value;
setSelectedValue(val);
onChange?.(val);
}, [onChange]);
const handleDoubleClick = useCallback(() => {
if (doubleClickTimer.current) clearTimeout(doubleClickTimer.current);
openQuickCreate(name, relationCollection);
setIsModalOpen(true);
}, [name, relationCollection, openQuickCreate]);
const handleCloseModal = useCallback(() => {
setIsModalOpen(false);
closeQuickCreate();
}, [closeQuickCreate]);
return (
<div className="flex flex-col gap-1.5 w-full">
{/* Label with hint */}
<div className="flex items-center justify-between">
<label
htmlFor={`field-${name}`}
className="text-[10px] font-black uppercase tracking-widest text-slate-500"
>
{label}
{required && <span className="text-cyan-400 ml-1">*</span>}
</label>
<span className="flex items-center gap-1 text-[9px] text-slate-600 font-bold uppercase tracking-wider">
<MousePointerClick size={10} />
Doble-click para crear nuevo
</span>
</div>
{/* Select Wrapper */}
<div className="relative group">
<select
id={`field-${name}`}
name={name}
value={selectedValue}
onChange={handleChange}
onDoubleClick={handleDoubleClick}
required={required}
disabled={disabled}
className="w-full appearance-none bg-slate-950 border border-slate-800 rounded-xl px-4 py-2.5 text-white text-sm
focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500/30
group-hover:border-slate-700 transition-all pr-10
disabled:opacity-50 disabled:cursor-not-allowed
cursor-pointer"
aria-label={label}
>
<option value="">— Seleccionar —</option>
{mergedOptions.map((opt) => (
<option key={opt.$id} value={opt.$id}>
{String(opt[labelKey] ?? opt.$id)}
</option>
))}
</select>
<ChevronDown
size={16}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 pointer-events-none group-hover:text-slate-400 transition-colors"
/>
</div>
{/* Quick Create Portal */}
{isModalOpen && (
<QuickCreatePortal
collection={relationCollection}
fieldName={name}
onClose={handleCloseModal}
/>
)}
</div>
);
};
export default RelationSelectField;

View File

@ -0,0 +1,189 @@
'use client';
/**
* @file ToastProvider.tsx
* @description Lightweight global Toast/Notification system.
* Uses React Context + Zustand-like imperative API via a singleton store.
*
* Usage:
* 1. Wrap your layout with <ToastProvider>
* 2. Call toast.success('Mensaje') anywhere in the app
*/
import React, { createContext, useCallback, useContext, useReducer, useRef } from 'react';
import { createPortal } from 'react-dom';
import { CheckCircle, AlertCircle, Info, X, AlertTriangle } from 'lucide-react';
// ─────────────────────────────────────────────
// TYPES
// ─────────────────────────────────────────────
export type ToastVariant = 'success' | 'error' | 'warning' | 'info';
export interface Toast {
id: string;
message: string;
variant: ToastVariant;
duration?: number;
}
type ToastAction =
| { type: 'ADD'; toast: Toast }
| { type: 'REMOVE'; id: string };
interface ToastContextValue {
toasts: Toast[];
addToast: (message: string, variant?: ToastVariant, duration?: number) => void;
removeToast: (id: string) => void;
}
// ─────────────────────────────────────────────
// CONTEXT
// ─────────────────────────────────────────────
const ToastContext = createContext<ToastContextValue | null>(null);
function toastReducer(state: Toast[], action: ToastAction): Toast[] {
switch (action.type) {
case 'ADD': return [action.toast, ...state].slice(0, 5); // Max 5 toasts
case 'REMOVE': return state.filter((t) => t.id !== action.id);
default: return state;
}
}
// ─────────────────────────────────────────────
// PROVIDER
// ─────────────────────────────────────────────
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, dispatch] = useReducer(toastReducer, []);
const counterRef = useRef(0);
const removeToast = useCallback((id: string) => {
dispatch({ type: 'REMOVE', id });
}, []);
const addToast = useCallback((
message: string,
variant: ToastVariant = 'info',
duration = 4000
) => {
const id = `toast-${Date.now()}-${++counterRef.current}`;
dispatch({ type: 'ADD', toast: { id, message, variant, duration } });
if (duration > 0) {
setTimeout(() => dispatch({ type: 'REMOVE', id }), duration);
}
}, []);
return (
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
{children}
<ToastContainer toasts={toasts} onRemove={removeToast} />
</ToastContext.Provider>
);
}
// ─────────────────────────────────────────────
// HOOK
// ─────────────────────────────────────────────
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used inside <ToastProvider>');
return {
success: (msg: string, duration?: number) => ctx.addToast(msg, 'success', duration),
error: (msg: string, duration?: number) => ctx.addToast(msg, 'error', duration),
warning: (msg: string, duration?: number) => ctx.addToast(msg, 'warning', duration),
info: (msg: string, duration?: number) => ctx.addToast(msg, 'info', duration),
remove: ctx.removeToast,
};
}
// ─────────────────────────────────────────────
// TOAST ITEM
// ─────────────────────────────────────────────
const variantStyles: Record<ToastVariant, { bg: string; border: string; icon: React.ReactNode }> = {
success: {
bg: 'bg-emerald-950/90',
border: 'border-emerald-500/40',
icon: <CheckCircle size={16} className="text-emerald-400 shrink-0" />,
},
error: {
bg: 'bg-red-950/90',
border: 'border-red-500/40',
icon: <AlertCircle size={16} className="text-red-400 shrink-0" />,
},
warning: {
bg: 'bg-amber-950/90',
border: 'border-amber-500/40',
icon: <AlertTriangle size={16} className="text-amber-400 shrink-0" />,
},
info: {
bg: 'bg-slate-900/90',
border: 'border-cyan-500/30',
icon: <Info size={16} className="text-cyan-400 shrink-0" />,
},
};
const ToastItem = React.memo(function ToastItem({
toast,
onRemove,
}: {
toast: Toast;
onRemove: (id: string) => void;
}) {
const styles = variantStyles[toast.variant];
return (
<div
role="alert"
aria-live="assertive"
className={`
flex items-start gap-3 px-4 py-3 rounded-xl border shadow-2xl
backdrop-blur-xl min-w-[280px] max-w-[400px]
animate-in slide-in-from-right-4 fade-in duration-300
${styles.bg} ${styles.border}
`}
>
{styles.icon}
<p className="text-sm font-medium text-white flex-1 leading-snug">{toast.message}</p>
<button
type="button"
onClick={() => onRemove(toast.id)}
className="text-slate-500 hover:text-white transition-colors ml-1 shrink-0"
aria-label="Cerrar notificación"
>
<X size={14} />
</button>
</div>
);
});
// ─────────────────────────────────────────────
// TOAST CONTAINER (Portal)
// ─────────────────────────────────────────────
function ToastContainer({
toasts,
onRemove,
}: {
toasts: Toast[];
onRemove: (id: string) => void;
}) {
if (typeof document === 'undefined' || toasts.length === 0) return null;
return createPortal(
<div
className="fixed bottom-6 right-6 z-[99999] flex flex-col gap-3"
aria-label="Notificaciones"
>
{toasts.map((t) => (
<ToastItem key={t.id} toast={t} onRemove={onRemove} />
))}
</div>,
document.body
);
}

View File

@ -0,0 +1,109 @@
/**
* @file validators/publications.ts
* @description Zod schemas for server AND client-side validation of publications.
* Schemas are used in the API Route (server) and the PublicationLayout (client).
*/
import { z } from 'zod';
// ─────────────────────────────────────────────
// HELPERS
// ─────────────────────────────────────────────
/** Regex para validar slugs URL-friendly */
const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
// ─────────────────────────────────────────────
// SCHEMAS
// ─────────────────────────────────────────────
/** Schema de validación principal para crear/actualizar un Post */
export const PostSchema = z.object({
titulo: z
.string()
.min(5, 'El título debe tener al menos 5 caracteres.')
.max(250, 'El título no puede superar los 250 caracteres.'),
slug: z
.string()
.min(3, 'El slug debe tener al menos 3 caracteres.')
.regex(slugRegex, 'El slug solo puede tener letras minúsculas, números y guiones.'),
contenido: z
.string()
.min(10, 'El contenido debe tener al menos 10 caracteres.'),
estado: z.enum(['Borrador', 'Pendiente', 'Publicado', 'Archivado'], {
errorMap: () => ({ message: 'Estado inválido.' }),
}),
fecha_publicacion: z
.string()
.refine((val) => !isNaN(Date.parse(val)), { message: 'La fecha de publicación no es válida.' }),
tipo_publicacion_id: z
.string()
.min(1, 'Debe seleccionar un tipo de publicación.'),
// Campos opcionales
resumen_redes: z.string().max(500, 'El resumen no puede superar 500 caracteres.').optional(),
fecha_finalizacion: z.string().optional().nullable(),
cliente_id: z.string().optional().nullable(),
imagen_destacada_id: z.string().optional().nullable(),
url_destino: z.string().url('La URL de destino no es válida.').optional().or(z.literal('')),
video_url: z.string().url('La URL de video no es válida.').optional().or(z.literal('')),
og_image_url: z.string().url('La OG Image URL no es válida.').optional().or(z.literal('')),
seo_titulo: z.string().max(70, 'El SEO título no puede superar 70 caracteres.').optional(),
seo_descripcion: z.string().max(160, 'La SEO descripción no puede superar 160 caracteres.').optional(),
});
/** Schema para creación rápida de relaciones (QuickCreateModal) */
export const QuickCreateSchema = z.object({
collection: z.string().min(1, 'La colección es requerida.'),
data: z.record(z.unknown()),
});
/** Schema para actualización parcial (PATCH) */
export const PostPatchSchema = PostSchema.partial().extend({
$id: z.string().min(1, 'El ID del documento es requerido para actualizar.'),
});
// ─────────────────────────────────────────────
// INFERRED TYPES
// ─────────────────────────────────────────────
/** Tipo inferido del schema principal */
export type PostFormData = z.infer<typeof PostSchema>;
/** Tipo inferido del schema de actualización parcial */
export type PostPatchData = z.infer<typeof PostPatchSchema>;
/** Tipo inferido del schema de creación rápida */
export type QuickCreateData = z.infer<typeof QuickCreateSchema>;
// ─────────────────────────────────────────────
// VALIDATION HELPERS
// ─────────────────────────────────────────────
/**
* Valida un FormData de navegador contra el PostSchema.
* Retorna `{ success: true, data }` o `{ success: false, errors }`.
*/
export function validatePostFormData(formData: FormData) {
const raw = Object.fromEntries(formData.entries());
return PostSchema.safeParse(raw);
}
/**
* Genera un slug a partir de un título.
*/
export function generateSlug(titulo: string): string {
return titulo
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') // Quita acentos
.replace(/[^a-z0-9\s-]/g, '') // Quita caracteres especiales
.trim()
.replace(/\s+/g, '-') // Espacios a guiones
.replace(/-+/g, '-') // Múltiples guiones a uno
.substring(0, 100); // Máximo 100 chars
}

View File

@ -0,0 +1,104 @@
/**
* @file usePublicationStore.ts
* @description Zustand store for the Publications module.
* Manages relation caches, QuickCreateModal state, and optimistic UI updates.
*/
import { create } from 'zustand';
import { PublicationStore, PublicationStoreState } from '@/types/publications';
const initialState: PublicationStoreState = {
relationCache: {},
pendingRelationField: null,
pendingRelationCollection: null,
optimisticState: 'idle',
errorMessage: null,
};
/**
* Global Zustand store for the Publications module.
*
* Usage:
* ```tsx
* const { openQuickCreate, injectRelationValue } = usePublicationStore();
* ```
*/
export const usePublicationStore = create<PublicationStore>((set, get) => ({
...initialState,
/**
* Opens the QuickCreateModal for a specific relation field.
*/
openQuickCreate: (fieldName, collection) => {
set({
pendingRelationField: fieldName,
pendingRelationCollection: collection,
});
},
/**
* Closes the QuickCreateModal and clears pending state.
*/
closeQuickCreate: () => {
set({
pendingRelationField: null,
pendingRelationCollection: null,
});
},
/**
* After a successful QuickCreate:
* 1. Adds the new document to the relation cache.
* 2. The RelationSelectField component listens to this cache and auto-selects the new value.
*/
injectRelationValue: (fieldName, collection, newDoc) => {
const { relationCache } = get();
const currentOptions = relationCache[collection] || [];
// Avoid duplicates
const alreadyExists = currentOptions.some((opt) => opt.$id === newDoc.$id);
const updatedOptions = alreadyExists
? currentOptions
: [newDoc, ...currentOptions];
set({
relationCache: {
...relationCache,
[collection]: updatedOptions,
// Also key by fieldName for direct field lookup
[fieldName]: updatedOptions,
},
// Signal which field just got a new value (RelationSelectField listens to this)
pendingRelationField: fieldName,
pendingRelationCollection: null,
});
},
/**
* Updates the cache for a relation collection after initial data load.
*/
setRelationCache: (collection, data) => {
set((state) => ({
relationCache: {
...state.relationCache,
[collection]: data,
},
}));
},
/**
* Sets the optimistic UI state. Use 'saving' before API call, 'saved' or 'error' after.
*/
setOptimisticState: (optimisticState, error) => {
set({
optimisticState,
errorMessage: error ?? null,
});
},
/**
* Resets the store to initial state. Call on component unmount.
*/
reset: () => {
set(initialState);
},
}));

215
src/types/publications.ts Normal file
View File

@ -0,0 +1,215 @@
/**
* @file publications.ts
* @description Strict TypeScript interfaces and types for the Publications module.
* All entities, props, and enums are defined here to enforce type safety across
* the entire publications feature.
*/
// ─────────────────────────────────────────────
// ENUMS
// ─────────────────────────────────────────────
/** Roles de usuario para el sistema RBAC */
export type UserRole = 'admin' | 'editor' | 'viewer' | 'guest';
/** Estados posibles de una publicación */
export type PostStatus = 'Borrador' | 'Pendiente' | 'Publicado' | 'Archivado';
// ─────────────────────────────────────────────
// CORE ENTITIES
// ─────────────────────────────────────────────
/** Entidad del perfil de usuario autenticado */
export interface AuthUser {
$id: string;
name: string;
email: string;
prefs: {
role?: UserRole;
[key: string]: unknown;
};
}
/** Entidad de tipo de publicación */
export interface PublicationType {
$id: string;
codigo: string;
descripcion: string;
}
/** Entidad de ítem de media */
export interface MediaItem {
$id: string;
file_id: string;
bucket_id: string;
titulo: string;
alt_text?: string;
descripcion_seo?: string;
tipo: 'imagen' | 'video';
}
/** Entidad de cliente/patrocinador */
export interface Client {
$id: string;
nombre_entidad: string;
nit?: string;
slug: string;
contacto_nombre: string;
numero_whatsapp: string;
estado: boolean;
}
/** Entidad principal de publicación (Post) */
export interface Post {
$id: string;
$createdAt: string;
$updatedAt: string;
titulo: string;
slug: string;
contenido: string;
resumen_redes?: string;
estado: PostStatus;
fecha_publicacion: string;
fecha_finalizacion?: string;
og_image_url?: string;
url_destino?: string;
video_url?: string;
seo_titulo?: string;
seo_descripcion?: string;
// Relaciones (pueden llegar como string ID o como objeto resuelto)
tipo_publicacion_id: string | PublicationType;
imagen_destacada_id?: string | MediaItem;
cliente_id?: string | Client;
}
// ─────────────────────────────────────────────
// COMPONENT PROPS
// ─────────────────────────────────────────────
/** Props del PostCard (componente de dashboard) */
export interface PostCardProps {
post: Post;
userRole: UserRole;
onEdit?: (post: Post) => void;
onDelete?: (postId: string) => void;
/** URL base del endpoint de Appwrite para construir URLs de imagen */
appwriteEndpoint: string;
appwriteProjectId: string;
}
/** Props del PermissionGuard HOC */
export interface PermissionGuardProps {
/** Roles que tienen acceso al contenido protegido */
allowedRoles: UserRole[];
/** Rol actual del usuario */
userRole: UserRole;
/** Contenido a renderizar si el usuario tiene permiso */
children: React.ReactNode;
/** Contenido alternativo si no tiene permiso (opcional) */
fallback?: React.ReactNode;
}
/** Props del FormSection */
export interface FormSectionProps {
title: string;
icon?: React.ReactNode;
/** Número de columnas del grid interno (1–12) */
columns?: 1 | 2 | 3 | 4 | 6 | 12;
children: React.ReactNode;
/** Clase CSS adicional */
className?: string;
}
/** Props del RelationSelectField */
export interface RelationSelectFieldProps {
/** Nombre del campo en el formulario */
name: string;
/** Label visible */
label: string;
/** Opciones actuales disponibles */
options: Array<{ $id: string; [key: string]: unknown }>;
/** Propiedad del objeto a mostrar como texto */
labelKey: string;
/** Valor actualmente seleccionado */
value?: string;
onChange?: (value: string) => void;
/** Colección de Appwrite para creación rápida */
relationCollection: string;
/** Si el campo es obligatorio */
required?: boolean;
/** Desactiva el campo */
disabled?: boolean;
}
/** Props del PublicationLayout */
export interface PublicationLayoutProps {
/** Post existente para edición, undefined para creación */
initialData?: Partial<Post>;
/** Datos de relaciones pre-cargados desde el servidor */
relationData: Record<string, Array<{ $id: string; [key: string]: unknown }>>;
/** Acción de servidor para guardar */
submitAction: (formData: FormData) => Promise<{ success: boolean; error?: string; data?: unknown }>;
/** Usuario autenticado */
user: AuthUser;
/** Config de Appwrite para URLs de media */
appwriteConfig: {
endpoint: string;
projectId: string;
databaseId: string;
};
}
// ─────────────────────────────────────────────
// STORE TYPES (Zustand)
// ─────────────────────────────────────────────
/** Estado del store de publicaciones */
export interface PublicationStoreState {
/** Cache de opciones para campos de relación — evita re-fetches */
relationCache: Record<string, Array<{ $id: string; [key: string]: unknown }>>;
/** Campo actualmente abierto en QuickCreateModal */
pendingRelationField: string | null;
/** Colección destino del modal de creación rápida */
pendingRelationCollection: string | null;
/** Estado de la UI optimista durante el guardado */
optimisticState: 'idle' | 'saving' | 'saved' | 'error';
/** Mensaje de error si aplica */
errorMessage: string | null;
}
/** Acciones del store de publicaciones */
export interface PublicationStoreActions {
/** Abre el QuickCreateModal para un campo de relación específico */
openQuickCreate: (fieldName: string, collection: string) => void;
/** Cierra el QuickCreateModal */
closeQuickCreate: () => void;
/** Inyecta un nuevo documento en el cache y en el campo padre */
injectRelationValue: (
fieldName: string,
collection: string,
newDoc: { $id: string; [key: string]: unknown }
) => void;
/** Actualiza el cache de opciones de una relación */
setRelationCache: (
collection: string,
data: Array<{ $id: string; [key: string]: unknown }>
) => void;
/** Establece el estado optimista */
setOptimisticState: (state: PublicationStoreState['optimisticState'], error?: string) => void;
/** Reinicia el store */
reset: () => void;
}
export type PublicationStore = PublicationStoreState & PublicationStoreActions;
// ─────────────────────────────────────────────
// API TYPES
// ─────────────────────────────────────────────
/** Respuesta estándar de la API de publicaciones */
export interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
error?: string;
validationErrors?: Record<string, string[]>;
}