- Caricamento Video

This commit is contained in:
Surya Paolo
2025-12-19 22:59:13 +01:00
parent 99d623da79
commit 7b746e3b6f
21 changed files with 1562 additions and 25 deletions

View File

@@ -74,6 +74,8 @@ export default defineConfig((ctx) => {
'@paths': path.resolve(__dirname, 'src/store/Api/ApiRoutes.ts'),
'@images': path.resolve(__dirname, 'src/assets/images'),
'@icons': path.resolve(__dirname, 'src/public/myicons'),
'@types': path.resolve(__dirname, 'src/types'),
'@services': path.resolve(__dirname, 'src/services'),
},
};

View File

@@ -25,6 +25,7 @@ const msg_website_it = {
Ammetti: 'Ammetti',
AbilitaCircuito: 'Abilita Circuito',
installaApp: 'Installa App',
VideoPage: 'Video',
fundraising: 'Sostieni il Progetto',
notifs: 'Configura le Notifiche',
unsubscribe: 'Disiscriviti',
@@ -88,6 +89,7 @@ const msg_website_it = {
eventodef: 'Evento:',
prova: 'prova',
dbop: 'Operazioni',
VideoPage: 'Video',
dbopmacro: 'Operazioni Macro',
projall: 'Comunitari',
groups: 'Lista Gruppi',

View File

@@ -0,0 +1,150 @@
.video-gallery {
max-width: 1400px;
margin: 0 auto;
.breadcrumb-section {
background: rgba(0, 0, 0, 0.02);
}
.section-title {
font-weight: 500;
}
.cursor-pointer {
cursor: pointer;
}
// Folder Cards
.folder-card {
transition: all 0.3s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
// Video Cards
.video-card {
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
.video-container {
position: relative;
padding-top: 56.25%; // 16:9
background: #000;
overflow: hidden;
cursor: pointer;
}
.video-preview {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.play-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
transition: background 0.3s ease;
&:hover {
background: rgba(0, 0, 0, 0.5);
}
}
// List View
.video-list {
.video-list-item {
transition: background 0.2s ease;
&:hover {
background: rgba(0, 0, 0, 0.02);
}
}
}
.video-thumbnail {
position: relative;
overflow: hidden;
border-radius: 4px;
video {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumbnail-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
}
}
// Empty State
.empty-state {
text-align: center;
padding: 60px 20px;
}
// Dialogs
.video-player-dialog {
background: #000;
.video-player-container {
height: calc(100vh - 50px);
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.video-player {
max-width: 100%;
max-height: 100%;
}
}
.dialog-move {
min-width: 350px;
@media (max-width: 400px) {
min-width: 90vw;
}
}
}
// Dark Mode
.body--dark {
.video-gallery {
.breadcrumb-section {
background: rgba(255, 255, 255, 0.05);
}
.video-list-item:hover {
background: rgba(255, 255, 255, 0.05);
}
}
}

View File

@@ -0,0 +1,291 @@
import { defineComponent, ref, computed, onMounted, watch } from 'vue';
import { useQuasar } from 'quasar';
import { videoService } from '@/services/videoService';
import type { IVideo, IFolder, IFolderOption } from '@/types/video.types';
interface IBreadcrumb {
name: string;
path: string;
}
export default defineComponent({
name: 'VideoGallery',
props: {
refreshTrigger: {
type: Number,
default: 0
}
},
setup(props) {
const $q = useQuasar();
// State
const loading = ref(false);
const currentPath = ref('');
const videos = ref<IVideo[]>([]);
const subfolders = ref<IFolder[]>([]);
const allFolders = ref<IFolderOption[]>([]);
const viewMode = ref<'grid' | 'list'>('grid');
// Video player
const showVideoDialog = ref(false);
const currentVideo = ref<IVideo | null>(null);
const videoPlayer = ref<HTMLVideoElement | null>(null);
// Move dialog
const showMoveDialog = ref(false);
const moveDestination = ref('');
const videoToMove = ref<IVideo | null>(null);
const moving = ref(false);
// Computed
const breadcrumbs = computed<IBreadcrumb[]>(() => {
if (!currentPath.value) return [];
const parts = currentPath.value.split('/');
return parts.map((part, index) => ({
name: part,
path: parts.slice(0, index + 1).join('/')
}));
});
// Methods
const loadContent = async (): Promise<void> => {
loading.value = true;
try {
const response = await videoService.getVideos(currentPath.value);
videos.value = response.data?.videos || [];
subfolders.value = response.data?.folders || [];
} catch (error) {
$q.notify({
type: 'negative',
message: 'Errore nel caricamento dei contenuti'
});
} finally {
loading.value = false;
}
};
const loadAllFolders = async (): Promise<void> => {
try {
const response = await videoService.getFolders();
const folders = response.data?.folders || [];
allFolders.value = [
{ label: 'Root', value: '' },
...folders.map(f => ({
label: f.path,
value: f.path
}))
];
} catch (error) {
console.error('Error loading folders:', error);
}
};
const navigateTo = (path: string): void => {
currentPath.value = path;
loadContent();
};
const toggleViewMode = (): void => {
viewMode.value = viewMode.value === 'grid' ? 'list' : 'grid';
};
const openVideo = (video: IVideo): void => {
currentVideo.value = video;
showVideoDialog.value = true;
};
const getVideoUrl = (path: string): string => {
return videoService.getVideoUrl(path);
};
const downloadVideo = (video: IVideo): void => {
const link = document.createElement('a');
link.href = getVideoUrl(video.path);
link.download = video.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const renameVideo = (video: IVideo): void => {
$q.dialog({
title: 'Rinomina Video',
message: 'Inserisci il nuovo nome del file:',
prompt: {
model: video.filename,
type: 'text'
},
cancel: true,
persistent: true
}).onOk(async (newName: string) => {
if (!newName.trim() || newName === video.filename) return;
try {
await videoService.renameVideo(video.folder || currentPath.value, video.filename, newName);
$q.notify({ type: 'positive', message: 'Video rinominato!' });
loadContent();
} catch (error: any) {
$q.notify({
type: 'negative',
message: error.response?.data?.error || 'Errore durante la rinomina'
});
}
});
};
const moveVideo = async (video: IVideo): Promise<void> => {
await loadAllFolders();
videoToMove.value = video;
moveDestination.value = '';
showMoveDialog.value = true;
};
const confirmMoveVideo = async (): Promise<void> => {
if (!videoToMove.value) return;
moving.value = true;
try {
await videoService.moveVideo(
videoToMove.value.folder || currentPath.value,
videoToMove.value.filename,
moveDestination.value
);
$q.notify({ type: 'positive', message: 'Video spostato!' });
showMoveDialog.value = false;
loadContent();
} catch (error: any) {
$q.notify({
type: 'negative',
message: error.response?.data?.error || 'Errore durante lo spostamento'
});
} finally {
moving.value = false;
}
};
const confirmDeleteVideo = (video: IVideo): void => {
$q.dialog({
title: 'Conferma Eliminazione',
message: `Sei sicuro di voler eliminare "${video.filename}"?`,
cancel: true,
persistent: true,
color: 'negative'
}).onOk(async () => {
try {
await videoService.deleteVideo(video.folder || currentPath.value, video.filename);
$q.notify({ type: 'positive', message: 'Video eliminato!' });
loadContent();
} catch (error: any) {
$q.notify({
type: 'negative',
message: error.response?.data?.error || 'Errore durante l\'eliminazione'
});
}
});
};
const renameFolder = (folder: IFolder): void => {
$q.dialog({
title: 'Rinomina Cartella',
message: 'Inserisci il nuovo nome:',
prompt: {
model: folder.name,
type: 'text'
},
cancel: true,
persistent: true
}).onOk(async (newName: string) => {
if (!newName.trim() || newName === folder.name) return;
try {
await videoService.renameFolder(folder.path, newName);
$q.notify({ type: 'positive', message: 'Cartella rinominata!' });
loadContent();
} catch (error: any) {
$q.notify({
type: 'negative',
message: error.response?.data?.error || 'Errore durante la rinomina'
});
}
});
};
const confirmDeleteFolder = (folder: IFolder): void => {
$q.dialog({
title: 'Conferma Eliminazione',
message: `Sei sicuro di voler eliminare la cartella "${folder.name}" e tutto il suo contenuto?`,
cancel: true,
persistent: true,
color: 'negative'
}).onOk(async () => {
try {
await videoService.deleteFolder(folder.path);
$q.notify({ type: 'positive', message: 'Cartella eliminata!' });
loadContent();
} catch (error: any) {
$q.notify({
type: 'negative',
message: error.response?.data?.error || 'Errore durante l\'eliminazione'
});
}
});
};
const formatFileSize = (bytes: number): string => {
return videoService.formatFileSize(bytes);
};
const formatDate = (dateString: string): string => {
return videoService.formatDate(dateString);
};
// Watchers
watch(() => props.refreshTrigger, () => {
loadContent();
});
// Lifecycle
onMounted(() => {
loadContent();
});
return {
// State
loading,
currentPath,
videos,
subfolders,
allFolders,
viewMode,
showVideoDialog,
currentVideo,
videoPlayer,
showMoveDialog,
moveDestination,
moving,
// Computed
breadcrumbs,
// Methods
loadContent,
navigateTo,
toggleViewMode,
openVideo,
getVideoUrl,
downloadVideo,
renameVideo,
moveVideo,
confirmMoveVideo,
confirmDeleteVideo,
renameFolder,
confirmDeleteFolder,
formatFileSize,
formatDate
};
}
});

View File

@@ -0,0 +1,271 @@
<template>
<q-card class="video-gallery">
<q-card-section>
<div class="row items-center justify-between">
<div class="text-h6">
<q-icon name="video_library" class="q-mr-sm" />
Galleria Video
</div>
<div class="row q-gutter-sm">
<q-btn
flat
round
:icon="viewMode === 'grid' ? 'view_list' : 'grid_view'"
@click="toggleViewMode"
>
<q-tooltip>Cambia vista</q-tooltip>
</q-btn>
<q-btn
flat
round
icon="refresh"
:loading="loading"
@click="loadContent"
/>
</div>
</div>
</q-card-section>
<q-separator />
<!-- Breadcrumb -->
<q-card-section class="q-py-sm breadcrumb-section">
<q-breadcrumbs>
<q-breadcrumbs-el
icon="home"
label="Root"
class="cursor-pointer"
@click="navigateTo('')"
/>
<q-breadcrumbs-el
v-for="(crumb, index) in breadcrumbs"
:key="index"
:label="crumb.name"
class="cursor-pointer"
@click="navigateTo(crumb.path)"
/>
</q-breadcrumbs>
</q-card-section>
<q-separator />
<q-card-section>
<!-- Loading -->
<div v-if="loading" class="text-center q-pa-lg">
<q-spinner-orbit size="50px" color="primary" />
<div class="q-mt-md">Caricamento...</div>
</div>
<!-- Content -->
<div v-else>
<!-- Folders -->
<div v-if="subfolders.length > 0" class="q-mb-lg">
<div class="text-subtitle1 text-grey-7 q-mb-sm section-title">
<q-icon name="folder" class="q-mr-xs" />
Cartelle
</div>
<div class="row q-gutter-md">
<div
v-for="folder in subfolders"
:key="folder.path"
class="col-6 col-sm-4 col-md-3 col-lg-2"
>
<q-card
class="folder-card cursor-pointer"
flat
bordered
@click="navigateTo(folder.path)"
>
<q-card-section class="text-center">
<q-icon name="folder" size="48px" color="amber" />
<div class="text-subtitle2 q-mt-sm ellipsis">
{{ folder.name }}
</div>
</q-card-section>
<q-menu touch-position context-menu>
<q-list dense>
<q-item v-close-popup clickable @click="renameFolder(folder)">
<q-item-section avatar>
<q-icon name="edit" color="primary" />
</q-item-section>
<q-item-section>Rinomina</q-item-section>
</q-item>
<q-item v-close-popup clickable @click="confirmDeleteFolder(folder)">
<q-item-section avatar>
<q-icon name="delete" color="negative" />
</q-item-section>
<q-item-section>Elimina</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-card>
</div>
</div>
</div>
<!-- Videos -->
<div v-if="videos.length > 0">
<div class="text-subtitle1 text-grey-7 q-mb-sm section-title">
<q-icon name="movie" class="q-mr-xs" />
Video ({{ videos.length }})
</div>
<!-- Grid View -->
<div v-if="viewMode === 'grid'" class="row q-gutter-md">
<div
v-for="video in videos"
:key="video.id"
class="col-12 col-sm-6 col-md-4 col-lg-3"
>
<q-card class="video-card">
<div class="video-container" @click="openVideo(video)">
<video
:src="getVideoUrl(video.path)"
class="video-preview"
preload="metadata"
/>
<div class="play-overlay">
<q-icon name="play_circle" size="64px" color="white" />
</div>
</div>
<q-card-section class="q-py-sm">
<div class="text-subtitle2 ellipsis">{{ video.filename }}</div>
<div class="text-caption text-grey">
{{ formatFileSize(video.size) }}
{{ formatDate(video.createdAt) }}
</div>
</q-card-section>
<q-separator />
<q-card-actions>
<q-btn flat round icon="play_arrow" color="primary" @click="openVideo(video)">
<q-tooltip>Riproduci</q-tooltip>
</q-btn>
<q-btn flat round icon="download" color="secondary" @click="downloadVideo(video)">
<q-tooltip>Scarica</q-tooltip>
</q-btn>
<q-space />
<q-btn flat round icon="more_vert">
<q-menu>
<q-list dense>
<q-item v-close-popup clickable @click="renameVideo(video)">
<q-item-section avatar>
<q-icon name="edit" />
</q-item-section>
<q-item-section>Rinomina</q-item-section>
</q-item>
<q-item v-close-popup clickable @click="moveVideo(video)">
<q-item-section avatar>
<q-icon name="drive_file_move" />
</q-item-section>
<q-item-section>Sposta</q-item-section>
</q-item>
<q-separator />
<q-item v-close-popup clickable @click="confirmDeleteVideo(video)">
<q-item-section avatar>
<q-icon name="delete" color="negative" />
</q-item-section>
<q-item-section class="text-negative">Elimina</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
</q-card-actions>
</q-card>
</div>
</div>
<!-- List View -->
<q-list v-else separator class="video-list">
<q-item v-for="video in videos" :key="video.id" class="video-list-item">
<q-item-section avatar>
<q-avatar square size="60px" class="video-thumbnail">
<video :src="getVideoUrl(video.path)" preload="metadata" />
<div class="thumbnail-overlay">
<q-icon name="play_arrow" color="white" />
</div>
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label>{{ video.filename }}</q-item-label>
<q-item-label caption>
{{ formatFileSize(video.size) }} {{ formatDate(video.createdAt) }}
</q-item-label>
</q-item-section>
<q-item-section side>
<div class="row q-gutter-xs">
<q-btn flat round dense icon="play_arrow" color="primary" @click="openVideo(video)" />
<q-btn flat round dense icon="download" color="secondary" @click="downloadVideo(video)" />
<q-btn flat round dense icon="delete" color="negative" @click="confirmDeleteVideo(video)" />
</div>
</q-item-section>
</q-item>
</q-list>
</div>
<!-- Empty State -->
<div v-if="!loading && videos.length === 0 && subfolders.length === 0" class="empty-state">
<q-icon name="folder_off" size="80px" color="grey-5" />
<div class="text-h6 text-grey-6 q-mt-md">Nessun contenuto</div>
<div class="text-grey-5">
Questa cartella è vuota. Carica dei video per iniziare.
</div>
</div>
</div>
</q-card-section>
<!-- Video Player Dialog -->
<q-dialog v-model="showVideoDialog" maximized transition-show="fade" transition-hide="fade">
<q-card class="video-player-dialog">
<q-bar class="bg-grey-9">
<div class="text-white ellipsis">{{ currentVideo?.filename }}</div>
<q-space />
<q-btn v-close-popup dense flat icon="close" color="white" />
</q-bar>
<q-card-section class="video-player-container">
<video
v-if="currentVideo"
ref="videoPlayer"
:src="getVideoUrl(currentVideo.path)"
controls
autoplay
class="video-player"
/>
</q-card-section>
</q-card>
</q-dialog>
<!-- Move Video Dialog -->
<q-dialog v-model="showMoveDialog" persistent>
<q-card class="dialog-move">
<q-card-section>
<div class="text-h6">Sposta Video</div>
</q-card-section>
<q-card-section class="q-pt-none">
<q-select
v-model="moveDestination"
:options="allFolders"
label="Cartella di destinazione"
outlined
emit-value
map-options
/>
</q-card-section>
<q-card-actions align="right">
<q-btn v-close-popup flat label="Annulla" />
<q-btn color="primary" label="Sposta" :loading="moving" @click="confirmMoveVideo" />
</q-card-actions>
</q-card>
</q-dialog>
</q-card>
</template>
<script lang="ts" src="./VideoGallery.ts" />
<style lang="scss" src="./VideoGallery.scss" scoped />

View File

@@ -0,0 +1,73 @@
.video-uploader {
max-width: 800px;
margin: 0 auto;
.btn-new-folder {
height: 56px;
}
.dropzone {
border: 2px dashed #ccc;
border-radius: 8px;
min-height: 120px;
transition: border-color 0.3s ease;
&:hover {
border-color: var(--q-primary);
}
&:deep(.q-field__control) {
min-height: 120px;
}
}
.file-chip {
max-width: 100%;
.ellipsis {
max-width: 200px;
}
}
.upload-queue {
background: rgba(0, 0, 0, 0.02);
border-radius: 8px;
.q-item {
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
&:last-child {
border-bottom: none;
}
}
}
.dialog-new-folder {
min-width: 350px;
@media (max-width: 400px) {
min-width: 90vw;
}
}
}
// Dark mode support
.body--dark {
.video-uploader {
.dropzone {
border-color: rgba(255, 255, 255, 0.3);
&:hover {
border-color: var(--q-primary);
}
}
.upload-queue {
background: rgba(255, 255, 255, 0.05);
.q-item {
border-bottom-color: rgba(255, 255, 255, 0.1);
}
}
}
}

View File

@@ -0,0 +1,199 @@
import { defineComponent, ref, computed, onMounted } from 'vue';
import { useQuasar } from 'quasar';
import { videoService } from '@/services/videoService';
import {
IFolder,
IFolderOption,
IUploadQueueItem,
UploadStatus,
UPLOAD_STATUS_CONFIG,
MAX_FILES
} from '@/types/video.types';
export default defineComponent({
name: 'VideoUploader',
emits: ['upload-complete'],
setup(props, { emit }) {
const $q = useQuasar();
// State
const selectedFiles = ref<File[] | null>(null);
const selectedFolder = ref<string>('');
const folders = ref<IFolder[]>([]);
const loadingFolders = ref(false);
const uploadQueue = ref<IUploadQueueItem[]>([]);
const isUploading = ref(false);
// Dialog state
const showNewFolderDialog = ref(false);
const newFolderName = ref('');
const newFolderParent = ref('');
const creatingFolder = ref(false);
// Constants
const maxFiles = MAX_FILES;
// Computed
const folderOptions = computed<IFolderOption[]>(() => [
{ label: '📁 Root (principale)', value: '' },
...folders.value.map(f => ({
label: `${' '.repeat((f.level || 1) - 1)}📂 ${f.name}`,
value: f.path
}))
]);
const parentFolderOptions = computed<IFolderOption[]>(() => [
{ label: 'Root', value: '' },
...folderOptions.value.slice(1)
]);
// Methods
const loadFolders = async (): Promise<void> => {
loadingFolders.value = true;
try {
const response = await videoService.getFolders();
folders.value = response.data?.folders || [];
} catch (error) {
$q.notify({
type: 'negative',
message: 'Errore nel caricamento delle cartelle'
});
} finally {
loadingFolders.value = false;
}
};
const onFilesSelected = (files: File[] | null): void => {
if (!files) return;
const fileArray = Array.isArray(files) ? files : [files];
uploadQueue.value = fileArray.map(file => ({
file,
progress: 0,
status: 'pending' as UploadStatus
}));
};
const startUpload = async (): Promise<void> => {
if (uploadQueue.value.length === 0) return;
isUploading.value = true;
let completedCount = 0;
for (const item of uploadQueue.value) {
if (item.status === 'complete') continue;
item.status = 'uploading';
try {
await videoService.uploadVideo(
item.file,
selectedFolder.value || 'default',
(progress: number) => {
item.progress = progress;
}
);
item.status = 'complete';
item.progress = 100;
completedCount++;
} catch (error: any) {
item.status = 'error';
item.error = error.response?.data?.error || error.message;
$q.notify({
type: 'negative',
message: `Errore upload ${item.file.name}: ${item.error}`
});
}
}
isUploading.value = false;
if (completedCount > 0) {
$q.notify({
type: 'positive',
message: `${completedCount} video caricati con successo!`
});
emit('upload-complete');
}
};
const createNewFolder = async (): Promise<void> => {
if (!newFolderName.value.trim()) {
$q.notify({
type: 'warning',
message: 'Inserisci un nome per la cartella'
});
return;
}
creatingFolder.value = true;
try {
await videoService.createFolder(newFolderName.value, newFolderParent.value);
$q.notify({ type: 'positive', message: 'Cartella creata!' });
await loadFolders();
selectedFolder.value = newFolderParent.value
? `${newFolderParent.value}/${newFolderName.value}`
: newFolderName.value;
showNewFolderDialog.value = false;
newFolderName.value = '';
newFolderParent.value = '';
} catch (error: any) {
$q.notify({
type: 'negative',
message: error.response?.data?.error || 'Errore nella creazione della cartella'
});
} finally {
creatingFolder.value = false;
}
};
const clearQueue = (): void => {
uploadQueue.value = [];
selectedFiles.value = null;
};
const formatFileSize = (bytes: number): string => {
return videoService.formatFileSize(bytes);
};
const getStatusConfig = (status: UploadStatus) => {
return UPLOAD_STATUS_CONFIG[status];
};
// Lifecycle
onMounted(() => {
loadFolders();
});
return {
// State
selectedFiles,
selectedFolder,
loadingFolders,
uploadQueue,
isUploading,
showNewFolderDialog,
newFolderName,
newFolderParent,
creatingFolder,
maxFiles,
// Computed
folderOptions,
parentFolderOptions,
// Methods
onFilesSelected,
startUpload,
createNewFolder,
clearQueue,
formatFileSize,
getStatusConfig
};
}
});

View File

@@ -0,0 +1,181 @@
<template>
<q-card class="video-uploader">
<q-card-section>
<div class="text-h6">
<q-icon name="cloud_upload" class="q-mr-sm" />
Carica Video
</div>
</q-card-section>
<q-separator />
<q-card-section>
<!-- Selezione Cartella -->
<div class="row q-gutter-md q-mb-md">
<div class="col-12 col-md-8">
<q-select
v-model="selectedFolder"
:options="folderOptions"
label="Seleziona Cartella di Destinazione"
outlined
emit-value
map-options
:loading="loadingFolders"
>
<template #prepend>
<q-icon name="folder" />
</template>
<template #option="scope">
<q-item v-bind="scope.itemProps">
<q-item-section avatar>
<q-icon name="folder" color="amber" />
</q-item-section>
<q-item-section>
<q-item-label>{{ scope.opt.label }}</q-item-label>
</q-item-section>
</q-item>
</template>
</q-select>
</div>
<div class="col-12 col-md-4">
<q-btn
color="secondary"
icon="create_new_folder"
label="Nuova Cartella"
class="full-width btn-new-folder"
@click="showNewFolderDialog = true"
/>
</div>
</div>
<!-- Dropzone -->
<q-file
v-model="selectedFiles"
label="Trascina i video qui o clicca per selezionare"
outlined
multiple
counter
accept="video/*"
:max-files="maxFiles"
class="dropzone"
@update:model-value="onFilesSelected"
>
<template #prepend>
<q-icon name="videocam" />
</template>
<template #file="{ file }">
<q-chip class="full-width q-my-xs file-chip" square>
<q-avatar>
<q-icon name="movie" />
</q-avatar>
<div class="ellipsis relative-position">
{{ file.name }}
<q-tooltip>{{ file.name }}</q-tooltip>
</div>
<q-chip dense class="q-ml-sm" color="primary" text-color="white">
{{ formatFileSize(file.size) }}
</q-chip>
</q-chip>
</template>
</q-file>
<!-- Lista Upload Queue -->
<q-list v-if="uploadQueue.length > 0" class="q-mt-md upload-queue">
<q-item v-for="(item, index) in uploadQueue" :key="index">
<q-item-section avatar>
<q-icon
:name="getStatusConfig(item.status).icon"
:color="getStatusConfig(item.status).color"
/>
</q-item-section>
<q-item-section>
<q-item-label>{{ item.file.name }}</q-item-label>
<q-item-label caption>
{{ formatFileSize(item.file.size) }}
</q-item-label>
<q-linear-progress
v-if="item.status === 'uploading'"
:value="item.progress / 100"
color="primary"
class="q-mt-sm"
/>
<q-item-label v-if="item.error" caption class="text-negative">
{{ item.error }}
</q-item-label>
</q-item-section>
<q-item-section side>
<q-badge
:color="getStatusConfig(item.status).color"
:label="getStatusConfig(item.status).label"
/>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
<q-separator />
<q-card-actions align="right">
<q-btn
flat
label="Pulisci"
color="grey"
:disable="isUploading"
@click="clearQueue"
/>
<q-btn
color="primary"
icon="cloud_upload"
label="Carica Video"
:loading="isUploading"
:disable="uploadQueue.length === 0 || !selectedFolder"
@click="startUpload"
/>
</q-card-actions>
<!-- Dialog Nuova Cartella -->
<q-dialog v-model="showNewFolderDialog" persistent>
<q-card class="dialog-new-folder">
<q-card-section>
<div class="text-h6">Nuova Cartella</div>
</q-card-section>
<q-card-section class="q-pt-none">
<q-input
v-model="newFolderName"
label="Nome Cartella"
autofocus
outlined
@keyup.enter="createNewFolder"
/>
<q-select
v-model="newFolderParent"
:options="parentFolderOptions"
label="Cartella Padre (opzionale)"
outlined
emit-value
map-options
class="q-mt-md"
/>
</q-card-section>
<q-card-actions align="right">
<q-btn v-close-popup flat label="Annulla" />
<q-btn
color="primary"
label="Crea"
:loading="creatingFolder"
@click="createNewFolder"
/>
</q-card-actions>
</q-card>
</q-dialog>
</q-card>
</template>
<script lang="ts" src="./VideoUploader.ts" />
<style lang="scss" src="./VideoUploader.scss" scoped />

View File

@@ -85,6 +85,7 @@ const msg_website_it = {
eventodef: 'Evento:',
prova: 'prova',
dbop: 'Operazioni',
VideoPage: 'Video',
projall: 'Comunitari',
groups: 'Lista Gruppi',
projectsShared: 'Condivisi da me',

View File

@@ -250,6 +250,7 @@ export interface IUserFields {
made_gift?: boolean
tokens?: IToken[]
date_reg?: Date
date_deleted?: Date
lasttimeonline?: Date
profile: IUserProfile
qualified?: boolean

16
src/pages/VideosPage.scss Normal file
View File

@@ -0,0 +1,16 @@
.videos-page {
padding: 16px;
background: #f5f5f5;
min-height: 100vh;
.container {
max-width: 1400px;
margin: 0 auto;
}
}
.body--dark {
.videos-page {
background: #1d1d1d;
}
}

25
src/pages/VideosPage.ts Normal file
View File

@@ -0,0 +1,25 @@
import { defineComponent, ref } from 'vue';
import VideoUploader from '@/components/video/VideoUploader.vue';
import VideoGallery from '@/components/video/VideoGallery.vue';
export default defineComponent({
name: 'VideosPage',
components: {
VideoUploader,
VideoGallery
},
setup() {
const refreshTrigger = ref(0);
const onUploadComplete = (): void => {
refreshTrigger.value++;
};
return {
refreshTrigger,
onUploadComplete
};
}
});

11
src/pages/VideosPage.vue Normal file
View File

@@ -0,0 +1,11 @@
<template>
<q-page class="videos-page">
<div class="container">
<VideoUploader @upload-complete="onUploadComplete" />
<VideoGallery :refresh-trigger="refreshTrigger" class="q-mt-lg" />
</div>
</q-page>
</template>
<script lang="ts" src="./VideosPage.ts" />
<style lang="scss" src="./VideosPage.scss" scoped />

View File

@@ -196,6 +196,15 @@
mykey="Email"
:myvalue="myuser.email"
/>
<CKeyAndValue
mykey="Registrata il"
:mydate="myuser.date_reg"
/>
<CKeyAndValue
v-if="myuser.date_deleted"
mykey="Cancellato il"
:mydate="myuser.date_deleted"
/>
<CKeyAndValue
mykey="Email Verificata"
:myvalue="myuser.verified_email"

View File

@@ -43,6 +43,19 @@ function getRoutesAd(site: ISites) {
submenu: true,
onlyAdmin: true
},
{
active: true,
order: 125,
path: '/admin/videos',
materialIcon: 'fas fa-video',
name: 'pages.VideoPage',
component: () => import('@/pages/VideosPage.vue'),
meta: { requiresAuth: true },
inmenu: false,
onlyManager: true,
onlyAdmin: true,
infooter: false,
},
{
active: true,
order: 1020,

View File

@@ -0,0 +1,167 @@
import { Api } from '@/store/Api';
const BASE_URL = process.env.VITE_MONGODB_HOST;
export const videoService = {
// ============ FOLDER METHODS ============
async getFolders() {
const response = await Api.SendReq('/api/video/folders', 'GET');
return response.data;
},
async createFolder(folderName, parentPath = '') {
const response = await Api.SendReq('/api/video/folders', 'POST', {
folderName,
parentPath,
});
return response.data;
},
async renameFolder(folderPath, newName) {
const response = await Api.SendReq(`/api/video/folders/${folderPath}`, 'PUT', {
newName,
});
return response.data;
},
async deleteFolder(folderPath) {
const response = await Api.SendReq(`/api/video/folders/${folderPath}`, 'DELETE');
return response.data;
},
// ============ VIDEO METHODS ============
async getVideos(folder = '') {
const response = await Api.SendReq('/api/video/videos', 'GET', { folder });
return response.data;
},
async uploadVideo(file, folder, onProgress) {
const formData = new FormData();
formData.append('video', file);
// ✅ Folder come query parameter nell'URL
const targetFolder = encodeURIComponent(folder || 'default');
const response = await Api.SendReq(
`/api/video/videos/upload?folder=${targetFolder}`,
'POSTFORMDATA',
null,
false,
false,
1,
5000,
formData,
null,
{
timeout: 600000,
onUploadProgress: (progressEvent) => {
if (progressEvent.total && onProgress) {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
onProgress(percentCompleted);
}
},
}
);
return response.data;
},
async uploadVideos(files, folder, onProgress) {
const formData = new FormData();
files.forEach((file) => formData.append('videos', file));
// ✅ Folder come query parameter
const targetFolder = encodeURIComponent(folder || 'default');
const response = await Api.SendReq(
`/api/video/videos/upload-multiple?folder=${targetFolder}`,
'POSTFORMDATA',
null,
false,
false,
1,
5000,
formData,
null,
{
timeout: 600000,
onUploadProgress: (progressEvent) => {
if (progressEvent.total && onProgress) {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
onProgress(percentCompleted);
}
},
}
);
return response.data;
},
async renameVideo(folder, filename, newFilename) {
const response = await Api.SendReq(
`/api/video/videos/${folder}/${filename}/rename`,
'PUT',
{ newFilename }
);
return response.data;
},
async moveVideo(folder, filename, destinationFolder) {
const response = await Api.SendReq(
`/api/video/videos/${folder}/${filename}/move`,
'PUT',
{ destinationFolder }
);
return response.data;
},
async deleteVideo(folder, filename) {
const response = await Api.SendReq(
`/api/video/videos/${folder}/${filename}`,
'DELETE'
);
return response.data;
},
// ============ UTILITY METHODS ============
getVideoUrl(videoPath) {
return `${BASE_URL}${videoPath}`;
},
getStreamUrl(folder, filename) {
return `${BASE_URL}/api/video/stream/${folder}/${filename}`;
},
formatFileSize(bytes) {
if (!bytes || bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
},
formatDate(dateString) {
if (!dateString) return 'N/A';
try {
return new Date(dateString).toLocaleDateString('it-IT', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return 'N/A';
}
},
};
export default videoService;

View File

@@ -1,23 +1,36 @@
import axios, {
AxiosInstance, AxiosPromise, AxiosResponse, AxiosInterceptorManager,
} from 'axios'
import { Api } from '@api'
import type * as Types from '@/store/Api/ApiTypes'
AxiosInstance,
AxiosPromise,
AxiosResponse,
AxiosInterceptorManager,
} from 'axios';
import { Api } from '@api';
import type * as Types from '@/store/Api/ApiTypes';
// Funzione che smista la richiesta in base al metodo
async function sendRequest(url, method, mydata, myformdata = null, responsedata = null, options = null) {
async function sendRequest(
url: any,
method: string,
mydata: any,
myformdata: any = null,
responsedata: any = null,
options: any = null
) {
const actions = {
get: () => Api.get(url, mydata, responsedata),
post: () => Api.post(url, mydata, responsedata, options),
postformdata: () => Api.postFormData(url, myformdata, responsedata),
postformdata: () => Api.postFormData(url, myformdata, responsedata, options), // ✅ Aggiunto options
delete: () => Api.Delete(url, mydata, responsedata),
put: () => Api.put(url, mydata, responsedata),
patch: () => Api.patch(url, mydata, responsedata),
};
const key = method.toLowerCase();
if (actions[key]) return await actions[key]();
throw new Error(`Metodo non supportato: ${method}`);
if (actions[key]) {
return await actions[key]();
}
export default sendRequest
throw new Error(`Metodo non supportato: ${method}`);
}
export default sendRequest;

View File

@@ -145,7 +145,17 @@ async function Request(
// ✅ AGGIUNGI IL TIMEOUT DALLE OPTIONS
if (options?.timeout) {
config.timeout = options.timeout; // in millisecondi (es. 300000 = 5 minuti)
config.timeout = options.timeout;
}
// ✅ AGGIUNGI SUPPORTO PER onUploadProgress
if (options?.onUploadProgress) {
config.onUploadProgress = options.onUploadProgress;
}
// ✅ AGGIUNGI SUPPORTO PER onDownloadProgress (opzionale)
if (options?.onDownloadProgress) {
config.onDownloadProgress = options.onDownloadProgress;
}
if (options?.stream) config.responseType = 'stream';
@@ -210,7 +220,6 @@ async function Request(
},
...responsedata,
});*/
} else if (type === 'postFormData') {
response = await axiosInstance.post(path, payload, config);
} else {
@@ -221,11 +230,9 @@ async function Request(
// Gestione aggiornamento token se necessario
//const setAuthToken = path === '/updatepwd' || path === '/users/login';
const setAuthToken = !!x_auth_token;
if (
response && setAuthToken
) {
if (response && setAuthToken) {
const refreshToken = String(response.headers['x-refrtok'] || '');
const browser_random = userStore.getBrowserRandom()
const browser_random = userStore.getBrowserRandom();
if (!x_auth_token) {
userStore.setServerCode(toolsext.ERR_AUTHENTICATION);
}
@@ -245,7 +252,7 @@ async function Request(
return new Types.AxiosSuccess(response.data, response.status);
} catch (error) {
// Aggiornamento asincrono dello stato di connessione (setTimeout per dare tempo a eventuali animazioni)
console.error('Errore funzione Request', error)
console.error('Errore funzione Request', error);
setTimeout(() => {
if (['get'].includes(type.toLowerCase())) {
globalStore.connData.downloading_server =

View File

@@ -49,11 +49,11 @@ export const Api = {
return await Request('post', path, payload, responsedata, options);
},
async postFormData(path: string, payload?: any, responsedata?: any) {
async postFormData(path: string, payload?: any, responsedata?: any, options?: any) {
const globalStore = useGlobalStore();
globalStore.connData.uploading_server = 1;
globalStore.connData.downloading_server = 1;
return await Request('postFormData', path, payload, responsedata);
globalStore.connData.uploading_server = 1;
return await Request('postFormData', path, payload, responsedata, options);
},
async get(path: string, payload?: any, responsedata?: any) {
@@ -241,7 +241,7 @@ export const Api = {
if (res.status === serv_constants.RIS_CODE__HTTP_INVALID_TOKEN) {
userStore.setServerCode(toolsext.ERR_AUTHENTICATION);
userStore.setAuth('', '');
userStore.setAuth('', '', '');
// throw { code: toolsext.ERR_AUTHENTICATION };
throw { status: toolsext.ERR_RETRY_LOGIN };
}

100
src/types/video.types.ts Normal file
View File

@@ -0,0 +1,100 @@
// ============ INTERFACES ============
export interface IFolder {
name: string;
path: string;
level?: number;
createdAt?: string;
}
export interface IVideo {
id: string;
filename: string;
originalName?: string;
folder: string;
path: string;
size: number;
mimetype?: string;
createdAt: string;
modifiedAt?: string;
uploadedAt?: string;
}
export interface IFolderOption {
label: string;
value: string;
level?: number;
}
export interface IUploadQueueItem {
file: File;
progress: number;
status: UploadStatus;
error?: string;
}
export interface IVideoResponse {
success: boolean;
data?: {
videos?: IVideo[];
folders?: IFolder[];
video?: IVideo;
folder?: IFolder;
currentPath?: string;
totalVideos?: number;
newPath?: string;
};
message?: string;
error?: string;
}
export interface IFolderResponse {
success: boolean;
data?: {
folders?: IFolder[];
folder?: IFolder;
};
message?: string;
error?: string;
}
// ============ TYPES ============
export type UploadStatus = 'pending' | 'uploading' | 'complete' | 'error';
// ============ CONSTANTS ============
export const UPLOAD_STATUS_CONFIG = {
pending: {
icon: 'schedule',
color: 'grey',
label: 'In attesa'
},
uploading: {
icon: 'cloud_upload',
color: 'primary',
label: 'Caricamento...'
},
complete: {
icon: 'check_circle',
color: 'positive',
label: 'Completato'
},
error: {
icon: 'error',
color: 'negative',
label: 'Errore'
}
} as const;
export const ALLOWED_VIDEO_TYPES = [
'video/mp4',
'video/webm',
'video/ogg',
'video/quicktime',
'video/x-msvideo',
'video/x-matroska'
];
export const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB
export const MAX_FILES = 10;

View File

@@ -6,7 +6,6 @@
"target": "ESNext",
"jsx": "react-jsx",
"strict": true,
"baseUrl": "./",
"skipLibCheck": true,
"allowJs": true,
@@ -88,6 +87,12 @@
"@icons": [
"src/public/myicons/*"
],
"@types/*": [
"src/types/*"
],
"@services/*": [
"src/services/*"
],
"@images": [
"src/public/images/*"
],