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:
parent
7659019ca3
commit
dda9ae0f4a
28
package-lock.json
generated
28
package-lock.json
generated
@ -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"
|
||||
|
||||
@ -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": {
|
||||
|
||||
61
src/app/api/publications/quick-create/route.ts
Normal file
61
src/app/api/publications/quick-create/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
223
src/app/api/publications/route.ts
Normal file
223
src/app/api/publications/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
79
src/components/publications/ErrorBoundary.tsx
Normal file
79
src/components/publications/ErrorBoundary.tsx
Normal 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;
|
||||
62
src/components/publications/FormSection.tsx
Normal file
62
src/components/publications/FormSection.tsx
Normal 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;
|
||||
53
src/components/publications/PermissionGuard.tsx
Normal file
53
src/components/publications/PermissionGuard.tsx
Normal 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;
|
||||
195
src/components/publications/PostCard.tsx
Normal file
195
src/components/publications/PostCard.tsx
Normal 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;
|
||||
168
src/components/publications/PublicationDrawer.tsx
Normal file
168
src/components/publications/PublicationDrawer.tsx
Normal 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;
|
||||
496
src/components/publications/PublicationLayout.tsx
Normal file
496
src/components/publications/PublicationLayout.tsx
Normal 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;
|
||||
299
src/components/publications/PublicationsList.tsx
Normal file
299
src/components/publications/PublicationsList.tsx
Normal 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;
|
||||
297
src/components/publications/RelationSelectField.tsx
Normal file
297
src/components/publications/RelationSelectField.tsx
Normal 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;
|
||||
189
src/components/publications/ToastProvider.tsx
Normal file
189
src/components/publications/ToastProvider.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
109
src/lib/validators/publications.ts
Normal file
109
src/lib/validators/publications.ts
Normal 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
|
||||
}
|
||||
104
src/store/usePublicationStore.ts
Normal file
104
src/store/usePublicationStore.ts
Normal 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
215
src/types/publications.ts
Normal 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[]>;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user