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/extension-underline": "^3.21.0",
|
||||||
"@tiptap/react": "^3.21.0",
|
"@tiptap/react": "^3.21.0",
|
||||||
"@tiptap/starter-kit": "^3.21.0",
|
"@tiptap/starter-kit": "^3.21.0",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/video.js": "^7.3.58",
|
"@types/video.js": "^7.3.58",
|
||||||
"appwrite": "^23.0.0",
|
"appwrite": "^23.0.0",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"dompurify": "^3.3.3",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"node-appwrite": "^22.1.3",
|
"node-appwrite": "^22.1.3",
|
||||||
@ -28,6 +30,7 @@
|
|||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"video.js": "^8.23.7",
|
"video.js": "^8.23.7",
|
||||||
|
"zod": "^4.3.6",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -2086,6 +2089,15 @@
|
|||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@ -2157,6 +2169,12 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@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": {
|
"node_modules/@types/use-sync-external-store": {
|
||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
|
||||||
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
|
"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": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@ -7893,7 +7920,6 @@
|
|||||||
"version": "4.3.6",
|
"version": "4.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
|||||||
@ -19,10 +19,12 @@
|
|||||||
"@tiptap/extension-underline": "^3.21.0",
|
"@tiptap/extension-underline": "^3.21.0",
|
||||||
"@tiptap/react": "^3.21.0",
|
"@tiptap/react": "^3.21.0",
|
||||||
"@tiptap/starter-kit": "^3.21.0",
|
"@tiptap/starter-kit": "^3.21.0",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/video.js": "^7.3.58",
|
"@types/video.js": "^7.3.58",
|
||||||
"appwrite": "^23.0.0",
|
"appwrite": "^23.0.0",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"dompurify": "^3.3.3",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"node-appwrite": "^22.1.3",
|
"node-appwrite": "^22.1.3",
|
||||||
@ -30,6 +32,7 @@
|
|||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"video.js": "^8.23.7",
|
"video.js": "^8.23.7",
|
||||||
|
"zod": "^4.3.6",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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,53 +1,98 @@
|
|||||||
import { APPWRITE_ALMEIRA_CONFIG, projectId, endpoint } from '@/lib/appwrite';
|
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 { collectionMap, SCHEMA_MAP } from '@/lib/schema';
|
||||||
import { createDocumentServer } from '@/app/actions/appwrite';
|
|
||||||
import { saveEntityAction } from '@/app/actions/entities';
|
import { saveEntityAction } from '@/app/actions/entities';
|
||||||
import { redirect } from 'next/navigation';
|
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 { entity } = await params;
|
||||||
|
|
||||||
const collectionId = collectionMap[entity];
|
const collectionId = collectionMap[entity];
|
||||||
const fields = SCHEMA_MAP[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) {
|
if (!collectionId || fields.length === 0) {
|
||||||
return (
|
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();
|
// Auth check
|
||||||
if (!sessionClient) {
|
const user = await getLoggedInUser();
|
||||||
throw new Error("No sesión activa");
|
if (!user) redirect('/login');
|
||||||
}
|
|
||||||
|
|
||||||
const relationData: Record<string, any[]> = {};
|
// Pre-load all relation data server-side
|
||||||
await Promise.all(
|
const relationData: Record<string, Array<{ $id: string; [key: string]: unknown }>> = {};
|
||||||
fields.filter(f => f.type === 'relation' && f.relationCollection).map(async (field) => {
|
|
||||||
try {
|
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 relKey = field.relationCollection as string;
|
||||||
if(!collectionMap[relKey]) return;
|
const relColId = collectionMap[relKey];
|
||||||
const res = await sessionClient.databases.listDocuments(APPWRITE_ALMEIRA_CONFIG.databaseId, collectionMap[relKey], []);
|
if (!relColId) return;
|
||||||
relationData[field.name] = res.documents;
|
try {
|
||||||
} catch(e) { console.error('Error obteniendo relaciones de', field.name, e); }
|
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 boundSave = saveEntityAction.bind(null, entity);
|
const boundSave = saveEntityAction.bind(null, entity);
|
||||||
|
const appwriteConfig = {
|
||||||
|
endpoint,
|
||||||
|
projectId,
|
||||||
|
databaseId: APPWRITE_ALMEIRA_CONFIG.databaseId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 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={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 (
|
return (
|
||||||
<EntityForm
|
<EntityForm
|
||||||
entity={entity}
|
entity={entity}
|
||||||
entityName={entityName}
|
entityName={entityNames[entity] || entity.replace(/_/g, ' ')}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
relationData={relationData}
|
relationData={relationData}
|
||||||
submitAction={boundSave}
|
submitAction={boundSave}
|
||||||
|
|||||||
@ -1,24 +1,123 @@
|
|||||||
import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite';
|
import { APPWRITE_ALMEIRA_CONFIG, endpoint, projectId } from '@/lib/appwrite';
|
||||||
import { createSessionClient, getLoggedInUser } from '@/lib/appwrite.server';
|
import { createAdminClient, createSessionClient, getLoggedInUser } from '@/lib/appwrite.server';
|
||||||
import { Query } from 'node-appwrite';
|
import { Query } from 'node-appwrite';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { collectionMap, SCHEMA_MAP } from '@/lib/schema';
|
import { collectionMap, SCHEMA_MAP } from '@/lib/schema';
|
||||||
import { DataTable } from '@/components/dashboard/DataTable';
|
import { DataTable } from '@/components/dashboard/DataTable';
|
||||||
import { getUserPreferences } from '@/lib/preferences';
|
import { getUserPreferences } from '@/lib/preferences';
|
||||||
|
import { ErrorBoundary } from '@/components/publications/ErrorBoundary';
|
||||||
|
import { saveEntityAction } from '@/app/actions/entities';
|
||||||
|
import type { Post, AuthUser } from '@/types/publications';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// 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({
|
export default async function EntityPage({
|
||||||
params,
|
params,
|
||||||
searchParams
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ entity: string }>,
|
params: Promise<{ entity: string }>;
|
||||||
searchParams: Promise<{ q?: string, page?: string, sort?: string, dir?: string }>
|
searchParams: Promise<{ q?: string; page?: string; sort?: string; dir?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { entity } = await params;
|
const { entity } = await params;
|
||||||
const sParams = await searchParams;
|
const sParams = await searchParams;
|
||||||
const q = sParams.q || '';
|
const q = sParams.q || '';
|
||||||
const page = Number(sParams.page) || 1;
|
const page = Number(sParams.page) || 1;
|
||||||
const sort = sParams.sort || '$createdAt';
|
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 limit = 15;
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
@ -26,9 +125,10 @@ export default async function EntityPage({
|
|||||||
const fieldsConfig = SCHEMA_MAP[entity] || [];
|
const fieldsConfig = SCHEMA_MAP[entity] || [];
|
||||||
|
|
||||||
const entityNames: Record<string, string> = {
|
const entityNames: Record<string, string> = {
|
||||||
posts: 'Publicaciones', categories: 'Categorías', clients: 'Clientes', client_types: 'Tipos de Cliente',
|
posts: 'Publicaciones', categories: 'Categorías', clients: 'Clientes',
|
||||||
countries: 'Países', departments: 'Departamentos', cities: 'Ciudades', contracts: 'Contratos',
|
client_types: 'Tipos de Cliente', countries: 'Países', departments: 'Departamentos',
|
||||||
payment_methods: 'Formas de Pago', publication_types: 'Tipos de Publicación'
|
cities: 'Ciudades', contracts: 'Contratos', payment_methods: 'Formas de Pago',
|
||||||
|
publication_types: 'Tipos de Publicación',
|
||||||
};
|
};
|
||||||
const entityName = entityNames[entity] || entity.replace(/_/g, ' ');
|
const entityName = entityNames[entity] || entity.replace(/_/g, ' ');
|
||||||
|
|
||||||
@ -42,25 +142,25 @@ export default async function EntityPage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch user and preferences
|
|
||||||
const user = await getLoggedInUser();
|
const user = await getLoggedInUser();
|
||||||
const prefs = await getUserPreferences();
|
const prefs = await getUserPreferences();
|
||||||
const hiddenColumns = prefs.hiddenColumns?.[entity] || [];
|
const hiddenColumns = prefs.hiddenColumns?.[entity] || [];
|
||||||
|
|
||||||
let documents: any[] = [];
|
// Redirect the 'posts' entity to the specialized card view
|
||||||
let total = 0;
|
if (entity === 'posts') {
|
||||||
try {
|
return <PostsPage entity={entity} user={user as AuthUser} />;
|
||||||
const sessionClient = await createSessionClient();
|
|
||||||
if (!sessionClient) {
|
|
||||||
throw new Error("No sesión activa");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const queries = [
|
// Generic DataTable for all other entities
|
||||||
Query.limit(limit),
|
let documents: Array<Record<string, unknown>> = [];
|
||||||
Query.offset(offset),
|
let total = 0;
|
||||||
];
|
|
||||||
|
try {
|
||||||
|
const sessionClient = await createSessionClient();
|
||||||
|
if (!sessionClient) throw new Error('No sesión activa');
|
||||||
|
|
||||||
|
const queries = [Query.limit(limit), Query.offset(offset)];
|
||||||
|
|
||||||
// Apply Sorting
|
|
||||||
if (dir === 'asc') {
|
if (dir === 'asc') {
|
||||||
queries.push(Query.orderAsc(sort));
|
queries.push(Query.orderAsc(sort));
|
||||||
} else {
|
} else {
|
||||||
@ -69,8 +169,8 @@ export default async function EntityPage({
|
|||||||
|
|
||||||
if (q) {
|
if (q) {
|
||||||
const searchableFields = fieldsConfig
|
const searchableFields = fieldsConfig
|
||||||
.filter(f => f.type === 'text' || f.type === 'textarea')
|
.filter((f) => f.type === 'text' || f.type === 'textarea')
|
||||||
.map(f => f.name)
|
.map((f) => f.name)
|
||||||
.slice(0, 2);
|
.slice(0, 2);
|
||||||
|
|
||||||
if (searchableFields.length > 0) {
|
if (searchableFields.length > 0) {
|
||||||
@ -83,7 +183,7 @@ export default async function EntityPage({
|
|||||||
collectionId,
|
collectionId,
|
||||||
queries
|
queries
|
||||||
);
|
);
|
||||||
documents = response.documents;
|
documents = response.documents as Array<Record<string, unknown>>;
|
||||||
total = response.total;
|
total = response.total;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error fetching from ${collectionId}:`, error);
|
console.error(`Error fetching from ${collectionId}:`, error);
|
||||||
@ -114,7 +214,10 @@ export default async function EntityPage({
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</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
|
<span className="text-xl leading-none -mt-1">+</span> Nuevo
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { Sidebar } from '@/components/Sidebar';
|
import { Sidebar } from '@/components/Sidebar';
|
||||||
import { getLoggedInUser } from '@/lib/appwrite.server';
|
import { getLoggedInUser } from '@/lib/appwrite.server';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
import { ToastProvider } from '@/components/publications/ToastProvider';
|
||||||
|
import { ErrorBoundary } from '@/components/publications/ErrorBoundary';
|
||||||
|
|
||||||
export default async function DashboardLayout({
|
export default async function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
@ -13,12 +15,16 @@ export default async function DashboardLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<ToastProvider>
|
||||||
<div className="flex h-screen bg-[#0f172a] text-white pt-[80px]">
|
<div className="flex h-screen bg-[#0f172a] text-white pt-[80px]">
|
||||||
<Sidebar user={user} />
|
<Sidebar user={user} />
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main className="flex-1 overflow-y-auto bg-[#020617] p-8 md:p-12 shadow-inner border-l border-slate-800">
|
<main className="flex-1 overflow-y-auto bg-[#020617] p-8 md:p-12 shadow-inner border-l border-slate-800">
|
||||||
|
<ErrorBoundary>
|
||||||
{children}
|
{children}
|
||||||
|
</ErrorBoundary>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
</ToastProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,11 +3,17 @@ import { createAdminClient } from '@/lib/appwrite.server';
|
|||||||
import TopBanner from './TopBanner';
|
import TopBanner from './TopBanner';
|
||||||
import ClientSidebars from './ClientSidebars';
|
import ClientSidebars from './ClientSidebars';
|
||||||
|
|
||||||
async function fetchSettings() {
|
interface Settings {
|
||||||
|
rotation_seconds: number;
|
||||||
|
ticker_count: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSettings(): Promise<Settings> {
|
||||||
try {
|
try {
|
||||||
const client = await createAdminClient();
|
const client = await createAdminClient();
|
||||||
const res = await client.databases.listDocuments(APPWRITE_ALMEIRA_CONFIG.databaseId, 'settings', [Query.limit(1)]);
|
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) {
|
} catch(e) {
|
||||||
console.error("fetchSettings error:", e);
|
console.error("fetchSettings error:", e);
|
||||||
}
|
}
|
||||||
@ -51,7 +57,7 @@ export async function GlobalTopBanner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function GlobalSidebar() {
|
export async function GlobalSidebar() {
|
||||||
let settings = { rotation_seconds: 5 };
|
let settings = { rotation_seconds: 5 } as Settings;
|
||||||
let groupedAds: Record<string, any[]> = {};
|
let groupedAds: Record<string, any[]> = {};
|
||||||
|
|
||||||
try {
|
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