feat: finalize Almeira Multimedia Ecosystem integration and UI refinements
This commit is contained in:
parent
3ee7b28d3d
commit
0ecfa29bf0
1140
package-lock.json
generated
1140
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@ -9,6 +9,16 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/extension-color": "^3.21.0",
|
||||
"@tiptap/extension-font-family": "^3.21.0",
|
||||
"@tiptap/extension-highlight": "^3.21.0",
|
||||
"@tiptap/extension-link": "^3.21.0",
|
||||
"@tiptap/extension-text-align": "^3.21.0",
|
||||
"@tiptap/extension-text-style": "^3.21.0",
|
||||
"@tiptap/extension-underline": "^3.21.0",
|
||||
"@tiptap/react": "^3.21.0",
|
||||
"@tiptap/starter-kit": "^3.21.0",
|
||||
"@types/video.js": "^7.3.58",
|
||||
"appwrite": "^23.0.0",
|
||||
"axios": "^1.13.6",
|
||||
"clsx": "^2.1.1",
|
||||
@ -17,7 +27,9 @@
|
||||
"node-appwrite": "^22.1.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"video.js": "^8.23.7",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
|
||||
75
setup_whatsapp.js
Normal file
75
setup_whatsapp.js
Normal file
@ -0,0 +1,75 @@
|
||||
const sdk = require('node-appwrite');
|
||||
|
||||
const client = new sdk.Client();
|
||||
const databases = new sdk.Databases(client);
|
||||
|
||||
// Use credentials for Almeira Web Cloud (Coolify)
|
||||
const ENDPOINT = process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT || 'https://appwrite.almeira.com.co/v1';
|
||||
const PROJECT_ID = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID || '69b84df5003bd8957c5c';
|
||||
const API_KEY = process.env.APPWRITE_API_KEY || 'standard_ee81a4ca6784c0fa6a23abab0a983dbba9cd0c2d8e211a00c759d9801f5d8e27b43a605bfa373e13106da87acfc7673608f0ffb0eb04e10cae3dae93d2404bfda8676f96e3fcfd6bd642f6e12d69367916b796c54e98e2e4c4bc0b7a27aa42ba5048582fcbd45b1365b51c5014cb939c1853923c418048ff0e39c2237065da3c';
|
||||
|
||||
client
|
||||
.setEndpoint(ENDPOINT)
|
||||
.setProject(PROJECT_ID)
|
||||
.setKey(API_KEY);
|
||||
|
||||
const delay = ms => new Promise(res => setTimeout(res, ms));
|
||||
const dbId = 'almeira';
|
||||
const collectionId = 'whatsapp_messages';
|
||||
|
||||
async function setupWhatsApp() {
|
||||
try {
|
||||
console.log('========================================');
|
||||
console.log('Almeira Web — Appwrite Messaging Setup');
|
||||
console.log('========================================\n');
|
||||
|
||||
console.log('[1/2] Creando colección: whatsapp_messages...');
|
||||
try {
|
||||
// Permisos de lectura públicos: SOLO para mensajes aprobados (se gestionará mediante queries o permisos más adelante)
|
||||
// Por ahora, solo permitimos lectura por el rol 'any' para simular real-time en el dashboard (se puede restringir a usuarios autenticados después)
|
||||
await databases.createCollection(dbId, collectionId, 'Mensajes WhatsApp (Evolution API)', [
|
||||
sdk.Permission.read(sdk.Role.any()), // Permitido lectura para el locutor y realtime (para simplicidad de demo)
|
||||
sdk.Permission.write(sdk.Role.any()) // El webhook usará Client Admin con API_KEY, pero dejamos esto base
|
||||
], false);
|
||||
console.log(' ✓ Colección creada.');
|
||||
} catch(e){
|
||||
console.log(' > Colección ya existe.');
|
||||
}
|
||||
await delay(1000);
|
||||
|
||||
console.log('[2/2] Configurando atributos...');
|
||||
|
||||
const attributes = [
|
||||
{ name: 'sender_name', type: 'string', size: 255, required: true },
|
||||
{ name: 'phone_number', type: 'string', size: 50, required: true },
|
||||
{ name: 'content', type: 'string', size: 5000, required: true },
|
||||
{ name: 'timestamp', type: 'datetime', required: true },
|
||||
{ name: 'status', type: 'string', size: 50, required: true, default: 'pending' },
|
||||
{ name: 'platform', type: 'string', size: 50, required: true, default: 'whatsapp' },
|
||||
{ name: 'avatar', type: 'string', size: 1000, required: false }
|
||||
];
|
||||
|
||||
for (const attr of attributes) {
|
||||
try {
|
||||
if (attr.type === 'string') {
|
||||
await databases.createStringAttribute(dbId, collectionId, attr.name, attr.size, attr.required, attr.default);
|
||||
} else if (attr.type === 'datetime') {
|
||||
await databases.createDatetimeAttribute(dbId, collectionId, attr.name, attr.required);
|
||||
}
|
||||
console.log(` ✓ Atributo ${attr.name} creado.`);
|
||||
await delay(500);
|
||||
} catch (e) {
|
||||
console.log(` > Atributo ${attr.name} ya existe.`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n========================================');
|
||||
console.log('✅ Base de Datos para WhatsApp LISTA!');
|
||||
console.log('========================================');
|
||||
|
||||
} catch(e) {
|
||||
console.error('\n❌ Error setups:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
setupWhatsApp();
|
||||
23
src/app/actions/user.ts
Normal file
23
src/app/actions/user.ts
Normal file
@ -0,0 +1,23 @@
|
||||
'use server';
|
||||
|
||||
import { getUserPreferences, updateUserPreference, UserPreferences } from '@/lib/preferences';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
/**
|
||||
* Server action to get user preferences
|
||||
*/
|
||||
export async function fetchUserPreferencesAction() {
|
||||
return await getUserPreferences();
|
||||
}
|
||||
|
||||
/**
|
||||
* Server action to update user preferences and revalidate the dashboard
|
||||
*/
|
||||
export async function updateUserPreferencesAction(update: Partial<UserPreferences>) {
|
||||
const success = await updateUserPreference(update);
|
||||
if (success) {
|
||||
// Revalidate all dashboard pages to reflect UI changes immediately
|
||||
revalidatePath('/dashboard', 'layout');
|
||||
}
|
||||
return { success };
|
||||
}
|
||||
36
src/app/api/webhooks/whatsapp/route.ts
Normal file
36
src/app/api/webhooks/whatsapp/route.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { ID, Databases } from 'node-appwrite';
|
||||
import { createAdminClient } from '@/lib/appwrite.server';
|
||||
import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const payload = await req.json();
|
||||
|
||||
// Estructura típica de Evolution API
|
||||
const messageData = {
|
||||
sender_name: payload.data?.pushName || payload.data?.key?.remoteJid || 'Usuario WhatsApp',
|
||||
phone_number: payload.data?.key?.remoteJid?.split('@')[0] || 'Desconocido',
|
||||
content: payload.data?.message?.conversation || payload.data?.message?.extendedTextMessage?.text || 'Mensaje Multimedia',
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'pending', // Por defecto entran para moderación
|
||||
platform: 'whatsapp',
|
||||
avatar: payload.data?.profilePicUrl || ''
|
||||
};
|
||||
|
||||
const { databases } = await createAdminClient();
|
||||
|
||||
// NOTA: Asegúrate de que la colección 'whatsapp_messages' exista en Appwrite
|
||||
await databases.createDocument(
|
||||
APPWRITE_ALMEIRA_CONFIG.databaseId,
|
||||
'whatsapp_messages',
|
||||
ID.unique(),
|
||||
messageData
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Mensaje recibido y guardado' }, { status: 200 });
|
||||
} catch (error: any) {
|
||||
console.error('Error in WhatsApp Webhook:', error);
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,9 @@
|
||||
import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite';
|
||||
import { APPWRITE_ALMEIRA_CONFIG, projectId, endpoint } from '@/lib/appwrite';
|
||||
import { createSessionClient } from '@/lib/appwrite.server';
|
||||
import { collectionMap, SCHEMA_MAP } from '@/lib/schema';
|
||||
import Link from 'next/link';
|
||||
import { updateDocumentServer } from '@/app/actions/appwrite';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { EntityForm } from '@/components/dashboard/EntityForm';
|
||||
|
||||
export default async function EditEntityPage({ params }: { params: Promise<{ entity: string, id: string }> }) {
|
||||
const { entity, id } = await params;
|
||||
@ -11,6 +11,13 @@ export default async function EditEntityPage({ params }: { params: Promise<{ ent
|
||||
const collectionId = collectionMap[entity];
|
||||
const fields = SCHEMA_MAP[entity] || [];
|
||||
|
||||
const entityNames: Record<string, string> = {
|
||||
posts: 'Publicaciones', categories: 'Categorías', clients: 'Clientes', client_types: 'Tipos de Cliente',
|
||||
countries: 'Países', departments: 'Departamentos', cities: 'Ciudades', contracts: 'Contratos',
|
||||
payment_methods: 'Formas de Pago', publication_types: 'Tipos de Publicación'
|
||||
};
|
||||
const entityName = entityNames[entity] || entity.replace(/_/g, ' ');
|
||||
|
||||
if (!collectionId || fields.length === 0) {
|
||||
return (
|
||||
<div className="p-8"><h1 className="text-red-500 font-bold">Configuración no encontrada para {entity}</h1></div>
|
||||
@ -32,8 +39,9 @@ export default async function EditEntityPage({ params }: { params: Promise<{ ent
|
||||
);
|
||||
} catch (e) {
|
||||
return (
|
||||
<div className="p-8"><h1 className="text-red-500 font-bold">No se encontró el registro o no tienes permisos.</h1>
|
||||
<Link href={`/dashboard/${entity}`} className="text-cyan-400 mt-4 inline-block">← Volver</Link></div>
|
||||
<div className="p-8">
|
||||
<h1 className="text-red-500 font-bold">No se encontró el registro o no tienes permisos.</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -53,7 +61,6 @@ export default async function EditEntityPage({ params }: { params: Promise<{ ent
|
||||
async function updateAction(formData: FormData) {
|
||||
'use server';
|
||||
|
||||
// Convert formData to simple object
|
||||
const data: Record<string, any> = {};
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (key.startsWith('$ACTION')) continue;
|
||||
@ -64,6 +71,10 @@ export default async function EditEntityPage({ params }: { params: Promise<{ ent
|
||||
for (const nKey of numericFields) {
|
||||
if(data[nKey]) data[nKey] = parseFloat(data[nKey]);
|
||||
}
|
||||
|
||||
if (data.estado && typeof data.estado === 'string') {
|
||||
data.estado = data.estado.toLowerCase();
|
||||
}
|
||||
|
||||
const res = await updateDocumentServer(collectionId, id, data);
|
||||
if(res.success) {
|
||||
@ -73,105 +84,15 @@ export default async function EditEntityPage({ params }: { params: Promise<{ ent
|
||||
}
|
||||
}
|
||||
|
||||
const getDefValue = (field: any) => {
|
||||
const val = document[field.name];
|
||||
if (val === undefined || val === null) return '';
|
||||
if (field.type === 'boolean') return val ? 'true' : 'false';
|
||||
if (field.type === 'date') return new Date(val).toISOString().slice(0, 16);
|
||||
if (field.type === 'relation') return typeof val === 'object' ? val.$id : val;
|
||||
return val;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6 animate-fade-in">
|
||||
<div className="flex items-center gap-4 bg-slate-900 border border-slate-800 p-6 rounded-2xl shadow-xl">
|
||||
<Link href={`/dashboard/${entity}`} className="text-slate-400 hover:text-white transition-colors bg-slate-800 px-4 py-2 rounded-lg font-bold text-sm tracking-widest uppercase hover:bg-slate-700">
|
||||
← Cancelar
|
||||
</Link>
|
||||
<h1 className="text-3xl font-black capitalize font-outfit uppercase tracking-tighter text-white truncate max-w-[400px]">
|
||||
EDITAR: <span className="text-cyan-400 font-mono text-xl ml-2">{id}</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-8 md:p-10 shadow-2xl relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-pink-600/5 blur-[100px] pointer-events-none"></div>
|
||||
<form action={updateAction} className="space-y-6 relative z-10">
|
||||
|
||||
{fields.map(field => {
|
||||
const defValue = getDefValue(field);
|
||||
|
||||
return (
|
||||
<div key={field.name} className="flex flex-col gap-2">
|
||||
<label className="text-sm font-bold tracking-widest text-slate-300 uppercase">
|
||||
{field.label} {field.required && <span className="text-pink-500">*</span>}
|
||||
</label>
|
||||
|
||||
{field.type === 'text' && (
|
||||
<input type="text" name={field.name} defaultValue={defValue} required={field.required} className="bg-slate-950 border border-slate-800 rounded-xl px-5 py-3.5 text-white focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all placeholder-slate-600" />
|
||||
)}
|
||||
|
||||
{field.type === 'number' && (
|
||||
<input type="number" step="any" name={field.name} defaultValue={defValue} required={field.required} className="bg-slate-950 border border-slate-800 rounded-xl px-5 py-3.5 text-white focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all" />
|
||||
)}
|
||||
|
||||
{field.type === 'url' && (
|
||||
<input type="url" name={field.name} defaultValue={defValue} required={field.required} className="bg-slate-950 border border-slate-800 rounded-xl px-5 py-3.5 text-white focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all" />
|
||||
)}
|
||||
|
||||
{field.type === 'date' && (
|
||||
<input type="datetime-local" name={field.name} defaultValue={defValue} required={field.required} className="bg-slate-950 border border-slate-800 rounded-xl px-5 py-3.5 text-slate-300 focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all [color-scheme:dark]" />
|
||||
)}
|
||||
|
||||
{field.type === 'textarea' && (
|
||||
<textarea name={field.name} defaultValue={defValue} required={field.required} rows={5} className="bg-slate-950 border border-slate-800 rounded-xl px-5 py-3.5 text-white focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all"></textarea>
|
||||
)}
|
||||
|
||||
{field.type === 'boolean' && (
|
||||
<div className="relative">
|
||||
<select name={field.name} defaultValue={defValue} required={field.required} className="w-full bg-slate-950 border border-slate-800 rounded-xl px-5 py-3.5 text-white focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all appearance-none pr-10">
|
||||
<option value="true">Sí (Afirmativo)</option>
|
||||
<option value="false">No (Negativo)</option>
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-4 pointer-events-none text-slate-500">▼</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{field.type === 'select' && field.options && (
|
||||
<div className="relative">
|
||||
<select name={field.name} defaultValue={defValue} required={field.required} className="w-full bg-slate-950 border border-slate-800 rounded-xl px-5 py-3.5 text-white focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all text-sm appearance-none pr-10">
|
||||
<option value="">Seleccione una opción...</option>
|
||||
{field.options.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-4 pointer-events-none text-slate-500">▼</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{field.type === 'relation' && (
|
||||
<div className="relative">
|
||||
<select name={field.name} defaultValue={defValue} required={field.required} className="w-full bg-slate-950 border border-slate-800 rounded-xl px-5 py-3.5 text-white focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all text-sm appearance-none pr-10">
|
||||
<option value="">Buscar en {field.label}...</option>
|
||||
{relationData[field.name]?.map(relDoc => (
|
||||
<option key={relDoc.$id} value={relDoc.$id}>
|
||||
{relDoc[field.relationLabel || 'codigo'] || relDoc.$id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-4 pointer-events-none text-slate-500">▼</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="pt-8">
|
||||
<button type="submit" className="w-full relative group overflow-hidden bg-slate-800 text-white font-black uppercase tracking-widest py-4 px-6 rounded-xl transition-all border border-pink-500/50 hover:border-pink-400">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-pink-600 to-purple-500 opacity-90 group-hover:opacity-100 transition-opacity"></div>
|
||||
<span className="relative z-10">Guardar</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<EntityForm
|
||||
entity={entity}
|
||||
entityName={entityName}
|
||||
initialData={document}
|
||||
fields={fields}
|
||||
relationData={relationData}
|
||||
submitAction={updateAction}
|
||||
appwriteConfig={{ endpoint, projectId }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite';
|
||||
import { APPWRITE_ALMEIRA_CONFIG, projectId, endpoint } from '@/lib/appwrite';
|
||||
import { createSessionClient } from '@/lib/appwrite.server';
|
||||
import { collectionMap, SCHEMA_MAP } from '@/lib/schema';
|
||||
import Link from 'next/link';
|
||||
import { createDocumentServer } from '@/app/actions/appwrite';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { EntityForm } from '@/components/dashboard/EntityForm';
|
||||
|
||||
export default async function NewEntityPage({ params }: { params: Promise<{ entity: string }> }) {
|
||||
const { entity } = await params;
|
||||
@ -24,14 +24,11 @@ export default async function NewEntityPage({ params }: { params: Promise<{ enti
|
||||
);
|
||||
}
|
||||
|
||||
// Inject user session secure database client
|
||||
const sessionClient = await createSessionClient();
|
||||
if (!sessionClient) {
|
||||
throw new Error("No sesión activa");
|
||||
}
|
||||
|
||||
// Fetch relationship options
|
||||
// For each field of type 'relation', fetch the collection
|
||||
const relationData: Record<string, any[]> = {};
|
||||
await Promise.all(
|
||||
fields.filter(f => f.type === 'relation' && f.relationCollection).map(async (field) => {
|
||||
@ -47,116 +44,37 @@ export default async function NewEntityPage({ params }: { params: Promise<{ enti
|
||||
async function submitAction(formData: FormData) {
|
||||
'use server';
|
||||
|
||||
// Convert formData to simple object
|
||||
const data: Record<string, any> = {};
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (key.startsWith('$ACTION')) continue;
|
||||
data[key] = value;
|
||||
}
|
||||
|
||||
// Attempt coercing numeric fields manually if defined in schema as 'number'
|
||||
const numericFields = fields.filter(f => f.type === 'number').map(f => f.name);
|
||||
for (const nKey of numericFields) {
|
||||
if(data[nKey]) data[nKey] = parseFloat(data[nKey]);
|
||||
}
|
||||
|
||||
if (data.estado && typeof data.estado === 'string') {
|
||||
data.estado = data.estado.toLowerCase();
|
||||
}
|
||||
|
||||
const res = await createDocumentServer(collectionId, data);
|
||||
if(res.success) {
|
||||
redirect(`/dashboard/${entity}`);
|
||||
} else {
|
||||
console.error("Server Action res error", res.error);
|
||||
// Fallback redirect with error state could be used here
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6 animate-fade-in">
|
||||
<div className="flex items-center gap-4 bg-slate-900 border border-slate-800 p-6 rounded-2xl shadow-xl">
|
||||
<Link href={`/dashboard/${entity}`} className="text-slate-400 hover:text-white transition-colors bg-slate-800 px-4 py-2 rounded-lg font-bold text-sm tracking-widest uppercase hover:bg-slate-700">
|
||||
← Regresar
|
||||
</Link>
|
||||
<h1 className="text-3xl font-black capitalize font-outfit uppercase tracking-tighter text-white">
|
||||
NUEVO: <span className="text-cyan-400">{entityName}</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-8 md:p-10 shadow-2xl relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-blue-600/10 blur-[100px] pointer-events-none"></div>
|
||||
<form action={submitAction} className="space-y-6 relative z-10">
|
||||
|
||||
{fields.map(field => {
|
||||
return (
|
||||
<div key={field.name} className="flex flex-col gap-2">
|
||||
<label className="text-sm font-bold tracking-widest text-slate-300 uppercase">
|
||||
{field.label} {field.required && <span className="text-pink-500">*</span>}
|
||||
</label>
|
||||
|
||||
{field.type === 'text' && (
|
||||
<input type="text" name={field.name} required={field.required} className="bg-slate-950 border border-slate-800 rounded-xl px-5 py-3.5 text-white focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all placeholder-slate-600" placeholder={`Ingresa ${field.label.toLowerCase()}`} />
|
||||
)}
|
||||
|
||||
{field.type === 'number' && (
|
||||
<input type="number" step="any" name={field.name} required={field.required} className="bg-slate-950 border border-slate-800 rounded-xl px-5 py-3.5 text-white focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all" />
|
||||
)}
|
||||
|
||||
{field.type === 'url' && (
|
||||
<input type="url" name={field.name} required={field.required} className="bg-slate-950 border border-slate-800 rounded-xl px-5 py-3.5 text-white focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all" placeholder="https://" />
|
||||
)}
|
||||
|
||||
{field.type === 'date' && (
|
||||
<input type="datetime-local" name={field.name} required={field.required} className="bg-slate-950 border border-slate-800 rounded-xl px-5 py-3.5 text-slate-300 focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all [color-scheme:dark]" />
|
||||
)}
|
||||
|
||||
{field.type === 'textarea' && (
|
||||
<textarea name={field.name} required={field.required} rows={5} className="bg-slate-950 border border-slate-800 rounded-xl px-5 py-3.5 text-white focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all"></textarea>
|
||||
)}
|
||||
|
||||
{field.type === 'boolean' && (
|
||||
<div className="relative">
|
||||
<select name={field.name} required={field.required} className="w-full bg-slate-950 border border-slate-800 rounded-xl px-5 py-3.5 text-white focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all appearance-none pr-10">
|
||||
<option value="true">Sí (Afirmativo)</option>
|
||||
<option value="false">No (Negativo)</option>
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-4 pointer-events-none text-slate-500">▼</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{field.type === 'select' && field.options && (
|
||||
<div className="relative">
|
||||
<select name={field.name} required={field.required} className="w-full bg-slate-950 border border-slate-800 rounded-xl px-5 py-3.5 text-white focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all text-sm appearance-none pr-10">
|
||||
<option value="">Seleccione una opción...</option>
|
||||
{field.options.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-4 pointer-events-none text-slate-500">▼</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{field.type === 'relation' && (
|
||||
<div className="relative">
|
||||
<select name={field.name} required={field.required} className="w-full bg-slate-950 border border-slate-800 rounded-xl px-5 py-3.5 text-white focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all text-sm appearance-none pr-10">
|
||||
<option value="">Buscar en {field.label}...</option>
|
||||
{relationData[field.name]?.map(relDoc => (
|
||||
<option key={relDoc.$id} value={relDoc.$id}>
|
||||
{relDoc[field.relationLabel || 'codigo'] || relDoc.$id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-4 pointer-events-none text-slate-500">▼</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="pt-8">
|
||||
<button type="submit" className="w-full relative group overflow-hidden bg-slate-800 text-white font-black uppercase tracking-widest py-4 px-6 rounded-xl transition-all border border-blue-500/50 hover:border-cyan-400">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-blue-600 to-cyan-500 opacity-90 group-hover:opacity-100 transition-opacity"></div>
|
||||
<span className="relative z-10">Guardar</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<EntityForm
|
||||
entity={entity}
|
||||
entityName={entityName}
|
||||
fields={fields}
|
||||
relationData={relationData}
|
||||
submitAction={submitAction}
|
||||
appwriteConfig={{ endpoint, projectId }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,22 +2,23 @@ import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite';
|
||||
import { createSessionClient } from '@/lib/appwrite.server';
|
||||
import { Query } from 'node-appwrite';
|
||||
import Link from 'next/link';
|
||||
import { Pencil } from 'lucide-react';
|
||||
import { DeleteButton } from '@/components/DeleteButton';
|
||||
|
||||
import { collectionMap, SCHEMA_MAP } from '@/lib/schema';
|
||||
import { DataTable } from '@/components/dashboard/DataTable';
|
||||
import { getUserPreferences } from '@/lib/preferences';
|
||||
|
||||
export default async function EntityPage({
|
||||
params,
|
||||
searchParams
|
||||
}: {
|
||||
params: Promise<{ entity: string }>,
|
||||
searchParams: Promise<{ q?: string, page?: string }>
|
||||
searchParams: Promise<{ q?: string, page?: string, sort?: string, dir?: string }>
|
||||
}) {
|
||||
const { entity } = await params;
|
||||
const sParams = await searchParams;
|
||||
const q = sParams.q || '';
|
||||
const page = Number(sParams.page) || 1;
|
||||
const sort = sParams.sort || '$createdAt';
|
||||
const dir = (sParams.dir === 'asc' || sParams.dir === 'desc') ? sParams.dir : 'desc';
|
||||
const limit = 15;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
@ -41,6 +42,10 @@ export default async function EntityPage({
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch preferences
|
||||
const prefs = await getUserPreferences();
|
||||
const hiddenColumns = prefs.hiddenColumns?.[entity] || [];
|
||||
|
||||
let documents: any[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
@ -52,18 +57,22 @@ export default async function EntityPage({
|
||||
const queries = [
|
||||
Query.limit(limit),
|
||||
Query.offset(offset),
|
||||
Query.orderDesc('$createdAt')
|
||||
];
|
||||
|
||||
// Apply Sorting
|
||||
if (dir === 'asc') {
|
||||
queries.push(Query.orderAsc(sort));
|
||||
} else {
|
||||
queries.push(Query.orderDesc(sort));
|
||||
}
|
||||
|
||||
if (q) {
|
||||
const searchableFields = fieldsConfig
|
||||
.filter(f => f.type === 'text' || f.type === 'textarea')
|
||||
.map(f => f.name)
|
||||
.slice(0, 2); // Limit to first two text fields for performance
|
||||
.slice(0, 2);
|
||||
|
||||
if (searchableFields.length > 0) {
|
||||
// Note: Appwrite multiple contains are AND-ed by default.
|
||||
// For a better search we might need something else but this is a good start.
|
||||
queries.push(Query.contains(searchableFields[0], q));
|
||||
}
|
||||
}
|
||||
@ -79,18 +88,6 @@ export default async function EntityPage({
|
||||
console.error(`Error fetching from ${collectionId}:`, error);
|
||||
}
|
||||
|
||||
let rawHeaders: string[] = [];
|
||||
if (documents.length > 0) {
|
||||
rawHeaders = Object.keys(documents[0]).filter(k => !k.startsWith('$'));
|
||||
} else if (fieldsConfig.length > 0) {
|
||||
rawHeaders = fieldsConfig.map(f => f.name);
|
||||
}
|
||||
|
||||
const getHeaderLabel = (key: string) => {
|
||||
const field = fieldsConfig.find(f => f.name === key);
|
||||
return field ? field.label : key.replace(/_/g, ' ');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in pb-10">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center bg-slate-900 border border-slate-800 p-6 rounded-2xl shadow-xl shadow-cyan-500/5 gap-6">
|
||||
@ -121,73 +118,21 @@ export default async function EntityPage({
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900/50 backdrop-blur-xl border border-slate-800 rounded-2xl overflow-hidden shadow-2xl">
|
||||
{documents.length === 0 ? (
|
||||
<div className="p-16 text-center text-slate-500 border border-dashed border-slate-700 m-8 rounded-xl bg-slate-900/30">
|
||||
<h3 className="text-xl font-bold text-white mb-2 font-outfit tracking-wider">NO SE ENCONTRARON REGISTROS</h3>
|
||||
Prueba ajustando tu búsqueda o crea un nuevo elemento.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto p-4">
|
||||
<table className="w-full text-left text-sm whitespace-nowrap">
|
||||
<thead className="bg-slate-950 border-b border-slate-800 text-cyan-500 uppercase text-xs font-black tracking-widest">
|
||||
<tr>
|
||||
{rawHeaders.map(h => (
|
||||
<th key={h} className="px-6 py-5">
|
||||
{getHeaderLabel(h)}
|
||||
</th>
|
||||
))}
|
||||
<th className="px-6 py-5 text-center">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60">
|
||||
{documents.map((doc) => (
|
||||
<tr key={doc.$id} className="hover:bg-slate-800/50 transition-colors group">
|
||||
{rawHeaders.map(h => {
|
||||
let value = doc[h];
|
||||
const field = fieldsConfig.find(f => f.name === h);
|
||||
|
||||
// Resolve Relations
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if (field?.type === 'relation' && field.relationLabel) {
|
||||
value = value[field.relationLabel] || value.$id || '[Vinculado]';
|
||||
} else {
|
||||
value = Array.isArray(value) ? `[Lista (${value.length})]` : value.$id || '[Vinculado]';
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
value = value ? 'Sí' : 'No';
|
||||
}
|
||||
if (typeof value === 'string' && value.length > 50) {
|
||||
value = value.substring(0, 50) + '...';
|
||||
}
|
||||
|
||||
return (
|
||||
<td key={h} className="px-6 py-4 text-slate-300 font-medium group-hover:text-white transition-colors">
|
||||
{String(value || '')}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
<td className="px-6 py-4 text-center space-x-2 w-24">
|
||||
<Link href={`/dashboard/${entity}/${doc.$id}`} className="inline-flex p-2 text-slate-400 hover:text-cyan-400 hover:bg-cyan-500/10 rounded-lg transition-colors border border-transparent hover:border-cyan-500/20" title="Editar Registro">
|
||||
<Pencil size={16} />
|
||||
</Link>
|
||||
<DeleteButton collectionId={collectionId} documentId={doc.$id} entityAlias={entity} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
entity={entity}
|
||||
collectionId={collectionId}
|
||||
documents={documents}
|
||||
fieldsConfig={fieldsConfig}
|
||||
initialHiddenColumns={hiddenColumns}
|
||||
currentSortField={sort}
|
||||
currentSortDir={dir}
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
{total > limit && (
|
||||
<div className="flex justify-center items-center gap-4 mt-8">
|
||||
<Link
|
||||
href={`/dashboard/${entity}?page=${page - 1}${q ? `&q=${q}` : ''}`}
|
||||
href={`/dashboard/${entity}?page=${page - 1}${q ? `&q=${q}` : ''}${sort ? `&sort=${sort}` : ''}${dir ? `&dir=${dir}` : ''}`}
|
||||
className={`px-6 py-2 rounded-full font-bold text-sm transition-all border border-slate-800 ${page <= 1 ? 'opacity-30 pointer-events-none bg-slate-900' : 'bg-slate-900 text-white hover:border-cyan-500'}`}
|
||||
>
|
||||
ANTERIOR
|
||||
@ -196,7 +141,7 @@ export default async function EntityPage({
|
||||
Página {page} de {Math.ceil(total / limit)}
|
||||
</span>
|
||||
<Link
|
||||
href={`/dashboard/${entity}?page=${page + 1}${q ? `&q=${q}` : ''}`}
|
||||
href={`/dashboard/${entity}?page=${page + 1}${q ? `&q=${q}` : ''}${sort ? `&sort=${sort}` : ''}${dir ? `&dir=${dir}` : ''}`}
|
||||
className={`px-6 py-2 rounded-full font-bold text-sm transition-all border border-slate-800 ${page >= Math.ceil(total / limit) ? 'opacity-30 pointer-events-none bg-slate-900' : 'bg-slate-900 text-white hover:border-cyan-500'}`}
|
||||
>
|
||||
SIGUIENTE
|
||||
|
||||
337
src/app/dashboard/locutor/page.tsx
Normal file
337
src/app/dashboard/locutor/page.tsx
Normal file
@ -0,0 +1,337 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import client, { databases, APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite';
|
||||
import { Query } from 'appwrite';
|
||||
import { RealtimeResponseEvent } from 'appwrite';
|
||||
import {
|
||||
MessageCircle, CheckCircle, XCircle, Trash2,
|
||||
User, Phone, Clock, Radio, ShieldAlert,
|
||||
ExternalLink, Zap, Bell, Monitor, Settings
|
||||
} from 'lucide-react';
|
||||
|
||||
export default function LocutorPage() {
|
||||
const [messages, setMessages] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [lastReceivedId, setLastReceivedId] = useState<string | null>(null);
|
||||
|
||||
const fetchInitialMessages = async () => {
|
||||
try {
|
||||
const res = await databases.listDocuments(
|
||||
APPWRITE_ALMEIRA_CONFIG.databaseId,
|
||||
'whatsapp_messages',
|
||||
[Query.orderDesc('timestamp'), Query.limit(30)]
|
||||
);
|
||||
setMessages(res.documents);
|
||||
} catch (error) {
|
||||
console.error('Error fetching messages:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchInitialMessages();
|
||||
|
||||
// Realtime Subscription
|
||||
const unsubscribe = client.subscribe(
|
||||
`databases.${APPWRITE_ALMEIRA_CONFIG.databaseId}.collections.whatsapp_messages.documents`,
|
||||
(response: RealtimeResponseEvent<any>) => {
|
||||
if (response.events.includes('databases.*.collections.*.documents.*.create')) {
|
||||
setMessages((prev) => {
|
||||
// Evitar duplicados por si acaso
|
||||
if (prev.some(m => m.$id === response.payload.$id)) return prev;
|
||||
return [response.payload, ...prev].slice(0, 50);
|
||||
});
|
||||
setLastReceivedId(response.payload.$id);
|
||||
}
|
||||
if (response.events.includes('databases.*.collections.*.documents.*.update')) {
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) => msg.$id === (response.payload as any).$id ? response.payload : msg)
|
||||
);
|
||||
}
|
||||
if (response.events.includes('databases.*.collections.*.documents.*.delete')) {
|
||||
setMessages((prev) => prev.filter((msg) => msg.$id !== (response.payload as any).$id));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
const updateStatus = async (id: string, status: 'approved' | 'rejected') => {
|
||||
try {
|
||||
await databases.updateDocument(
|
||||
APPWRITE_ALMEIRA_CONFIG.databaseId,
|
||||
'whatsapp_messages',
|
||||
id,
|
||||
{ status }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error updating status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteMessage = async (id: string) => {
|
||||
if (!confirm('¿Eliminar este mensaje permanentemente?')) return;
|
||||
try {
|
||||
await databases.deleteDocument(
|
||||
APPWRITE_ALMEIRA_CONFIG.databaseId,
|
||||
'whatsapp_messages',
|
||||
id
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error deleting message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#020617] text-slate-200">
|
||||
{/* Cabecera Técnica Estilo Studio */}
|
||||
<div className="border-b border-white/5 bg-slate-900/20 backdrop-blur-md sticky top-0 z-30">
|
||||
<div className="max-w-[1600px] mx-auto px-6 h-20 flex items-center justify-between">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="relative">
|
||||
<div className="w-12 h-12 bg-red-600 rounded-xl flex items-center justify-center shadow-lg shadow-red-600/20 animate-pulse">
|
||||
<Radio className="text-white" size={24} />
|
||||
</div>
|
||||
<div className="absolute -top-1 -right-1 w-4 h-4 bg-white rounded-full flex items-center justify-center border-2 border-red-600">
|
||||
<div className="w-1.5 h-1.5 bg-red-600 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-black text-white uppercase tracking-tighter leading-none flex items-center gap-2">
|
||||
Control Master <span className="bg-white/5 border border-white/10 text-[9px] px-2 py-1 rounded text-slate-400 font-bold tracking-widest">LIVE 360</span>
|
||||
</h1>
|
||||
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-[0.3em] mt-1.5 italic">Almeira Multimedia Ecosystem</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:flex items-center gap-3">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-[9px] font-black text-slate-500 uppercase tracking-widest">Estado del Sistema</span>
|
||||
<span className="text-xs font-bold text-cyan-500 flex items-center gap-1.5">
|
||||
<Zap size={10} fill="currentColor" /> Webhook Activo
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-px h-8 bg-white/5 mx-2"></div>
|
||||
<button className="p-2.5 bg-white/5 hover:bg-white/10 rounded-xl transition-all border border-white/5">
|
||||
<Settings size={18} className="text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="max-w-[1600px] mx-auto p-6 grid grid-cols-1 xl:grid-cols-12 gap-8">
|
||||
|
||||
{/* Panel Central de Mensajería (8 columnas) */}
|
||||
<div className="xl:col-span-8 space-y-6">
|
||||
<div className="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">
|
||||
<MessageCircle size={18} />
|
||||
</div>
|
||||
<h2 className="text-sm font-black uppercase tracking-widest text-slate-300">Interactividad WhatsApp</h2>
|
||||
</div>
|
||||
<div className="flex gap-2 text-[10px] font-black uppercase">
|
||||
<span className="px-3 py-1.5 bg-slate-900 border border-slate-800 rounded-full text-slate-500">
|
||||
Pendientes: {messages.filter(m => m.status === 'pending').length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="h-[60vh] flex flex-col items-center justify-center gap-4 border border-white/5 rounded-3xl bg-slate-900/20">
|
||||
<div className="w-12 h-12 border-4 border-cyan-500/20 border-t-cyan-500 rounded-full animate-spin"></div>
|
||||
<p className="text-[10px] font-black text-slate-500 uppercase tracking-widest">Sincronizando Base de Datos...</p>
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="h-[60vh] flex flex-col items-center justify-center border-2 border-dashed border-slate-800 rounded-3xl p-12 text-center">
|
||||
<div className="w-20 h-20 bg-slate-900 rounded-full flex items-center justify-center mb-6 text-slate-700">
|
||||
<ShieldAlert size={40} />
|
||||
</div>
|
||||
<h3 className="text-white font-black uppercase tracking-tighter text-lg mb-2">Sin actividad reciente</h3>
|
||||
<p className="text-slate-500 text-sm max-w-xs mx-auto">Los mensajes de WhatsApp aparecerán aquí automáticamente en tiempo real una vez configurado el webhook.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 overflow-y-auto max-h-[calc(100vh-280px)] pr-2 custom-scrollbar">
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.$id}
|
||||
className={`
|
||||
group relative overflow-hidden transition-all duration-300 border rounded-2xl p-5 flex items-start gap-5
|
||||
${msg.$id === lastReceivedId ? 'animate-[incoming_1s_ease-out] border-cyan-500 shadow-[0_0_20px_rgba(6,182,212,0.1)]' : ''}
|
||||
${msg.status === 'approved' ? 'bg-cyan-500/[0.03] border-cyan-500/20' :
|
||||
msg.status === 'rejected' ? 'bg-red-500/[0.02] border-red-500/10 opacity-60 grayscale-[0.5]' :
|
||||
'bg-slate-900/40 border-slate-800 hover:border-slate-700'}
|
||||
`}
|
||||
>
|
||||
{/* Avatar & Icon */}
|
||||
<div className="relative">
|
||||
<div className="w-12 h-12 rounded-2xl bg-slate-800 border border-white/5 overflow-hidden flex items-center justify-center shrink-0">
|
||||
{msg.avatar ? (
|
||||
<img src={msg.avatar} alt="Avatar" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<User className="text-slate-500" size={24} />
|
||||
)}
|
||||
</div>
|
||||
<div className={`absolute -bottom-1 -right-1 w-5 h-5 rounded-lg flex items-center justify-center border-2 border-[#020617] ${
|
||||
msg.platform === 'whatsapp' ? 'bg-green-600' : 'bg-cyan-600'
|
||||
}`}>
|
||||
<MessageCircle size={10} className="text-white fill-current" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<h4 className="text-sm font-black text-white uppercase tracking-tight truncate">{msg.sender_name}</h4>
|
||||
<span className="text-[10px] font-bold text-slate-500 font-mono tracking-tighter">{msg.phone_number}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={12} className="text-slate-600" />
|
||||
<span className="text-[10px] text-slate-500 font-bold uppercase">{new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/[0.02] border border-white/5 rounded-xl p-4 group-hover:bg-white/[0.04] transition-colors">
|
||||
<p className="text-sm text-slate-300 leading-relaxed font-medium">
|
||||
{msg.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2 shrink-0">
|
||||
{msg.status === 'pending' ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => updateStatus(msg.$id, 'approved')}
|
||||
className="w-10 h-10 bg-cyan-500 text-white rounded-xl flex items-center justify-center hover:bg-cyan-400 transition-all shadow-lg shadow-cyan-500/20 active:scale-90"
|
||||
title="Aprobar para el Ticker"
|
||||
>
|
||||
<CheckCircle size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateStatus(msg.$id, 'rejected')}
|
||||
className="w-10 h-10 bg-slate-800 text-slate-400 rounded-xl flex items-center justify-center hover:bg-red-500 hover:text-white transition-all active:scale-90"
|
||||
title="Rechazar"
|
||||
>
|
||||
<XCircle size={20} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${
|
||||
msg.status === 'approved' ? 'bg-cyan-500/20 text-cyan-500' : 'bg-red-500/10 text-red-500'
|
||||
}`}>
|
||||
{msg.status === 'approved' ? <Radio className="animate-pulse" size={20} /> : <XCircle size={20} />}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => deleteMessage(msg.$id)}
|
||||
className="w-10 h-10 text-slate-600 hover:text-red-400 transition-colors flex items-center justify-center"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Indicadores de estado visuales */}
|
||||
{msg.status === 'approved' && (
|
||||
<div className="absolute top-0 right-0 p-1">
|
||||
<div className="bg-cyan-500 text-[8px] font-black text-white px-2 py-0.5 rounded-bl-lg uppercase tracking-widest">Al Aire</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar Moderno de Control (4 columnas) */}
|
||||
<div className="xl:col-span-4 space-y-6">
|
||||
|
||||
{/* Tarjeta de Estadísticas Rápidas */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-slate-900/40 border border-white/5 p-4 rounded-3xl">
|
||||
<p className="text-[9px] font-black text-slate-500 uppercase tracking-widest mb-1">Aprobados</p>
|
||||
<p className="text-2xl font-black text-cyan-400">{messages.filter(m => m.status === 'approved').length}</p>
|
||||
</div>
|
||||
<div className="bg-slate-900/40 border border-white/5 p-4 rounded-3xl">
|
||||
<p className="text-[9px] font-black text-slate-500 uppercase tracking-widest mb-1">Total Hoy</p>
|
||||
<p className="text-2xl font-black text-white">{messages.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="bg-slate-900/40 border border-white/5 rounded-3xl p-6 overflow-hidden relative">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-cyan-500/5 blur-3xl rounded-full -mr-16 -mt-16"></div>
|
||||
|
||||
<h3 className="text-xs font-black text-white uppercase tracking-widest mb-6 flex items-center gap-2">
|
||||
<Zap size={14} className="text-cyan-400" /> Broadcast Engine
|
||||
</h3>
|
||||
|
||||
<div className="space-y-5 relative z-10">
|
||||
<div>
|
||||
<label className="text-[9px] font-black uppercase text-slate-500 mb-2 block tracking-[0.2em]">Fuente de Overlay</label>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<button className="flex items-center justify-between w-full bg-slate-950/50 border border-white/5 hover:border-cyan-500/50 p-3 rounded-xl text-xs font-bold transition-all group">
|
||||
<span className="flex items-center gap-3"><Monitor size={14} className="text-slate-500 group-hover:text-cyan-400" /> Noticias + Clima</span>
|
||||
<div className="w-2 h-2 rounded-full bg-cyan-500 shadow-[0_0_8px_rgba(6,182,212,0.5)]"></div>
|
||||
</button>
|
||||
<button className="flex items-center justify-between w-full bg-slate-950/50 border border-white/5 hover:border-pink-500/50 p-3 rounded-xl text-xs font-bold transition-all group opacity-50">
|
||||
<span className="flex items-center gap-3"><Zap size={14} className="text-slate-500 group-hover:text-pink-400" /> Eventos Próximos</span>
|
||||
<div className="w-2 h-2 rounded-full bg-slate-700"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="w-full group relative bg-gradient-to-br from-pink-600 to-rose-700 hover:from-pink-500 hover:to-rose-600 text-white font-black uppercase text-[10px] tracking-[0.2em] py-5 rounded-2xl transition-all shadow-xl shadow-pink-600/20 active:scale-95 overflow-hidden">
|
||||
<span className="relative z-10 flex items-center justify-center gap-2">
|
||||
<Bell size={14} /> Disparar Alerta LIVE
|
||||
</span>
|
||||
<div className="absolute inset-0 bg-white/10 translate-y-full group-hover:translate-y-0 transition-transform duration-300"></div>
|
||||
</button>
|
||||
|
||||
<button className="w-full border border-white/5 bg-white/5 hover:bg-white/10 text-slate-300 font-bold uppercase text-[9px] tracking-widest py-3 rounded-xl transition-all flex items-center justify-center gap-2">
|
||||
<ExternalLink size={12} /> Ver Previsualización Stream
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-slate-900/40 border border-white/5 rounded-3xl p-6">
|
||||
<h3 className="text-xs font-black text-white uppercase tracking-widest mb-4">Ayuda Cabina</h3>
|
||||
<ul className="text-[10px] space-y-4 text-slate-400 font-medium leading-relaxed">
|
||||
<li className="flex gap-3">
|
||||
<span className="shrink-0 w-5 h-5 bg-cyan-500/10 text-cyan-400 rounded flex items-center justify-center font-black">1</span>
|
||||
<span>Al marcar como <strong className="text-cyan-400">"Al Aire"</strong>, el mensaje rotará cada 15 segundos en el News Ticker global.</span>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="shrink-0 w-5 h-5 bg-pink-500/10 text-pink-400 rounded flex items-center justify-center font-black">2</span>
|
||||
<span>Las notificaciones <strong className="text-pink-400">LIVE</strong> interrumpen cualquier overlay previo en la App móvil.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style jsx global>{`
|
||||
@keyframes incoming {
|
||||
0% { transform: translateX(20px); opacity: 0; background: rgba(6, 182, 212, 0.2); }
|
||||
100% { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(6, 182, 212, 0.3);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -4,6 +4,7 @@ import { Navbar } from "@/components/Navbar";
|
||||
import NewsTicker from "@/components/NewsTicker";
|
||||
import { GlobalTopBanner } from "@/components/AdWrappers";
|
||||
import { getEntity } from "@/app/actions/entity";
|
||||
import { GlobalPlayer } from "@/components/media/GlobalPlayer";
|
||||
import "./globals.css";
|
||||
|
||||
const outfit = Outfit({ subsets: ["latin"], variable: "--font-outfit" });
|
||||
@ -38,6 +39,7 @@ export default async function RootLayout({
|
||||
<GlobalTopBanner />
|
||||
{children}
|
||||
</div>
|
||||
<GlobalPlayer />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@ -2,12 +2,12 @@ import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite';
|
||||
import { createSessionClient } from '@/lib/appwrite.server';
|
||||
import { Query } from 'node-appwrite';
|
||||
import Link from 'next/link';
|
||||
import { MessageCircle } from 'lucide-react';
|
||||
|
||||
export default async function NewsTicker() {
|
||||
let noticias: any[] = [];
|
||||
let tickerItems: any[] = [];
|
||||
|
||||
try {
|
||||
// NewsTicker is public, we use admin client if session is not available
|
||||
let client: any = await createSessionClient();
|
||||
if (!client) {
|
||||
const { createAdminClient } = await import('@/lib/appwrite.server');
|
||||
@ -16,24 +16,18 @@ export default async function NewsTicker() {
|
||||
|
||||
if (!client) throw new Error("No Appwrite client available");
|
||||
|
||||
// We need an Anonymous or Public read client if this is shown in home.
|
||||
// Since 'createSessionClient' uses the session cookie, we might have issues on public incognito pages.
|
||||
// The user previously used Admin API key for everything or a local backdoor.
|
||||
|
||||
// Settings
|
||||
// 1. Fetch Settings
|
||||
const settingsRes = await client.databases.listDocuments(APPWRITE_ALMEIRA_CONFIG.databaseId, 'settings', []);
|
||||
const settings: any = settingsRes.documents.length > 0 ? settingsRes.documents[0] : {};
|
||||
const tickerCount = settings.ticker_count || 10;
|
||||
|
||||
// 2. Get NOTICIA type ID
|
||||
// 2. Fetch News (NOTICIA)
|
||||
const typeRes = await client.databases.listDocuments(APPWRITE_ALMEIRA_CONFIG.databaseId, 'publication_types', [
|
||||
Query.equal('codigo', 'NOTICIA')
|
||||
]);
|
||||
|
||||
let newsItems: any[] = [];
|
||||
if (typeRes.documents.length > 0) {
|
||||
const typeId = typeRes.documents[0].$id;
|
||||
|
||||
const postsRes = await client.databases.listDocuments(APPWRITE_ALMEIRA_CONFIG.databaseId, 'posts', [
|
||||
Query.equal('tipo_publicacion_id', typeId),
|
||||
Query.equal('estado', 'Publicado'),
|
||||
@ -51,55 +45,86 @@ export default async function NewsTicker() {
|
||||
mediaRes.documents.forEach((m: any) => mediaMap.set(m.$id, m));
|
||||
}
|
||||
|
||||
newsItems = postsRes.documents.map((post: any) => {
|
||||
postsRes.documents.forEach((post: any) => {
|
||||
const media = mediaMap.get(post.imagen_destacada_id);
|
||||
let imagenUrl = post.og_image_url || null;
|
||||
if (media) {
|
||||
imagenUrl = `${process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT || 'https://appwrite.almeira.com.co/v1'}/storage/buckets/${media.bucket_id}/files/${media.file_id}/preview?project=${process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID || '69b84df5003bd8957c5c'}`;
|
||||
imagenUrl = `${APPWRITE_ALMEIRA_CONFIG.endpoint}/storage/buckets/${media.bucket_id}/files/${media.file_id}/preview?project=${APPWRITE_ALMEIRA_CONFIG.projectId}`;
|
||||
}
|
||||
|
||||
return {
|
||||
tickerItems.push({
|
||||
type: 'news',
|
||||
id: post.$id,
|
||||
titulo: post.titulo,
|
||||
slug: post.slug || post.$id,
|
||||
imagenUrl
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
noticias = newsItems; // Assign resolved news items to 'noticias'
|
||||
|
||||
// 3. Fetch Approved WhatsApp Messages
|
||||
try {
|
||||
const waRes = await client.databases.listDocuments(APPWRITE_ALMEIRA_CONFIG.databaseId, 'whatsapp_messages', [
|
||||
Query.equal('status', 'approved'),
|
||||
Query.orderDesc('timestamp'),
|
||||
Query.limit(5)
|
||||
]);
|
||||
|
||||
waRes.documents.forEach((msg: any) => {
|
||||
tickerItems.push({
|
||||
type: 'whatsapp',
|
||||
id: msg.$id,
|
||||
titulo: `${msg.sender_name}: ${msg.content}`,
|
||||
slug: null,
|
||||
imagenUrl: msg.avatar || null
|
||||
});
|
||||
});
|
||||
} catch (waError) {
|
||||
console.warn("Skipping WhatsApp messages in ticker:", waError);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error("Ticker fetch error", e);
|
||||
}
|
||||
|
||||
if (noticias.length === 0) return null;
|
||||
|
||||
const ENDPOINT = process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT || 'https://appwrite.almeira.com.co/v1';
|
||||
const PROJECT_ID = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID || '69b84df5003bd8957c5c';
|
||||
if (tickerItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="w-full bg-cyan-950 border-y border-cyan-800 overflow-hidden relative flex items-center h-12">
|
||||
<div className="absolute left-0 z-20 bg-gradient-to-r from-cyan-950 to-transparent w-8 h-full"></div>
|
||||
<div className="absolute right-0 z-20 bg-gradient-to-l from-cyan-950 to-transparent w-8 h-full"></div>
|
||||
<div className="w-full bg-cyan-950/80 backdrop-blur-md border-y border-cyan-800/50 overflow-hidden relative flex items-center h-12 z-40">
|
||||
<div className="absolute left-0 z-20 bg-gradient-to-r from-cyan-950 to-transparent w-20 h-full"></div>
|
||||
<div className="absolute right-0 z-20 bg-gradient-to-l from-cyan-950 to-transparent w-20 h-full"></div>
|
||||
|
||||
<div className="flex animate-marquee whitespace-nowrap items-center hover:pause hover:[animation-play-state:paused]">
|
||||
{/* Doble render para bucle infinito visual de CSS Marquee */}
|
||||
{[...noticias, ...noticias].map((noticia, idx) => {
|
||||
return (
|
||||
<Link
|
||||
href={`/${noticia.slug}`}
|
||||
key={idx}
|
||||
className="flex items-center gap-4 mx-8 group cursor-pointer border-r border-cyan-800/50 pr-8"
|
||||
>
|
||||
{noticia.imagenUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={noticia.imagenUrl} alt={noticia.titulo} className="w-16 h-9 aspect-video object-cover rounded shadow-[0_0_10px_rgba(6,182,212,0.3)] group-hover:scale-105 group-hover:shadow-[0_0_15px_rgba(6,182,212,0.5)] transition-all duration-300 border border-white/10" />
|
||||
<div className="flex animate-marquee whitespace-nowrap items-center group-hover:[animation-play-state:paused]">
|
||||
{/* Doble render para bucle infinito visual */}
|
||||
{[...tickerItems, ...tickerItems].map((item, idx) => {
|
||||
const isWA = item.type === 'whatsapp';
|
||||
|
||||
const Content = (
|
||||
<div className="flex items-center gap-4 mx-8 group cursor-pointer border-r border-cyan-800/30 pr-8 py-1">
|
||||
{isWA ? (
|
||||
<div className="w-9 h-9 rounded-full bg-green-500/20 flex items-center justify-center border border-green-500/30">
|
||||
<MessageCircle size={14} className="text-green-400 fill-current" />
|
||||
</div>
|
||||
) : (
|
||||
item.imagenUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={item.imagenUrl} alt={item.titulo} className="w-12 h-7 aspect-video object-cover rounded shadow-[0_0_10px_rgba(6,182,212,0.3)] group-hover:scale-110 transition-all duration-500 border border-white/10" />
|
||||
)
|
||||
)}
|
||||
<span className="text-sm font-bold text-slate-300 group-hover:text-cyan-400 transition-colors uppercase tracking-widest flex items-center gap-2">
|
||||
{!noticia.imagenUrl && <span className="w-2 h-2 rounded-full bg-rose-500 animate-pulse"></span>}
|
||||
{noticia.titulo.length > 55 ? noticia.titulo.substring(0, 55) + '...' : noticia.titulo}
|
||||
|
||||
<span className={`text-[11px] font-black uppercase tracking-widest flex items-center gap-2 transition-colors duration-300 ${
|
||||
isWA ? 'text-green-400 group-hover:text-white' : 'text-slate-200 group-hover:text-cyan-400'
|
||||
}`}>
|
||||
{isWA && <span className="text-[9px] font-bold px-1.5 py-0.5 bg-green-500/20 rounded border border-green-500/30 text-green-400">COMUNIDAD</span>}
|
||||
{item.titulo.length > 80 ? item.titulo.substring(0, 80) + '...' : item.titulo}
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
</div>
|
||||
);
|
||||
|
||||
if (item.slug) {
|
||||
return <Link key={idx} href={`/${item.slug}`} className="flex items-center">{Content}</Link>;
|
||||
}
|
||||
return <div key={idx} className="flex items-center">{Content}</div>;
|
||||
})}
|
||||
</div>
|
||||
|
||||
@ -109,7 +134,10 @@ export default async function NewsTicker() {
|
||||
100% { transform: translateX(-50%); }
|
||||
}
|
||||
.animate-marquee {
|
||||
animation: marquee 30s linear infinite;
|
||||
animation: marquee 40s linear infinite;
|
||||
}
|
||||
.animate-marquee:hover {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
`}} />
|
||||
</div>
|
||||
|
||||
92
src/components/dashboard/ColumnFilter.tsx
Normal file
92
src/components/dashboard/ColumnFilter.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { Settings2, Check } from 'lucide-react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { FieldDefinition } from '@/lib/schema';
|
||||
|
||||
interface ColumnFilterProps {
|
||||
fields: FieldDefinition[];
|
||||
visibleColumns: string[];
|
||||
onChange: (columns: string[]) => void;
|
||||
}
|
||||
|
||||
export const ColumnFilter = ({ fields, visibleColumns, onChange }: ColumnFilterProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const toggleColumn = (name: string) => {
|
||||
if (visibleColumns.includes(name)) {
|
||||
if (visibleColumns.length > 1) { // Prevents hiding all columns
|
||||
onChange(visibleColumns.filter(c => c !== name));
|
||||
}
|
||||
} else {
|
||||
onChange([...visibleColumns, name]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white px-4 py-2 rounded-xl transition-all border border-slate-700 hover:border-cyan-500/50 shadow-lg text-sm font-bold tracking-widest uppercase"
|
||||
>
|
||||
<Settings2 size={16} />
|
||||
<span>Columnas</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-2 w-64 bg-slate-900 border border-slate-800 rounded-2xl shadow-2xl p-4 z-[100] animate-in fade-in zoom-in duration-200">
|
||||
<div className="text-xs font-black text-cyan-500 uppercase tracking-widest mb-4 border-b border-slate-800 pb-2 flex justify-between items-center">
|
||||
<span>Mostrar / Ocultar</span>
|
||||
<span className="text-slate-600 lowercase font-normal">{visibleColumns.length} visibles</span>
|
||||
</div>
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto custom-scrollbar pr-2">
|
||||
{fields.map((field) => (
|
||||
<label
|
||||
key={field.name}
|
||||
className="flex items-center justify-between px-3 py-2 rounded-lg hover:bg-slate-800/50 cursor-pointer transition-colors group"
|
||||
onClick={() => toggleColumn(field.name)}
|
||||
>
|
||||
<span className={`text-sm font-medium transition-colors ${visibleColumns.includes(field.name) ? 'text-white' : 'text-slate-500'}`}>
|
||||
{field.label}
|
||||
</span>
|
||||
<div className={`w-5 h-5 rounded flex items-center justify-center border transition-all ${
|
||||
visibleColumns.includes(field.name)
|
||||
? 'bg-cyan-500 border-cyan-500 text-white shadow-[0_0_10px_rgba(6,182,212,0.5)]'
|
||||
: 'bg-slate-950 border-slate-700 text-transparent group-hover:border-slate-500'
|
||||
}`}>
|
||||
<Check size={14} strokeWidth={4} />
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-slate-800 flex justify-between">
|
||||
<button
|
||||
onClick={() => onChange(fields.map(f => f.name))}
|
||||
className="text-[10px] font-black uppercase tracking-tighter text-slate-500 hover:text-white transition-colors"
|
||||
>
|
||||
Mostrar Todas
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-[10px] font-black uppercase tracking-tighter text-cyan-500 hover:text-cyan-400 transition-colors"
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
163
src/components/dashboard/DataTable.tsx
Normal file
163
src/components/dashboard/DataTable.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Pencil, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { DeleteButton } from '@/components/DeleteButton';
|
||||
import { ColumnFilter } from './ColumnFilter';
|
||||
import { FieldDefinition } from '@/lib/schema';
|
||||
import { updateUserPreferencesAction } from '@/app/actions/user';
|
||||
|
||||
interface DataTableProps {
|
||||
entity: string;
|
||||
collectionId: string;
|
||||
documents: any[];
|
||||
fieldsConfig: FieldDefinition[];
|
||||
initialHiddenColumns: string[];
|
||||
currentSortField: string;
|
||||
currentSortDir: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export const DataTable = ({
|
||||
entity,
|
||||
collectionId,
|
||||
documents,
|
||||
fieldsConfig,
|
||||
initialHiddenColumns,
|
||||
currentSortField,
|
||||
currentSortDir
|
||||
}: DataTableProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [visibleColumns, setVisibleColumns] = useState<string[]>(() => {
|
||||
const allFields = fieldsConfig.map(f => f.name);
|
||||
return allFields.filter(f => !initialHiddenColumns.includes(f));
|
||||
});
|
||||
|
||||
const handleColumnChange = async (newVisible: string[]) => {
|
||||
setVisibleColumns(newVisible);
|
||||
|
||||
// Calculate hidden columns to persist
|
||||
const allFields = fieldsConfig.map(f => f.name);
|
||||
const hidden = allFields.filter(f => !newVisible.includes(f));
|
||||
|
||||
// Persist to Appwrite Prefs
|
||||
await updateUserPreferencesAction({
|
||||
hiddenColumns: {
|
||||
[entity]: hidden
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSort = (field: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
let newDir: 'asc' | 'desc' = 'desc';
|
||||
|
||||
if (currentSortField === field) {
|
||||
newDir = currentSortDir === 'desc' ? 'asc' : 'desc';
|
||||
}
|
||||
|
||||
params.set('sort', field);
|
||||
params.set('dir', newDir);
|
||||
|
||||
startTransition(() => {
|
||||
router.push(`?${params.toString()}`);
|
||||
});
|
||||
};
|
||||
|
||||
const getHeaderLabel = (key: string) => {
|
||||
const field = fieldsConfig.find(f => f.name === key);
|
||||
return field ? field.label : key.replace(/_/g, ' ');
|
||||
};
|
||||
|
||||
const renderSortIcon = (field: string) => {
|
||||
if (currentSortField !== field) return <ArrowUpDown size={14} className="opacity-20 group-hover:opacity-100 transition-opacity" />;
|
||||
return currentSortDir === 'asc' ? <ArrowUp size={14} className="text-cyan-400" /> : <ArrowDown size={14} className="text-cyan-400" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end pr-4">
|
||||
<ColumnFilter
|
||||
fields={fieldsConfig}
|
||||
visibleColumns={visibleColumns}
|
||||
onChange={handleColumnChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900/50 backdrop-blur-xl border border-slate-800 rounded-2xl overflow-hidden shadow-2xl relative">
|
||||
{isPending && (
|
||||
<div className="absolute inset-0 bg-slate-950/20 backdrop-blur-[1px] z-50 flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-4 border-cyan-500/20 border-t-cyan-500 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto p-4 custom-scrollbar">
|
||||
<table className="w-full text-left text-sm whitespace-nowrap">
|
||||
<thead className="bg-slate-950 border-b border-slate-800 text-cyan-500 uppercase text-xs font-black tracking-widest">
|
||||
<tr>
|
||||
{fieldsConfig.map(field => {
|
||||
if (!visibleColumns.includes(field.name)) return null;
|
||||
return (
|
||||
<th
|
||||
key={field.name}
|
||||
className="px-6 py-5 cursor-pointer group select-none hover:bg-slate-900/50 transition-colors"
|
||||
onClick={() => toggleSort(field.name)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{field.label}
|
||||
{renderSortIcon(field.name)}
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
<th className="px-6 py-5 text-center">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60">
|
||||
{documents.map((doc) => (
|
||||
<tr key={doc.$id} className="hover:bg-slate-800/50 transition-colors group">
|
||||
{fieldsConfig.map(field => {
|
||||
if (!visibleColumns.includes(field.name)) return null;
|
||||
|
||||
let value = doc[field.name];
|
||||
|
||||
// Resolve Relations
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if (field.type === 'relation' && field.relationLabel) {
|
||||
value = value[field.relationLabel] || value.$id || '[Vinculado]';
|
||||
} else {
|
||||
value = Array.isArray(value) ? `[Lista (${value.length})]` : value.$id || '[Vinculado]';
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
value = value ? 'Sí' : 'No';
|
||||
}
|
||||
if (typeof value === 'string' && value.length > 60) {
|
||||
value = value.substring(0, 60) + '...';
|
||||
}
|
||||
|
||||
return (
|
||||
<td key={field.name} className="px-6 py-4 text-slate-300 font-medium group-hover:text-white transition-colors">
|
||||
{String(value || '')}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
<td className="px-6 py-4 text-center space-x-2 w-24">
|
||||
<Link href={`/dashboard/${entity}/${doc.$id}`} className="inline-flex p-2 text-slate-400 hover:text-cyan-400 hover:bg-cyan-500/10 rounded-lg transition-colors border border-transparent hover:border-cyan-500/20" title="Editar Registro">
|
||||
<Pencil size={16} />
|
||||
</Link>
|
||||
<DeleteButton collectionId={collectionId} documentId={doc.$id} entityAlias={entity} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
343
src/components/dashboard/EntityForm.tsx
Normal file
343
src/components/dashboard/EntityForm.tsx
Normal file
@ -0,0 +1,343 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useTransition } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft, Save, Globe, Calendar, Tag, Image as ImageIcon, Eye, Code } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { FieldDefinition } from '@/lib/schema';
|
||||
import { MediaPickerMini } from './MediaPickerMini';
|
||||
import { RichTextEditor } from './RichTextEditor';
|
||||
|
||||
// Minimalistic image preview for relations with 'media'
|
||||
const MediaPreview = ({ fileId, bucketId, endpoint, projectId }: any) => {
|
||||
if(!fileId) return null;
|
||||
const url = `${endpoint}/storage/buckets/${bucketId}/files/${fileId}/view?project=${projectId}`;
|
||||
return (
|
||||
<div className="mt-2 rounded-xl overflow-hidden border border-slate-800 bg-slate-950 aspect-video relative group">
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={url} alt="Preview" className="w-full h-full object-cover" />
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<span className="text-[10px] font-bold text-white uppercase tracking-widest">Imagen Seleccionada</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EntityFormProps {
|
||||
entity: string;
|
||||
entityName: string;
|
||||
initialData?: any;
|
||||
fields: FieldDefinition[];
|
||||
relationData: Record<string, any[]>;
|
||||
submitAction: (formData: FormData) => Promise<any>;
|
||||
appwriteConfig: {
|
||||
endpoint: string;
|
||||
projectId: string;
|
||||
}
|
||||
}
|
||||
|
||||
export const EntityForm = ({
|
||||
entity,
|
||||
entityName,
|
||||
initialData,
|
||||
fields,
|
||||
relationData,
|
||||
submitAction,
|
||||
appwriteConfig
|
||||
}: EntityFormProps) => {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [title, setTitle] = useState(initialData?.titulo || initialData?.nombre || '');
|
||||
const [slug, setSlug] = useState(initialData?.slug || '');
|
||||
const [isAutoSlug, setIsAutoSlug] = useState(!initialData?.slug);
|
||||
const [featuredImageId, setFeaturedImageId] = useState(initialData?.imagen_destacada_id || '');
|
||||
const [editorValues, setEditorValues] = useState<Record<string, string>>({});
|
||||
|
||||
// Initialize editor values
|
||||
useEffect(() => {
|
||||
const initialValues: Record<string, string> = {};
|
||||
fields.forEach(f => {
|
||||
if (f.type === 'textarea') {
|
||||
initialValues[f.name] = initialData?.[f.name] || '';
|
||||
}
|
||||
});
|
||||
setEditorValues(initialValues);
|
||||
}, [fields, initialData]);
|
||||
|
||||
// Update slug automatically if auto-slug is enabled
|
||||
useEffect(() => {
|
||||
if (isAutoSlug) {
|
||||
const generated = title
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
setSlug(generated);
|
||||
}
|
||||
}, [title, isAutoSlug]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
|
||||
// Add editor values to formData
|
||||
for (const [key, value] of Object.entries(editorValues)) {
|
||||
formData.set(key, value);
|
||||
}
|
||||
|
||||
// Ensure featured image ID is set if present
|
||||
if (featuredImageId) {
|
||||
formData.set('imagen_destacada_id', featuredImageId);
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
await submitAction(formData);
|
||||
});
|
||||
};
|
||||
|
||||
// Organize fields into Main Content and Sidebar Metadata
|
||||
const sidebarFieldNames = ['estado', 'fecha_publicacion', 'fecha_finalizacion', 'slug', 'imagen_destacada_id', 'categoria_id', 'tipo_publicacion_id'];
|
||||
const mainFields = fields.filter(f => !sidebarFieldNames.includes(f.name));
|
||||
const sidebarFields = fields.filter(f => sidebarFieldNames.includes(f.name));
|
||||
|
||||
const getDefValue = (field: FieldDefinition) => {
|
||||
const val = initialData ? initialData[field.name] : null;
|
||||
|
||||
// Set smart defaults for new items
|
||||
if (!initialData) {
|
||||
if (field.name === 'estado') return 'borrador';
|
||||
if (field.name === 'fecha_publicacion' || field.type === 'date') {
|
||||
// Get local ISO string for datetime-local input
|
||||
const now = new Date();
|
||||
return new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
||||
}
|
||||
if (field.type === 'boolean') return 'false';
|
||||
}
|
||||
|
||||
if (val === undefined || val === null) return '';
|
||||
if (field.type === 'boolean') return val ? 'true' : 'false';
|
||||
if (field.type === 'date') return new Date(val).toISOString().slice(0, 16);
|
||||
if (field.type === 'relation') return typeof val === 'object' ? val.$id : val;
|
||||
return val;
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="max-w-7xl mx-auto space-y-8 animate-fade-in pb-20">
|
||||
{/* Header Sticky */}
|
||||
<div className="sticky top-4 z-40 flex items-center justify-between bg-slate-900/80 backdrop-blur-xl border border-slate-800 p-4 rounded-2xl shadow-2xl shadow-cyan-500/10">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={`/dashboard/${entity}`} className="p-2 text-slate-400 hover:text-white bg-slate-800 rounded-xl transition-all">
|
||||
<ArrowLeft size={20} />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-black uppercase font-outfit tracking-tighter text-white">
|
||||
{initialData ? 'Editando' : 'Nuevo'}: <span className="text-cyan-400">{entityName}</span>
|
||||
</h1>
|
||||
{initialData && <p className="text-[10px] font-mono text-slate-500 uppercase">{initialData.$id}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="hidden md:block px-6 py-2.5 rounded-xl text-sm font-bold text-slate-400 hover:text-white transition-colors"
|
||||
disabled={isPending}
|
||||
>
|
||||
Descartar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="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-black uppercase tracking-widest px-8 py-2.5 rounded-xl shadow-[0_0_20px_rgba(6,182,212,0.3)] transition-all hover:scale-105 active:scale-95 disabled:opacity-50"
|
||||
>
|
||||
{isPending ? <div className="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin"></div> : <Save size={18} />}
|
||||
<span>{initialData ? 'Actualizar' : 'Publicar'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Main Column */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-6 md:p-8 shadow-xl space-y-8 relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-blue-600 to-cyan-500 opacity-50"></div>
|
||||
|
||||
{mainFields.map(field => {
|
||||
const defaultValue = getDefValue(field);
|
||||
|
||||
if (field.name === 'titulo' || field.name === 'nombre_entidad' || field.name === 'numero_contrato') {
|
||||
return (
|
||||
<div key={field.name} className="space-y-2">
|
||||
<label className="text-xs font-black uppercase tracking-[0.2em] text-cyan-500/80 mb-2 block">{field.label}</label>
|
||||
<input
|
||||
type="text"
|
||||
name={field.name}
|
||||
required={field.required}
|
||||
defaultValue={defaultValue}
|
||||
onChange={(e) => (field.name === 'titulo' || field.name === 'nombre_entidad') && setTitle(e.target.value)}
|
||||
placeholder={`Ingresa el ${field.label.toLowerCase()}...`}
|
||||
className="w-full bg-transparent border-b-2 border-slate-800 py-4 text-2xl md:text-3xl font-bold text-white focus:outline-none focus:border-cyan-500 transition-all placeholder:text-slate-700"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'textarea') {
|
||||
return (
|
||||
<RichTextEditor
|
||||
key={field.name}
|
||||
label={field.label}
|
||||
value={editorValues[field.name] || ''}
|
||||
onChange={(val) => setEditorValues(prev => ({ ...prev, [field.name]: val }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={field.name} className="flex flex-col gap-2">
|
||||
<label className="text-sm font-bold tracking-widest text-slate-400 uppercase">
|
||||
{field.label} {field.required && <span className="text-pink-500">*</span>}
|
||||
</label>
|
||||
|
||||
{field.type === 'text' && (
|
||||
<input type="text" name={field.name} defaultValue={defaultValue} required={field.required} className="bg-slate-950 border border-slate-800 rounded-xl px-5 py-3.5 text-white focus:outline-none focus:border-cyan-500 transition-all" />
|
||||
)}
|
||||
|
||||
{field.type === 'number' && (
|
||||
<input type="number" step="any" name={field.name} defaultValue={defaultValue} required={field.required} className="bg-slate-950 border border-slate-800 rounded-xl px-5 py-3.5 text-white focus:outline-none focus:border-cyan-500 transition-all" />
|
||||
)}
|
||||
|
||||
{field.type === 'url' && (
|
||||
<input type="url" name={field.name} defaultValue={defaultValue} required={field.required} className="bg-slate-950 border border-slate-800 rounded-xl px-5 py-3.5 text-white focus:outline-none focus:border-cyan-500 transition-all placeholder:text-slate-700" placeholder="https://" />
|
||||
)}
|
||||
|
||||
{field.type === 'boolean' && (
|
||||
<select name={field.name} defaultValue={defaultValue} required={field.required} className="bg-slate-950 border border-slate-800 rounded-xl px-5 py-3.5 text-white focus:outline-none focus:border-cyan-500 transition-all appearance-none pr-10">
|
||||
<option value="true">Sí (Activo)</option>
|
||||
<option value="false">No (Inactivo)</option>
|
||||
</select>
|
||||
)}
|
||||
|
||||
{field.type === 'select' && field.options && (
|
||||
<select name={field.name} defaultValue={defaultValue} required={field.required} className="bg-slate-950 border border-slate-800 rounded-xl px-5 py-3.5 text-white focus:outline-none focus:border-cyan-500 transition-all appearance-none pr-10 capitalize">
|
||||
<option value="">Seleccione {field.label}...</option>
|
||||
{field.options.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{field.type === 'relation' && (
|
||||
<select name={field.name} defaultValue={defaultValue} required={field.required} className="bg-slate-950 border border-slate-800 rounded-xl px-5 py-3.5 text-white focus:outline-none focus:border-cyan-500 transition-all appearance-none pr-10">
|
||||
<option value="">Buscar vinculación...</option>
|
||||
{relationData[field.name]?.map(relDoc => (
|
||||
<option key={relDoc.$id} value={relDoc.$id}>
|
||||
{relDoc[field.relationLabel || 'codigo'] || relDoc.$id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Column */}
|
||||
<div className="space-y-6">
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-6 shadow-xl space-y-6">
|
||||
<h2 className="text-xs font-black uppercase tracking-[0.2em] text-slate-500 pb-4 border-b border-slate-800 flex items-center gap-2">
|
||||
<Globe size={14} className="text-cyan-500" /> Configuración de Publicación
|
||||
</h2>
|
||||
|
||||
{sidebarFields.map(field => {
|
||||
const defaultValue = getDefValue(field);
|
||||
|
||||
return (
|
||||
<div key={field.name} className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-slate-400 flex items-center gap-1">
|
||||
{field.name === 'fecha_publicacion' && <Calendar size={12} className="text-pink-500" />}
|
||||
{field.name === 'estado' && <Tag size={12} className="text-blue-500" />}
|
||||
{field.name === 'imagen_destacada_id' && <ImageIcon size={12} className="text-purple-500" />}
|
||||
{field.label}
|
||||
</label>
|
||||
{field.name === 'slug' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsAutoSlug(!isAutoSlug)}
|
||||
className={`text-[9px] font-bold px-1.5 py-0.5 rounded transition-colors ${isAutoSlug ? 'bg-cyan-500/10 text-cyan-500' : 'bg-slate-800 text-slate-500'}`}
|
||||
>
|
||||
{isAutoSlug ? 'AUTO' : 'MANUAL'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{field.name === 'slug' ? (
|
||||
<input
|
||||
type="text"
|
||||
name={field.name}
|
||||
value={slug}
|
||||
onChange={(e) => { setIsAutoSlug(false); setSlug(e.target.value); }}
|
||||
required={field.required}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-3 text-sm text-cyan-100 font-mono focus:outline-none focus:border-cyan-500 transition-all font-bold"
|
||||
/>
|
||||
) : field.name === 'imagen_destacada_id' ? (
|
||||
<MediaPickerMini
|
||||
label={field.label}
|
||||
value={featuredImageId}
|
||||
onChange={setFeaturedImageId}
|
||||
/>
|
||||
) : field.type === 'date' ? (
|
||||
<input
|
||||
type="datetime-local"
|
||||
name={field.name}
|
||||
defaultValue={defaultValue}
|
||||
required={field.required}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-3 text-sm text-white focus:outline-none focus:border-cyan-500 transition-all [color-scheme:dark]"
|
||||
/>
|
||||
) : field.type === 'select' ? (
|
||||
<select name={field.name} defaultValue={defaultValue} required={field.required} className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-3 text-sm text-white focus:outline-none focus:border-cyan-500 transition-all capitalize">
|
||||
{field.options?.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
) : field.type === 'relation' ? (
|
||||
<>
|
||||
<select name={field.name} defaultValue={defaultValue} required={field.required} className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-3 text-sm text-white focus:outline-none focus:border-cyan-500 transition-all">
|
||||
<option value="">Seleccionar...</option>
|
||||
{relationData[field.name]?.map(relDoc => (
|
||||
<option key={relDoc.$id} value={relDoc.$id}>
|
||||
{relDoc[field.relationLabel || 'titulo'] || relDoc.$id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{field.relationCollection === 'media' && (
|
||||
<MediaPreview
|
||||
fileId={defaultValue}
|
||||
bucketId="media"
|
||||
endpoint={appwriteConfig.endpoint}
|
||||
projectId={appwriteConfig.projectId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<input type="text" name={field.name} defaultValue={defaultValue} className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-3 text-sm text-white focus:outline-none focus:border-cyan-500 transition-all" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-indigo-900/20 to-blue-900/10 border border-slate-800 rounded-3xl p-6 shadow-xl">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">Estado del Sistema</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 leading-relaxed italic">
|
||||
“Recuerda verificar el slug antes de publicar. Los cambios en el estado afectarán inmediatamente la visibilidad en el portal de noticias.”
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
205
src/components/dashboard/MediaPickerMini.tsx
Normal file
205
src/components/dashboard/MediaPickerMini.tsx
Normal file
@ -0,0 +1,205 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ImageIcon, X, Search, CheckCircle2 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { databases, APPWRITE_ALMEIRA_CONFIG, endpoint, projectId } from '@/lib/appwrite';
|
||||
import { Query } from 'appwrite';
|
||||
|
||||
interface MediaPickerMiniProps {
|
||||
value: string;
|
||||
onChange: (fileId: string) => void;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const MediaPickerMini = ({ value, onChange, label }: MediaPickerMiniProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [mediaItems, setMediaItems] = useState<any[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchMedia();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const fetchMedia = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// We use the client-side databases instance for browsing media
|
||||
const res = await databases.listDocuments(
|
||||
APPWRITE_ALMEIRA_CONFIG.databaseId,
|
||||
'media',
|
||||
[
|
||||
Query.limit(100),
|
||||
Query.orderDesc('$createdAt')
|
||||
]
|
||||
);
|
||||
setMediaItems(res.documents);
|
||||
} catch (error) {
|
||||
console.error('Error fetching media:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getImageUrl = (fileId: string) => {
|
||||
return `${endpoint}/storage/buckets/media/files/${fileId}/view?project=${projectId}&width=200&height=200&gravity=center`;
|
||||
};
|
||||
|
||||
const filteredItems = mediaItems.filter(item =>
|
||||
item.titulo.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-slate-400 flex items-center gap-1">
|
||||
<ImageIcon size={12} className="text-purple-500" /> {label}
|
||||
</label>
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('')}
|
||||
className="text-[9px] font-bold text-pink-500 hover:text-pink-400 transition-colors uppercase"
|
||||
>
|
||||
Quitar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={`relative aspect-video rounded-2xl border-2 border-dashed transition-all cursor-pointer flex flex-col items-center justify-center gap-2 overflow-hidden bg-slate-950 group ${
|
||||
value ? 'border-cyan-500/50 hover:border-cyan-400' : 'border-slate-800 hover:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
{value ? (
|
||||
<>
|
||||
<img
|
||||
src={`${endpoint}/storage/buckets/media/files/${value}/view?project=${projectId}`}
|
||||
alt="Selected"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-slate-900/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<span className="bg-white/10 backdrop-blur-md px-4 py-2 rounded-full text-[10px] font-black uppercase tracking-[0.2em] text-white border border-white/20 shadow-xl">
|
||||
Cambiar Imagen
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="p-3 bg-slate-900 rounded-2xl text-slate-600 group-hover:text-cyan-500 transition-colors border border-slate-800 group-hover:border-cyan-500/30">
|
||||
<ImageIcon size={24} />
|
||||
</div>
|
||||
<span className="text-[10px] font-black text-slate-500 uppercase tracking-widest">Seleccionar Medio</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hidden input to carry the value in the form */}
|
||||
<input type="hidden" name="imagen_destacada_id" value={value || ''} />
|
||||
|
||||
{/* Modal */}
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 md:p-8 animate-in fade-in duration-200">
|
||||
<div className="absolute inset-0 bg-slate-950/80 backdrop-blur-md" onClick={() => setIsOpen(false)}></div>
|
||||
|
||||
<div className="relative w-full max-w-4xl max-h-[80vh] bg-slate-900 border border-slate-800 rounded-3xl shadow-2xl flex flex-col overflow-hidden animate-in zoom-in-95 duration-200">
|
||||
{/* Modal Header */}
|
||||
<div className="p-6 border-b border-slate-800 flex items-center justify-between bg-slate-900/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-500/10 rounded-xl text-purple-400 border border-purple-500/20">
|
||||
<ImageIcon size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-black uppercase tracking-tighter text-white">Biblioteca de Medios</h3>
|
||||
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Selecciona una imagen para esta publicación</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-2 hover:bg-slate-800 rounded-xl text-slate-500 hover:text-white transition-all"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Search */}
|
||||
<div className="p-6 pb-2">
|
||||
<div className="relative group">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 group-focus-within:text-cyan-500 transition-colors" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por título..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-2xl pl-12 pr-6 py-4 text-white focus:outline-none focus:border-cyan-500 transition-all font-medium"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 pt-2 custom-scrollbar">
|
||||
{isLoading ? (
|
||||
<div className="h-64 flex flex-col items-center justify-center gap-4">
|
||||
<div className="w-12 h-12 border-4 border-cyan-500/20 border-t-cyan-500 rounded-full animate-spin"></div>
|
||||
<span className="text-xs font-black uppercase tracking-widest text-slate-500">Cargando Galería...</span>
|
||||
</div>
|
||||
) : filteredItems.length > 0 ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{filteredItems.map((item) => (
|
||||
<div
|
||||
key={item.$id}
|
||||
onClick={() => {
|
||||
onChange(item.file_id);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={`group relative aspect-square rounded-2xl border-2 transition-all cursor-pointer overflow-hidden bg-slate-950 ${
|
||||
value === item.file_id ? 'border-cyan-500 shadow-xl shadow-cyan-500/20' : 'border-slate-800 hover:border-slate-600 shadow-lg'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={getImageUrl(item.file_id)}
|
||||
alt={item.titulo}
|
||||
className="w-full h-full object-cover transition-transform group-hover:scale-110"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-950/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity p-3 flex flex-col justify-end">
|
||||
<span className="text-[10px] font-black text-white uppercase truncate drop-shadow-lg">{item.titulo}</span>
|
||||
</div>
|
||||
{value === item.file_id && (
|
||||
<div className="absolute top-2 right-2 bg-cyan-500 text-white rounded-full p-1 shadow-lg">
|
||||
<CheckCircle2 size={16} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-64 flex flex-col items-center justify-center text-slate-500 border-2 border-dashed border-slate-800 rounded-3xl gap-2">
|
||||
<p className="font-bold uppercase tracking-widest text-sm text-slate-400">Sin Resultados</p>
|
||||
<p className="text-[10px]">No encontramos imágenes con ese título.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="p-6 bg-slate-950/50 border-t border-slate-800 flex justify-between items-center text-slate-500">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest">
|
||||
{filteredItems.length} Elementos Disponibles
|
||||
</span>
|
||||
<Link
|
||||
href="/dashboard/media/new"
|
||||
className="text-[10px] font-black uppercase tracking-widest bg-slate-800 hover:bg-slate-700 text-white px-4 py-2 rounded-lg transition-all border border-slate-700"
|
||||
>
|
||||
Subir Nuevo Archivo
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
187
src/components/dashboard/RichTextEditor.tsx
Normal file
187
src/components/dashboard/RichTextEditor.tsx
Normal file
@ -0,0 +1,187 @@
|
||||
'use client';
|
||||
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import { TextStyle } from '@tiptap/extension-text-style';
|
||||
import { Color } from '@tiptap/extension-color';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import Highlight from '@tiptap/extension-highlight';
|
||||
import {
|
||||
Bold, Italic, Underline as UnderlineIcon,
|
||||
Link as LinkIcon, AlignLeft, AlignCenter,
|
||||
AlignRight, Highlighter, Type,
|
||||
List, ListOrdered, Quote, Undo, Redo
|
||||
} from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface RichTextEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const MenuBar = ({ editor }: { editor: any }) => {
|
||||
if (!editor) return null;
|
||||
|
||||
const setLink = () => {
|
||||
const url = window.prompt('URL del enlace:');
|
||||
if (url) {
|
||||
editor.chain().focus().setLink({ href: url }).run();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1 p-2 bg-slate-900 border-b border-slate-800 sticky top-0 z-10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive('bold') ? 'text-cyan-400 bg-slate-800' : 'text-slate-400'}`}
|
||||
title="Negrita"
|
||||
>
|
||||
<Bold size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive('italic') ? 'text-cyan-400 bg-slate-800' : 'text-slate-400'}`}
|
||||
title="Cursiva"
|
||||
>
|
||||
<Italic size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive('underline') ? 'text-cyan-400 bg-slate-800' : 'text-slate-400'}`}
|
||||
title="Subrayado"
|
||||
>
|
||||
<UnderlineIcon size={18} />
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-slate-800 mx-1 self-center" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().setTextAlign('left').run()}
|
||||
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive({ textAlign: 'left' }) ? 'text-cyan-400 bg-slate-800' : 'text-slate-400'}`}
|
||||
>
|
||||
<AlignLeft size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().setTextAlign('center').run()}
|
||||
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive({ textAlign: 'center' }) ? 'text-cyan-400 bg-slate-800' : 'text-slate-400'}`}
|
||||
>
|
||||
<AlignCenter size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().setTextAlign('right').run()}
|
||||
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive({ textAlign: 'right' }) ? 'text-cyan-400 bg-slate-800' : 'text-slate-400'}`}
|
||||
>
|
||||
<AlignRight size={18} />
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-slate-800 mx-1 self-center" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive('bulletList') ? 'text-cyan-400 bg-slate-800' : 'text-slate-400'}`}
|
||||
>
|
||||
<List size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive('orderedList') ? 'text-cyan-400 bg-slate-800' : 'text-slate-400'}`}
|
||||
>
|
||||
<ListOrdered size={18} />
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-slate-800 mx-1 self-center" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={setLink}
|
||||
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive('link') ? 'text-cyan-400 bg-slate-800' : 'text-slate-400'}`}
|
||||
>
|
||||
<LinkIcon size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().toggleHighlight().run()}
|
||||
className={`p-2 rounded hover:bg-slate-800 transition-colors ${editor.isActive('highlight') ? 'text-yellow-400 bg-slate-800' : 'text-slate-400'}`}
|
||||
>
|
||||
<Highlighter size={18} />
|
||||
</button>
|
||||
|
||||
<div className="flex-grow" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
className="p-2 text-slate-500 hover:text-white transition-colors"
|
||||
>
|
||||
<Undo size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
className="p-2 text-slate-500 hover:text-white transition-colors"
|
||||
>
|
||||
<Redo size={18} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const RichTextEditor = ({ value, onChange, label }: RichTextEditorProps) => {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Underline,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
class: 'text-cyan-400 underline decoration-cyan-400/50 hover:text-cyan-300 transition-colors',
|
||||
},
|
||||
}),
|
||||
TextStyle,
|
||||
Color,
|
||||
TextAlign.configure({
|
||||
types: ['heading', 'paragraph'],
|
||||
}),
|
||||
Highlight.configure({ multicolor: true }),
|
||||
],
|
||||
content: value,
|
||||
onUpdate: ({ editor }) => {
|
||||
onChange(editor.getHTML());
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'prose prose-invert max-w-none focus:outline-none min-h-[300px] p-6 text-slate-200 leading-relaxed',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Keep content synced if value changes externally
|
||||
useEffect(() => {
|
||||
if (editor && value !== editor.getHTML()) {
|
||||
editor.commands.setContent(value);
|
||||
}
|
||||
}, [value, editor]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-bold tracking-widest text-slate-300 uppercase">{label}</label>
|
||||
<div className="bg-slate-950 border border-slate-800 rounded-2xl overflow-hidden focus-within:border-cyan-500/50 transition-all shadow-xl">
|
||||
<MenuBar editor={editor} />
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
{/* Hidden input for form submission if needed, though we use controlled value */}
|
||||
<input type="hidden" name={label.toLowerCase().replace(/ /g, '_')} value={value} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
191
src/components/media/GlobalPlayer.tsx
Normal file
191
src/components/media/GlobalPlayer.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { usePlayerStore } from '@/store/usePlayerStore';
|
||||
import { Play, Pause, Volume2, VolumeX, Radio, Music, Maximize2, Minimize2, Video, X } from 'lucide-react';
|
||||
import { StreamOverlays } from './StreamOverlays';
|
||||
import videojs from 'video.js';
|
||||
import 'video.js/dist/video-js.css';
|
||||
|
||||
export const GlobalPlayer = () => {
|
||||
const {
|
||||
audioUrl, isAudioPlaying, audioVolume, toggleAudio, setAudioVolume,
|
||||
isVideoActive, videoUrl, setVideoActive
|
||||
} = usePlayerStore();
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const videoRef = useRef<HTMLDivElement>(null);
|
||||
const playerRef = useRef<any>(null);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [lastVolume, setLastVolume] = useState(audioVolume);
|
||||
|
||||
// Sync Audio State
|
||||
useEffect(() => {
|
||||
if (!audioRef.current) return;
|
||||
|
||||
if (isAudioPlaying) {
|
||||
audioRef.current.play().catch(e => {
|
||||
console.error("Audio playback error:", e);
|
||||
toggleAudio(false);
|
||||
});
|
||||
} else {
|
||||
audioRef.current.pause();
|
||||
}
|
||||
}, [isAudioPlaying, audioUrl, toggleAudio]);
|
||||
|
||||
// Handle Video Player Initialization
|
||||
useEffect(() => {
|
||||
if (isVideoActive && videoRef.current && !playerRef.current) {
|
||||
const videoElement = document.createElement('video-js');
|
||||
videoElement.classList.add('vjs-big-play-centered', 'vjs-almeira');
|
||||
videoRef.current.appendChild(videoElement);
|
||||
|
||||
const player = playerRef.current = videojs(videoElement, {
|
||||
autoplay: true,
|
||||
controls: true,
|
||||
responsive: true,
|
||||
fluid: true,
|
||||
sources: [{
|
||||
src: videoUrl || 'https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8', // Fallback para test
|
||||
type: 'application/x-mpegURL'
|
||||
}]
|
||||
});
|
||||
|
||||
// Cuando el video está activo, bajamos el volumen de la radio
|
||||
if (isAudioPlaying) setAudioVolume(0.1);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (playerRef.current) {
|
||||
playerRef.current.dispose();
|
||||
playerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isVideoActive, videoUrl]);
|
||||
|
||||
// Sync Volume
|
||||
useEffect(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.volume = isMuted ? 0 : audioVolume;
|
||||
}
|
||||
}, [audioVolume, isMuted]);
|
||||
|
||||
const handleToggleMute = () => {
|
||||
if (isMuted) {
|
||||
setIsMuted(false);
|
||||
setAudioVolume(lastVolume);
|
||||
} else {
|
||||
setLastVolume(audioVolume);
|
||||
setIsMuted(true);
|
||||
setAudioVolume(0);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hidden Audio Element */}
|
||||
<audio ref={audioRef} src={audioUrl} preload="auto" />
|
||||
|
||||
{/* VOLUMETRIC VIDEO OVERLAY (When Active) */}
|
||||
{isVideoActive && (
|
||||
<div className="fixed inset-0 z-[100] bg-black flex items-center justify-center animate-in fade-in zoom-in duration-300">
|
||||
<div className="relative w-full h-full max-w-6xl aspect-video bg-slate-900 shadow-2xl overflow-hidden md:rounded-3xl border border-white/10">
|
||||
<div ref={videoRef} className="w-full h-full" />
|
||||
|
||||
{/* Interactive Data Layer */}
|
||||
<StreamOverlays />
|
||||
|
||||
{/* Top Controls */}
|
||||
<div className="absolute top-6 right-6 z-20">
|
||||
<button
|
||||
onClick={() => setVideoActive(false)}
|
||||
className="p-3 bg-white/10 backdrop-blur-md hover:bg-white/20 text-white rounded-full transition-all border border-white/20"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating Global Controller */}
|
||||
<div className={`fixed bottom-6 left-6 z-[60] flex items-center gap-3 transition-opacity duration-500 ${isVideoActive ? 'opacity-0 pointer-events-none' : 'opacity-100'}`}>
|
||||
<div className="bg-slate-900/40 backdrop-blur-2xl border border-white/10 p-2 rounded-2xl shadow-2xl flex items-center gap-4 pr-6 group hover:bg-slate-900/60 transition-all">
|
||||
{/* Play/Pause Main Button */}
|
||||
<button
|
||||
onClick={() => toggleAudio()}
|
||||
className={`w-12 h-12 rounded-xl flex items-center justify-center transition-all shadow-lg overflow-hidden relative ${
|
||||
isAudioPlaying
|
||||
? 'bg-cyan-500 text-white shadow-cyan-500/20'
|
||||
: 'bg-white/5 text-slate-400 hover:text-white hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{isAudioPlaying ? (
|
||||
<div className="flex items-end gap-[2px] h-4">
|
||||
<div className="w-1 bg-white animate-[music-bar_0.6s_ease-in-out_infinite]"></div>
|
||||
<div className="w-1 bg-white animate-[music-bar_0.8s_ease-in-out_infinite]"></div>
|
||||
<div className="w-1 bg-white animate-[music-bar_0.5s_ease-in-out_infinite]"></div>
|
||||
<div className="w-1 bg-white animate-[music-bar_0.9s_ease-in-out_infinite]"></div>
|
||||
</div>
|
||||
) : (
|
||||
<Play size={20} fill="currentColor" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[10px] font-black uppercase tracking-[0.2em] transition-colors ${isAudioPlaying ? 'text-cyan-400' : 'text-slate-500'}`}>
|
||||
{isAudioPlaying ? 'En Vivo' : 'Radio Offline'}
|
||||
</span>
|
||||
{isAudioPlaying && <div className="w-1.5 h-1.5 bg-red-500 rounded-full animate-pulse shadow-[0_0_8px_rgba(239,68,68,0.5)]"></div>}
|
||||
</div>
|
||||
<h4 className="text-xs font-bold text-white tracking-wider font-outfit truncate max-w-[120px]">ALMEIRA RADIO</h4>
|
||||
</div>
|
||||
|
||||
{/* Volume Control (Desktop Only) */}
|
||||
<div className="hidden md:flex items-center gap-3 border-l border-white/5 pl-4 ml-2">
|
||||
<button onClick={handleToggleMute} className="text-slate-500 hover:text-white transition-colors">
|
||||
{isMuted || audioVolume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={audioVolume}
|
||||
onChange={(e) => {
|
||||
const val = parseFloat(e.target.value);
|
||||
setAudioVolume(val);
|
||||
if(val > 0) setIsMuted(false);
|
||||
}}
|
||||
className="w-20 h-1 bg-slate-800 rounded-full appearance-none cursor-pointer accent-cyan-500 hover:accent-cyan-400 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Switcher Mini (If Stream is Active) */}
|
||||
<button
|
||||
onClick={() => setVideoActive(!isVideoActive)}
|
||||
className={`w-12 h-12 rounded-2xl flex items-center justify-center transition-all border shadow-2xl relative overflow-hidden ${
|
||||
isVideoActive
|
||||
? 'bg-pink-600 border-pink-500 text-white shadow-pink-500/20'
|
||||
: 'bg-slate-900/40 backdrop-blur-2xl border-white/10 text-slate-500 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Video size={18} />
|
||||
{isVideoActive && (
|
||||
<div className="absolute inset-x-0 bottom-0 h-1 bg-white/30 animate-pulse"></div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Add Music Bar Animation to globals.css */}
|
||||
<style jsx global>{`
|
||||
@keyframes music-bar {
|
||||
0%, 100% { height: 4px; }
|
||||
50% { height: 16px; }
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
};
|
||||
158
src/components/media/StreamOverlays.tsx
Normal file
158
src/components/media/StreamOverlays.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import client, { databases, APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite';
|
||||
import { Query } from 'appwrite';
|
||||
import { MessageSquare, Calendar, Tag, Radio } from 'lucide-react';
|
||||
|
||||
export const StreamOverlays = () => {
|
||||
const [approvedMessages, setApprovedMessages] = useState<any[]>([]);
|
||||
const [rotatingContent, setRotatingContent] = useState<any[]>([]);
|
||||
const [activeContentIndex, setActiveContentIndex] = useState(0);
|
||||
|
||||
// 1. Fetch Approved Messages for Ticker
|
||||
const fetchApprovedMessages = async () => {
|
||||
try {
|
||||
const res = await databases.listDocuments(
|
||||
APPWRITE_ALMEIRA_CONFIG.databaseId,
|
||||
'whatsapp_messages',
|
||||
[Query.equal('status', 'approved'), Query.limit(10), Query.orderDesc('timestamp')]
|
||||
);
|
||||
setApprovedMessages(res.documents);
|
||||
} catch (error) {
|
||||
console.error('Error fetching approved messages:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Fetch Content for Side Overlay (News/Events/Classifieds)
|
||||
const fetchRotatingContent = async () => {
|
||||
try {
|
||||
// Fetch latest posts from any type to rotate
|
||||
const res = await databases.listDocuments(
|
||||
APPWRITE_ALMEIRA_CONFIG.databaseId,
|
||||
'posts',
|
||||
[Query.limit(10), Query.orderDesc('$createdAt'), Query.equal('estado', 'Publicado')]
|
||||
);
|
||||
|
||||
setRotatingContent(res.documents);
|
||||
} catch (error) {
|
||||
console.error('Error fetching content for overlays:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchApprovedMessages();
|
||||
fetchRotatingContent();
|
||||
|
||||
// Realtime Subscription for approved messages
|
||||
const unsubscribe = client.subscribe(
|
||||
`databases.${APPWRITE_ALMEIRA_CONFIG.databaseId}.collections.whatsapp_messages.documents`,
|
||||
(response) => {
|
||||
if (response.events.includes('databases.*.collections.*.documents.*.update') ||
|
||||
response.events.includes('databases.*.collections.*.documents.*.create')) {
|
||||
const payload = response.payload as any;
|
||||
if (payload.status === 'approved') {
|
||||
setApprovedMessages(prev => {
|
||||
const filtered = prev.filter(m => m.$id !== payload.$id);
|
||||
return [payload, ...filtered].slice(0, 10);
|
||||
});
|
||||
} else {
|
||||
setApprovedMessages(prev => prev.filter(m => m.$id !== payload.$id));
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Rotation Timer
|
||||
const rotationInterval = setInterval(() => {
|
||||
setRotatingContent(current => {
|
||||
if (current.length === 0) return current;
|
||||
setActiveContentIndex(prev => (prev + 1) % current.length);
|
||||
return current;
|
||||
});
|
||||
}, 10000); // 10s rotation
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
clearInterval(rotationInterval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const activeItem = rotatingContent[activeContentIndex];
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden font-outfit">
|
||||
{/* TOP BAR - LIVE INDICATOR */}
|
||||
<div className="absolute top-6 left-6 flex items-center gap-3">
|
||||
<div className="bg-red-600 text-white px-3 py-1 rounded-md text-[10px] font-black uppercase tracking-widest flex items-center gap-2 shadow-xl animate-pulse">
|
||||
<div className="w-1.5 h-1.5 bg-white rounded-full"></div>
|
||||
En Vivo
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MESSAGE TICKER (BOTTOM) */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-10 bg-slate-950/80 backdrop-blur-xl border-t border-white/10 flex items-center overflow-hidden">
|
||||
<div className="px-4 h-full bg-cyan-600 flex items-center gap-2 text-white z-10">
|
||||
<MessageSquare size={14} fill="currentColor" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest whitespace-nowrap">Comunidad</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 relative flex items-center h-full">
|
||||
{approvedMessages.length > 0 ? (
|
||||
<div className="flex gap-12 animate-marquee whitespace-nowrap items-center h-full">
|
||||
{approvedMessages.map((msg, i) => (
|
||||
<div key={msg.$id + i} className="flex items-center gap-3">
|
||||
<span className="text-cyan-400 font-black text-xs uppercase">{msg.sender_name}:</span>
|
||||
<span className="text-white text-xs font-medium">{msg.content}</span>
|
||||
<span className="text-slate-600">•</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="pl-6 text-[10px] font-bold text-slate-500 uppercase tracking-widest">
|
||||
Conéctate con Almeira Radio & TV vía WhatsApp...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SIDE CONTENT CAROUSEL (BOTTOM LEFT ABOVE TICKER) */}
|
||||
<div className="absolute bottom-16 left-6 w-72 transition-all">
|
||||
{activeItem && (
|
||||
<div className="bg-slate-900/40 backdrop-blur-2xl border border-white/10 rounded-2xl overflow-hidden shadow-2xl animate-in slide-in-from-left duration-700">
|
||||
<div className="relative aspect-video">
|
||||
{activeItem.imagen_destacada_id ? (
|
||||
<img
|
||||
src={`${APPWRITE_ALMEIRA_CONFIG.endpoint}/storage/buckets/media/files/${activeItem.imagen_destacada_id}/preview?project=${APPWRITE_ALMEIRA_CONFIG.projectId}`}
|
||||
className="w-full h-full object-cover"
|
||||
alt={activeItem.titulo}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-slate-800 flex items-center justify-center">
|
||||
<Radio className="text-slate-700" size={48} />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-slate-950/20 to-transparent px-4 py-3 flex flex-col justify-end">
|
||||
<h4 className="text-sm font-black text-white leading-tight uppercase tracking-tight line-clamp-2">
|
||||
{activeItem.titulo}
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes marquee {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-100%); }
|
||||
}
|
||||
.animate-marquee {
|
||||
animation: marquee 30s linear infinite;
|
||||
display: inline-flex;
|
||||
min-width: 100%;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -2,8 +2,8 @@ import { Client, Databases, Storage, Query } from 'appwrite';
|
||||
|
||||
const client = new Client();
|
||||
|
||||
const endpoint = process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT || 'https://appwrite.almeira.com.co/v1';
|
||||
const projectId = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID || '69b84df5003bd8957c5c';
|
||||
export const endpoint = process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT || 'https://appwrite.almeira.com.co/v1';
|
||||
export const projectId = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID || '69b84df5003bd8957c5c';
|
||||
|
||||
client
|
||||
.setEndpoint(endpoint)
|
||||
@ -14,6 +14,8 @@ export const storage = new Storage(client);
|
||||
export { Query };
|
||||
|
||||
export const APPWRITE_ALMEIRA_CONFIG = {
|
||||
endpoint,
|
||||
projectId,
|
||||
databaseId: 'almeira',
|
||||
collections: {
|
||||
// Existentes
|
||||
@ -32,6 +34,7 @@ export const APPWRITE_ALMEIRA_CONFIG = {
|
||||
paymentMethods: 'payment_methods',
|
||||
contracts: 'contracts',
|
||||
contractDetails: 'contract_details',
|
||||
whatsappMessages: 'whatsapp_messages',
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
41
src/lib/preferences.ts
Normal file
41
src/lib/preferences.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { createSessionClient } from './appwrite.server';
|
||||
|
||||
export interface UserPreferences {
|
||||
hiddenColumns?: Record<string, string[]>; // entidad -> lista de nombres de campos ocultos
|
||||
sortConfig?: Record<string, { field: string, direction: 'asc' | 'desc' }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene las preferencias persistidas del usuario desde Appwrite Account Prefs
|
||||
*/
|
||||
export async function getUserPreferences(): Promise<UserPreferences> {
|
||||
try {
|
||||
const sessionClient = await createSessionClient();
|
||||
if (!sessionClient) return {};
|
||||
|
||||
const account = await sessionClient.account.get();
|
||||
return (account.prefs as UserPreferences) || {};
|
||||
} catch (error) {
|
||||
console.error('Error fetching user prefs:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza una preferencia específica del usuario
|
||||
*/
|
||||
export async function updateUserPreference(update: Partial<UserPreferences>): Promise<boolean> {
|
||||
try {
|
||||
const sessionClient = await createSessionClient();
|
||||
if (!sessionClient) return false;
|
||||
|
||||
const currentPrefs = await getUserPreferences();
|
||||
const newPrefs = { ...currentPrefs, ...update };
|
||||
|
||||
await sessionClient.account.updatePrefs(newPrefs);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error updating user prefs:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -89,6 +89,15 @@ export const SCHEMA_MAP: Record<string, FieldDefinition[]> = {
|
||||
{ name: 'alt_text', label: 'Alt Text (SEO)', type: 'text' },
|
||||
{ name: 'descripcion_seo', label: 'Descripción y Contexto (SEO)', type: 'textarea' },
|
||||
{ name: 'tipo', label: 'Tipo de Recurso', type: 'select', options: ['imagen', 'video'], required: true }
|
||||
],
|
||||
whatsapp_messages: [
|
||||
{ name: 'sender_name', label: 'Remitente', type: 'text', required: true },
|
||||
{ name: 'phone_number', label: 'Teléfono', type: 'text', required: true },
|
||||
{ name: 'content', label: 'Mensaje', type: 'textarea', required: true },
|
||||
{ name: 'timestamp', label: 'Fecha/Hora', type: 'date', required: true },
|
||||
{ name: 'status', label: 'Estado', type: 'select', options: ['pending', 'approved', 'rejected'], required: true },
|
||||
{ name: 'platform', label: 'Plataforma', type: 'text', required: true },
|
||||
{ name: 'avatar', label: 'Avatar URL', type: 'url' }
|
||||
]
|
||||
};
|
||||
|
||||
@ -106,5 +115,6 @@ export const collectionMap: Record<string, string> = {
|
||||
publication_types: APPWRITE_ALMEIRA_CONFIG.collections.publicationTypes,
|
||||
media: 'media',
|
||||
settings: 'settings',
|
||||
metrics: 'metrics'
|
||||
metrics: 'metrics',
|
||||
whatsapp_messages: 'whatsapp_messages'
|
||||
};
|
||||
|
||||
63
src/store/usePlayerStore.ts
Normal file
63
src/store/usePlayerStore.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
interface PlayerState {
|
||||
// Audio Radio (AzuraCast)
|
||||
audioUrl: string;
|
||||
isAudioPlaying: boolean;
|
||||
audioVolume: number;
|
||||
|
||||
// Video Streaming (Restreamer)
|
||||
videoUrl: string;
|
||||
isVideoActive: boolean;
|
||||
videoVolume: number;
|
||||
|
||||
// Overlays & Content
|
||||
isOverlayVisible: boolean;
|
||||
activeOverlayTab: 'whatsapp' | 'classifieds' | 'events';
|
||||
|
||||
// Actions
|
||||
setAudioUrl: (url: string) => void;
|
||||
toggleAudio: (force?: boolean) => void;
|
||||
setAudioVolume: (vol: number) => void;
|
||||
|
||||
setVideoUrl: (url: string) => void;
|
||||
setVideoActive: (active: boolean) => void;
|
||||
setVideoVolume: (vol: number) => void;
|
||||
|
||||
setOverlayVisible: (visible: boolean) => void;
|
||||
setOverlayTab: (tab: 'whatsapp' | 'classifieds' | 'events') => void;
|
||||
}
|
||||
|
||||
export const usePlayerStore = create<PlayerState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
// Default values
|
||||
audioUrl: 'https://radio.almeira.com.co/listen/almeira/radio.mp3',
|
||||
isAudioPlaying: false,
|
||||
audioVolume: 0.8,
|
||||
|
||||
videoUrl: 'https://tv.almeira.com.co/hls/stream.m3u8',
|
||||
isVideoActive: false,
|
||||
videoVolume: 0.5,
|
||||
|
||||
isOverlayVisible: true,
|
||||
activeOverlayTab: 'whatsapp',
|
||||
|
||||
// Action implementations
|
||||
setAudioUrl: (url) => set({ audioUrl: url }),
|
||||
toggleAudio: (force) => set((state) => ({ isAudioPlaying: force !== undefined ? force : !state.isAudioPlaying })),
|
||||
setAudioVolume: (vol) => set({ audioVolume: vol }),
|
||||
|
||||
setVideoUrl: (url) => set({ videoUrl: url }),
|
||||
setVideoActive: (active) => set({ isVideoActive: active }),
|
||||
setVideoVolume: (vol) => set({ videoVolume: vol }),
|
||||
|
||||
setOverlayVisible: (visible) => set({ isOverlayVisible: visible }),
|
||||
setOverlayTab: (tab) => set({ activeOverlayTab: tab }),
|
||||
}),
|
||||
{
|
||||
name: 'almeira-player-storage',
|
||||
}
|
||||
)
|
||||
);
|
||||
Loading…
Reference in New Issue
Block a user