feat(ui): optimización de UI/UX en CMS y GlobalPlayer, correcciones backend Appwrite
This commit is contained in:
parent
d48d7bc094
commit
58b28378fb
@ -89,3 +89,43 @@ export async function uploadMediaServer(formData: FormData) {
|
|||||||
return { success: false, error: error.message };
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -44,14 +44,32 @@ export async function updateEntityConfig(data: any) {
|
|||||||
const databases = await getAdminClient();
|
const databases = await getAdminClient();
|
||||||
const existing = await getEntityConfig();
|
const existing = await getEntityConfig();
|
||||||
|
|
||||||
let parsedData = data;
|
let parsedData: any = data;
|
||||||
if (typeof FormData !== 'undefined' && data instanceof FormData) {
|
if (typeof FormData !== 'undefined' && data instanceof FormData) {
|
||||||
parsedData = {};
|
parsedData = {};
|
||||||
data.forEach((value, key) => {
|
for (const [key, value] of Array.from(data.entries())) {
|
||||||
if (key !== '$id' && key !== '$collectionId' && key !== '$databaseId') {
|
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) {
|
if (!parsedData || Object.keys(parsedData).length === 0) {
|
||||||
|
|||||||
@ -3,8 +3,9 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { ImageIcon, X, Search, CheckCircle2, UploadCloud, Loader2 } from 'lucide-react';
|
import { ImageIcon, X, Search, CheckCircle2, UploadCloud, Loader2 } from 'lucide-react';
|
||||||
import { databases, storage, APPWRITE_ALMEIRA_CONFIG, endpoint, projectId } from '@/lib/appwrite';
|
import { databases, APPWRITE_ALMEIRA_CONFIG, endpoint, projectId } from '@/lib/appwrite';
|
||||||
import { Query, ID } from 'appwrite';
|
import { Query } from 'appwrite';
|
||||||
|
import { uploadMediaDocumentServer } from '@/app/actions/appwrite';
|
||||||
|
|
||||||
/** Utility to generate frontend Appwrite image previews */
|
/** Utility to generate frontend Appwrite image previews */
|
||||||
export function getAppwriteImageUrl(fileId: string, width = 400, height = 250) {
|
export function getAppwriteImageUrl(fileId: string, width = 400, height = 250) {
|
||||||
@ -79,26 +80,22 @@ export const MediaGridSelector = ({
|
|||||||
|
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
try {
|
try {
|
||||||
// 1. Upload to storage bucket
|
const formData = new FormData();
|
||||||
const uploadedFile = await storage.createFile('media', ID.unique(), file);
|
formData.append('files', file);
|
||||||
|
const res = await uploadMediaDocumentServer(formData);
|
||||||
|
|
||||||
// 2. Create document in media collection
|
if (!res.success || !res.data || res.data.length === 0) {
|
||||||
const doc = await databases.createDocument(APPWRITE_ALMEIRA_CONFIG.databaseId, 'media', ID.unique(), {
|
throw new Error(res.error || "Error from server edge while uploading media.");
|
||||||
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
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Update state
|
const doc = res.data[0];
|
||||||
setMediaItems(prev => [doc, ...prev]);
|
setMediaItems(prev => [doc, ...prev]);
|
||||||
const url = getAppwriteImageUrl(uploadedFile.$id, 1200, 800);
|
const url = getAppwriteImageUrl(doc.file_id, 1200, 800);
|
||||||
onChange(doc.$id, url);
|
onChange(doc.$id, url);
|
||||||
closeModal();
|
closeModal();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error upload:", 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 {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,7 @@ export const GlobalPlayer = () => {
|
|||||||
const playerRef = useRef<any>(null);
|
const playerRef = useRef<any>(null);
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
const [lastVolume, setLastVolume] = useState(audioVolume);
|
const [lastVolume, setLastVolume] = useState(audioVolume);
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
// Sync Audio State
|
// Sync Audio State
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -110,7 +111,24 @@ export const GlobalPlayer = () => {
|
|||||||
|
|
||||||
{/* Floating Global Controller */}
|
{/* 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={`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 */}
|
{/* Play/Pause Main Button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleAudio()}
|
onClick={() => toggleAudio()}
|
||||||
@ -132,14 +150,14 @@ export const GlobalPlayer = () => {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col pr-3">
|
||||||
<div className="flex items-center gap-2">
|
<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'}`}>
|
<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>
|
</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>}
|
{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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Volume Control (Desktop Only) */}
|
{/* 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"
|
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>
|
||||||
</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>
|
<div className="absolute inset-x-0 bottom-0 h-1 bg-white/30 animate-pulse"></div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add Music Bar Animation to globals.css */}
|
{/* Add Music Bar Animation to globals.css */}
|
||||||
|
|||||||
@ -389,14 +389,14 @@ export const PublicationLayout: React.FC<PublicationLayoutProps> = ({
|
|||||||
<FieldWrapper label="Estado" required error={validationErrors.estado}>
|
<FieldWrapper label="Estado" required error={validationErrors.estado}>
|
||||||
<select
|
<select
|
||||||
name="estado"
|
name="estado"
|
||||||
defaultValue={initialData?.estado || 'Borrador'}
|
defaultValue={initialData?.estado?.toLowerCase() || 'borrador'}
|
||||||
required
|
required
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
>
|
>
|
||||||
<option value="Borrador">Borrador</option>
|
<option value="borrador">Borrador</option>
|
||||||
<option value="Pendiente">Pendiente revisión</option>
|
<option value="pendiente">Pendiente revisión</option>
|
||||||
<option value="Publicado">Publicado</option>
|
<option value="publicado">Publicado</option>
|
||||||
<option value="Archivado">Archivado</option>
|
<option value="archivado">Archivado</option>
|
||||||
</select>
|
</select>
|
||||||
</FieldWrapper>
|
</FieldWrapper>
|
||||||
|
|
||||||
@ -481,36 +481,40 @@ export const PublicationLayout: React.FC<PublicationLayoutProps> = ({
|
|||||||
</FormSection>
|
</FormSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Submit Button */}
|
{/* Sticky Action Bar */}
|
||||||
<PermissionGuard allowedRoles={['editor', 'admin']} userRole={userRole}>
|
<PermissionGuard allowedRoles={['editor', 'admin']} userRole={userRole}>
|
||||||
<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">
|
||||||
type="submit"
|
<div className="max-w-7xl mx-auto flex justify-end">
|
||||||
disabled={isLoading || isSaved}
|
<button
|
||||||
className="w-full relative flex items-center justify-center gap-3 py-4 px-6 rounded-2xl
|
type="submit"
|
||||||
font-black uppercase tracking-widest text-sm overflow-hidden
|
disabled={isLoading || isSaved}
|
||||||
bg-gradient-to-r from-cyan-500 to-blue-600
|
className="w-full md:w-auto relative flex items-center justify-center gap-3 py-3 px-8 rounded-xl
|
||||||
hover:from-cyan-400 hover:to-blue-500
|
font-black uppercase tracking-widest text-sm overflow-hidden
|
||||||
text-slate-950 shadow-[0_0_30px_rgba(6,182,212,0.3)]
|
bg-gradient-to-r from-cyan-500 to-blue-600
|
||||||
transition-all disabled:opacity-60 disabled:cursor-not-allowed
|
hover:from-cyan-400 hover:to-blue-500
|
||||||
active:scale-98"
|
text-slate-950 shadow-[0_0_20px_rgba(6,182,212,0.3)]
|
||||||
>
|
transition-all disabled:opacity-60 disabled:cursor-not-allowed
|
||||||
{isLoading ? (
|
active:scale-98"
|
||||||
<>
|
>
|
||||||
<Loader2 size={18} className="animate-spin" />
|
{isLoading ? (
|
||||||
Guardando…
|
<>
|
||||||
</>
|
<Loader2 size={18} className="animate-spin" />
|
||||||
) : isSaved ? (
|
Guardando…
|
||||||
<>
|
</>
|
||||||
<CheckCircle size={18} />
|
) : isSaved ? (
|
||||||
¡Guardado!
|
<>
|
||||||
</>
|
<CheckCircle size={18} />
|
||||||
) : (
|
¡Guardado!
|
||||||
<>
|
</>
|
||||||
<Save size={18} />
|
) : (
|
||||||
{initialData?.$id ? 'Guardar Cambios' : 'Crear Publicación'}
|
<>
|
||||||
</>
|
<Save size={18} />
|
||||||
)}
|
{initialData?.$id ? 'Actualizar Cambios' : 'Crear Publicación'}
|
||||||
</button>
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</PermissionGuard>
|
</PermissionGuard>
|
||||||
|
|
||||||
{/* No permission notice */}
|
{/* No permission notice */}
|
||||||
|
|||||||
@ -56,28 +56,30 @@ export async function getLoggedInUser() {
|
|||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const sessionCookie = cookieStore.get(COOKIE_NAME);
|
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") {
|
if (sessionCookie?.value === "admin-bypass-session") {
|
||||||
console.log("Acceso de emergencia detectado en Dashboard.");
|
|
||||||
return {
|
return {
|
||||||
$id: "admin-emergency",
|
$id: "admin-emergency",
|
||||||
name: "Administrador (Emergencia)",
|
name: "Administrador (Emergencia)",
|
||||||
email: "admin@almeira.com.co",
|
email: "admin@almeira.com.co",
|
||||||
prefs: {}
|
prefs: { role: 'admin' }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!sessionCookie || !sessionCookie.value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const sessionClient = await createSessionClient();
|
const sessionClient = await createSessionClient();
|
||||||
if (!sessionClient) {
|
if (!sessionClient) {
|
||||||
console.log("getLoggedInUser: No se encontró cookie de sesión o cliente inválido.");
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await sessionClient.account.get();
|
return await sessionClient.account.get();
|
||||||
console.log("getLoggedInUser: Sesión validada para usuario:", user.email);
|
|
||||||
return user;
|
|
||||||
} catch (error: any) {
|
} 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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user