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
# 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.*

View file

@ -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)?;
@ -327,4 +327,4 @@ pub async fn list_user_shares(pool: &State<PgPool>, user: AuthenticatedUser) ->
.collect();
Json(share_infos)
}
}

View file

@ -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,14 +33,13 @@ 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)
.connect(&db_url)
.await
.expect("Failed to connect to database");
let figment = rocket::Config::figment()
.merge(("port", 8082))
.merge(("address", "0.0.0.0"))
@ -54,10 +47,14 @@ async fn main() -> Result<(), rocket::Error> {
.limit("form", ByteUnit::Gigabyte(5)) // 5 GB
.limit("file", ByteUnit::Gigabyte(1)) // 1 GB
.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,10 +73,10 @@ 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?;
Ok(())
}
}

View file

@ -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>,

File diff suppressed because it is too large Load diff

View file

@ -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",

View file

@ -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} />
</>
);
};

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 { 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,27 +135,87 @@ export const FilesComponent: React.FC<FilesComponentProps> = ({ isDarkMode }) =>
}
};
const formatDate = (dateString: string) => {
const now = new Date();
const date = new Date(dateString);
const diff = (now.getTime() - date.getTime()) / 1000;
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);
}
};
const isSameDay = (d1: Date, d2: Date) =>
d1.getFullYear() === d2.getFullYear() &&
d1.getMonth() === d2.getMonth() &&
d1.getDate() === d2.getDate();
// 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);
}
};
const yesterday = new Date();
yesterday.setDate(now.getDate() - 1);
// Pulisci la URL temporanea quando chiudi la preview
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)) {
return `yesterday at ${date.toLocaleTimeString()}`;
}
const isSameDay = (d1: Date, d2: Date) =>
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) => {
@ -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>

View file

@ -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 }),

View file

@ -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 }),

View file

@ -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'

View file

@ -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">
{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'}`}>
{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>
)}
<button
onClick={() => copyToClipboard(share.id)}
className={`share-link ${isDarkMode ? 'share-link-dark' : 'share-link-light'}`}
title="Click to copy link"
<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) => (
<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'}
>
/shared/{share.id.substring(0, 8)}...
</button>
</div>
<button
onClick={() => handleDeleteShare(share.id)}
className="delete-share-button"
title="Delete share"
>
<Trash2 size={16} />
</button>
</div>
</div>
))}
{share.file_name}
</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="Copy share link"
>
/api/shared/{share.id.substring(0, 8)}...
</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 && (
<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>

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;