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:
Mercurio 2025-06-07 23:36:20 +02:00
parent 0f671fa1b1
commit 2cf335c5ef
18 changed files with 2416 additions and 175 deletions

41
.gitignore vendored
View file

@ -11,27 +11,22 @@ Cargo.lock
# See https://www.dartlang.org/guides/libraries/private-files # See https://www.dartlang.org/guides/libraries/private-files
# Files and directories created by pub # Files and directories created by pub
lightcloud_app/dart_tool/ frontend/node_modules/
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
# Directory created by dartdoc # Ignora file e cartelle Node.js nella sottocartella frontend
# If you don't generate documentation locally you can remove this line. /frontend/node_modules/
lightcloud_app/doc/api/ /frontend/dist/
/frontend/build/
# dotenv environment variables file /frontend/.next/
lightcloud_app/.env* /frontend/.turbo/
/frontend/.cache/
# Avoid committing generated Javascript files: /frontend/.eslintcache
lightcloud_app/*.dart.js /frontend/npm-debug.log*
lightcloud_app/*.info.json # Produced by the --dump-info flag. /frontend/yarn-debug.log*
lightcloud_app/*.js # When generated by dart2js. Don't specify *.js if your /frontend/yarn-error.log*
# project includes source files written in JavaScript. /frontend/pnpm-debug.log*
lightcloud_app/.js_ /frontend/package-lock.json
lightcloud_app/*.js.deps /frontend/yarn.lock
lightcloud_app/*.js.map /frontend/pnpm-lock.yaml
/frontend/.env
lightcloud_app/.flutter-plugins /frontend/.env.*
lightcloud_app/.flutter-plugins-dependencies

View file

@ -36,7 +36,7 @@ pub async fn upload_file(
let file_id = Uuid::new_v4().to_string(); let file_id = Uuid::new_v4().to_string();
let user_id = user.user_id.ok_or(Status::BadRequest)?; 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); let storage_path = format!("{}/{}", &user_dir, file_id);
fs::create_dir_all(&user_dir).await.map_err(|_| Status::InternalServerError)?; fs::create_dir_all(&user_dir).await.map_err(|_| Status::InternalServerError)?;
@ -327,4 +327,4 @@ pub async fn list_user_shares(pool: &State<PgPool>, user: AuthenticatedUser) ->
.collect(); .collect();
Json(share_infos) Json(share_infos)
} }

View file

@ -1,27 +1,21 @@
#[macro_use] #[macro_use]
extern crate rocket; extern crate rocket;
use rocket::fs::{FileServer, Options, relative};
use rocket::{fs::FileServer, Request}; use rocket::Request;
use rocket::serde::json::Json; use rocket::serde::json::Json;
use rocket::http::Status; use rocket::http::Status;
use rocket::response::status::Custom; use rocket::response::status::Custom;
use rocket::data::ByteUnit; use rocket::data::ByteUnit;
use sqlx::postgres::PgPoolOptions; use sqlx::postgres::PgPoolOptions;
use std::env; use std::env;
use dotenv::dotenv; use dotenv::dotenv;
use rocket_cors::{CorsOptions}; use rocket_cors::{CorsOptions};
mod auth; mod auth;
mod encryption; mod encryption;
mod file; mod file;
mod user; mod user;
mod admin; mod admin;
mod models; mod models;
use crate::file::*; use crate::file::*;
use crate::user::*; use crate::user::*;
use crate::admin::*; use crate::admin::*;
@ -39,14 +33,13 @@ fn all_options() -> &'static str {
#[rocket::main] #[rocket::main]
async fn main() -> Result<(), rocket::Error> { async fn main() -> Result<(), rocket::Error> {
dotenv().ok(); dotenv().ok();
let db_url = env::var("DATABASE_URL").expect("DATABASE_URL not set"); let db_url = env::var("DATABASE_URL").expect("DATABASE_URL not set");
let pool = PgPoolOptions::new() let pool = PgPoolOptions::new()
.max_connections(5) .max_connections(5)
.connect(&db_url) .connect(&db_url)
.await .await
.expect("Failed to connect to database"); .expect("Failed to connect to database");
let figment = rocket::Config::figment() let figment = rocket::Config::figment()
.merge(("port", 8082)) .merge(("port", 8082))
.merge(("address", "0.0.0.0")) .merge(("address", "0.0.0.0"))
@ -54,10 +47,14 @@ async fn main() -> Result<(), rocket::Error> {
.limit("form", ByteUnit::Gigabyte(5)) // 5 GB .limit("form", ByteUnit::Gigabyte(5)) // 5 GB
.limit("file", ByteUnit::Gigabyte(1)) // 1 GB .limit("file", ByteUnit::Gigabyte(1)) // 1 GB
.limit("data-form", ByteUnit::Gigabyte(5)) // 5 GB for multipart/form-data .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) rocket::custom(figment)
.attach(cors)
.manage(pool) .manage(pool)
.mount("/api", routes![ .mount("/api", routes![
register, register,
@ -76,10 +73,10 @@ async fn main() -> Result<(), rocket::Error> {
update_quota, update_quota,
all_options, all_options,
]) ])
.mount("/", FileServer::from("./static").rank(10)) .mount("/", FileServer::new("./static", Options::Index | Options::DotFiles | Options::Missing))
.register("/", catchers![not_found]) .register("/", catchers![not_found])
.launch() .launch()
.await?; .await?;
Ok(()) Ok(())
} }

View file

@ -25,7 +25,7 @@ pub struct File {
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct Share { pub struct Share {
pub id: Uuid, pub id: Uuid,
pub file_id: Option<i32>, pub file_id: Option<i32>, // Changed to Option<i32>
pub shared_by: i32, pub shared_by: i32,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub expires_at: Option<chrono::NaiveDateTime>, pub expires_at: Option<chrono::NaiveDateTime>,

File diff suppressed because it is too large Load diff

View file

@ -12,8 +12,12 @@
"dependencies": { "dependencies": {
"@tailwindcss/postcss": "^4.1.8", "@tailwindcss/postcss": "^4.1.8",
"lucide-react": "^0.513.0", "lucide-react": "^0.513.0",
"pdfjs-dist": "^5.3.31",
"react": "^19.1.0", "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": { "devDependencies": {
"@eslint/js": "^9.25.0", "@eslint/js": "^9.25.0",

View file

@ -76,7 +76,7 @@ export const App: React.FC = () => {
return ( return (
<> <>
<ThemeToggle isDarkMode={isDarkMode} onToggle={toggleTheme} /> <ThemeToggle isDarkMode={isDarkMode} onToggle={toggleTheme} />
<MainPage isDarkMode={isDarkMode} onLogout={handleLogout} /> <MainPage isDarkMode={isDarkMode} onLogout={handleLogout} onThemeToggle={toggleTheme} />
</> </>
); );
}; };

View 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;
};

View file

@ -1,5 +1,7 @@
import React, { useState, useEffect, useRef } from "react"; 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 { interface File {
id: number; id: number;
@ -22,6 +24,8 @@ export const FilesComponent: React.FC<FilesComponentProps> = ({ isDarkMode }) =>
const [openDropdown, setOpenDropdown] = useState<number | null>(null); const [openDropdown, setOpenDropdown] = useState<number | null>(null);
const [token, setToken] = useState<string | null>(null); const [token, setToken] = useState<string | null>(null);
const [shareDuration, setShareDuration] = useState(7); // durata di default 7 giorni 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 fileInputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLTableDataCellElement | null>(null); const dropdownRef = useRef<HTMLTableDataCellElement | null>(null);
@ -32,24 +36,6 @@ export const FilesComponent: React.FC<FilesComponentProps> = ({ isDarkMode }) =>
setToken(storedToken); 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 () => { const fetchFiles = async () => {
if (!token) return; // Non chiamare se il token non è pronto if (!token) return; // Non chiamare se il token non è pronto
try { try {
@ -149,27 +135,87 @@ export const FilesComponent: React.FC<FilesComponentProps> = ({ isDarkMode }) =>
} }
}; };
const formatDate = (dateString: string) => { const handleDownload = async (file: File) => {
const now = new Date(); try {
const date = new Date(dateString); setLoading(true);
const diff = (now.getTime() - date.getTime()) / 1000; 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);
}
};
const isSameDay = (d1: Date, d2: Date) => // Funzione per ottenere e decriptare il file dal server
d1.getFullYear() === d2.getFullYear() && const handlePreviewFile = async (file: File) => {
d1.getMonth() === d2.getMonth() && try {
d1.getDate() === d2.getDate(); 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);
}
};
const yesterday = new Date(); // Pulisci la URL temporanea quando chiudi la preview
yesterday.setDate(now.getDate() - 1); const handleClosePreview = () => {
if (previewBlobUrl) {
URL.revokeObjectURL(previewBlobUrl);
setPreviewBlobUrl(null);
}
setPreviewFile(null);
};
if (diff < 10) return "now"; const formatDate = (dateString: string) => {
const now = new Date();
const date = new Date(dateString);
const diff = (now.getTime() - date.getTime()) / 1000;
if (isSameDay(date, yesterday)) { const isSameDay = (d1: Date, d2: Date) =>
return `yesterday at ${date.toLocaleTimeString()}`; d1.getFullYear() === d2.getFullYear() &&
} d1.getMonth() === d2.getMonth() &&
d1.getDate() === d2.getDate();
return date.toLocaleString(); const yesterday = new Date();
}; yesterday.setDate(now.getDate() - 1);
if (diff < 10) return "now";
if (isSameDay(date, yesterday)) {
return `yesterday at ${date.toLocaleTimeString()}`;
}
return date.toLocaleString();
};
const formatFileSize = (bytes: number) => { const formatFileSize = (bytes: number) => {
@ -179,6 +225,8 @@ const formatDate = (dateString: string) => {
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]; 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 ( return (
<div className="component-container"> <div className="component-container">
{/* Success/Error Messages */} {/* Success/Error Messages */}
@ -195,6 +243,18 @@ const formatDate = (dateString: string) => {
</div> </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"> <div className="component-header">
<h1 className={`component-title ${isDarkMode ? 'text-white' : 'text-gray-800'}`}> <h1 className={`component-title ${isDarkMode ? 'text-white' : 'text-gray-800'}`}>
My Files My Files
@ -237,7 +297,13 @@ const formatDate = (dateString: string) => {
<tbody className={`table-body ${isDarkMode ? 'table-body-dark' : 'table-body-light'}`}> <tbody className={`table-body ${isDarkMode ? 'table-body-dark' : 'table-body-light'}`}>
{files.map((file) => ( {files.map((file) => (
<tr key={file.id} className={isDarkMode ? 'table-row-dark' : 'table-row-light'}> <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'}`}>{formatFileSize(file.size)}</td>
<td className={`table-cell ${isDarkMode ? 'text-gray-300' : 'text-gray-500'}`}>{formatDate(file.uploaded_at)}</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}> <td className="table-cell relative actions-cell" ref={dropdownRef}>
@ -280,6 +346,12 @@ const formatDate = (dateString: string) => {
<Trash2 size={16} /> <Trash2 size={16} />
Delete Delete
</button> </button>
<button
onClick={() => handleDownload(file)}
className={`dropdown-item ${isDarkMode ? 'dropdown-item-dark' : 'dropdown-item-light'}`}
>
Download
</button>
</div> </div>
)} )}
</td> </td>

View file

@ -1,4 +1,5 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { apiCall } from "../api/apiClient";
interface LoginProps { interface LoginProps {
onLoginSuccess: (token: string) => void; onLoginSuccess: (token: string) => void;
@ -22,7 +23,7 @@ export const Login: React.FC<LoginProps> = ({
setError(null); setError(null);
try { try {
const res = await fetch("/api/login", { const res = await apiCall("/api/login", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),

View file

@ -1,4 +1,5 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { apiCall } from "../api/apiClient";
interface RegisterProps { interface RegisterProps {
onSwitchToLogin: () => void; onSwitchToLogin: () => void;
@ -22,7 +23,7 @@ export const Register: React.FC<RegisterProps> = ({
setSuccess(false); setSuccess(false);
try { try {
const res = await fetch("/api/register", { const res = await apiCall("/api/register", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),

View file

@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Sun, Moon, Palette } from "lucide-react"; import {Palette} from "lucide-react";
interface GradientColors { interface GradientColors {
dark: { from: string; to: string }; dark: { from: string; to: string };
@ -15,7 +15,6 @@ interface SettingsComponentProps {
export const SettingsComponent: React.FC<SettingsComponentProps> = ({ export const SettingsComponent: React.FC<SettingsComponentProps> = ({
isDarkMode, isDarkMode,
onThemeToggle,
gradientColors, gradientColors,
setGradientColors setGradientColors
}) => { }) => {
@ -46,24 +45,6 @@ export const SettingsComponent: React.FC<SettingsComponentProps> = ({
</div> </div>
<div className="settings-content"> <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 */} {/* Gradient Colors */}
<div className={`gradient-settings ${ <div className={`gradient-settings ${
isDarkMode ? 'gradient-settings-dark' : 'gradient-settings-light' isDarkMode ? 'gradient-settings-dark' : 'gradient-settings-light'

View file

@ -1,5 +1,6 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { apiCall } from "../api/apiClient";
interface ShareInfo { interface ShareInfo {
id: string; id: string;
@ -20,28 +21,6 @@ export const SharesComponent: React.FC<SharesComponentProps> = ({ isDarkMode })
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = 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 () => { const fetchShares = async () => {
try { try {
setLoading(true); 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(); return new Date(dateString).toLocaleString();
}; };
@ -107,9 +109,7 @@ export const SharesComponent: React.FC<SharesComponentProps> = ({ isDarkMode })
)} )}
<div className="component-header"> <div className="component-header">
<h1 className={`component-title ${isDarkMode ? 'text-white' : 'text-gray-800'}`}> <h1 className={`component-title ${isDarkMode ? 'text-white' : 'text-gray-800'}`}>Shared Files</h1>
Shared Files
</h1>
</div> </div>
{loading && ( {loading && (
@ -118,49 +118,54 @@ export const SharesComponent: React.FC<SharesComponentProps> = ({ isDarkMode })
</div> </div>
)} )}
<div className="shares-grid"> <div className={`table-container ${isDarkMode ? 'table-container-dark' : 'table-container-light'}`}>
{shares.map((share) => ( <table className="data-table full-width-table">
<div key={share.id} className={`share-card ${ <thead className={isDarkMode ? 'table-header-dark' : 'table-header-light'}>
isDarkMode ? 'share-card-dark' : 'share-card-light' <tr>
}`}> <th className="table-header-cell">Name</th>
<div className="share-card-content"> <th className="table-header-cell">Created</th>
<div className="share-info"> <th className="table-header-cell">Expires</th>
<h3 className={`share-title ${isDarkMode ? 'text-white' : 'text-gray-900'}`}> <th className="table-header-cell">Link</th>
{share.file_name} <th className="table-header-cell actions-header-cell"></th>
</h3> </tr>
<p className={`share-detail ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}> </thead>
Created: {formatDate(share.created_at)} <tbody className={`table-body ${isDarkMode ? 'table-body-dark' : 'table-body-light'}`}>
</p> {shares.map((share) => (
{share.expires_at && ( <tr key={share.id} className={isDarkMode ? 'table-row-dark' : 'table-row-light'}>
<p className={`share-detail ${ <td
share.is_expired ? 'text-red-500' : isDarkMode ? 'text-gray-400' : 'text-gray-500' className={`table-cell ${isDarkMode ? 'text-white' : 'text-gray-900'}`}
}`}> style={{ cursor: share.is_expired ? 'not-allowed' : 'pointer', textDecoration: 'underline' }}
{share.is_expired ? 'Expired' : 'Expires'}: {formatDate(share.expires_at)} onClick={() => !share.is_expired && handleDownload(share)}
</p> title={share.is_expired ? 'Expired' : 'Download shared file'}
)}
<button
onClick={() => copyToClipboard(share.id)}
className={`share-link ${isDarkMode ? 'share-link-dark' : 'share-link-light'}`}
title="Click to copy link"
> >
/shared/{share.id.substring(0, 8)}... {share.file_name}
</button> </td>
</div> <td className={`table-cell ${isDarkMode ? 'text-gray-300' : 'text-gray-500'}`}>{formatDate(share.created_at)}</td>
<button <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>
onClick={() => handleDeleteShare(share.id)} <td className={`table-cell ${isDarkMode ? 'text-gray-300' : 'text-gray-500'}`}>
className="delete-share-button" <button
title="Delete share" onClick={() => copyToClipboard(share.id)}
> className={`share-link ${isDarkMode ? 'share-link-dark' : 'share-link-light'}`}
<Trash2 size={16} /> title="Copy share link"
</button> >
</div> /api/shared/{share.id.substring(0, 8)}...
</div> </button>
))} </td>
<td className="table-cell actions-cell">
<button
onClick={() => handleDeleteShare(share.id)}
className="dropdown-item dropdown-item-danger"
title="Delete share"
>
<Trash2 size={16} />
</button>
</td>
</tr>
))}
</tbody>
</table>
{shares.length === 0 && !loading && ( {shares.length === 0 && !loading && (
<div className={`empty-state ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}> <div className={`empty-state ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>No shared files yet</div>
No shared files yet
</div>
)} )}
</div> </div>
</div> </div>

View 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;

View 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;

View 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;

View 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;

View 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;