feat: finalize Almeira Multimedia Ecosystem integration and UI refinements

This commit is contained in:
Carlos (Almeira) 2026-03-27 16:29:10 -05:00
parent 3ee7b28d3d
commit 0ecfa29bf0
22 changed files with 3214 additions and 333 deletions

1140
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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
View 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
View 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 };
}

View 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 });
}
}

View File

@ -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">&larr; 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">
&larr; 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 }}
/>
);
}

View File

@ -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">
&larr; 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 }}
/>
);
}

View File

@ -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

View 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>
);
}

View File

@ -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>
);

View File

@ -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>

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
</>
);
};

View 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>
);
};

View File

@ -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
View 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;
}
}

View File

@ -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'
};

View 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',
}
)
);