fix gestor de contenido, wip reproductor y diseño

This commit is contained in:
Abraham
2026-01-12 16:36:47 -08:00
parent 1a1049b653
commit 854a0940ae
11 changed files with 159 additions and 222 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 KiB

After

Width:  |  Height:  |  Size: 49 KiB

BIN
assets/images/logo_nh1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

View File

@@ -12,7 +12,7 @@ const String apiGatewayUrl = 'https://cbl.cbluna-dev.com/uapi/lu/api';
const String n8nUrl = 'https://u-n8n.cbluna-dev.com/webhook';
const bearerApiGateway = "Basic YWlyZmxvdzpjYiF1bmEyMDIz";
const int organizationId = 10;
const int organizationId = 17;
const String lectoresUrl = 'https://lectoresurbanos.com/';
const themeId = String.fromEnvironment('themeId', defaultValue: '2');

View File

@@ -153,4 +153,9 @@ class MediaFileModel {
DateTime? get lastViewedAt => metadataJson?['last_viewed_at'] != null
? DateTime.tryParse(metadataJson!['last_viewed_at'])
: null;
// Poster information from metadata_json
String? get posterUrl => metadataJson?['poster_url'];
String? get posterFileName => metadataJson?['poster_file_name'];
int? get fileSizeBytesFromMetadata => metadataJson?['file_size_bytes'];
}

View File

@@ -145,35 +145,16 @@ class _LoginFormState extends State<LoginForm> with TickerProviderStateMixin {
margin: const EdgeInsets.only(bottom: 50),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [
Color(0xFF3B82F6),
Color(0xFF10B981)
],
),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: const Color(0xFF3B82F6)
.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: Padding(
padding: const EdgeInsets.all(10),
child: Image.asset(
'assets/images/favicon.png',
fit: BoxFit.contain,
),
SizedBox(
width: 75,
height: 75,
child: Image.asset(
'assets/images/favicon.png',
fit: BoxFit.contain,
),
),
const SizedBox(width: 16),
@@ -181,9 +162,9 @@ class _LoginFormState extends State<LoginForm> with TickerProviderStateMixin {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Bienvenido a NetHive',
'Bienvenido a EnergyMedia',
style: GoogleFonts.inter(
fontSize: 28,
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
@@ -200,17 +181,6 @@ class _LoginFormState extends State<LoginForm> with TickerProviderStateMixin {
),
],
),
const SizedBox(height: 20),
Container(
width: 60,
height: 3,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF10B981), Color(0xFF3B82F6)],
),
borderRadius: BorderRadius.circular(2),
),
),
],
),
)
@@ -262,7 +232,7 @@ class _LoginFormState extends State<LoginForm> with TickerProviderStateMixin {
fontWeight: FontWeight.w400,
),
decoration: InputDecoration(
hintText: 'admin@nethive.com',
hintText: 'admin@energymedia.com',
hintStyle: GoogleFonts.inter(
color: isMobile
? Colors.white.withOpacity(0.6)

View File

@@ -251,6 +251,9 @@ class _GestorVideosPageState extends State<GestorVideosPage> {
rendererContext.row.cells['video']?.value as MediaFileModel?;
if (video == null) return const SizedBox();
// Obtener poster desde metadata_json
final posterUrl = video.posterUrl;
return Container(
margin: const EdgeInsets.all(4),
decoration: BoxDecoration(
@@ -259,9 +262,9 @@ class _GestorVideosPageState extends State<GestorVideosPage> {
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: video.fileUrl != null
child: posterUrl != null && posterUrl.isNotEmpty
? Image.network(
video.fileUrl!,
posterUrl,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Icon(
Icons.video_library,
@@ -285,8 +288,8 @@ class _GestorVideosPageState extends State<GestorVideosPage> {
width: 250,
),
PlutoColumn(
title: 'Archivo',
field: 'fileName',
title: 'Descripción',
field: 'file_description',
type: PlutoColumnType.text(),
width: 200,
),
@@ -381,28 +384,37 @@ class _GestorVideosPageState extends State<GestorVideosPage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (video.fileUrl != null)
ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
child: AspectRatio(
aspectRatio: 16 / 9,
child: Image.network(
video.fileUrl!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Container(
color: AppTheme.of(context).tertiaryBackground,
child: Icon(
Icons.video_library,
size: 64,
color: AppTheme.of(context).tertiaryText,
),
),
),
),
// Mostrar poster si existe, sino mostrar placeholder
ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
child: AspectRatio(
aspectRatio: 16 / 9,
child: video.posterUrl != null && video.posterUrl!.isNotEmpty
? Image.network(
video.posterUrl!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Container(
color: AppTheme.of(context).tertiaryBackground,
child: Icon(
Icons.video_library,
size: 64,
color: AppTheme.of(context).tertiaryText,
),
),
)
: Container(
color: AppTheme.of(context).tertiaryBackground,
child: Icon(
Icons.video_library,
size: 64,
color: AppTheme.of(context).tertiaryText,
),
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
@@ -586,7 +598,7 @@ class _GestorVideosPageState extends State<GestorVideosPage> {
.where((cat) => cat.mediaCategoriesId == video.mediaCategoryFk)
.firstOrNull;
await showDialog(
final result = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
@@ -671,7 +683,7 @@ class _GestorVideosPageState extends State<GestorVideosPage> {
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
onPressed: () => Navigator.pop(context, false),
child: Text(
'Cancelar',
style: TextStyle(
@@ -681,8 +693,6 @@ class _GestorVideosPageState extends State<GestorVideosPage> {
),
ElevatedButton(
onPressed: () async {
Navigator.pop(context);
// Actualizar campos
if (titleController.text != video.title) {
await provider.updateVideoTitle(
@@ -707,16 +717,7 @@ class _GestorVideosPageState extends State<GestorVideosPage> {
);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Video actualizado exitosamente'),
backgroundColor: Colors.green,
),
);
await _loadData();
Navigator.pop(context, true);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.of(context).primaryColor,
@@ -728,6 +729,20 @@ class _GestorVideosPageState extends State<GestorVideosPage> {
),
),
);
// Manejar resultado después de cerrar el diálogo
if (result == true) {
await _loadData();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Video actualizado exitosamente'),
backgroundColor: Colors.green,
),
);
}
}
Future<void> _deleteVideo(

View File

@@ -63,6 +63,9 @@ class _VideosLayoutState extends State<VideosLayout> {
}
Widget _buildHeader(bool isMobile) {
final isDark = AppTheme.themeMode == ThemeMode.dark;
final isLightBackground = !isDark;
return Container(
padding: EdgeInsets.all(isMobile ? 16 : 24),
decoration: BoxDecoration(
@@ -82,26 +85,15 @@ class _VideosLayoutState extends State<VideosLayout> {
color: AppTheme.of(context).primaryText,
onPressed: () => _scaffoldKey.currentState?.openDrawer(),
),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
gradient: AppTheme.of(context).primaryGradient,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.energy_savings_leaf,
color: Color(0xFF0B0B0D),
size: 24,
),
),
const Gap(12),
Text(
'EnergyMedia',
style: AppTheme.of(context).title2.override(
fontFamily: 'Poppins',
color: AppTheme.of(context).primaryText,
fontWeight: FontWeight.bold,
),
// Logo de EnergyMedia
Image.asset(
isMobile
? 'assets/images/favicon.png'
: isLightBackground
? 'assets/images/logo_nh.png'
: 'assets/images/logo_nh_b.png',
height: isMobile ? 32 : 75,
fit: BoxFit.contain,
),
const Spacer(),
Text(
@@ -154,29 +146,13 @@ class _VideosLayoutState extends State<VideosLayout> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.energy_savings_leaf,
color: Color(0xFF0B0B0D),
size: 32,
),
// Logo de EnergyMedia
Image.asset(
'assets/images/logo_nh_b.png',
height: 50,
fit: BoxFit.contain,
),
const Gap(16),
Text(
'EnergyMedia',
style: AppTheme.of(context).title2.override(
fontFamily: 'Poppins',
color: const Color(0xFF0B0B0D),
fontWeight: FontWeight.bold,
fontSize: 24,
),
),
const Gap(4),
const Gap(12),
Text(
'Content Manager',
style: AppTheme.of(context).bodyText2.override(
@@ -436,28 +412,13 @@ class _VideosLayoutState extends State<VideosLayout> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.energy_savings_leaf,
color: Color(0xFF0B0B0D),
size: 32,
),
// Logo de EnergyMedia
Image.asset(
'assets/images/logo_nh.png',
height: 45,
fit: BoxFit.contain,
),
const Gap(12),
Text(
'EnergyMedia',
style: AppTheme.of(context).title2.override(
fontFamily: 'Poppins',
color: const Color(0xFF0B0B0D),
fontWeight: FontWeight.bold,
),
),
const Gap(4),
Text(
'Content Manager',
style: AppTheme.of(context).bodyText2.override(

View File

@@ -131,24 +131,42 @@ class VideosProvider extends ChangeNotifier {
videosRows.add(
PlutoRow(
cells: {
'id': PlutoCell(value: media.mediaFileId),
'thumbnail':
PlutoCell(value: media.fileUrl), // Para mostrar thumbnail
'video': PlutoCell(value: media), // Objeto completo para renderers
'thumbnail': PlutoCell(value: media.fileUrl),
'title': PlutoCell(value: media.title ?? media.fileName),
'description': PlutoCell(value: media.fileDescription ?? ''),
'file_description': PlutoCell(value: media.fileDescription),
'category':
PlutoCell(value: _getCategoryName(media.mediaCategoryFk)),
'reproducciones': PlutoCell(value: media.reproducciones),
'duration': PlutoCell(value: media.seconds ?? 0),
'size': PlutoCell(value: _formatFileSize(media.fileSizeBytes)),
'created_at': PlutoCell(value: media.createdAt),
'actions': PlutoCell(value: media.mediaFileId),
'duration': PlutoCell(
value: media.seconds != null
? _formatDuration(media.seconds!)
: '-'),
'createdAt': PlutoCell(
value: media.createdAt?.toString().split('.')[0] ?? '-'),
'actions': PlutoCell(value: media),
},
),
);
}
}
/// Format duration in seconds to human readable
String _formatDuration(int seconds) {
final duration = Duration(seconds: seconds);
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
final secs = duration.inSeconds.remainder(60);
if (hours > 0) {
return '${hours}h ${minutes}m';
} else if (minutes > 0) {
return '${minutes}m ${secs}s';
} else {
return '${secs}s';
}
}
/// Get category name by ID
String _getCategoryName(int? categoryId) {
if (categoryId == null) return 'Sin categoría';
@@ -258,21 +276,24 @@ class VideosProvider extends ChangeNotifier {
.from('energymedia')
.getPublicUrl(videoStoragePath!);
// 3. Upload poster if exists
int? posterFileId;
// 3. Upload poster if exists (solo storage, no DB)
String? posterUrlUploaded;
if (webPosterBytes != null && posterName != null) {
posterFileId = await _uploadPoster();
posterUrlUploaded = await _uploadPoster();
}
// 4. Create media_files record
// 4. Create media_files record (UN SOLO REGISTRO con poster en metadata_json)
final metadataJson = {
'uploaded_at': DateTime.now().toIso8601String(),
'reproducciones': 0,
'original_file_name': videoName,
'duration_seconds': durationSeconds,
'file_size_bytes': webVideoBytes!.length, // Peso del video
if (posterUrlUploaded != null) 'poster_url': posterUrlUploaded,
if (posterUrlUploaded != null) 'poster_file_name': posterName,
};
final response = await supabaseML.from('media_files').insert({
await supabaseML.from('media_files').insert({
'file_name': fileName,
'title': title,
'file_description': description,
@@ -288,16 +309,7 @@ class VideosProvider extends ChangeNotifier {
'seconds': durationSeconds,
'is_public_file': true,
'uploaded_by_user_id': currentUser?.id,
}).select();
// 5. Create poster relationship if exists
if (posterFileId != null && response.isNotEmpty) {
final mediaFileId = response[0]['media_file_id'];
await supabaseML.from('media_posters').insert({
'media_file_id': mediaFileId,
'poster_file_id': posterFileId,
});
}
});
// Clean up
_clearUploadState();
@@ -317,8 +329,9 @@ class VideosProvider extends ChangeNotifier {
}
}
/// Upload poster image (internal helper)
Future<int?> _uploadPoster() async {
/// Upload poster image to storage only (NO database record)
/// Returns the public URL of the uploaded poster
Future<String?> _uploadPoster() async {
if (webPosterBytes == null || posterName == null) return null;
try {
@@ -326,6 +339,7 @@ class VideosProvider extends ChangeNotifier {
final fileName = '${timestamp}_$posterName';
posterStoragePath = 'imagenes/$fileName';
// Solo subir al storage, NO crear registro en media_files
await supabaseML.storage.from('energymedia').uploadBinary(
posterStoragePath!,
webPosterBytes!,
@@ -335,26 +349,12 @@ class VideosProvider extends ChangeNotifier {
),
);
// Obtener URL pública del poster
posterUrl = supabaseML.storage
.from('energymedia')
.getPublicUrl(posterStoragePath!);
// Create media_files record for poster
final response = await supabaseML.from('media_files').insert({
'file_name': fileName,
'title': 'Poster',
'file_type': 'image',
'mime_type': _getMimeType(posterFileExtension),
'file_extension': posterFileExtension,
'file_size_bytes': webPosterBytes!.length,
'file_url': posterUrl,
'storage_path': posterStoragePath,
'organization_fk': organizationId,
'is_public_file': true,
'uploaded_by_user_id': currentUser?.id,
}).select();
return response[0]['media_file_id'] as int;
return posterUrl; // Retornar solo la URL, no el ID
} catch (e) {
print('Error en _uploadPoster: $e');
return null;
@@ -500,23 +500,30 @@ class VideosProvider extends ChangeNotifier {
.single();
final storagePath = response['storage_path'] as String?;
final metadataJson = response['metadata_json'] as Map<String, dynamic>?;
// Delete from storage if path exists
// Delete video from storage if path exists
if (storagePath != null) {
await supabaseML.storage.from('energymedia').remove([storagePath]);
}
// Delete associated posters
final posters = await supabaseML
.from('media_posters')
.select('poster_file_id')
.eq('media_file_id', mediaFileId);
// Delete poster from storage if exists in metadata_json
if (metadataJson != null && metadataJson['poster_url'] != null) {
final posterUrl = metadataJson['poster_url'] as String;
// Extraer el path del storage desde la URL
// URL format: https://xxx.supabase.co/storage/v1/object/public/energymedia/imagenes/filename.png
final uri = Uri.parse(posterUrl);
final pathSegments = uri.pathSegments;
for (var poster in posters) {
await _deletePosterFile(poster['poster_file_id']);
// Encontrar el índice después de 'energymedia' y construir el path
final bucketIndex = pathSegments.indexOf('energymedia');
if (bucketIndex != -1 && bucketIndex < pathSegments.length - 1) {
final posterPath = pathSegments.sublist(bucketIndex + 1).join('/');
await supabaseML.storage.from('energymedia').remove([posterPath]);
}
}
// Delete database record (cascade will delete posters relationship)
// Delete database record
await supabaseML
.from('media_files')
.delete()
@@ -537,30 +544,6 @@ class VideosProvider extends ChangeNotifier {
}
}
/// Delete poster file (internal helper)
Future<void> _deletePosterFile(int posterFileId) async {
try {
final response = await supabaseML
.from('media_files')
.select('storage_path')
.eq('media_file_id', posterFileId)
.single();
final storagePath = response['storage_path'] as String?;
if (storagePath != null) {
await supabaseML.storage.from('energymedia').remove([storagePath]);
}
await supabaseML
.from('media_files')
.delete()
.eq('media_file_id', posterFileId);
} catch (e) {
print('Error en _deletePosterFile: $e');
}
}
// ========== ANALYTICS METHODS ==========
/// Increment view count
@@ -659,17 +642,20 @@ class VideosProvider extends ChangeNotifier {
videosRows.add(
PlutoRow(
cells: {
'id': PlutoCell(value: media.mediaFileId),
'video': PlutoCell(value: media),
'thumbnail': PlutoCell(value: media.fileUrl),
'title': PlutoCell(value: media.title ?? media.fileName),
'description': PlutoCell(value: media.fileDescription ?? ''),
'fileName': PlutoCell(value: media.fileName),
'category':
PlutoCell(value: _getCategoryName(media.mediaCategoryFk)),
'reproducciones': PlutoCell(value: media.reproducciones),
'duration': PlutoCell(value: media.seconds ?? 0),
'size': PlutoCell(value: _formatFileSize(media.fileSizeBytes)),
'created_at': PlutoCell(value: media.createdAt),
'actions': PlutoCell(value: media.mediaFileId),
'duration': PlutoCell(
value: media.seconds != null
? _formatDuration(media.seconds!)
: '-'),
'createdAt': PlutoCell(
value: media.createdAt?.toString().split('.')[0] ?? '-'),
'actions': PlutoCell(value: media),
},
),
);