Compare commits

...

2 commits

Author SHA1 Message Date
Mercurio 315746852b Merge branch 'main' of https://git.mercurio.moe/Mercury/litecloud 2025-06-05 23:34:05 +02:00
Mercurio ca9f143b30 wip frontend [do not push to live] 2025-06-05 23:33:32 +02:00
86 changed files with 6315 additions and 2834 deletions

View file

@ -22,6 +22,7 @@ rocket_cors = "0.6"
lazy_static = "1.4" lazy_static = "1.4"
password-hash = "0.5" password-hash = "0.5"
argon2 = "0.5" argon2 = "0.5"
utoipa = "5.3.1"
[build-dependencies] [build-dependencies]
dotenv = "0.15" dotenv = "0.15"

View file

@ -15,8 +15,9 @@ use crate::encryption::{encrypt_data, decrypt_data};
use crate::models::{File, Share}; use crate::models::{File, Share};
#[derive(FromForm)] #[derive(FromForm)]
pub struct Upload<'r> { pub struct Upload<'f> {
file: TempFile<'r>, pub file: TempFile<'f>,
pub filename: Option<String>,
} }
#[post("/upload", data = "<upload>", format = "multipart/form-data")] #[post("/upload", data = "<upload>", format = "multipart/form-data")]
@ -26,32 +27,28 @@ pub async fn upload_file(
upload: Form<Upload<'_>> upload: Form<Upload<'_>>
) -> Result<Status, Status> { ) -> Result<Status, Status> {
let mut buffer = Vec::new(); let mut buffer = Vec::new();
if let Err(_) = { let mut file = upload.file.open().await.map_err(|_| Status::BadRequest)?;
let mut file = match upload.file.open().await { file.read_to_end(&mut buffer).await.map_err(|_| Status::BadRequest)?;
Ok(file) => file,
Err(_) => return Err(Status::BadRequest),
};
file.read_to_end(&mut buffer).await
} {
return Err(Status::BadRequest);
}
let nonce = rand::random::<[u8; 12]>(); let nonce = rand::random::<[u8; 12]>();
let key = std::env::var("ENCRYPTION_KEY").expect("ENCRYPTION_KEY must be set"); let key = std::env::var("ENCRYPTION_KEY").expect("ENCRYPTION_KEY must be set");
let encrypted = encrypt_data(&buffer, key.as_bytes(), &nonce); let encrypted = encrypt_data(&buffer, key.as_bytes(), &nonce);
let file_id = Uuid::new_v4().to_string(); let file_id = Uuid::new_v4().to_string();
let storage_path = format!("./data/{}", file_id); let user_id = user.user_id.ok_or(Status::BadRequest)?;
let user_dir = format!("./data/{}", user_id);
let storage_path = format!("{}/{}", &user_dir, file_id);
fs::write(&storage_path, &encrypted).await.ok(); fs::create_dir_all(&user_dir).await.map_err(|_| Status::InternalServerError)?;
fs::write(format!("{}.nonce", &storage_path), &nonce).await.ok(); fs::write(&storage_path, &encrypted).await.map_err(|_| Status::InternalServerError)?;
fs::write(format!("{}.nonce", &storage_path), &nonce).await.map_err(|_| Status::InternalServerError)?;
let original_name = upload.file.name().unwrap_or("file.bin").to_string(); let original_name = upload.filename.as_deref().ok_or(Status::BadRequest)?;
let size = buffer.len() as i64; let size = buffer.len() as i64;
sqlx::query!( sqlx::query!(
"INSERT INTO files (user_id, original_name, storage_path, uploaded_at, size) VALUES ($1, $2, $3, $4, $5)", "INSERT INTO files (user_id, original_name, storage_path, uploaded_at, size) VALUES ($1, $2, $3, $4, $5)",
user.user_id, user_id,
original_name, original_name,
storage_path, storage_path,
Utc::now(), Utc::now(),
@ -64,6 +61,7 @@ pub async fn upload_file(
Ok(Status::Created) Ok(Status::Created)
} }
#[get("/files")] #[get("/files")]
pub async fn list_user_files(pool: &State<PgPool>, user: AuthenticatedUser) -> Json<Vec<File>> { pub async fn list_user_files(pool: &State<PgPool>, user: AuthenticatedUser) -> Json<Vec<File>> {
let Some(user_id) = user.user_id else { let Some(user_id) = user.user_id else {
@ -184,15 +182,7 @@ pub async fn share_file(pool: &State<PgPool>, user: AuthenticatedUser, req: Json
.await .await
.map_err(|_| Status::InternalServerError)?; .map_err(|_| Status::InternalServerError)?;
let share = sqlx::query_as::<_, Share>( Ok(Json(id.to_string()))
"SELECT id, file_id, shared_by, created_at, expires_at FROM shares WHERE id = $1"
)
.bind(id)
.fetch_one(pool.inner())
.await
.map_err(|_| Status::InternalServerError)?;
Ok(Json(share.id.to_string()))
} }
#[get("/shared/<link>")] #[get("/shared/<link>")]
@ -240,4 +230,101 @@ pub async fn download_shared(pool: &State<PgPool>, link: &str) -> Option<FileDow
data: decrypted, data: decrypted,
filename: file.original_name, filename: file.original_name,
}) })
}
#[delete("/shares/<share_id>")]
pub async fn delete_share(
pool: &State<PgPool>,
user: AuthenticatedUser,
share_id: &str
) -> Status {
let Some(user_id) = user.user_id else {
return Status::Unauthorized;
};
// Parse the UUID
let uuid = match Uuid::parse_str(share_id) {
Ok(id) => id,
Err(_) => return Status::BadRequest,
};
// Check if the share exists and belongs to the user
let share = sqlx::query!(
"SELECT id FROM shares WHERE id = $1 AND shared_by = $2",
uuid,
user_id
)
.fetch_optional(pool.inner())
.await;
match share {
Ok(Some(_)) => {
// Delete the share
match sqlx::query!("DELETE FROM shares WHERE id = $1", uuid)
.execute(pool.inner())
.await
{
Ok(_) => Status::NoContent,
Err(_) => Status::InternalServerError,
}
}
Ok(None) => Status::NotFound,
Err(_) => Status::InternalServerError,
}
}
#[derive(serde::Serialize)]
pub struct ShareInfo {
pub id: String,
pub file_id: i32,
pub file_name: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub expires_at: Option<chrono::NaiveDateTime>,
pub is_expired: bool,
}
#[get("/shares")]
pub async fn list_user_shares(pool: &State<PgPool>, user: AuthenticatedUser) -> Json<Vec<ShareInfo>> {
let Some(user_id) = user.user_id else {
return Json(Vec::new());
};
let shares = sqlx::query!(
r#"
SELECT
s.id,
s.file_id,
s.created_at,
s.expires_at,
f.original_name as file_name
FROM shares s
JOIN files f ON s.file_id = f.id
WHERE s.shared_by = $1
ORDER BY s.created_at DESC
"#,
user_id
)
.fetch_all(pool.inner())
.await
.unwrap_or_default();
let now = Utc::now().naive_utc();
let share_infos = shares
.into_iter()
.map(|record| {
let expires_at = record.expires_at.map(|dt| dt.naive_utc());
let is_expired = expires_at.map_or(false, |exp| exp < now);
ShareInfo {
id: record.id.to_string(),
file_id: record.file_id.unwrap_or(0),
file_name: record.file_name,
created_at: record.created_at,
expires_at,
is_expired,
}
})
.collect();
Json(share_infos)
} }

View file

@ -7,12 +7,13 @@ 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, AllowedOrigins}; use rocket_cors::{CorsOptions};
mod auth; mod auth;
mod encryption; mod encryption;
@ -67,7 +68,9 @@ async fn main() -> Result<(), rocket::Error> {
download_file, download_file,
download_shared, download_shared,
list_user_files, list_user_files,
list_user_shares,
delete_file, delete_file,
delete_share,
share_file, share_file,
update_role, update_role,
update_quota, update_quota,

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>, // Changed to Option<i32> pub file_id: 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>,

24
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

54
frontend/README.md Normal file
View file

@ -0,0 +1,54 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactDom.configs.recommended.rules,
},
})
```

28
frontend/eslint.config.js Normal file
View file

@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4087
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

34
frontend/package.json Normal file
View file

@ -0,0 +1,34 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/postcss": "^4.1.8",
"lucide-react": "^0.513.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.21",
"eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"postcss": "^8.5.4",
"tailwindcss": "^4.1.8",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5"
}
}

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
};

1
frontend/public/vite.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
frontend/src/App.css Normal file
View file

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

82
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,82 @@
import React, { useEffect, useState } from "react";
import { Login } from "./components/Login";
import { Register } from "./components/Register";
import { MainPage } from "./components/MainPage";
import { ThemeToggle } from "./components/ThemeToggle";
const TOKEN_STORAGE_KEY = "litecloud_token";
const THEME_STORAGE_KEY = "litecloud_theme";
export const App: React.FC = () => {
const [token, setToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [showRegister, setShowRegister] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(true);
useEffect(() => {
const savedToken = localStorage.getItem(TOKEN_STORAGE_KEY);
if (savedToken) {
setToken(savedToken);
}
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY);
if (savedTheme !== null) {
setIsDarkMode(savedTheme === 'dark');
}
setLoading(false);
}, []);
const handleLoginSuccess = (token: string) => {
localStorage.setItem(TOKEN_STORAGE_KEY, token);
setToken(token);
};
const handleLogout = () => {
localStorage.removeItem(TOKEN_STORAGE_KEY);
setToken(null);
setShowRegister(false);
};
const toggleTheme = () => {
const newTheme = !isDarkMode;
setIsDarkMode(newTheme);
localStorage.setItem(THEME_STORAGE_KEY, newTheme ? 'dark' : 'light');
};
if (loading) {
return (
<div className="loading-container">
<div className="loading-spinner"></div>
<p>Loading...</p>
</div>
);
}
if (!token) {
return (
<>
<ThemeToggle isDarkMode={isDarkMode} onToggle={toggleTheme} />
{showRegister ? (
<Register
onSwitchToLogin={() => setShowRegister(false)}
isDarkMode={isDarkMode}
/>
) : (
<Login
onLoginSuccess={handleLoginSuccess}
onSwitchToRegister={() => setShowRegister(true)}
isDarkMode={isDarkMode}
/>
)}
</>
);
}
return (
<>
<ThemeToggle isDarkMode={isDarkMode} onToggle={toggleTheme} />
<MainPage isDarkMode={isDarkMode} onLogout={handleLogout} />
</>
);
};

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4 KiB

View file

@ -0,0 +1,275 @@
import React, { useState, useEffect, useRef } from "react";
import { ChevronDown, Upload, Share, Trash2 } from "lucide-react";
interface File {
id: number;
user_id: number;
original_name: string;
storage_path: string;
uploaded_at: string;
size: number;
}
interface FilesComponentProps {
isDarkMode: boolean;
}
export const FilesComponent: React.FC<FilesComponentProps> = ({ isDarkMode }) => {
const [files, setFiles] = useState<File[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [openDropdown, setOpenDropdown] = useState<number | null>(null);
const [token, setToken] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLTableDataCellElement | null>(null);
// Recupera il token solo una volta al mount o quando cambia
useEffect(() => {
const storedToken = localStorage.getItem('token');
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 {
setLoading(true);
setError(null);
const response = await apiCall('http://192.168.1.120:8082/api/files');
const filesData = await response.json();
setFiles(filesData);
} catch (err) {
setError('Failed to fetch files');
} finally {
setLoading(false);
}
};
useEffect(() => {
if (token) fetchFiles();
}, [token]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setOpenDropdown(null);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
setLoading(true);
setError(null);
const formData = new FormData();
formData.append('file', file);
formData.append('filename', file.name);
const token = getAuthToken();
const response = await fetch('http://192.168.1.120:8082/api/upload', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
body: formData,
});
if (response.ok) {
setSuccess('File uploaded successfully!');
fetchFiles();
} else {
throw new Error('Upload failed');
}
} catch (err) {
setError('Failed to upload file');
} finally {
setLoading(false);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const handleFileAction = async (action: 'share' | 'delete', fileId: number) => {
try {
setLoading(true);
setError(null);
if (action === 'share') {
const response = await apiCall('http://192.168.1.120:8082/api/share', {
method: 'POST',
body: JSON.stringify({
file_id: fileId,
expires_in_days: 7
}),
});
const shareId = await response.json();
setSuccess(`Share link created: /shared/${shareId}`);
} else if (action === 'delete') {
await apiCall(`http://192.168.1.120:8082/api/files/${fileId}`, {
method: 'DELETE',
});
setSuccess('File deleted successfully');
fetchFiles();
}
} catch (err) {
setError(`Failed to ${action} file`);
} finally {
setLoading(false);
setOpenDropdown(null);
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
const formatFileSize = (bytes: number) => {
const sizes = ['B', 'KB', 'MB', 'GB'];
if (bytes === 0) return '0 B';
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
};
return (
<div className="component-container">
{/* Success/Error Messages */}
{error && (
<div className="notification notification-error">
{error}
<button onClick={() => setError(null)} className="notification-close">×</button>
</div>
)}
{success && (
<div className="notification notification-success">
{success}
<button onClick={() => setSuccess(null)} className="notification-close">×</button>
</div>
)}
<div className="component-header">
<h1 className={`component-title ${isDarkMode ? 'text-white' : 'text-gray-800'}`}>
My Files
</h1>
<input
ref={fileInputRef}
type="file"
onChange={handleFileUpload}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={loading}
className={`upload-button ${
isDarkMode
? 'upload-button-dark'
: 'upload-button-light'
}`}
>
<Upload size={18} />
Upload File
</button>
</div>
{loading && (
<div className="loading-center">
<div className="loading-spinner-small"></div>
</div>
)}
<div className={`table-container ${isDarkMode ? 'table-container-dark' : 'table-container-light'}`}>
<table className="data-table">
<thead className={isDarkMode ? 'table-header-dark' : 'table-header-light'}>
<tr>
<th className="table-header-cell">Name</th>
<th className="table-header-cell">Size</th>
<th className="table-header-cell">Uploaded</th>
<th className="table-header-cell">Actions</th>
</tr>
</thead>
<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-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" ref={dropdownRef}>
<button
onClick={() => setOpenDropdown(openDropdown === file.id ? null : file.id)}
className={`actions-button ${
isDarkMode ? 'actions-button-dark' : 'actions-button-light'
}`}
>
Actions
<ChevronDown size={16} />
</button>
{openDropdown === file.id && (
<div className={`dropdown-menu ${
isDarkMode ? 'dropdown-menu-dark' : 'dropdown-menu-light'
}`}>
<button
onClick={() => handleFileAction('share', file.id)}
className={`dropdown-item ${
isDarkMode ? 'dropdown-item-dark' : 'dropdown-item-light'
}`}
>
<Share size={16} />
Share
</button>
<button
onClick={() => handleFileAction('delete', file.id)}
className="dropdown-item dropdown-item-danger"
>
<Trash2 size={16} />
Delete
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
{files.length === 0 && !loading && (
<div className={`empty-state ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
No files uploaded yet
</div>
)}
</div>
</div>
);
};

View file

@ -0,0 +1,108 @@
import React, { useState } from "react";
interface LoginProps {
onLoginSuccess: (token: string) => void;
onSwitchToRegister: () => void;
isDarkMode: boolean;
}
export const Login: React.FC<LoginProps> = ({
onLoginSuccess,
onSwitchToRegister,
isDarkMode
}) => {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
try {
const res = await fetch("http://192.168.1.120:8082/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(`Login failed: ${res.status} ${res.statusText} - ${errorText}`);
}
const data = await res.json();
onLoginSuccess(data.token);
} catch (err: any) {
setError(err.message || "Unknown error");
} finally {
setLoading(false);
}
}
return (
<div className={`gradient-bg ${isDarkMode ? 'gradient-bg-dark' : 'gradient-bg-light'}`}>
<div
className={`auth-container ${
isDarkMode ? 'auth-container-dark' : 'auth-container-light'
}`}
>
<h2 className={`auth-title ${isDarkMode ? 'text-white' : 'text-gray-500'}`}>
Login - Litecloud
</h2>
{error && (
<div className="error-message">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="auth-form">
<input
type="text"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className={`auth-input ${
isDarkMode ? 'auth-input-dark' : 'auth-input-light'
}`}
required
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={`auth-input ${
isDarkMode ? 'auth-input-dark' : 'auth-input-light'
}`}
required
/>
<button
type="submit"
disabled={loading}
className={`auth-button ${
isDarkMode ? 'auth-button-dark' : 'auth-button-light'
}`}
>
{loading ? "Logging in..." : "Login"}
</button>
</form>
<button
type="button"
onClick={onSwitchToRegister}
className={`auth-switch ${
isDarkMode ? 'auth-switch-dark' : 'auth-switch-light'
}`}
>
New user? Register now
</button>
</div>
</div>
);
};

View file

@ -0,0 +1,119 @@
import React, { useState } from "react";
import { Files, Share, Settings, LogOut } from "lucide-react";
import { FilesComponent } from "./FileComponent";
import { SettingsComponent } from "./Settings";
import { SharesComponent } from "./ShareComponent";
interface MainPageProps {
isDarkMode: boolean;
onLogout: () => void;
onThemeToggle: () => void;
}
interface GradientColors {
dark: { from: string; to: string };
light: { from: string; to: string };
}
export const MainPage: React.FC<MainPageProps> = ({
isDarkMode,
onLogout,
onThemeToggle
}) => {
const [activeView, setActiveView] = useState<'files' | 'shares' | 'settings'>('files');
const [gradientColors, setGradientColors] = useState<GradientColors>({
dark: { from: '#7b2ff7', to: '#f107a3' },
light: { from: '#06b6d4', to: '#3b82f6' }
});
const currentGradient = isDarkMode ? gradientColors.dark : gradientColors.light;
const gradientStyle = {
background: `linear-gradient(135deg, ${currentGradient.from} 0%, ${currentGradient.to} 100%)`
};
const renderActiveView = () => {
switch (activeView) {
case 'files':
return <FilesComponent isDarkMode={isDarkMode} />;
case 'shares':
return <SharesComponent isDarkMode={isDarkMode} />;
case 'settings':
return (
<SettingsComponent
isDarkMode={isDarkMode}
onThemeToggle={onThemeToggle}
gradientColors={gradientColors}
setGradientColors={setGradientColors}
/>
);
default:
return <FilesComponent isDarkMode={isDarkMode} />;
}
};
return (
<div className="app-container" style={gradientStyle}>
<div className="app-layout">
{/* Sidebar */}
<div className={`sidebar ${isDarkMode ? 'sidebar-dark' : 'sidebar-light'}`}>
<h2 className={`sidebar-title ${isDarkMode ? 'text-white' : 'text-gray-800'}`}>
LiteCloud
</h2>
<nav className="sidebar-nav">
<button
onClick={() => setActiveView('files')}
className={`nav-button ${
activeView === 'files'
? isDarkMode ? 'nav-button-active-dark' : 'nav-button-active-light'
: isDarkMode ? 'nav-button-inactive-dark' : 'nav-button-inactive-light'
}`}
>
<Files size={20} />
My Files
</button>
<button
onClick={() => setActiveView('shares')}
className={`nav-button ${
activeView === 'shares'
? isDarkMode ? 'nav-button-active-dark' : 'nav-button-active-light'
: isDarkMode ? 'nav-button-inactive-dark' : 'nav-button-inactive-light'
}`}
>
<Share size={20} />
Shares
</button>
<button
onClick={() => setActiveView('settings')}
className={`nav-button ${
activeView === 'settings'
? isDarkMode ? 'nav-button-active-dark' : 'nav-button-active-light'
: isDarkMode ? 'nav-button-inactive-dark' : 'nav-button-inactive-light'
}`}
>
<Settings size={20} />
Settings
</button>
</nav>
<div className="sidebar-footer">
<button
onClick={onLogout}
className="logout-button-sidebar"
>
<LogOut size={20} />
Logout
</button>
</div>
</div>
{/* Main Content */}
<div className={`main-content ${isDarkMode ? 'main-content-dark' : 'main-content-light'}`}>
{renderActiveView()}
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,116 @@
import React, { useState } from "react";
interface RegisterProps {
onSwitchToLogin: () => void;
isDarkMode: boolean;
}
export const Register: React.FC<RegisterProps> = ({
onSwitchToLogin,
isDarkMode
}) => {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
setSuccess(false);
try {
const res = await fetch("http://localhost:8000/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(`Registration failed: ${res.status} ${res.statusText} - ${errorText}`);
}
setSuccess(true);
setTimeout(() => {
onSwitchToLogin();
}, 2000);
} catch (err: any) {
setError(err.message || "Unknown error");
} finally {
setLoading(false);
}
}
return (
<div className={`gradient-bg ${isDarkMode ? 'gradient-bg-dark' : 'gradient-bg-light'}`}>
<div
className={`auth-container ${
isDarkMode ? 'auth-container-dark' : 'auth-container-light'
}`}
>
<h2 className={`auth-title ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>
Register
</h2>
{error && (
<div className="error-message">
{error}
</div>
)}
{success && (
<div className="success-message">
Registration successful! Redirecting to login...
</div>
)}
<form onSubmit={handleSubmit} className="auth-form">
<input
type="text"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className={`auth-input ${
isDarkMode ? 'auth-input-dark' : 'auth-input-light'
}`}
required
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={`auth-input ${
isDarkMode ? 'auth-input-dark' : 'auth-input-light'
}`}
required
/>
<button
type="submit"
disabled={loading}
className={`auth-button ${
isDarkMode ? 'auth-button-dark' : 'auth-button-light'
}`}
>
{loading ? "Registering..." : "Register"}
</button>
</form>
<button
type="button"
onClick={onSwitchToLogin}
className={`auth-switch ${
isDarkMode ? 'auth-switch-dark' : 'auth-switch-light'
}`}
>
Already have an account? Login now
</button>
</div>
</div>
);
};

View file

@ -0,0 +1,164 @@
import React from "react";
import { Sun, Moon, Palette } from "lucide-react";
interface GradientColors {
dark: { from: string; to: string };
light: { from: string; to: string };
}
interface SettingsComponentProps {
isDarkMode: boolean;
onThemeToggle: () => void;
gradientColors: GradientColors;
setGradientColors: React.Dispatch<React.SetStateAction<GradientColors>>;
}
export const SettingsComponent: React.FC<SettingsComponentProps> = ({
isDarkMode,
onThemeToggle,
gradientColors,
setGradientColors
}) => {
const ColorPicker: React.FC<{
label: string;
value: string;
onChange: (value: string) => void
}> = ({ label, value, onChange }) => (
<div className="color-picker-container">
<span className={`color-picker-label ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
{label}
</span>
<input
type="color"
value={value}
onChange={(e) => onChange(e.target.value)}
className="color-picker-input"
/>
</div>
);
return (
<div className="component-container">
<div className="component-header">
<h1 className={`component-title ${isDarkMode ? 'text-white' : 'text-gray-800'}`}>
Settings
</h1>
</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'
}`}>
<h3 className={`gradient-settings-title ${
isDarkMode ? 'text-white' : 'text-gray-800'
}`}>
<Palette size={18} />
Gradient Colors
</h3>
<div className="gradient-settings-content">
<div className="gradient-group">
<h4 className={`gradient-group-title ${
isDarkMode ? 'text-gray-300' : 'text-gray-600'
}`}>
Dark Mode Gradient
</h4>
<div className="gradient-controls">
<ColorPicker
label="From"
value={gradientColors.dark.from}
onChange={(value) => setGradientColors(prev => ({
...prev,
dark: { ...prev.dark, from: value }
}))}
/>
<ColorPicker
label="To"
value={gradientColors.dark.to}
onChange={(value) => setGradientColors(prev => ({
...prev,
dark: { ...prev.dark, to: value }
}))}
/>
</div>
<div
className="gradient-preview"
style={{
background: `linear-gradient(135deg, ${gradientColors.dark.from} 0%, ${gradientColors.dark.to} 100%)`
}}
/>
</div>
<div className="gradient-group">
<h4 className={`gradient-group-title ${
isDarkMode ? 'text-gray-300' : 'text-gray-600'
}`}>
Light Mode Gradient
</h4>
<div className="gradient-controls">
<ColorPicker
label="From"
value={gradientColors.light.from}
onChange={(value) => setGradientColors(prev => ({
...prev,
light: { ...prev.light, from: value }
}))}
/>
<ColorPicker
label="To"
value={gradientColors.light.to}
onChange={(value) => setGradientColors(prev => ({
...prev,
light: { ...prev.light, to: value }
}))}
/>
</div>
<div
className="gradient-preview"
style={{
background: `linear-gradient(135deg, ${gradientColors.light.from} 0%, ${gradientColors.light.to} 100%)`
}}
/>
</div>
</div>
</div>
{/* Additional Settings Placeholder */}
<div className={`settings-section ${
isDarkMode ? 'settings-section-dark' : 'settings-section-light'
}`}>
<h3 className={`settings-section-title ${
isDarkMode ? 'text-white' : 'text-gray-800'
}`}>
Coming Soon
</h3>
<p className={`settings-section-description ${
isDarkMode ? 'text-gray-400' : 'text-gray-500'
}`}>
More settings will be available in future updates, including file management preferences,
notification settings, and account management options.
</p>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,168 @@
import React, { useState, useEffect } from "react";
import { Trash2 } from "lucide-react";
interface ShareInfo {
id: string;
file_id: number;
file_name: string;
created_at: string;
expires_at: string | null;
is_expired: boolean;
}
interface SharesComponentProps {
isDarkMode: boolean;
}
export const SharesComponent: React.FC<SharesComponentProps> = ({ isDarkMode }) => {
const [shares, setShares] = useState<ShareInfo[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const getAuthToken = () => {
return localStorage.getItem('authToken') || '';
};
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);
setError(null);
const response = await apiCall('/api/shares');
const sharesData = await response.json();
setShares(sharesData);
} catch (err) {
setError('Failed to fetch shares');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchShares();
}, []);
const handleDeleteShare = async (shareId: string) => {
try {
setLoading(true);
setError(null);
await apiCall(`/api/shares/${shareId}`, {
method: 'DELETE',
});
setSuccess('Share deleted successfully');
fetchShares();
} catch (err) {
setError('Failed to delete share');
} finally {
setLoading(false);
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
const copyToClipboard = async (shareId: string) => {
try {
const shareUrl = `${window.location.origin}/shared/${shareId}`;
await navigator.clipboard.writeText(shareUrl);
setSuccess('Share link copied to clipboard!');
} catch (err) {
setError('Failed to copy link');
}
};
return (
<div className="component-container">
{/* Success/Error Messages */}
{error && (
<div className="notification notification-error">
{error}
<button onClick={() => setError(null)} className="notification-close">×</button>
</div>
)}
{success && (
<div className="notification notification-success">
{success}
<button onClick={() => setSuccess(null)} className="notification-close">×</button>
</div>
)}
<div className="component-header">
<h1 className={`component-title ${isDarkMode ? 'text-white' : 'text-gray-800'}`}>
Shared Files
</h1>
</div>
{loading && (
<div className="loading-center">
<div className="loading-spinner-small"></div>
</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"
>
/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>
))}
{shares.length === 0 && !loading && (
<div className={`empty-state ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
No shared files yet
</div>
)}
</div>
</div>
);
};

View file

@ -0,0 +1,19 @@
import React from "react";
interface ThemeToggleProps {
isDarkMode: boolean;
onToggle: () => void;
}
export const ThemeToggle: React.FC<ThemeToggleProps> = ({ isDarkMode, onToggle }) => {
return (
<button
onClick={onToggle}
className={`theme-toggle ${isDarkMode ? 'theme-toggle-dark' : 'theme-toggle-light'}`}
>
<div className="theme-icon">
{isDarkMode ? '☀️' : '🌙'}
</div>
</button>
);
};

771
frontend/src/index.css Normal file
View file

@ -0,0 +1,771 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
body, html, #root {
height: 100%;
margin: 0;
padding: 0;
}
/* Gradient Backgrounds */
.gradient-bg {
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 1rem;
transition: background 0.3s ease;
}
.gradient-bg-dark {
background: linear-gradient(135deg, #7b2ff7 0%, #f107a3 100%);
}
.gradient-bg-light {
background: linear-gradient(135deg, #06b6d4 0%, #3b82f6 100%);
}
/* Auth Container */
.auth-container {
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
border-radius: 1.5rem;
padding: 2rem;
max-width: 28rem;
width: 100%;
display: flex;
flex-direction: column;
gap: 1.5rem;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.auth-container-dark {
background: rgba(31, 41, 55, 0.75);
}
.auth-container-light {
background: rgba(255, 255, 255, 0.75);
}
/* Auth Title */
.auth-title {
font-size: 1.875rem;
font-weight: 600;
text-align: center;
margin: 0;
}
/* Auth Form */
.auth-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* Auth Inputs */
.auth-input {
padding: 0.75rem 1rem;
border-radius: 0.5rem;
border: 1px solid;
font-size: 1rem;
transition: all 0.2s ease;
outline: none;
}
.auth-input:focus {
ring: 2px;
ring-offset: 2px;
}
.auth-input-dark {
background: rgba(55, 65, 81, 0.5);
border-color: rgba(75, 85, 99, 1);
color: white;
}
.auth-input-dark::placeholder {
color: rgba(156, 163, 175, 1);
}
.auth-input-dark:focus {
ring-color: rgba(147, 51, 234, 1);
border-color: rgba(147, 51, 234, 1);
}
.auth-input-light {
background: rgba(255, 255, 255, 0.7);
border-color: rgba(209, 213, 219, 1);
color: rgba(17, 24, 39, 1);
}
.auth-input-light::placeholder {
color: rgba(107, 114, 128, 1);
}
.auth-input-light:focus {
ring-color: rgba(59, 130, 246, 1);
border-color: rgba(59, 130, 246, 1);
}
/* Auth Buttons */
.auth-button {
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 600;
font-size: 1rem;
border: none;
cursor: pointer;
transition: all 0.2s ease;
color: white;
}
.auth-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.auth-button-dark {
background: rgba(147, 51, 234, 1);
}
.auth-button-dark:hover:not(:disabled) {
background: rgba(126, 34, 206, 1);
}
.auth-button-light {
background: rgba(59, 130, 246, 1);
}
.auth-button-light:hover:not(:disabled) {
background: rgba(37, 99, 235, 1);
}
/* Auth Switch */
.auth-switch {
background: none;
border: none;
font-weight: 500;
cursor: pointer;
text-align: center;
transition: all 0.2s ease;
padding: 0.5rem;
}
.auth-switch:hover {
text-decoration: underline;
}
.auth-switch-dark {
color: rgba(196, 181, 253, 1);
}
.auth-switch-dark:hover {
color: rgba(221, 214, 254, 1);
}
.auth-switch-light {
color: rgba(59, 130, 246, 1);
}
.auth-switch-light:hover {
color: rgba(37, 99, 235, 1);
}
/* Main Container */
.main-container {
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
border-radius: 1.5rem;
padding: 2rem;
text-align: center;
max-width: 28rem;
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.main-container-dark {
background: rgba(31, 41, 55, 0.75);
color: white;
}
.main-container-light {
background: rgba(255, 255, 255, 0.75);
color: rgba(17, 24, 39, 1);
}
.main-title {
font-size: 2.25rem;
font-weight: 700;
margin: 0 0 1rem 0;
}
.main-subtitle {
font-size: 1.125rem;
margin: 0 0 1.5rem 0;
opacity: 0.8;
}
/* Logout Button */
.logout-button {
padding: 0.75rem 1.5rem;
background: rgba(239, 68, 68, 1);
color: white;
border: none;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.logout-button:hover {
background: rgba(220, 38, 38, 1);
}
/* Theme Toggle */
.theme-toggle {
position: fixed;
top: 1rem;
right: 1rem;
padding: 0.75rem;
border-radius: 50%;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.25);
transition: all 0.2s ease;
border: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
z-index: 50;
}
.theme-toggle:hover {
transform: scale(1.1);
}
.theme-toggle-dark {
background: rgba(75, 85, 99, 0.75);
}
.theme-toggle-light {
background: rgba(255, 255, 255, 0.75);
}
.theme-icon {
width: 1.5rem;
height: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.125rem;
}
/* Messages */
.error-message {
color: rgba(239, 68, 68, 1);
text-align: center;
font-weight: 500;
background: rgba(254, 226, 226, 0.8);
padding: 0.75rem;
border-radius: 0.5rem;
border: 1px solid rgba(239, 68, 68, 0.2);
}
.success-message {
color: rgba(34, 197, 94, 1);
text-align: center;
font-weight: 500;
background: rgba(220, 252, 231, 0.8);
padding: 0.75rem;
border-radius: 0.5rem;
border: 1px solid rgba(34, 197, 94, 0.2);
}
/* Loading */
.loading-container {
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 1rem;
background: linear-gradient(135deg, #7b2ff7 0%, #f107a3 100%);
color: white;
}
.loading-spinner {
width: 2rem;
height: 2rem;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top: 3px solid white;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* ==== NEW MAIN APP LAYOUT STYLES ==== */
/* App Container */
.app-container {
min-height: 100vh;
padding: 1rem;
transition: background 0.3s ease;
}
/* App Layout */
.app-layout {
display: flex;
height: calc(100vh - 2rem);
gap: 10px;
max-width: 1400px;
margin: 0 auto;
}
/* Sidebar */
.sidebar {
width: 280px;
border-radius: 1rem;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
padding: 1.5rem;
transition: all 0.3s ease;
}
.sidebar-dark {
background: rgba(31, 41, 55, 0.75);
}
.sidebar-light {
background: rgba(255, 255, 255, 0.75);
}
/* Sidebar Title */
.sidebar-title {
font-size: 1.5rem;
font-weight: 700;
margin: 0 0 2rem 0;
text-align: center;
}
/* Sidebar Navigation */
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 1;
}
/* Navigation Buttons */
.nav-button {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
border: none;
border-radius: 0.75rem;
font-weight: 500;
font-size: 0.95rem;
cursor: pointer;
transition: all 0.2s ease;
text-align: left;
width: 100%;
}
.nav-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
/* Active Navigation Buttons */
.nav-button-active-dark {
background: linear-gradient(135deg, rgba(147, 51, 234, 0.8), rgba(126, 34, 206, 0.8));
color: white;
box-shadow: 0 4px 20px rgba(147, 51, 234, 0.3);
}
.nav-button-active-light {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8));
color: white;
box-shadow: 0 4px 20px rgba(59, 130, 246, 0.3);
}
/* Inactive Navigation Buttons */
.nav-button-inactive-dark {
background: rgba(55, 65, 81, 0.3);
color: rgba(209, 213, 219, 1);
border: 1px solid rgba(75, 85, 99, 0.3);
}
.nav-button-inactive-dark:hover {
background: rgba(55, 65, 81, 0.5);
color: white;
}
.nav-button-inactive-light {
background: rgba(255, 255, 255, 0.4);
color: rgba(55, 65, 81, 1);
border: 1px solid rgba(209, 213, 219, 0.3);
}
.nav-button-inactive-light:hover {
background: rgba(255, 255, 255, 0.7);
color: rgba(17, 24, 39, 1);
}
/* Sidebar Footer */
.sidebar-footer {
margin-top: auto;
padding-top: 1rem;
}
/* Logout Button in Sidebar */
.logout-button-sidebar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
background: rgba(239, 68, 68, 0.1);
color: rgba(239, 68, 68, 1);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
width: 100%;
}
.logout-button-sidebar:hover {
background: rgba(239, 68, 68, 0.9);
color: white;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
}
/* Main Content Area */
.main-content {
flex: 1;
border-radius: 1rem;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 2rem;
overflow: hidden;
position: relative;
}
.main-content-dark {
background: rgba(31, 41, 55, 0.75);
color: white;
}
.main-content-light {
background: rgba(255, 255, 255, 0.75);
color: rgba(17, 24, 39, 1);
}
/* Files Component */
.files-container {
height: 100%;
display: flex;
flex-direction: column;
}
.files-header {
display: flex;
justify-content: between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.files-title {
font-size: 1.75rem;
font-weight: 600;
margin: 0;
}
.files-table-container {
flex: 1;
overflow-y: auto;
margin-bottom: 1rem;
}
/* Files Table */
.files-table {
width: 100%;
border-collapse: collapse;
}
.files-table thead {
position: sticky;
top: 0;
z-index: 10;
}
.files-table th {
padding: 1rem;
text-align: left;
font-weight: 600;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 2px solid rgba(255, 255, 255, 0.1);
}
.files-table-dark th {
background: rgba(55, 65, 81, 0.5);
color: rgba(209, 213, 219, 1);
}
.files-table-light th {
background: rgba(249, 250, 251, 0.8);
color: rgba(55, 65, 81, 1);
}
.files-table td {
padding: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
vertical-align: middle;
}
.files-table tbody tr {
transition: all 0.2s ease;
}
.files-table-dark tbody tr:hover {
background: rgba(55, 65, 81, 0.3);
}
.files-table-light tbody tr:hover {
background: rgba(249, 250, 251, 0.5);
}
/* File Row Actions */
.file-actions {
position: relative;
}
.file-actions-button {
padding: 0.5rem;
border: none;
background: none;
cursor: pointer;
border-radius: 0.375rem;
transition: all 0.2s ease;
}
.file-actions-button:hover {
background: rgba(107, 114, 128, 0.1);
}
.file-actions-menu {
position: absolute;
right: 0;
top: 100%;
background: white;
border-radius: 0.5rem;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.25);
border: 1px solid rgba(209, 213, 219, 1);
min-width: 120px;
z-index: 20;
overflow: hidden;
}
.file-actions-menu-dark {
background: rgba(55, 65, 81, 1);
border-color: rgba(75, 85, 99, 1);
}
.file-actions-menu button {
display: block;
width: 100%;
padding: 0.75rem 1rem;
border: none;
background: none;
text-align: left;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.875rem;
}
.file-actions-menu-dark button {
color: white;
}
.file-actions-menu-dark button:hover {
background: rgba(75, 85, 99, 1);
}
.file-actions-menu button:hover {
background: rgba(249, 250, 251, 1);
}
.file-actions-menu button.delete-action {
color: rgba(239, 68, 68, 1);
}
.file-actions-menu button.delete-action:hover {
background: rgba(254, 226, 226, 1);
}
/* Upload Button */
.upload-button {
position: fixed;
bottom: 2rem;
right: 2rem;
width: 60px;
height: 60px;
border-radius: 50%;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: white;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.25);
transition: all 0.2s ease;
z-index: 30;
}
.upload-button:hover {
transform: scale(1.1);
box-shadow: 0 15px 35px -5px rgba(0, 0, 0, 0.35);
}
.upload-button-dark {
background: linear-gradient(135deg, rgba(147, 51, 234, 0.9), rgba(126, 34, 206, 0.9));
}
.upload-button-light {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.9), rgba(37, 99, 235, 0.9));
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
text-align: center;
opacity: 0.6;
}
.empty-state-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.empty-state h3 {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 0.5rem 0;
}
.empty-state p {
font-size: 0.875rem;
margin: 0;
opacity: 0.8;
}
/* Responsive Design */
@media (max-width: 1024px) {
.app-layout {
flex-direction: column;
height: auto;
min-height: calc(100vh - 2rem);
}
.sidebar {
width: 100%;
order: 2;
}
.sidebar-nav {
flex-direction: row;
justify-content: center;
gap: 0.5rem;
}
.nav-button {
flex: 1;
justify-content: center;
max-width: 120px;
}
.sidebar-footer {
margin-top: 1rem;
padding-top: 0;
}
.main-content {
order: 1;
min-height: 60vh;
}
}
@media (max-width: 640px) {
.app-container {
padding: 0.5rem;
}
.app-layout {
height: auto;
min-height: calc(100vh - 1rem);
}
.sidebar,
.main-content {
padding: 1rem;
}
.files-table th,
.files-table td {
padding: 0.5rem;
font-size: 0.875rem;
}
.upload-button {
width: 50px;
height: 50px;
bottom: 1rem;
right: 1rem;
}
.sidebar-nav {
gap: 0.25rem;
}
.nav-button {
padding: 0.5rem;
font-size: 0.875rem;
}
}

10
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

1
frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {},
},
plugins: [],
};

View file

@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

7
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})

View file

@ -1,43 +0,0 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

View file

@ -1,45 +0,0 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "761747bfc538b5af34aa0d3fac380f1bc331ec49"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
base_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
- platform: android
create_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
base_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
- platform: ios
create_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
base_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
- platform: linux
create_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
base_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
- platform: macos
create_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
base_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
- platform: web
create_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
base_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
- platform: windows
create_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
base_revision: 761747bfc538b5af34aa0d3fac380f1bc331ec49
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

View file

@ -1,16 +0,0 @@
# lightcloud
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

View file

@ -1,31 +0,0 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
analyzer:
errors:
use_build_context_synchronously: ignore
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

View file

@ -1,13 +0,0 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
# Remember to never publicly share your keystore.
# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app
key.properties
**/*.keystore
**/*.jks

View file

@ -1,58 +0,0 @@
plugins {
id "com.android.application"
id "kotlin-android"
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id "dev.flutter.flutter-gradle-plugin"
}
def localProperties = new Properties()
def localPropertiesFile = rootProject.file("local.properties")
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader("UTF-8") { reader ->
localProperties.load(reader)
}
}
def flutterVersionCode = localProperties.getProperty("flutter.versionCode")
if (flutterVersionCode == null) {
flutterVersionCode = "1"
}
def flutterVersionName = localProperties.getProperty("flutter.versionName")
if (flutterVersionName == null) {
flutterVersionName = "1.0"
}
android {
namespace = "com.lightcloud.lightcloud"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.lightcloud.lightcloud"
// You can update the following values to match your application needs.
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutterVersionCode.toInteger()
versionName = flutterVersionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.debug
}
}
}
flutter {
source = "../.."
}

View file

@ -1,45 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="lightcloud"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View file

@ -1,5 +0,0 @@
package com.lightcloud.lightcloud
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity()

View file

@ -1,5 +0,0 @@
package com.lightcloud.lightcloud_app
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity()

View file

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View file

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View file

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View file

@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View file

@ -1,18 +0,0 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
rootProject.buildDir = "../build"
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}

View file

@ -1,3 +0,0 @@
org.gradle.jvmargs=-Xmx4G -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true

View file

@ -1,5 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip

View file

@ -1,25 +0,0 @@
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "7.3.0" apply false
id "org.jetbrains.kotlin.android" version "1.7.10" apply false
}
include ":app"

View file

@ -1,9 +0,0 @@
import 'package:flutter_dotenv/flutter_dotenv.dart';
class Config {
static String get apiBaseUrl =>
dotenv.env['API_URL'] ?? 'http://localhost:8082';
static String get apiPath => '/api';
static String get apiUrl => '$apiBaseUrl$apiPath';
}

View file

@ -1,264 +0,0 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'mainpage.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'config.dart';
Future<void> main() async {
await dotenv.load();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return FutureBuilder<SharedPreferences>(
future: SharedPreferences.getInstance(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const CircularProgressIndicator();
}
final prefs = snapshot.data!;
final jwt = prefs.getString('jwt');
return MaterialApp(
title: 'Litecloud alpha',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color.fromARGB(255, 72, 4, 117)),
useMaterial3: true,
),
home: jwt != null ? const MainPage() : const AuthScreen(),
);
},
);
}
}
class AuthScreen extends StatefulWidget {
const AuthScreen({super.key});
@override
State<AuthScreen> createState() => _AuthScreenState();
}
class _AuthScreenState extends State<AuthScreen> {
bool _isLogin = true;
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmController = TextEditingController();
bool _loading = false;
String? _error;
void _toggleAuthMode() {
setState(() {
_isLogin = !_isLogin;
_error = null;
});
}
Future<void> _submit() async {
setState(() {
_loading = true;
_error = null;
});
final email = _emailController.text.trim();
final password = _passwordController.text.trim();
if (!_isLogin && password != _confirmController.text.trim()) {
setState(() {
_error = "Le password non coincidono";
_loading = false;
});
return;
}
final url =
Uri.parse('${Config.apiUrl}/${_isLogin ? "login" : "register"}');
final body = jsonEncode({
"username": email,
"password": password,
});
try {
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: body,
);
if (response.statusCode == 200 || response.statusCode == 201) {
final data = jsonDecode(response.body);
final token = data['token'];
final prefs = await SharedPreferences.getInstance();
await prefs.setString('jwt', token);
if (mounted) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const MainPage()),
);
}
} else {
setState(() {
_error = "Credenziali non valide";
});
}
} catch (e) {
setState(() {
_error = "Errore di rete";
});
} finally {
setState(() {
_loading = false;
});
}
}
@override
Widget build(BuildContext context) {
final deviceSize = MediaQuery.of(context).size;
return Scaffold(
body: Container(
width: deviceSize.width,
height: deviceSize.height,
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Colors.purple, Color.fromARGB(255, 72, 4, 117)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 60),
// Logo and tagline
Container(
margin: const EdgeInsets.only(bottom: 40.0),
child: Column(
children: [
Text(
'Litecloud',
style: TextStyle(
fontSize: 42,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onPrimary,
),
),
const SizedBox(height: 8),
Text(
'Rust based cloud storage',
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onPrimary,
),
),
const SizedBox(height: 8),
Text(
'Version 0.0.1 pre-alpha',
style: TextStyle(
fontSize: 8,
color: Theme.of(context).colorScheme.onPrimary,
),
),
],
),
),
// Auth card
Card(
margin: const EdgeInsets.symmetric(horizontal: 600.0),
elevation: 8.0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.0),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Text(
_isLogin ? 'Login' : 'Register',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 40),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Username',
prefixIcon: Icon(Icons.person),
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'Password',
prefixIcon: Icon(Icons.lock),
border: OutlineInputBorder(),
),
obscureText: true,
),
if (!_isLogin) const SizedBox(height: 16),
if (!_isLogin)
TextFormField(
controller: _confirmController,
decoration: const InputDecoration(
labelText: 'Confirm Password',
prefixIcon: Icon(Icons.lock),
border: OutlineInputBorder(),
),
obscureText: true,
),
const SizedBox(height: 24),
FilledButton(
onPressed: _loading ? null : _submit,
style: FilledButton.styleFrom(
minimumSize: const Size(double.infinity, 50),
),
child: _loading
? const CircularProgressIndicator()
: Text(
_isLogin ? 'LOGIN' : 'REGISTER',
style: const TextStyle(fontSize: 16),
),
),
if (_error != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(_error!,
style: const TextStyle(color: Colors.red)),
),
const SizedBox(height: 12),
TextButton(
onPressed: _toggleAuthMode,
child: Text(
_isLogin
? 'Create new account'
: 'I already have an account',
),
),
],
),
),
),
],
),
),
),
),
),
);
}
}

View file

@ -1,395 +0,0 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:file_picker/file_picker.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'main.dart';
import 'sidebar.dart';
import 'shares.dart';
import 'sharedialog.dart';
import 'package:path_provider/path_provider.dart';
import 'package:file_saver/file_saver.dart';
import 'dart:io';
import 'config.dart';
class FileInfo {
final int id;
final String name;
final int size;
final DateTime uploadedAt;
FileInfo({
required this.id,
required this.name,
required this.size,
required this.uploadedAt,
});
factory FileInfo.fromJson(Map<String, dynamic> json) {
return FileInfo(
id: json['id'],
name: json['original_name'],
size: json['size'],
uploadedAt: DateTime.parse(json['uploaded_at']),
);
}
}
class MainPage extends StatefulWidget {
const MainPage({super.key});
@override
State<MainPage> createState() => _MainPageState();
}
class _MainPageState extends State<MainPage> {
SidebarPage _selectedPage = SidebarPage.myFiles;
List<FileInfo> _files = [];
bool _isLoading = false;
String? _error;
@override
void initState() {
super.initState();
_fetchFiles();
}
Future<void> _fetchFiles() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final prefs = await SharedPreferences.getInstance();
final jwt = prefs.getString('jwt');
if (jwt == null) {
setState(() {
_error = 'Sessione scaduta, effettua di nuovo il login.';
_isLoading = false;
});
return;
}
final uri = Uri.parse('${Config.apiUrl}/files');
final response = await http.get(
uri,
headers: {'Authorization': 'Bearer $jwt'},
);
if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(response.body);
setState(() {
_files = data.map((file) => FileInfo.fromJson(file)).toList();
_isLoading = false;
});
} else {
setState(() {
_error = 'Errore durante il recupero dei file.';
_isLoading = false;
});
}
} catch (e) {
setState(() {
_error = 'Errore di rete: ${e.toString()}';
_isLoading = false;
});
}
}
Future<void> _logout() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove('jwt');
if (mounted) {
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const AuthScreen()),
(route) => false,
);
}
}
void _onPageSelected(SidebarPage page) {
setState(() {
_selectedPage = page;
});
}
Future<void> _uploadFile() async {
final result = await FilePicker.platform.pickFiles();
if (result == null || result.files.isEmpty) return;
final file = result.files.first;
final prefs = await SharedPreferences.getInstance();
final jwt = prefs.getString('jwt');
if (jwt == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Sessione scaduta, effettua di nuovo il login.')),
);
return;
}
final uri = Uri.parse('${Config.apiUrl}/upload');
final request = http.MultipartRequest('POST', uri)
..headers['Authorization'] = 'Bearer $jwt'
..files.add(
http.MultipartFile.fromBytes(
'file',
file.bytes!,
filename: file.name,
),
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Caricamento di ${file.name} in corso...')),
);
final response = await request.send();
if (response.statusCode == 200 || response.statusCode == 201) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('File caricato con successo!')),
);
_fetchFiles(); // Refresh the file list
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Errore durante l\'upload.')),
);
}
}
Future<void> _downloadFile(FileInfo file) async {
try {
final prefs = await SharedPreferences.getInstance();
final jwt = prefs.getString('jwt');
if (jwt == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Sessione scaduta, effettua di nuovo il login.')),
);
return;
}
final uri = Uri.parse('${Config.apiUrl}/files/${file.id}');
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Download in corso...')),
);
final response = await http.get(
uri,
headers: {'Authorization': 'Bearer $jwt'},
);
if (response.statusCode == 200) {
if (Platform.isAndroid || Platform.isIOS) {
await FileSaver.instance.saveFile(
name: file.name,
bytes: response.bodyBytes,
);
} else {
// Desktop platforms
final directory = await getDownloadsDirectory();
if (directory != null) {
final filePath = '${directory.path}/${file.name}';
final fileObj = File(filePath);
await fileObj.writeAsBytes(response.bodyBytes);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('File salvato in: $filePath')),
);
} else {
throw Exception('Could not access downloads directory');
}
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('File ${file.name} scaricato con successo!')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Errore durante il download.')),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Errore di rete: ${e.toString()}')),
);
}
}
Future<void> _deleteFile(FileInfo file) async {
try {
final prefs = await SharedPreferences.getInstance();
final jwt = prefs.getString('jwt');
if (jwt == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Sessione scaduta, effettua di nuovo il login.')),
);
return;
}
final uri = Uri.parse('${Config.apiUrl}/files/${file.id}');
final response = await http.delete(
uri,
headers: {'Authorization': 'Bearer $jwt'},
);
if (response.statusCode == 204) {
setState(() {
_files.removeWhere((f) => f.id == file.id);
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('File ${file.name} eliminato con successo!')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Errore durante l\'eliminazione.')),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Errore di rete: ${e.toString()}')),
);
}
}
void _showShareDialog(FileInfo file) {
showDialog(
context: context,
builder: (context) => ShareDialog(
fileId: file.id,
fileName: file.name,
),
);
}
void _showFileOptions(FileInfo file) {
showModalBottomSheet(
context: context,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.download),
title: const Text('Download'),
onTap: () {
Navigator.pop(context);
_downloadFile(file);
},
),
ListTile(
leading: const Icon(Icons.share),
title: const Text('Condividi'),
onTap: () {
Navigator.pop(context);
_showShareDialog(file);
},
),
ListTile(
leading: Icon(Icons.delete, color: Colors.red[700]),
title: Text('Elimina', style: TextStyle(color: Colors.red[700])),
onTap: () {
Navigator.pop(context);
_deleteFile(file);
},
),
],
),
),
);
}
Widget _buildMyFiles() {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_error!, style: TextStyle(color: Colors.red[700])),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _fetchFiles,
child: const Text('Riprova'),
),
],
),
);
}
if (_files.isEmpty) {
return const Center(
child: Text('Nessun file trovato. Carica il tuo primo file!'),
);
}
return ListView.separated(
padding: const EdgeInsets.all(32),
itemCount: _files.length,
separatorBuilder: (_, __) => const Divider(),
itemBuilder: (context, idx) {
final file = _files[idx];
return ListTile(
leading: const Icon(Icons.insert_drive_file),
title: Text(file.name),
subtitle: Text(
"Size: ${(file.size / 1024).toStringAsFixed(1)} KB • Uploaded: ${file.uploadedAt.toLocal()}",
style: const TextStyle(fontSize: 12),
),
trailing: IconButton(
icon: const Icon(Icons.more_vert),
onPressed: () => _showFileOptions(file),
),
);
},
);
}
@override
Widget build(BuildContext context) {
Widget content;
if (_selectedPage == SidebarPage.myFiles) {
content = Stack(
children: [
_buildMyFiles(),
Positioned(
bottom: 32,
right: 32,
child: FloatingActionButton.extended(
onPressed: _uploadFile,
icon: const Icon(Icons.upload_file),
label: const Text("Upload"),
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
),
),
],
);
} else if (_selectedPage == SidebarPage.shared) {
content = const SharesPage();
} else {
content = const Center(child: Text("Settings (coming soon)"));
}
return Scaffold(
body: Row(
children: [
Sidebar(
selectedPage: _selectedPage,
onPageSelected: _onPageSelected,
onLogout: _logout,
),
Expanded(child: content),
],
),
);
}
}

View file

@ -1,178 +0,0 @@
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter/services.dart';
import 'config.dart';
class ShareDialog extends StatefulWidget {
final int fileId;
final String fileName;
const ShareDialog({
required this.fileId,
required this.fileName,
super.key,
});
@override
State<ShareDialog> createState() => _ShareDialogState();
}
class _ShareDialogState extends State<ShareDialog> {
bool _isPublicShare = true;
int _expirationDays = 7;
bool _isLoading = false;
String? shareLink;
String? _error;
Future<void> _createShare() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final prefs = await SharedPreferences.getInstance();
final jwt = prefs.getString('jwt');
if (jwt == null) {
setState(() {
_error = 'Sessione scaduta, effettua di nuovo il login.';
_isLoading = false;
});
return;
}
final uri = Uri.parse('${Config.apiUrl}/share');
final response = await http.post(
uri,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $jwt',
},
body: jsonEncode({
'file_id': widget.fileId,
'expires_in_days': _isPublicShare ? _expirationDays : null,
}),
);
if (response.statusCode == 200) {
final shareId = jsonDecode(response.body);
setState(() {
shareLink = '${Config.apiUrl}/shared/$shareId';
_isLoading = false;
});
} else {
setState(() {
_error = 'Errore durante la creazione del link di condivisione.';
_isLoading = false;
});
}
} catch (e) {
setState(() {
_error = 'Errore di rete: ${e.toString()}';
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('Condividi "${widget.fileName}"'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile(
title: const Text('Link pubblico'),
subtitle: const Text('Chiunque con il link può accedere al file'),
value: _isPublicShare,
onChanged: (value) {
setState(() {
_isPublicShare = value;
});
},
),
if (_isPublicShare) ...[
const SizedBox(height: 16),
const Text('Scadenza del link:'),
Slider(
value: _expirationDays.toDouble(),
min: 1,
max: 30,
divisions: 29,
label: _expirationDays.toString(),
onChanged: (value) {
setState(() {
_expirationDays = value.round();
});
},
),
Text('Il link scadrà dopo $_expirationDays giorni'),
],
if (shareLink != null) ...[
const SizedBox(height: 16),
const Text('Link di condivisione:'),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(4),
),
child: Row(
children: [
Expanded(
child: Text(
shareLink!,
style: const TextStyle(fontFamily: 'monospace'),
),
),
IconButton(
icon: const Icon(Icons.copy),
tooltip: 'Copia link',
onPressed: () {
Clipboard.setData(ClipboardData(text: shareLink ?? ''));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Link copiato negli appunti')),
);
},
),
],
),
),
],
if (_error != null) ...[
const SizedBox(height: 16),
Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Chiudi'),
),
if (shareLink == null)
ElevatedButton(
onPressed: _isLoading ? null : _createShare,
child: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Crea link'),
),
],
);
}
}

View file

@ -1,216 +0,0 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import 'package:file_saver/file_saver.dart';
import 'dart:io';
class SharedFile {
final String id;
final int fileId;
final String fileName;
final DateTime createdAt;
final DateTime? expiresAt;
SharedFile({
required this.id,
required this.fileId,
required this.fileName,
required this.createdAt,
this.expiresAt,
});
}
class SharesPage extends StatefulWidget {
const SharesPage({super.key});
@override
State<SharesPage> createState() => _SharesPageState();
}
class _SharesPageState extends State<SharesPage> {
List<SharedFile> _sharedFiles = [];
bool _isLoading = false;
String? _error;
@override
void initState() {
super.initState();
_fetchSharedFiles();
}
Future<void> _fetchSharedFiles() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final prefs = await SharedPreferences.getInstance();
final jwt = prefs.getString('jwt');
if (jwt == null) {
setState(() {
_error = 'Sessione scaduta, effettua di nuovo il login.';
_isLoading = false;
});
return;
}
final uri = Uri.parse('http://localhost:8082/api/shares');
final response = await http.get(
uri,
headers: {'Authorization': 'Bearer $jwt'},
);
if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(response.body);
setState(() {
_sharedFiles = data
.map((share) => SharedFile(
id: share['id'],
fileId: share['file_id'],
fileName: share['file_name'],
createdAt: DateTime.parse(share['created_at']),
expiresAt: share['expires_at'] != null
? DateTime.parse(share['expires_at'])
: null,
))
.toList();
_isLoading = false;
});
} else {
setState(() {
_error = 'Errore durante il recupero dei file condivisi.';
_isLoading = false;
});
}
} catch (e) {
setState(() {
_error = 'Errore di rete: ${e.toString()}';
_isLoading = false;
});
}
}
Future<void> _downloadSharedFile(SharedFile share) async {
try {
final shareLink = 'http://localhost:8082/api/shared/${share.id}';
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Download in corso...')),
);
final response = await http.get(Uri.parse(shareLink));
if (response.statusCode == 200) {
// Save the file to disk
if (Platform.isAndroid || Platform.isIOS) {
// Mobile platforms
await FileSaver.instance.saveFile(
name: share.fileName,
bytes: response.bodyBytes,
);
} else {
// Desktop platforms
final directory = await getDownloadsDirectory();
if (directory != null) {
final filePath = '${directory.path}/${share.fileName}';
final fileObj = File(filePath);
await fileObj.writeAsBytes(response.bodyBytes);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('File salvato in: $filePath')),
);
} else {
throw Exception('Could not access downloads directory');
}
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('File ${share.fileName} scaricato con successo!')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Errore durante il download.')),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Errore di rete: ${e.toString()}')),
);
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_error!, style: TextStyle(color: Colors.red[700])),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _fetchSharedFiles,
child: const Text('Riprova'),
),
],
),
);
}
if (_sharedFiles.isEmpty) {
return const Center(
child: Text(
'Nessun file condiviso. Condividi un file dalla sezione "My Files"!'),
);
}
return ListView.separated(
padding: const EdgeInsets.all(32),
itemCount: _sharedFiles.length,
separatorBuilder: (_, __) => const Divider(),
itemBuilder: (context, idx) {
final share = _sharedFiles[idx];
return ListTile(
leading: const Icon(Icons.link),
title: Text(share.fileName),
subtitle: Text(
"Creato: ${share.createdAt.toLocal()}${share.expiresAt != null ? ' • Scade: ${share.expiresAt!.toLocal()}' : ' • Non scade mai'}",
style: const TextStyle(fontSize: 12),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.download),
tooltip: 'Scarica file',
onPressed: () => _downloadSharedFile(share),
),
IconButton(
icon: const Icon(Icons.copy),
tooltip: 'Copia link',
onPressed: () {
final shareLink =
'http://localhost:8082/api/shared/${share.id}';
Clipboard.setData(ClipboardData(text: shareLink));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Link copiato negli appunti')),
);
},
),
],
),
);
},
);
}
}

View file

@ -1,73 +0,0 @@
import 'package:flutter/material.dart';
enum SidebarPage { myFiles, shared, settings }
class Sidebar extends StatelessWidget {
final SidebarPage selectedPage;
final ValueChanged<SidebarPage> onPageSelected;
final VoidCallback onLogout;
const Sidebar({
required this.selectedPage,
required this.onPageSelected,
required this.onLogout,
super.key,
});
@override
Widget build(BuildContext context) {
final selectedColor =
Theme.of(context).colorScheme.primary.withOpacity(0.15);
final outlineColor = Colors.purple[100];
Widget buildTile(
{required IconData icon,
required String label,
required SidebarPage page}) {
final selected = selectedPage == page;
return Container(
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
decoration: selected
? BoxDecoration(
color: selectedColor,
border: Border.all(color: outlineColor!, width: 2),
borderRadius: BorderRadius.circular(12),
)
: null,
child: ListTile(
leading: Icon(icon,
color: selected ? Theme.of(context).colorScheme.primary : null),
title: Text(label,
style: TextStyle(
color:
selected ? Theme.of(context).colorScheme.primary : null)),
onTap: () => onPageSelected(page),
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
);
}
return NavigationDrawer(
backgroundColor: Theme.of(context).colorScheme.surface,
children: [
const SizedBox(height: 24),
buildTile(
icon: Icons.folder, label: 'My Files', page: SidebarPage.myFiles),
buildTile(
icon: Icons.share, label: 'Shared Files', page: SidebarPage.shared),
buildTile(
icon: Icons.settings,
label: 'Settings',
page: SidebarPage.settings),
const Spacer(),
ListTile(
leading: const Icon(Icons.logout),
title: const Text('Logout'),
onTap: onLogout,
),
const SizedBox(height: 20),
],
);
}
}

View file

@ -1,85 +0,0 @@
name: lightcloud
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
environment:
sdk: '>=3.4.3 <4.0.0'
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.6
shared_preferences: ^2.3.3
http: ^1.4.0
file_picker: ^8.3.2
path_provider: ^2.1.5
file_saver: ^0.2.14
flutter_dotenv: ^5.2.1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
assets:
- .env
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/assets-and-images/#from-packages
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/custom-fonts/#from-packages

View file

@ -1,30 +0,0 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lightcloud/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 917 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View file

@ -1,38 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project.">
<!-- iOS meta tags & icons -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="lightcloud">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>lightcloud</title>
<link rel="manifest" href="manifest.json">
</head>
<body>
<script src="flutter_bootstrap.js" async></script>
</body>
</html>

View file

@ -1,35 +0,0 @@
{
"name": "lightcloud",
"short_name": "lightcloud",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "A new Flutter project.",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
{
"src": "icons/Icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/Icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "icons/Icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/Icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

View file

@ -1,17 +0,0 @@
flutter/ephemeral/
# Visual Studio user-specific files.
*.suo
*.user
*.userosscache
*.sln.docstates
# Visual Studio build-related files.
x64/
x86/
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!*.[Cc]ache/

View file

@ -1,108 +0,0 @@
# Project-level configuration.
cmake_minimum_required(VERSION 3.14)
project(lightcloud_app LANGUAGES CXX)
# The name of the executable created for the application. Change this to change
# the on-disk name of your application.
set(BINARY_NAME "lightcloud_app")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.
cmake_policy(VERSION 3.14...3.25)
# Define build configuration option.
get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG)
if(IS_MULTICONFIG)
set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release"
CACHE STRING "" FORCE)
else()
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE "Debug" CACHE
STRING "Flutter build mode" FORCE)
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
"Debug" "Profile" "Release")
endif()
endif()
# Define settings for the Profile build mode.
set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}")
set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}")
set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}")
set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}")
# Use Unicode for all projects.
add_definitions(-DUNICODE -D_UNICODE)
# Compilation settings that should be applied to most targets.
#
# Be cautious about adding new options here, as plugins use this function by
# default. In most cases, you should add new options to specific targets instead
# of modifying this function.
function(APPLY_STANDARD_SETTINGS TARGET)
target_compile_features(${TARGET} PUBLIC cxx_std_17)
target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100")
target_compile_options(${TARGET} PRIVATE /EHsc)
target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0")
target_compile_definitions(${TARGET} PRIVATE "$<$<CONFIG:Debug>:_DEBUG>")
endfunction()
# Flutter library and tool build rules.
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
add_subdirectory(${FLUTTER_MANAGED_DIR})
# Application build; see runner/CMakeLists.txt.
add_subdirectory("runner")
# Generated plugin build rules, which manage building the plugins and adding
# them to the application.
include(flutter/generated_plugins.cmake)
# === Installation ===
# Support files are copied into place next to the executable, so that it can
# run in place. This is done instead of making a separate bundle (as on Linux)
# so that building and running from within Visual Studio will work.
set(BUILD_BUNDLE_DIR "$<TARGET_FILE_DIR:${BINARY_NAME}>")
# Make the "install" step default, as it's required to run.
set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1)
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
endif()
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}")
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
COMPONENT Runtime)
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
COMPONENT Runtime)
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
if(PLUGIN_BUNDLED_LIBRARIES)
install(FILES "${PLUGIN_BUNDLED_LIBRARIES}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endif()
# Copy the native assets provided by the build.dart from all packages.
set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/")
install(DIRECTORY "${NATIVE_ASSETS_DIR}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
# Fully re-copy the assets directory on each build to avoid having stale files
# from a previous install.
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
install(CODE "
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
" COMPONENT Runtime)
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
# Install the AOT library on non-Debug builds only.
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
CONFIGURATIONS Profile;Release
COMPONENT Runtime)

View file

@ -1,109 +0,0 @@
# This file controls Flutter-level build steps. It should not be edited.
cmake_minimum_required(VERSION 3.14)
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
# Configuration provided via flutter tool.
include(${EPHEMERAL_DIR}/generated_config.cmake)
# TODO: Move the rest of this into files in ephemeral. See
# https://github.com/flutter/flutter/issues/57146.
set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper")
# Set fallback configurations for older versions of the flutter tool.
if (NOT DEFINED FLUTTER_TARGET_PLATFORM)
set(FLUTTER_TARGET_PLATFORM "windows-x64")
endif()
# === Flutter Library ===
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll")
# Published to parent scope for install step.
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE)
list(APPEND FLUTTER_LIBRARY_HEADERS
"flutter_export.h"
"flutter_windows.h"
"flutter_messenger.h"
"flutter_plugin_registrar.h"
"flutter_texture_registrar.h"
)
list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/")
add_library(flutter INTERFACE)
target_include_directories(flutter INTERFACE
"${EPHEMERAL_DIR}"
)
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib")
add_dependencies(flutter flutter_assemble)
# === Wrapper ===
list(APPEND CPP_WRAPPER_SOURCES_CORE
"core_implementations.cc"
"standard_codec.cc"
)
list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/")
list(APPEND CPP_WRAPPER_SOURCES_PLUGIN
"plugin_registrar.cc"
)
list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/")
list(APPEND CPP_WRAPPER_SOURCES_APP
"flutter_engine.cc"
"flutter_view_controller.cc"
)
list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/")
# Wrapper sources needed for a plugin.
add_library(flutter_wrapper_plugin STATIC
${CPP_WRAPPER_SOURCES_CORE}
${CPP_WRAPPER_SOURCES_PLUGIN}
)
apply_standard_settings(flutter_wrapper_plugin)
set_target_properties(flutter_wrapper_plugin PROPERTIES
POSITION_INDEPENDENT_CODE ON)
set_target_properties(flutter_wrapper_plugin PROPERTIES
CXX_VISIBILITY_PRESET hidden)
target_link_libraries(flutter_wrapper_plugin PUBLIC flutter)
target_include_directories(flutter_wrapper_plugin PUBLIC
"${WRAPPER_ROOT}/include"
)
add_dependencies(flutter_wrapper_plugin flutter_assemble)
# Wrapper sources needed for the runner.
add_library(flutter_wrapper_app STATIC
${CPP_WRAPPER_SOURCES_CORE}
${CPP_WRAPPER_SOURCES_APP}
)
apply_standard_settings(flutter_wrapper_app)
target_link_libraries(flutter_wrapper_app PUBLIC flutter)
target_include_directories(flutter_wrapper_app PUBLIC
"${WRAPPER_ROOT}/include"
)
add_dependencies(flutter_wrapper_app flutter_assemble)
# === Flutter tool backend ===
# _phony_ is a non-existent file to force this command to run every time,
# since currently there's no way to get a full input/output list from the
# flutter tool.
set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_")
set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE)
add_custom_command(
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN}
${CPP_WRAPPER_SOURCES_APP}
${PHONY_OUTPUT}
COMMAND ${CMAKE_COMMAND} -E env
${FLUTTER_TOOL_ENVIRONMENT}
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat"
${FLUTTER_TARGET_PLATFORM} $<CONFIG>
VERBATIM
)
add_custom_target(flutter_assemble DEPENDS
"${FLUTTER_LIBRARY}"
${FLUTTER_LIBRARY_HEADERS}
${CPP_WRAPPER_SOURCES_CORE}
${CPP_WRAPPER_SOURCES_PLUGIN}
${CPP_WRAPPER_SOURCES_APP}
)

View file

@ -1,14 +0,0 @@
//
// Generated file. Do not edit.
//
// clang-format off
#include "generated_plugin_registrant.h"
#include <file_saver/file_saver_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSaverPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSaverPlugin"));
}

View file

@ -1,15 +0,0 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GENERATED_PLUGIN_REGISTRANT_
#define GENERATED_PLUGIN_REGISTRANT_
#include <flutter/plugin_registry.h>
// Registers Flutter plugins.
void RegisterPlugins(flutter::PluginRegistry* registry);
#endif // GENERATED_PLUGIN_REGISTRANT_

View file

@ -1,24 +0,0 @@
#
# Generated file, do not edit.
#
list(APPEND FLUTTER_PLUGIN_LIST
file_saver
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
)
set(PLUGIN_BUNDLED_LIBRARIES)
foreach(plugin ${FLUTTER_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin})
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
endforeach(plugin)
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin})
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
endforeach(ffi_plugin)

View file

@ -1,40 +0,0 @@
cmake_minimum_required(VERSION 3.14)
project(runner LANGUAGES CXX)
# Define the application target. To change its name, change BINARY_NAME in the
# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
# work.
#
# Any new source files that you add to the application should be added here.
add_executable(${BINARY_NAME} WIN32
"flutter_window.cpp"
"main.cpp"
"utils.cpp"
"win32_window.cpp"
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
"Runner.rc"
"runner.exe.manifest"
)
# Apply the standard set of build settings. This can be removed for applications
# that need different build settings.
apply_standard_settings(${BINARY_NAME})
# Add preprocessor definitions for the build version.
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"")
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}")
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}")
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}")
target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}")
# Disable Windows macros that collide with C++ standard library functions.
target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX")
# Add dependency libraries and include directories. Add any application-specific
# dependencies here.
target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app)
target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib")
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
# Run the Flutter tool portions of the build. This must not be removed.
add_dependencies(${BINARY_NAME} flutter_assemble)

View file

@ -1,121 +0,0 @@
// Microsoft Visual C++ generated resource script.
//
#pragma code_page(65001)
#include "resource.h"
#define APSTUDIO_READONLY_SYMBOLS
/////////////////////////////////////////////////////////////////////////////
//
// Generated from the TEXTINCLUDE 2 resource.
//
#include "winres.h"
/////////////////////////////////////////////////////////////////////////////
#undef APSTUDIO_READONLY_SYMBOLS
/////////////////////////////////////////////////////////////////////////////
// English (United States) resources
#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU)
LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US
#ifdef APSTUDIO_INVOKED
/////////////////////////////////////////////////////////////////////////////
//
// TEXTINCLUDE
//
1 TEXTINCLUDE
BEGIN
"resource.h\0"
END
2 TEXTINCLUDE
BEGIN
"#include ""winres.h""\r\n"
"\0"
END
3 TEXTINCLUDE
BEGIN
"\r\n"
"\0"
END
#endif // APSTUDIO_INVOKED
/////////////////////////////////////////////////////////////////////////////
//
// Icon
//
// Icon with lowest ID value placed first to ensure application icon
// remains consistent on all systems.
IDI_APP_ICON ICON "resources\\app_icon.ico"
/////////////////////////////////////////////////////////////////////////////
//
// Version
//
#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD)
#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD
#else
#define VERSION_AS_NUMBER 1,0,0,0
#endif
#if defined(FLUTTER_VERSION)
#define VERSION_AS_STRING FLUTTER_VERSION
#else
#define VERSION_AS_STRING "1.0.0"
#endif
VS_VERSION_INFO VERSIONINFO
FILEVERSION VERSION_AS_NUMBER
PRODUCTVERSION VERSION_AS_NUMBER
FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
#ifdef _DEBUG
FILEFLAGS VS_FF_DEBUG
#else
FILEFLAGS 0x0L
#endif
FILEOS VOS__WINDOWS32
FILETYPE VFT_APP
FILESUBTYPE 0x0L
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "040904e4"
BEGIN
VALUE "CompanyName", "com.lightcloud" "\0"
VALUE "FileDescription", "lightcloud_app" "\0"
VALUE "FileVersion", VERSION_AS_STRING "\0"
VALUE "InternalName", "lightcloud_app" "\0"
VALUE "LegalCopyright", "Copyright (C) 2025 com.lightcloud. All rights reserved." "\0"
VALUE "OriginalFilename", "lightcloud_app.exe" "\0"
VALUE "ProductName", "lightcloud_app" "\0"
VALUE "ProductVersion", VERSION_AS_STRING "\0"
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x409, 1252
END
END
#endif // English (United States) resources
/////////////////////////////////////////////////////////////////////////////
#ifndef APSTUDIO_INVOKED
/////////////////////////////////////////////////////////////////////////////
//
// Generated from the TEXTINCLUDE 3 resource.
//
/////////////////////////////////////////////////////////////////////////////
#endif // not APSTUDIO_INVOKED

View file

@ -1,71 +0,0 @@
#include "flutter_window.h"
#include <optional>
#include "flutter/generated_plugin_registrant.h"
FlutterWindow::FlutterWindow(const flutter::DartProject& project)
: project_(project) {}
FlutterWindow::~FlutterWindow() {}
bool FlutterWindow::OnCreate() {
if (!Win32Window::OnCreate()) {
return false;
}
RECT frame = GetClientArea();
// The size here must match the window dimensions to avoid unnecessary surface
// creation / destruction in the startup path.
flutter_controller_ = std::make_unique<flutter::FlutterViewController>(
frame.right - frame.left, frame.bottom - frame.top, project_);
// Ensure that basic setup of the controller was successful.
if (!flutter_controller_->engine() || !flutter_controller_->view()) {
return false;
}
RegisterPlugins(flutter_controller_->engine());
SetChildContent(flutter_controller_->view()->GetNativeWindow());
flutter_controller_->engine()->SetNextFrameCallback([&]() {
this->Show();
});
// Flutter can complete the first frame before the "show window" callback is
// registered. The following call ensures a frame is pending to ensure the
// window is shown. It is a no-op if the first frame hasn't completed yet.
flutter_controller_->ForceRedraw();
return true;
}
void FlutterWindow::OnDestroy() {
if (flutter_controller_) {
flutter_controller_ = nullptr;
}
Win32Window::OnDestroy();
}
LRESULT
FlutterWindow::MessageHandler(HWND hwnd, UINT const message,
WPARAM const wparam,
LPARAM const lparam) noexcept {
// Give Flutter, including plugins, an opportunity to handle window messages.
if (flutter_controller_) {
std::optional<LRESULT> result =
flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam,
lparam);
if (result) {
return *result;
}
}
switch (message) {
case WM_FONTCHANGE:
flutter_controller_->engine()->ReloadSystemFonts();
break;
}
return Win32Window::MessageHandler(hwnd, message, wparam, lparam);
}

View file

@ -1,33 +0,0 @@
#ifndef RUNNER_FLUTTER_WINDOW_H_
#define RUNNER_FLUTTER_WINDOW_H_
#include <flutter/dart_project.h>
#include <flutter/flutter_view_controller.h>
#include <memory>
#include "win32_window.h"
// A window that does nothing but host a Flutter view.
class FlutterWindow : public Win32Window {
public:
// Creates a new FlutterWindow hosting a Flutter view running |project|.
explicit FlutterWindow(const flutter::DartProject& project);
virtual ~FlutterWindow();
protected:
// Win32Window:
bool OnCreate() override;
void OnDestroy() override;
LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam,
LPARAM const lparam) noexcept override;
private:
// The project to run.
flutter::DartProject project_;
// The Flutter instance hosted by this window.
std::unique_ptr<flutter::FlutterViewController> flutter_controller_;
};
#endif // RUNNER_FLUTTER_WINDOW_H_

View file

@ -1,43 +0,0 @@
#include <flutter/dart_project.h>
#include <flutter/flutter_view_controller.h>
#include <windows.h>
#include "flutter_window.h"
#include "utils.h"
int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
_In_ wchar_t *command_line, _In_ int show_command) {
// Attach to console when present (e.g., 'flutter run') or create a
// new console when running with a debugger.
if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) {
CreateAndAttachConsole();
}
// Initialize COM, so that it is available for use in the library and/or
// plugins.
::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
flutter::DartProject project(L"data");
std::vector<std::string> command_line_arguments =
GetCommandLineArguments();
project.set_dart_entrypoint_arguments(std::move(command_line_arguments));
FlutterWindow window(project);
Win32Window::Point origin(10, 10);
Win32Window::Size size(1280, 720);
if (!window.Create(L"lightcloud_app", origin, size)) {
return EXIT_FAILURE;
}
window.SetQuitOnClose(true);
::MSG msg;
while (::GetMessage(&msg, nullptr, 0, 0)) {
::TranslateMessage(&msg);
::DispatchMessage(&msg);
}
::CoUninitialize();
return EXIT_SUCCESS;
}

View file

@ -1,16 +0,0 @@
//{{NO_DEPENDENCIES}}
// Microsoft Visual C++ generated include file.
// Used by Runner.rc
//
#define IDI_APP_ICON 101
// Next default values for new objects
//
#ifdef APSTUDIO_INVOKED
#ifndef APSTUDIO_READONLY_SYMBOLS
#define _APS_NEXT_RESOURCE_VALUE 102
#define _APS_NEXT_COMMAND_VALUE 40001
#define _APS_NEXT_CONTROL_VALUE 1001
#define _APS_NEXT_SYMED_VALUE 101
#endif
#endif

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

View file

@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 and Windows 11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
<!-- Windows 8.1 -->
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
<!-- Windows 8 -->
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
<!-- Windows 7 -->
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
</application>
</compatibility>
</assembly>

View file

@ -1,65 +0,0 @@
#include "utils.h"
#include <flutter_windows.h>
#include <io.h>
#include <stdio.h>
#include <windows.h>
#include <iostream>
void CreateAndAttachConsole() {
if (::AllocConsole()) {
FILE *unused;
if (freopen_s(&unused, "CONOUT$", "w", stdout)) {
_dup2(_fileno(stdout), 1);
}
if (freopen_s(&unused, "CONOUT$", "w", stderr)) {
_dup2(_fileno(stdout), 2);
}
std::ios::sync_with_stdio();
FlutterDesktopResyncOutputStreams();
}
}
std::vector<std::string> GetCommandLineArguments() {
// Convert the UTF-16 command line arguments to UTF-8 for the Engine to use.
int argc;
wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc);
if (argv == nullptr) {
return std::vector<std::string>();
}
std::vector<std::string> command_line_arguments;
// Skip the first argument as it's the binary name.
for (int i = 1; i < argc; i++) {
command_line_arguments.push_back(Utf8FromUtf16(argv[i]));
}
::LocalFree(argv);
return command_line_arguments;
}
std::string Utf8FromUtf16(const wchar_t* utf16_string) {
if (utf16_string == nullptr) {
return std::string();
}
unsigned int target_length = ::WideCharToMultiByte(
CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string,
-1, nullptr, 0, nullptr, nullptr)
-1; // remove the trailing null character
int input_length = (int)wcslen(utf16_string);
std::string utf8_string;
if (target_length == 0 || target_length > utf8_string.max_size()) {
return utf8_string;
}
utf8_string.resize(target_length);
int converted_length = ::WideCharToMultiByte(
CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string,
input_length, utf8_string.data(), target_length, nullptr, nullptr);
if (converted_length == 0) {
return std::string();
}
return utf8_string;
}

View file

@ -1,19 +0,0 @@
#ifndef RUNNER_UTILS_H_
#define RUNNER_UTILS_H_
#include <string>
#include <vector>
// Creates a console for the process, and redirects stdout and stderr to
// it for both the runner and the Flutter library.
void CreateAndAttachConsole();
// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string
// encoded in UTF-8. Returns an empty std::string on failure.
std::string Utf8FromUtf16(const wchar_t* utf16_string);
// Gets the command line arguments passed in as a std::vector<std::string>,
// encoded in UTF-8. Returns an empty std::vector<std::string> on failure.
std::vector<std::string> GetCommandLineArguments();
#endif // RUNNER_UTILS_H_

View file

@ -1,288 +0,0 @@
#include "win32_window.h"
#include <dwmapi.h>
#include <flutter_windows.h>
#include "resource.h"
namespace {
/// Window attribute that enables dark mode window decorations.
///
/// Redefined in case the developer's machine has a Windows SDK older than
/// version 10.0.22000.0.
/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute
#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE
#define DWMWA_USE_IMMERSIVE_DARK_MODE 20
#endif
constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW";
/// Registry key for app theme preference.
///
/// A value of 0 indicates apps should use dark mode. A non-zero or missing
/// value indicates apps should use light mode.
constexpr const wchar_t kGetPreferredBrightnessRegKey[] =
L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize";
constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme";
// The number of Win32Window objects that currently exist.
static int g_active_window_count = 0;
using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd);
// Scale helper to convert logical scaler values to physical using passed in
// scale factor
int Scale(int source, double scale_factor) {
return static_cast<int>(source * scale_factor);
}
// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module.
// This API is only needed for PerMonitor V1 awareness mode.
void EnableFullDpiSupportIfAvailable(HWND hwnd) {
HMODULE user32_module = LoadLibraryA("User32.dll");
if (!user32_module) {
return;
}
auto enable_non_client_dpi_scaling =
reinterpret_cast<EnableNonClientDpiScaling*>(
GetProcAddress(user32_module, "EnableNonClientDpiScaling"));
if (enable_non_client_dpi_scaling != nullptr) {
enable_non_client_dpi_scaling(hwnd);
}
FreeLibrary(user32_module);
}
} // namespace
// Manages the Win32Window's window class registration.
class WindowClassRegistrar {
public:
~WindowClassRegistrar() = default;
// Returns the singleton registrar instance.
static WindowClassRegistrar* GetInstance() {
if (!instance_) {
instance_ = new WindowClassRegistrar();
}
return instance_;
}
// Returns the name of the window class, registering the class if it hasn't
// previously been registered.
const wchar_t* GetWindowClass();
// Unregisters the window class. Should only be called if there are no
// instances of the window.
void UnregisterWindowClass();
private:
WindowClassRegistrar() = default;
static WindowClassRegistrar* instance_;
bool class_registered_ = false;
};
WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr;
const wchar_t* WindowClassRegistrar::GetWindowClass() {
if (!class_registered_) {
WNDCLASS window_class{};
window_class.hCursor = LoadCursor(nullptr, IDC_ARROW);
window_class.lpszClassName = kWindowClassName;
window_class.style = CS_HREDRAW | CS_VREDRAW;
window_class.cbClsExtra = 0;
window_class.cbWndExtra = 0;
window_class.hInstance = GetModuleHandle(nullptr);
window_class.hIcon =
LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON));
window_class.hbrBackground = 0;
window_class.lpszMenuName = nullptr;
window_class.lpfnWndProc = Win32Window::WndProc;
RegisterClass(&window_class);
class_registered_ = true;
}
return kWindowClassName;
}
void WindowClassRegistrar::UnregisterWindowClass() {
UnregisterClass(kWindowClassName, nullptr);
class_registered_ = false;
}
Win32Window::Win32Window() {
++g_active_window_count;
}
Win32Window::~Win32Window() {
--g_active_window_count;
Destroy();
}
bool Win32Window::Create(const std::wstring& title,
const Point& origin,
const Size& size) {
Destroy();
const wchar_t* window_class =
WindowClassRegistrar::GetInstance()->GetWindowClass();
const POINT target_point = {static_cast<LONG>(origin.x),
static_cast<LONG>(origin.y)};
HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST);
UINT dpi = FlutterDesktopGetDpiForMonitor(monitor);
double scale_factor = dpi / 96.0;
HWND window = CreateWindow(
window_class, title.c_str(), WS_OVERLAPPEDWINDOW,
Scale(origin.x, scale_factor), Scale(origin.y, scale_factor),
Scale(size.width, scale_factor), Scale(size.height, scale_factor),
nullptr, nullptr, GetModuleHandle(nullptr), this);
if (!window) {
return false;
}
UpdateTheme(window);
return OnCreate();
}
bool Win32Window::Show() {
return ShowWindow(window_handle_, SW_SHOWNORMAL);
}
// static
LRESULT CALLBACK Win32Window::WndProc(HWND const window,
UINT const message,
WPARAM const wparam,
LPARAM const lparam) noexcept {
if (message == WM_NCCREATE) {
auto window_struct = reinterpret_cast<CREATESTRUCT*>(lparam);
SetWindowLongPtr(window, GWLP_USERDATA,
reinterpret_cast<LONG_PTR>(window_struct->lpCreateParams));
auto that = static_cast<Win32Window*>(window_struct->lpCreateParams);
EnableFullDpiSupportIfAvailable(window);
that->window_handle_ = window;
} else if (Win32Window* that = GetThisFromHandle(window)) {
return that->MessageHandler(window, message, wparam, lparam);
}
return DefWindowProc(window, message, wparam, lparam);
}
LRESULT
Win32Window::MessageHandler(HWND hwnd,
UINT const message,
WPARAM const wparam,
LPARAM const lparam) noexcept {
switch (message) {
case WM_DESTROY:
window_handle_ = nullptr;
Destroy();
if (quit_on_close_) {
PostQuitMessage(0);
}
return 0;
case WM_DPICHANGED: {
auto newRectSize = reinterpret_cast<RECT*>(lparam);
LONG newWidth = newRectSize->right - newRectSize->left;
LONG newHeight = newRectSize->bottom - newRectSize->top;
SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth,
newHeight, SWP_NOZORDER | SWP_NOACTIVATE);
return 0;
}
case WM_SIZE: {
RECT rect = GetClientArea();
if (child_content_ != nullptr) {
// Size and position the child window.
MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left,
rect.bottom - rect.top, TRUE);
}
return 0;
}
case WM_ACTIVATE:
if (child_content_ != nullptr) {
SetFocus(child_content_);
}
return 0;
case WM_DWMCOLORIZATIONCOLORCHANGED:
UpdateTheme(hwnd);
return 0;
}
return DefWindowProc(window_handle_, message, wparam, lparam);
}
void Win32Window::Destroy() {
OnDestroy();
if (window_handle_) {
DestroyWindow(window_handle_);
window_handle_ = nullptr;
}
if (g_active_window_count == 0) {
WindowClassRegistrar::GetInstance()->UnregisterWindowClass();
}
}
Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept {
return reinterpret_cast<Win32Window*>(
GetWindowLongPtr(window, GWLP_USERDATA));
}
void Win32Window::SetChildContent(HWND content) {
child_content_ = content;
SetParent(content, window_handle_);
RECT frame = GetClientArea();
MoveWindow(content, frame.left, frame.top, frame.right - frame.left,
frame.bottom - frame.top, true);
SetFocus(child_content_);
}
RECT Win32Window::GetClientArea() {
RECT frame;
GetClientRect(window_handle_, &frame);
return frame;
}
HWND Win32Window::GetHandle() {
return window_handle_;
}
void Win32Window::SetQuitOnClose(bool quit_on_close) {
quit_on_close_ = quit_on_close;
}
bool Win32Window::OnCreate() {
// No-op; provided for subclasses.
return true;
}
void Win32Window::OnDestroy() {
// No-op; provided for subclasses.
}
void Win32Window::UpdateTheme(HWND const window) {
DWORD light_mode;
DWORD light_mode_size = sizeof(light_mode);
LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey,
kGetPreferredBrightnessRegValue,
RRF_RT_REG_DWORD, nullptr, &light_mode,
&light_mode_size);
if (result == ERROR_SUCCESS) {
BOOL enable_dark_mode = light_mode == 0;
DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE,
&enable_dark_mode, sizeof(enable_dark_mode));
}
}

View file

@ -1,102 +0,0 @@
#ifndef RUNNER_WIN32_WINDOW_H_
#define RUNNER_WIN32_WINDOW_H_
#include <windows.h>
#include <functional>
#include <memory>
#include <string>
// A class abstraction for a high DPI-aware Win32 Window. Intended to be
// inherited from by classes that wish to specialize with custom
// rendering and input handling
class Win32Window {
public:
struct Point {
unsigned int x;
unsigned int y;
Point(unsigned int x, unsigned int y) : x(x), y(y) {}
};
struct Size {
unsigned int width;
unsigned int height;
Size(unsigned int width, unsigned int height)
: width(width), height(height) {}
};
Win32Window();
virtual ~Win32Window();
// Creates a win32 window with |title| that is positioned and sized using
// |origin| and |size|. New windows are created on the default monitor. Window
// sizes are specified to the OS in physical pixels, hence to ensure a
// consistent size this function will scale the inputted width and height as
// as appropriate for the default monitor. The window is invisible until
// |Show| is called. Returns true if the window was created successfully.
bool Create(const std::wstring& title, const Point& origin, const Size& size);
// Show the current window. Returns true if the window was successfully shown.
bool Show();
// Release OS resources associated with window.
void Destroy();
// Inserts |content| into the window tree.
void SetChildContent(HWND content);
// Returns the backing Window handle to enable clients to set icon and other
// window properties. Returns nullptr if the window has been destroyed.
HWND GetHandle();
// If true, closing this window will quit the application.
void SetQuitOnClose(bool quit_on_close);
// Return a RECT representing the bounds of the current client area.
RECT GetClientArea();
protected:
// Processes and route salient window messages for mouse handling,
// size change and DPI. Delegates handling of these to member overloads that
// inheriting classes can handle.
virtual LRESULT MessageHandler(HWND window,
UINT const message,
WPARAM const wparam,
LPARAM const lparam) noexcept;
// Called when CreateAndShow is called, allowing subclass window-related
// setup. Subclasses should return false if setup fails.
virtual bool OnCreate();
// Called when Destroy is called.
virtual void OnDestroy();
private:
friend class WindowClassRegistrar;
// OS callback called by message pump. Handles the WM_NCCREATE message which
// is passed when the non-client area is being created and enables automatic
// non-client DPI scaling so that the non-client area automatically
// responds to changes in DPI. All other messages are handled by
// MessageHandler.
static LRESULT CALLBACK WndProc(HWND const window,
UINT const message,
WPARAM const wparam,
LPARAM const lparam) noexcept;
// Retrieves a class instance pointer for |window|
static Win32Window* GetThisFromHandle(HWND const window) noexcept;
// Update the window frame's theme to match the system theme.
static void UpdateTheme(HWND const window);
bool quit_on_close_ = false;
// window handle for top level window.
HWND window_handle_ = nullptr;
// window handle for hosted content.
HWND child_content_ = nullptr;
};
#endif // RUNNER_WIN32_WINDOW_H_