feat: Add file preview functionality and enhance file handling
- Added `FilePreviewer` component to handle different file types (images, markdown, PDFs, and text). - Integrated `react-easy-crop` for image cropping in `ImagePreview`. - Implemented PDF viewing with pagination using `react-pdf`. - Updated `FilesComponent` to support file previews and downloads. - Refactored API calls to use a centralized `apiCall` function for better token management. - Enhanced `Login` and `Register` components to utilize the new API client. - Removed unused theme toggle functionality from `SettingsComponent`. - Improved shared files display in `ShareComponent` with a table layout. - Added error handling and loading states for file operations. - Updated dependencies in `package.json` for new features.
This commit is contained in:
parent
0f671fa1b1
commit
2cf335c5ef
41
.gitignore
vendored
41
.gitignore
vendored
|
@ -11,27 +11,22 @@ Cargo.lock
|
|||
# See https://www.dartlang.org/guides/libraries/private-files
|
||||
|
||||
# Files and directories created by pub
|
||||
lightcloud_app/dart_tool/
|
||||
lightcloud_app/packages
|
||||
lightcloud_app/build/
|
||||
# If you're building an application, you may want to check-in your pubspec.lock
|
||||
lightcloud_app/pubspec.lock
|
||||
frontend/node_modules/
|
||||
|
||||
# Directory created by dartdoc
|
||||
# If you don't generate documentation locally you can remove this line.
|
||||
lightcloud_app/doc/api/
|
||||
|
||||
# dotenv environment variables file
|
||||
lightcloud_app/.env*
|
||||
|
||||
# Avoid committing generated Javascript files:
|
||||
lightcloud_app/*.dart.js
|
||||
lightcloud_app/*.info.json # Produced by the --dump-info flag.
|
||||
lightcloud_app/*.js # When generated by dart2js. Don't specify *.js if your
|
||||
# project includes source files written in JavaScript.
|
||||
lightcloud_app/.js_
|
||||
lightcloud_app/*.js.deps
|
||||
lightcloud_app/*.js.map
|
||||
|
||||
lightcloud_app/.flutter-plugins
|
||||
lightcloud_app/.flutter-plugins-dependencies
|
||||
# Ignora file e cartelle Node.js nella sottocartella frontend
|
||||
/frontend/node_modules/
|
||||
/frontend/dist/
|
||||
/frontend/build/
|
||||
/frontend/.next/
|
||||
/frontend/.turbo/
|
||||
/frontend/.cache/
|
||||
/frontend/.eslintcache
|
||||
/frontend/npm-debug.log*
|
||||
/frontend/yarn-debug.log*
|
||||
/frontend/yarn-error.log*
|
||||
/frontend/pnpm-debug.log*
|
||||
/frontend/package-lock.json
|
||||
/frontend/yarn.lock
|
||||
/frontend/pnpm-lock.yaml
|
||||
/frontend/.env
|
||||
/frontend/.env.*
|
|
@ -36,7 +36,7 @@ pub async fn upload_file(
|
|||
|
||||
let file_id = Uuid::new_v4().to_string();
|
||||
let user_id = user.user_id.ok_or(Status::BadRequest)?;
|
||||
let user_dir = format!("./data/{}", user_id);
|
||||
let user_dir = format!("/wdblue/litecloud-store/{}", user_id);
|
||||
let storage_path = format!("{}/{}", &user_dir, file_id);
|
||||
|
||||
fs::create_dir_all(&user_dir).await.map_err(|_| Status::InternalServerError)?;
|
||||
|
|
|
@ -1,27 +1,21 @@
|
|||
#[macro_use]
|
||||
extern crate rocket;
|
||||
|
||||
use rocket::{fs::FileServer, Request};
|
||||
use rocket::fs::{FileServer, Options, relative};
|
||||
use rocket::Request;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::http::Status;
|
||||
use rocket::response::status::Custom;
|
||||
use rocket::data::ByteUnit;
|
||||
|
||||
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
|
||||
use std::env;
|
||||
use dotenv::dotenv;
|
||||
|
||||
use rocket_cors::{CorsOptions};
|
||||
|
||||
mod auth;
|
||||
mod encryption;
|
||||
mod file;
|
||||
mod user;
|
||||
mod admin;
|
||||
mod models;
|
||||
|
||||
use crate::file::*;
|
||||
use crate::user::*;
|
||||
use crate::admin::*;
|
||||
|
@ -39,7 +33,6 @@ fn all_options() -> &'static str {
|
|||
#[rocket::main]
|
||||
async fn main() -> Result<(), rocket::Error> {
|
||||
dotenv().ok();
|
||||
|
||||
let db_url = env::var("DATABASE_URL").expect("DATABASE_URL not set");
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
|
@ -56,8 +49,12 @@ async fn main() -> Result<(), rocket::Error> {
|
|||
.limit("data-form", ByteUnit::Gigabyte(5)) // 5 GB for multipart/form-data
|
||||
));
|
||||
|
||||
let cors = CorsOptions::default()
|
||||
.to_cors()
|
||||
.expect("Failed to create CORS fairing");
|
||||
|
||||
rocket::custom(figment)
|
||||
.attach(cors)
|
||||
.manage(pool)
|
||||
.mount("/api", routes![
|
||||
register,
|
||||
|
@ -76,7 +73,7 @@ async fn main() -> Result<(), rocket::Error> {
|
|||
update_quota,
|
||||
all_options,
|
||||
])
|
||||
.mount("/", FileServer::from("./static").rank(10))
|
||||
.mount("/", FileServer::new("./static", Options::Index | Options::DotFiles | Options::Missing))
|
||||
.register("/", catchers![not_found])
|
||||
.launch()
|
||||
.await?;
|
||||
|
|
|
@ -25,7 +25,7 @@ pub struct File {
|
|||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Share {
|
||||
pub id: Uuid,
|
||||
pub file_id: Option<i32>,
|
||||
pub file_id: Option<i32>, // Changed to Option<i32>
|
||||
pub shared_by: i32,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub expires_at: Option<chrono::NaiveDateTime>,
|
||||
|
|
1998
frontend/package-lock.json
generated
1998
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -12,8 +12,12 @@
|
|||
"dependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
"lucide-react": "^0.513.0",
|
||||
"pdfjs-dist": "^5.3.31",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
"react-dom": "^19.1.0",
|
||||
"react-easy-crop": "^5.4.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-pdf": "^9.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
|
|
|
@ -76,7 +76,7 @@ export const App: React.FC = () => {
|
|||
return (
|
||||
<>
|
||||
<ThemeToggle isDarkMode={isDarkMode} onToggle={toggleTheme} />
|
||||
<MainPage isDarkMode={isDarkMode} onLogout={handleLogout} />
|
||||
<MainPage isDarkMode={isDarkMode} onLogout={handleLogout} onThemeToggle={toggleTheme} />
|
||||
</>
|
||||
);
|
||||
};
|
23
frontend/src/api/apiClient.ts
Normal file
23
frontend/src/api/apiClient.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
// src/api/apiClient.ts
|
||||
|
||||
export const getAuthToken = () => {
|
||||
return localStorage.getItem('litecloud_token') || '';
|
||||
};
|
||||
|
||||
export const apiCall = async (url: string, options: RequestInit = {}) => {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
|
@ -1,5 +1,7 @@
|
|||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { ChevronDown, Upload, Share, Trash2 } from "lucide-react";
|
||||
import { Upload, Share, Trash2 } from "lucide-react";
|
||||
import { apiCall } from "../api/apiClient";
|
||||
import FilePreviewer from "./preview/FilePreviewer";
|
||||
|
||||
interface File {
|
||||
id: number;
|
||||
|
@ -22,6 +24,8 @@ export const FilesComponent: React.FC<FilesComponentProps> = ({ isDarkMode }) =>
|
|||
const [openDropdown, setOpenDropdown] = useState<number | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [shareDuration, setShareDuration] = useState(7); // durata di default 7 giorni
|
||||
const [previewFile, setPreviewFile] = useState<{ url: string; name: string } | null>(null);
|
||||
const [previewBlobUrl, setPreviewBlobUrl] = useState<string | null>(null);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const dropdownRef = useRef<HTMLTableDataCellElement | null>(null);
|
||||
|
@ -32,24 +36,6 @@ export const FilesComponent: React.FC<FilesComponentProps> = ({ isDarkMode }) =>
|
|||
setToken(storedToken);
|
||||
}, []);
|
||||
|
||||
const apiCall = async (url: string, options: RequestInit = {}) => {
|
||||
if (!token) throw new Error('No auth token found');
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
const fetchFiles = async () => {
|
||||
if (!token) return; // Non chiamare se il token non è pronto
|
||||
try {
|
||||
|
@ -149,7 +135,67 @@ export const FilesComponent: React.FC<FilesComponentProps> = ({ isDarkMode }) =>
|
|||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const handleDownload = async (file: File) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
if (!token) throw new Error('No auth token found');
|
||||
const response = await fetch(`/api/files/${file.id}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw new Error('Download failed');
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = file.original_name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
setError('Failed to download file');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setOpenDropdown(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Funzione per ottenere e decriptare il file dal server
|
||||
const handlePreviewFile = async (file: File) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
if (!token) throw new Error('No auth token found');
|
||||
const response = await fetch(`/api/files/${file.id}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw new Error('Impossibile scaricare il file');
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
setPreviewBlobUrl(blobUrl);
|
||||
setPreviewFile({ url: blobUrl, name: file.original_name });
|
||||
} catch (err) {
|
||||
setError('Errore durante il download per anteprima');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Pulisci la URL temporanea quando chiudi la preview
|
||||
const handleClosePreview = () => {
|
||||
if (previewBlobUrl) {
|
||||
URL.revokeObjectURL(previewBlobUrl);
|
||||
setPreviewBlobUrl(null);
|
||||
}
|
||||
setPreviewFile(null);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const now = new Date();
|
||||
const date = new Date(dateString);
|
||||
const diff = (now.getTime() - date.getTime()) / 1000;
|
||||
|
@ -169,7 +215,7 @@ const formatDate = (dateString: string) => {
|
|||
}
|
||||
|
||||
return date.toLocaleString();
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
|
@ -179,6 +225,8 @@ const formatDate = (dateString: string) => {
|
|||
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// Funzione per ottenere la URL del file (puoi adattare se serve fetch protetto)
|
||||
|
||||
return (
|
||||
<div className="component-container">
|
||||
{/* Success/Error Messages */}
|
||||
|
@ -195,6 +243,18 @@ const formatDate = (dateString: string) => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal anteprima file */}
|
||||
{previewFile && (
|
||||
<div className="modal-overlay" style={{ position: 'fixed', top:0, left:0, right:0, bottom:0, background: 'rgba(0,0,0,0.85)', zIndex: 1000, display: 'flex', alignItems: 'stretch', justifyContent: 'center' }}>
|
||||
<div className="modal-content" style={{ background: isDarkMode ? '#222' : '#fff', padding: 0, borderRadius: 0, width: '100vw', height: '100vh', maxWidth: '100vw', maxHeight: '100vh', overflow: 'auto', position: 'relative', display: 'flex', flexDirection: 'column' }}>
|
||||
<button onClick={handleClosePreview} style={{ position: 'absolute', top: 16, right: 24, fontSize: 36, background: 'none', border: 'none', color: isDarkMode ? '#fff' : '#222', cursor: 'pointer', zIndex: 10 }}>×</button>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 0, minWidth: 0 }}>
|
||||
<FilePreviewer fileUrl={previewFile.url} fileName={previewFile.name} fullscreen />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="component-header">
|
||||
<h1 className={`component-title ${isDarkMode ? 'text-white' : 'text-gray-800'}`}>
|
||||
My Files
|
||||
|
@ -237,7 +297,13 @@ const formatDate = (dateString: string) => {
|
|||
<tbody className={`table-body ${isDarkMode ? 'table-body-dark' : 'table-body-light'}`}>
|
||||
{files.map((file) => (
|
||||
<tr key={file.id} className={isDarkMode ? 'table-row-dark' : 'table-row-light'}>
|
||||
<td className={`table-cell ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>{file.original_name}</td>
|
||||
<td className={`table-cell ${isDarkMode ? 'text-white' : 'text-gray-900'}`}
|
||||
style={{ cursor: 'pointer', textDecoration: 'underline' }}
|
||||
onClick={() => handlePreviewFile(file)}
|
||||
title="Anteprima file"
|
||||
>
|
||||
{file.original_name}
|
||||
</td>
|
||||
<td className={`table-cell ${isDarkMode ? 'text-gray-300' : 'text-gray-500'}`}>{formatFileSize(file.size)}</td>
|
||||
<td className={`table-cell ${isDarkMode ? 'text-gray-300' : 'text-gray-500'}`}>{formatDate(file.uploaded_at)}</td>
|
||||
<td className="table-cell relative actions-cell" ref={dropdownRef}>
|
||||
|
@ -280,6 +346,12 @@ const formatDate = (dateString: string) => {
|
|||
<Trash2 size={16} />
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDownload(file)}
|
||||
className={`dropdown-item ${isDarkMode ? 'dropdown-item-dark' : 'dropdown-item-light'}`}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState } from "react";
|
||||
import { apiCall } from "../api/apiClient";
|
||||
|
||||
interface LoginProps {
|
||||
onLoginSuccess: (token: string) => void;
|
||||
|
@ -22,7 +23,7 @@ export const Login: React.FC<LoginProps> = ({
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/login", {
|
||||
const res = await apiCall("/api/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState } from "react";
|
||||
import { apiCall } from "../api/apiClient";
|
||||
|
||||
interface RegisterProps {
|
||||
onSwitchToLogin: () => void;
|
||||
|
@ -22,7 +23,7 @@ export const Register: React.FC<RegisterProps> = ({
|
|||
setSuccess(false);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/register", {
|
||||
const res = await apiCall("/api/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React from "react";
|
||||
import { Sun, Moon, Palette } from "lucide-react";
|
||||
import {Palette} from "lucide-react";
|
||||
|
||||
interface GradientColors {
|
||||
dark: { from: string; to: string };
|
||||
|
@ -15,7 +15,6 @@ interface SettingsComponentProps {
|
|||
|
||||
export const SettingsComponent: React.FC<SettingsComponentProps> = ({
|
||||
isDarkMode,
|
||||
onThemeToggle,
|
||||
gradientColors,
|
||||
setGradientColors
|
||||
}) => {
|
||||
|
@ -46,24 +45,6 @@ export const SettingsComponent: React.FC<SettingsComponentProps> = ({
|
|||
</div>
|
||||
|
||||
<div className="settings-content">
|
||||
{/* Theme Toggle */}
|
||||
<div className="setting-item">
|
||||
<span className={`setting-label ${isDarkMode ? 'text-white' : 'text-gray-800'}`}>
|
||||
Dark Mode
|
||||
</span>
|
||||
<button
|
||||
onClick={onThemeToggle}
|
||||
className={`theme-toggle-button ${
|
||||
isDarkMode
|
||||
? 'theme-toggle-button-dark'
|
||||
: 'theme-toggle-button-light'
|
||||
}`}
|
||||
>
|
||||
{isDarkMode ? <Sun size={18} /> : <Moon size={18} />}
|
||||
{isDarkMode ? 'Light' : 'Dark'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Gradient Colors */}
|
||||
<div className={`gradient-settings ${
|
||||
isDarkMode ? 'gradient-settings-dark' : 'gradient-settings-light'
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { apiCall } from "../api/apiClient";
|
||||
|
||||
interface ShareInfo {
|
||||
id: string;
|
||||
|
@ -20,28 +21,6 @@ export const SharesComponent: React.FC<SharesComponentProps> = ({ isDarkMode })
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const getAuthToken = () => {
|
||||
return localStorage.getItem('litecloud_token') || '';
|
||||
};
|
||||
|
||||
const apiCall = async (url: string, options: RequestInit = {}) => {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
const fetchShares = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
@ -76,7 +55,30 @@ export const SharesComponent: React.FC<SharesComponentProps> = ({ isDarkMode })
|
|||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const handleDownload = async (share: ShareInfo) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await fetch(`/api/shared/${share.id}`);
|
||||
if (!response.ok) throw new Error('Download failed');
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = share.file_name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
setError('Failed to download file');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return '-';
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
|
@ -107,9 +109,7 @@ export const SharesComponent: React.FC<SharesComponentProps> = ({ isDarkMode })
|
|||
)}
|
||||
|
||||
<div className="component-header">
|
||||
<h1 className={`component-title ${isDarkMode ? 'text-white' : 'text-gray-800'}`}>
|
||||
Shared Files
|
||||
</h1>
|
||||
<h1 className={`component-title ${isDarkMode ? 'text-white' : 'text-gray-800'}`}>Shared Files</h1>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
|
@ -118,49 +118,54 @@ export const SharesComponent: React.FC<SharesComponentProps> = ({ isDarkMode })
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="shares-grid">
|
||||
<div className={`table-container ${isDarkMode ? 'table-container-dark' : 'table-container-light'}`}>
|
||||
<table className="data-table full-width-table">
|
||||
<thead className={isDarkMode ? 'table-header-dark' : 'table-header-light'}>
|
||||
<tr>
|
||||
<th className="table-header-cell">Name</th>
|
||||
<th className="table-header-cell">Created</th>
|
||||
<th className="table-header-cell">Expires</th>
|
||||
<th className="table-header-cell">Link</th>
|
||||
<th className="table-header-cell actions-header-cell"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={`table-body ${isDarkMode ? 'table-body-dark' : 'table-body-light'}`}>
|
||||
{shares.map((share) => (
|
||||
<div key={share.id} className={`share-card ${
|
||||
isDarkMode ? 'share-card-dark' : 'share-card-light'
|
||||
}`}>
|
||||
<div className="share-card-content">
|
||||
<div className="share-info">
|
||||
<h3 className={`share-title ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>
|
||||
<tr key={share.id} className={isDarkMode ? 'table-row-dark' : 'table-row-light'}>
|
||||
<td
|
||||
className={`table-cell ${isDarkMode ? 'text-white' : 'text-gray-900'}`}
|
||||
style={{ cursor: share.is_expired ? 'not-allowed' : 'pointer', textDecoration: 'underline' }}
|
||||
onClick={() => !share.is_expired && handleDownload(share)}
|
||||
title={share.is_expired ? 'Expired' : 'Download shared file'}
|
||||
>
|
||||
{share.file_name}
|
||||
</h3>
|
||||
<p className={`share-detail ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
Created: {formatDate(share.created_at)}
|
||||
</p>
|
||||
{share.expires_at && (
|
||||
<p className={`share-detail ${
|
||||
share.is_expired ? 'text-red-500' : isDarkMode ? 'text-gray-400' : 'text-gray-500'
|
||||
}`}>
|
||||
{share.is_expired ? 'Expired' : 'Expires'}: {formatDate(share.expires_at)}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className={`table-cell ${isDarkMode ? 'text-gray-300' : 'text-gray-500'}`}>{formatDate(share.created_at)}</td>
|
||||
<td className={`table-cell ${share.is_expired ? 'text-red-500' : isDarkMode ? 'text-gray-300' : 'text-gray-500'}`}>{share.expires_at ? (share.is_expired ? 'Expired' : formatDate(share.expires_at)) : '-'}</td>
|
||||
<td className={`table-cell ${isDarkMode ? 'text-gray-300' : 'text-gray-500'}`}>
|
||||
<button
|
||||
onClick={() => copyToClipboard(share.id)}
|
||||
className={`share-link ${isDarkMode ? 'share-link-dark' : 'share-link-light'}`}
|
||||
title="Click to copy link"
|
||||
title="Copy share link"
|
||||
>
|
||||
/shared/{share.id.substring(0, 8)}...
|
||||
/api/shared/{share.id.substring(0, 8)}...
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell actions-cell">
|
||||
<button
|
||||
onClick={() => handleDeleteShare(share.id)}
|
||||
className="delete-share-button"
|
||||
className="dropdown-item dropdown-item-danger"
|
||||
title="Delete share"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
{shares.length === 0 && !loading && (
|
||||
<div className={`empty-state ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
No shared files yet
|
||||
</div>
|
||||
<div className={`empty-state ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>No shared files yet</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
36
frontend/src/components/preview/FilePreviewer.tsx
Normal file
36
frontend/src/components/preview/FilePreviewer.tsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
import React from "react";
|
||||
import ImagePreview from "./ImagePreview";
|
||||
import MarkdownPreview from "./MarkdownPreview";
|
||||
import PdfPreview from "./PdfPreview";
|
||||
import TxtPreview from "./TxtPreview";
|
||||
|
||||
export interface FilePreviewerProps {
|
||||
fileUrl: string;
|
||||
fileName: string;
|
||||
fullscreen?: boolean;
|
||||
}
|
||||
|
||||
const getExtension = (fileName: string) => {
|
||||
const parts = fileName.split(".");
|
||||
return parts.length > 1 ? parts.pop()!.toLowerCase() : "";
|
||||
};
|
||||
|
||||
const FilePreviewer: React.FC<FilePreviewerProps> = ({ fileUrl, fileName, fullscreen }) => {
|
||||
const ext = getExtension(fileName);
|
||||
|
||||
if (["png", "jpg", "jpeg", "gif"].includes(ext)) {
|
||||
return <ImagePreview fileUrl={fileUrl} fileName={fileName} fullscreen={fullscreen} />;
|
||||
}
|
||||
if (ext === "md") {
|
||||
return <MarkdownPreview fileUrl={fileUrl} />;
|
||||
}
|
||||
if (ext === "pdf") {
|
||||
return <PdfPreview fileUrl={fileUrl} />;
|
||||
}
|
||||
if (ext === "txt") {
|
||||
return <TxtPreview fileUrl={fileUrl} />;
|
||||
}
|
||||
return <div>Anteprima non disponibile per questo tipo di file.</div>;
|
||||
};
|
||||
|
||||
export default FilePreviewer;
|
60
frontend/src/components/preview/ImagePreview.tsx
Normal file
60
frontend/src/components/preview/ImagePreview.tsx
Normal file
|
@ -0,0 +1,60 @@
|
|||
import React, { useState } from "react";
|
||||
import Cropper from "react-easy-crop";
|
||||
|
||||
interface ImagePreviewProps {
|
||||
fileUrl: string;
|
||||
fileName: string;
|
||||
fullscreen?: boolean;
|
||||
}
|
||||
|
||||
const ImagePreview: React.FC<ImagePreviewProps> = ({ fileUrl, fileName, fullscreen }) => {
|
||||
const [rotation, setRotation] = useState(0);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [showCrop, setShowCrop] = useState(false);
|
||||
|
||||
return (
|
||||
<div style={{ width: fullscreen ? '100vw' : 500, height: fullscreen ? '100vh' : 400, background: "#222", position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ position: "relative", width: fullscreen ? '80vw' : 400, height: fullscreen ? '80vh' : 300, background: "#222", margin: '0 auto' }}>
|
||||
{showCrop ? (
|
||||
<Cropper
|
||||
image={fileUrl}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
rotation={rotation}
|
||||
aspect={4 / 3}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onRotationChange={setRotation}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={fileUrl}
|
||||
alt={fileName}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
transform: `rotate(${rotation}deg) scale(${zoom})`,
|
||||
display: 'block',
|
||||
margin: '0 auto',
|
||||
background: '#222',
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: 16, display: "flex", gap: 10, flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
<button onClick={() => setRotation((r) => r - 90)}>Ruota -90°</button>
|
||||
<button onClick={() => setRotation((r) => r + 90)}>Ruota +90°</button>
|
||||
<button onClick={() => setZoom((z) => Math.max(0.1, z - 0.1))}>- Zoom</button>
|
||||
<button onClick={() => setZoom((z) => Math.min(5, z + 0.1))}>+ Zoom</button>
|
||||
<button onClick={() => setShowCrop((v) => !v)}>{showCrop ? 'Disabilita crop' : 'Abilita crop'}</button>
|
||||
<a href={fileUrl} download={fileName}>
|
||||
<button>Download</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImagePreview;
|
24
frontend/src/components/preview/MarkdownPreview.tsx
Normal file
24
frontend/src/components/preview/MarkdownPreview.tsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
interface MarkdownPreviewProps {
|
||||
fileUrl: string;
|
||||
}
|
||||
|
||||
const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({ fileUrl }) => {
|
||||
const [content, setContent] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetch(fileUrl)
|
||||
.then((res) => res.text())
|
||||
.then(setContent);
|
||||
}, [fileUrl]);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 600, margin: "0 auto", background: "#fff", padding: 16, borderRadius: 8 }}>
|
||||
<ReactMarkdown>{content}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownPreview;
|
35
frontend/src/components/preview/PdfPreview.tsx
Normal file
35
frontend/src/components/preview/PdfPreview.tsx
Normal file
|
@ -0,0 +1,35 @@
|
|||
import React, { useState } from "react";
|
||||
import { Document, Page, pdfjs } from "react-pdf";
|
||||
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?url';
|
||||
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = pdfjsWorker;
|
||||
|
||||
interface PdfPreviewProps {
|
||||
fileUrl: string;
|
||||
}
|
||||
|
||||
const PdfPreview: React.FC<PdfPreviewProps> = ({ fileUrl }) => {
|
||||
const [numPages, setNumPages] = useState<number>(0);
|
||||
const [pageNumber, setPageNumber] = useState<number>(1);
|
||||
|
||||
return (
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<Document file={fileUrl} onLoadSuccess={({ numPages }) => setNumPages(numPages)}>
|
||||
<Page pageNumber={pageNumber} />
|
||||
</Document>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<button onClick={() => setPageNumber((p) => Math.max(1, p - 1))} disabled={pageNumber <= 1}>
|
||||
Pagina precedente
|
||||
</button>
|
||||
<span style={{ margin: "0 8px" }}>
|
||||
Pagina {pageNumber} di {numPages}
|
||||
</span>
|
||||
<button onClick={() => setPageNumber((p) => Math.min(numPages, p + 1))} disabled={pageNumber >= numPages}>
|
||||
Pagina successiva
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PdfPreview;
|
23
frontend/src/components/preview/TxtPreview.tsx
Normal file
23
frontend/src/components/preview/TxtPreview.tsx
Normal file
|
@ -0,0 +1,23 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
|
||||
interface TxtPreviewProps {
|
||||
fileUrl: string;
|
||||
}
|
||||
|
||||
const TxtPreview: React.FC<TxtPreviewProps> = ({ fileUrl }) => {
|
||||
const [content, setContent] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetch(fileUrl)
|
||||
.then((res) => res.text())
|
||||
.then(setContent);
|
||||
}, [fileUrl]);
|
||||
|
||||
return (
|
||||
<pre style={{ maxWidth: 600, margin: "0 auto", background: "#f5f5f5", padding: 16, borderRadius: 8, whiteSpace: "pre-wrap" }}>
|
||||
{content}
|
||||
</pre>
|
||||
);
|
||||
};
|
||||
|
||||
export default TxtPreview;
|
Loading…
Reference in a new issue