feat(ui): optimización de UI/UX en CMS y GlobalPlayer, correcciones backend Appwrite

This commit is contained in:
Carlos (Almeira) 2026-03-29 00:51:47 -05:00
parent d48d7bc094
commit 58b28378fb
6 changed files with 155 additions and 65 deletions

View File

@ -89,3 +89,43 @@ export async function uploadMediaServer(formData: FormData) {
return { success: false, error: error.message };
}
}
export async function uploadMediaDocumentServer(formData: FormData) {
try {
const client = getAdminClient();
const storage = new Storage(client);
const databases = new Databases(client);
const files = formData.getAll('files') as File[];
if (!files || files.length === 0) {
throw new Error('No se encontraron archivos para subir');
}
const results = [];
for (const file of files) {
const buffer = Buffer.from(await file.arrayBuffer());
const uploadedFile = await storage.createFile('media', ID.unique(), {
buffer: buffer,
filename: file.name,
size: file.size,
mimeType: file.type
} as any);
const doc = await databases.createDocument(DATABASE_ID, 'media', ID.unique(), {
file_id: uploadedFile.$id,
bucket_id: 'media',
titulo: file.name,
alt_text: file.name.split('.')[0],
tipo: file.type.startsWith('image/') ? 'imagen' : 'video'
});
results.push(doc);
}
revalidatePath('/dashboard/media');
revalidatePath('/');
return { success: true, count: results.length, data: results };
} catch (error: any) {
console.error('Error uploading media and creating document:', error);
return { success: false, error: error.message };
}
}

View File

@ -44,14 +44,32 @@ export async function updateEntityConfig(data: any) {
const databases = await getAdminClient();
const existing = await getEntityConfig();
let parsedData = data;
let parsedData: any = data;
if (typeof FormData !== 'undefined' && data instanceof FormData) {
parsedData = {};
data.forEach((value, key) => {
for (const [key, value] of Array.from(data.entries())) {
if (key !== '$id' && key !== '$collectionId' && key !== '$databaseId') {
parsedData[key] = value;
if (typeof value === 'object' && 'size' in value && 'name' in value) {
const file = value as File;
if (file.size > 0) {
try {
const { uploadMediaServer } = await import('@/app/actions/appwrite');
const tempForm = new FormData();
tempForm.append('files', file);
const uploadRes = await uploadMediaServer(tempForm);
if (uploadRes.success && uploadRes.data && uploadRes.data[0]) {
const m = uploadRes.data[0];
parsedData[key] = `${ENDPOINT}/storage/buckets/${m.bucketId || 'media'}/files/${m.$id}/view?project=${PROJECT_ID}`;
}
} catch (uploadErr) {
console.error('Error uploading file for entity config:', uploadErr);
}
}
} else {
parsedData[key] = value;
}
}
});
}
}
if (!parsedData || Object.keys(parsedData).length === 0) {

View File

@ -3,8 +3,9 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import { ImageIcon, X, Search, CheckCircle2, UploadCloud, Loader2 } from 'lucide-react';
import { databases, storage, APPWRITE_ALMEIRA_CONFIG, endpoint, projectId } from '@/lib/appwrite';
import { Query, ID } from 'appwrite';
import { databases, APPWRITE_ALMEIRA_CONFIG, endpoint, projectId } from '@/lib/appwrite';
import { Query } from 'appwrite';
import { uploadMediaDocumentServer } from '@/app/actions/appwrite';
/** Utility to generate frontend Appwrite image previews */
export function getAppwriteImageUrl(fileId: string, width = 400, height = 250) {
@ -79,26 +80,22 @@ export const MediaGridSelector = ({
setIsUploading(true);
try {
// 1. Upload to storage bucket
const uploadedFile = await storage.createFile('media', ID.unique(), file);
const formData = new FormData();
formData.append('files', file);
const res = await uploadMediaDocumentServer(formData);
// 2. Create document in media collection
const doc = await databases.createDocument(APPWRITE_ALMEIRA_CONFIG.databaseId, 'media', ID.unique(), {
file_id: uploadedFile.$id,
bucket_id: 'media',
titulo: file.name,
alt_text: file.name.split('.')[0],
tipo: file.type.startsWith('image/') ? 'imagen' : 'video' // mostly images in this bucket
});
if (!res.success || !res.data || res.data.length === 0) {
throw new Error(res.error || "Error from server edge while uploading media.");
}
// 3. Update state
const doc = res.data[0];
setMediaItems(prev => [doc, ...prev]);
const url = getAppwriteImageUrl(uploadedFile.$id, 1200, 800);
const url = getAppwriteImageUrl(doc.file_id, 1200, 800);
onChange(doc.$id, url);
closeModal();
} catch (error) {
console.error("Error upload:", error);
alert("Error al subir el archivo.");
alert("Error al subir el archivo. Revisa que el perfil tenga el scope files.write.");
} finally {
setIsUploading(false);
}

View File

@ -18,6 +18,7 @@ export const GlobalPlayer = () => {
const playerRef = useRef<any>(null);
const [isMuted, setIsMuted] = useState(false);
const [lastVolume, setLastVolume] = useState(audioVolume);
const [isExpanded, setIsExpanded] = useState(false);
// Sync Audio State
useEffect(() => {
@ -110,7 +111,24 @@ export const GlobalPlayer = () => {
{/* 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">
{/* ESTADO COLAPSADO */}
{!isExpanded && (audioUrl || videoUrl) && (
<button
onClick={() => setIsExpanded(true)}
className="group relative h-14 px-5 bg-red-600 hover:bg-red-500 text-white rounded-full flex items-center justify-center gap-3 shadow-[0_0_20px_rgba(220,38,38,0.4)] transition-all animate-in zoom-in slide-in-from-bottom-5"
>
<div className="absolute inset-0 border-2 border-red-500 rounded-full animate-[ping_2s_ease-out_infinite] opacity-50 pointer-events-none"></div>
<Radio size={20} className="relative z-10" />
<span className="font-black tracking-widest uppercase text-xs whitespace-nowrap">En Vivo</span>
<div className="w-1.5 h-1.5 bg-white rounded-full animate-pulse relative z-10 shadow-[0_0_8px_rgba(255,255,255,0.8)]"></div>
</button>
)}
{/* ESTADO EXPANDIDO */}
{isExpanded && (
<>
<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-3 group hover:bg-slate-900/60 transition-all animate-in fade-in slide-in-from-left-4">
{/* Play/Pause Main Button */}
<button
onClick={() => toggleAudio()}
@ -132,14 +150,14 @@ export const GlobalPlayer = () => {
)}
</button>
<div className="flex flex-col">
<div className="flex flex-col pr-3">
<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'}
{isAudioPlaying ? 'Señal Activa' : '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>
<h4 className="text-xs font-bold text-white tracking-wider font-outfit truncate max-w-[100px]">ALMEIRA RADIO</h4>
</div>
{/* Volume Control (Desktop Only) */}
@ -160,6 +178,15 @@ export const GlobalPlayer = () => {
}}
className="w-20 h-1 bg-slate-800 rounded-full appearance-none cursor-pointer accent-cyan-500 hover:accent-cyan-400 transition-all"
/>
{/* Botón para colapsar */}
<button
onClick={() => setIsExpanded(false)}
title="Ocultar Player"
className="ml-2 w-8 h-8 flex items-center justify-center bg-white/5 hover:bg-white/10 border border-white/5 rounded-xl text-slate-400 hover:text-white transition-all"
>
<Minimize2 size={14} />
</button>
</div>
</div>
@ -177,6 +204,8 @@ export const GlobalPlayer = () => {
<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 */}

View File

@ -389,14 +389,14 @@ export const PublicationLayout: React.FC<PublicationLayoutProps> = ({
<FieldWrapper label="Estado" required error={validationErrors.estado}>
<select
name="estado"
defaultValue={initialData?.estado || 'Borrador'}
defaultValue={initialData?.estado?.toLowerCase() || 'borrador'}
required
className={inputClass}
>
<option value="Borrador">Borrador</option>
<option value="Pendiente">Pendiente revisión</option>
<option value="Publicado">Publicado</option>
<option value="Archivado">Archivado</option>
<option value="borrador">Borrador</option>
<option value="pendiente">Pendiente revisión</option>
<option value="publicado">Publicado</option>
<option value="archivado">Archivado</option>
</select>
</FieldWrapper>
@ -481,36 +481,40 @@ export const PublicationLayout: React.FC<PublicationLayoutProps> = ({
</FormSection>
</div>
{/* Submit Button */}
{/* Sticky Action Bar */}
<PermissionGuard allowedRoles={['editor', 'admin']} userRole={userRole}>
<button
type="submit"
disabled={isLoading || isSaved}
className="w-full relative flex items-center justify-center gap-3 py-4 px-6 rounded-2xl
font-black uppercase tracking-widest text-sm overflow-hidden
bg-gradient-to-r from-cyan-500 to-blue-600
hover:from-cyan-400 hover:to-blue-500
text-slate-950 shadow-[0_0_30px_rgba(6,182,212,0.3)]
transition-all disabled:opacity-60 disabled:cursor-not-allowed
active:scale-98"
>
{isLoading ? (
<>
<Loader2 size={18} className="animate-spin" />
Guardando…
</>
) : isSaved ? (
<>
<CheckCircle size={18} />
¡Guardado!
</>
) : (
<>
<Save size={18} />
{initialData?.$id ? 'Guardar Cambios' : 'Crear Publicación'}
</>
)}
</button>
<div className="fixed bottom-0 left-0 right-0 z-50 p-4 bg-slate-950/80 backdrop-blur-xl border-t border-slate-800/50 xl:pl-64">
<div className="max-w-7xl mx-auto flex justify-end">
<button
type="submit"
disabled={isLoading || isSaved}
className="w-full md:w-auto relative flex items-center justify-center gap-3 py-3 px-8 rounded-xl
font-black uppercase tracking-widest text-sm overflow-hidden
bg-gradient-to-r from-cyan-500 to-blue-600
hover:from-cyan-400 hover:to-blue-500
text-slate-950 shadow-[0_0_20px_rgba(6,182,212,0.3)]
transition-all disabled:opacity-60 disabled:cursor-not-allowed
active:scale-98"
>
{isLoading ? (
<>
<Loader2 size={18} className="animate-spin" />
Guardando…
</>
) : isSaved ? (
<>
<CheckCircle size={18} />
¡Guardado!
</>
) : (
<>
<Save size={18} />
{initialData?.$id ? 'Actualizar Cambios' : 'Crear Publicación'}
</>
)}
</button>
</div>
</div>
</PermissionGuard>
{/* No permission notice */}

View File

@ -56,28 +56,30 @@ export async function getLoggedInUser() {
const cookieStore = await cookies();
const sessionCookie = cookieStore.get(COOKIE_NAME);
// Verificación de bypass (Funciona en todos los entornos)
// Verificación de bypass (Evitamos imprimir log repetitivo a menos que sea un error)
if (sessionCookie?.value === "admin-bypass-session") {
console.log("Acceso de emergencia detectado en Dashboard.");
return {
$id: "admin-emergency",
name: "Administrador (Emergencia)",
email: "admin@almeira.com.co",
prefs: {}
prefs: { role: 'admin' }
};
}
if (!sessionCookie || !sessionCookie.value) {
return null;
}
const sessionClient = await createSessionClient();
if (!sessionClient) {
console.log("getLoggedInUser: No se encontró cookie de sesión o cliente inválido.");
return null;
}
const user = await sessionClient.account.get();
console.log("getLoggedInUser: Sesión validada para usuario:", user.email);
return user;
return await sessionClient.account.get();
} catch (error: any) {
console.error("getLoggedInUser: Error al validar sesión:", error.message || error);
if (error?.code !== 401) {
console.error("getLoggedInUser: Error al validar sesión:", error.message || error);
}
return null;
}
}