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
|
# 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
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(())
|
||||||
}
|
}
|
|
@ -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>,
|
||||||
|
|
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": {
|
"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",
|
||||||
|
|
|
@ -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} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
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 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>
|
||||||
|
|
|
@ -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 }),
|
||||||
|
|
|
@ -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 }),
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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>
|
||||||
|
|
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