Initial commit: Open sourcing all of the Maple Open Technologies code.
This commit is contained in:
commit
755d54a99d
2010 changed files with 448675 additions and 0 deletions
13
native/desktop/maplefile/frontend/index.html
Normal file
13
native/desktop/maplefile/frontend/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>maplefile</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="./src/main.jsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
1466
native/desktop/maplefile/frontend/package-lock.json
generated
Normal file
1466
native/desktop/maplefile/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
22
native/desktop/maplefile/frontend/package.json
Normal file
22
native/desktop/maplefile/frontend/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^7.9.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.17",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@vitejs/plugin-react": "^2.0.1",
|
||||
"vite": "^3.0.7"
|
||||
}
|
||||
}
|
||||
24
native/desktop/maplefile/frontend/src/App.css
Normal file
24
native/desktop/maplefile/frontend/src/App.css
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #ecf0f1;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
274
native/desktop/maplefile/frontend/src/App.jsx
Normal file
274
native/desktop/maplefile/frontend/src/App.jsx
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
// File Path: monorepo/native/desktop/maplefile/frontend/src/App.jsx
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
BrowserRouter as Router,
|
||||
Routes,
|
||||
Route,
|
||||
Navigate,
|
||||
useNavigate,
|
||||
} from "react-router-dom";
|
||||
import {
|
||||
IsLoggedIn,
|
||||
HasStoredPassword,
|
||||
DecryptLoginChallenge,
|
||||
} from "../wailsjs/go/app/Application";
|
||||
import PasswordPrompt from "./components/PasswordPrompt";
|
||||
import "./App.css";
|
||||
|
||||
// Anonymous Pages
|
||||
import IndexPage from "./pages/Anonymous/Index/IndexPage";
|
||||
import Register from "./pages/Anonymous/Register/Register";
|
||||
import RecoveryCode from "./pages/Anonymous/Register/RecoveryCode";
|
||||
import VerifyEmail from "./pages/Anonymous/Register/VerifyEmail";
|
||||
import VerifySuccess from "./pages/Anonymous/Register/VerifySuccess";
|
||||
import RequestOTT from "./pages/Anonymous/Login/RequestOTT";
|
||||
import VerifyOTT from "./pages/Anonymous/Login/VerifyOTT";
|
||||
import CompleteLogin from "./pages/Anonymous/Login/CompleteLogin";
|
||||
import SessionExpired from "./pages/Anonymous/Login/SessionExpired";
|
||||
import InitiateRecovery from "./pages/Anonymous/Recovery/InitiateRecovery";
|
||||
import VerifyRecovery from "./pages/Anonymous/Recovery/VerifyRecovery";
|
||||
import CompleteRecovery from "./pages/Anonymous/Recovery/CompleteRecovery";
|
||||
|
||||
// User Pages
|
||||
import Dashboard from "./pages/User/Dashboard/Dashboard";
|
||||
import FileManagerIndex from "./pages/User/FileManager/FileManagerIndex";
|
||||
import CollectionCreate from "./pages/User/FileManager/Collections/CollectionCreate";
|
||||
import CollectionDetails from "./pages/User/FileManager/Collections/CollectionDetails";
|
||||
import CollectionEdit from "./pages/User/FileManager/Collections/CollectionEdit";
|
||||
import CollectionShare from "./pages/User/FileManager/Collections/CollectionShare";
|
||||
import FileUpload from "./pages/User/FileManager/Files/FileUpload";
|
||||
import FileDetails from "./pages/User/FileManager/Files/FileDetails";
|
||||
import MeDetail from "./pages/User/Me/MeDetail";
|
||||
import DeleteAccount from "./pages/User/Me/DeleteAccount";
|
||||
import BlockedUsers from "./pages/User/Me/BlockedUsers";
|
||||
import TagCreate from "./pages/User/Tags/TagCreate";
|
||||
import TagEdit from "./pages/User/Tags/TagEdit";
|
||||
import TagSearch from "./pages/User/Tags/TagSearch";
|
||||
import FullTextSearch from "./pages/User/Search/FullTextSearch";
|
||||
|
||||
function AppContent() {
|
||||
const [authState, setAuthState] = useState({
|
||||
isLoggedIn: null, // null = checking, true/false = known
|
||||
hasPassword: null, // Does RAM have password?
|
||||
needsPassword: false, // Should we show password prompt?
|
||||
loading: true,
|
||||
email: null,
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for Wails runtime to be ready before checking auth
|
||||
let attempts = 0;
|
||||
const maxAttempts = 50; // 5 seconds max
|
||||
let isCancelled = false;
|
||||
|
||||
const checkWailsReady = () => {
|
||||
if (isCancelled) return;
|
||||
|
||||
if (window.go && window.go.app && window.go.app.Application) {
|
||||
checkAuthState();
|
||||
} else if (attempts < maxAttempts) {
|
||||
attempts++;
|
||||
setTimeout(checkWailsReady, 100);
|
||||
} else {
|
||||
// Timeout - assume not logged in
|
||||
console.error("Wails runtime failed to initialize");
|
||||
setAuthState({
|
||||
isLoggedIn: false,
|
||||
hasPassword: false,
|
||||
needsPassword: false,
|
||||
loading: false,
|
||||
email: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
checkWailsReady();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function checkAuthState() {
|
||||
try {
|
||||
// Double-check Wails runtime is available
|
||||
if (!window.go || !window.go.app || !window.go.app.Application) {
|
||||
throw new Error("Wails runtime not available");
|
||||
}
|
||||
|
||||
// Step 1: Check if user is logged in (session exists)
|
||||
const loggedIn = await IsLoggedIn();
|
||||
|
||||
if (!loggedIn) {
|
||||
// Not logged in → show login screen
|
||||
setAuthState({
|
||||
isLoggedIn: false,
|
||||
hasPassword: false,
|
||||
needsPassword: false,
|
||||
loading: false,
|
||||
email: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: User is logged in, check for stored password
|
||||
const hasPassword = await HasStoredPassword();
|
||||
|
||||
if (hasPassword) {
|
||||
// Password is stored in RAM → all good
|
||||
setAuthState({
|
||||
isLoggedIn: true,
|
||||
hasPassword: true,
|
||||
needsPassword: false,
|
||||
loading: false,
|
||||
email: null, // We could fetch session info if needed
|
||||
});
|
||||
} else {
|
||||
// No password in RAM → need to prompt
|
||||
setAuthState({
|
||||
isLoggedIn: true,
|
||||
hasPassword: false,
|
||||
needsPassword: true,
|
||||
loading: false,
|
||||
email: null, // PasswordPrompt will get this from session
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Auth state check failed:", error);
|
||||
setAuthState({
|
||||
isLoggedIn: false,
|
||||
hasPassword: false,
|
||||
needsPassword: false,
|
||||
loading: false,
|
||||
email: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const handlePasswordVerified = async (password) => {
|
||||
// For now, we'll assume verification happens in the PasswordPrompt
|
||||
// The password is stored by StorePasswordForSession in the component
|
||||
|
||||
// Update state to indicate password is now available
|
||||
setAuthState({
|
||||
...authState,
|
||||
hasPassword: true,
|
||||
needsPassword: false,
|
||||
});
|
||||
|
||||
// Navigate to dashboard
|
||||
navigate("/dashboard");
|
||||
|
||||
return true; // Password is valid
|
||||
};
|
||||
|
||||
// Show loading screen while checking auth
|
||||
if (authState.loading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100vh",
|
||||
background: "#f8f9fa",
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<h2>MapleFile</h2>
|
||||
<p style={{ color: "#666" }}>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show password prompt if logged in but no password
|
||||
if (authState.isLoggedIn && authState.needsPassword) {
|
||||
return (
|
||||
<PasswordPrompt
|
||||
email={authState.email || "Loading..."}
|
||||
onPasswordVerified={handlePasswordVerified}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
{/* Anonymous/Public Routes */}
|
||||
<Route path="/" element={<IndexPage />} />
|
||||
|
||||
{/* Registration Flow */}
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/register/recovery" element={<RecoveryCode />} />
|
||||
<Route path="/register/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="/register/verify-success" element={<VerifySuccess />} />
|
||||
|
||||
{/* Login Flow */}
|
||||
<Route path="/login" element={<RequestOTT />} />
|
||||
<Route path="/login/verify-ott" element={<VerifyOTT />} />
|
||||
<Route path="/login/complete" element={<CompleteLogin />} />
|
||||
<Route path="/session-expired" element={<SessionExpired />} />
|
||||
|
||||
{/* Recovery Flow */}
|
||||
<Route path="/recovery" element={<InitiateRecovery />} />
|
||||
<Route path="/recovery/initiate" element={<InitiateRecovery />} />
|
||||
<Route path="/recovery/verify" element={<VerifyRecovery />} />
|
||||
<Route path="/recovery/complete" element={<CompleteRecovery />} />
|
||||
|
||||
{/* Authenticated User Routes */}
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
|
||||
{/* File Manager Routes */}
|
||||
<Route path="/file-manager" element={<FileManagerIndex />} />
|
||||
<Route
|
||||
path="/file-manager/collections/create"
|
||||
element={<CollectionCreate />}
|
||||
/>
|
||||
<Route
|
||||
path="/file-manager/collections/:collectionId"
|
||||
element={<CollectionDetails />}
|
||||
/>
|
||||
<Route
|
||||
path="/file-manager/collections/:collectionId/edit"
|
||||
element={<CollectionEdit />}
|
||||
/>
|
||||
<Route
|
||||
path="/file-manager/collections/:collectionId/share"
|
||||
element={<CollectionShare />}
|
||||
/>
|
||||
<Route path="/file-manager/upload" element={<FileUpload />} />
|
||||
<Route path="/file-manager/files/:fileId" element={<FileDetails />} />
|
||||
{/* Export moved to Profile page - redirect old route */}
|
||||
<Route path="/file-manager/export" element={<Navigate to="/me?tab=export" replace />} />
|
||||
|
||||
{/* User Profile Routes */}
|
||||
<Route path="/me" element={<MeDetail />} />
|
||||
<Route path="/profile" element={<MeDetail />} />
|
||||
<Route path="/me/delete-account" element={<DeleteAccount />} />
|
||||
<Route path="/me/blocked-users" element={<BlockedUsers />} />
|
||||
|
||||
{/* Tags Routes */}
|
||||
<Route path="/tags/search" element={<TagSearch />} />
|
||||
<Route path="/me/tags/create" element={<TagCreate />} />
|
||||
<Route path="/me/tags/:tagId/edit" element={<TagEdit />} />
|
||||
|
||||
{/* Search Routes */}
|
||||
<Route path="/search" element={<FullTextSearch />} />
|
||||
|
||||
{/* Catch-all route */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<AppContent />
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
93
native/desktop/maplefile/frontend/src/assets/fonts/OFL.txt
Normal file
93
native/desktop/maplefile/frontend/src/assets/fonts/OFL.txt
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com),
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
187
native/desktop/maplefile/frontend/src/components/IconPicker.css
Normal file
187
native/desktop/maplefile/frontend/src/components/IconPicker.css
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
/* IconPicker.css - Styles for the IconPicker component */
|
||||
|
||||
.icon-picker-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.icon-picker-modal {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
max-width: 650px;
|
||||
width: 100%;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.icon-picker-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.icon-picker-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.icon-picker-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.icon-picker-close:hover {
|
||||
background: #f3f4f6;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.icon-picker-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.icon-picker-sidebar {
|
||||
width: 140px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.icon-picker-category-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #4b5563;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.icon-picker-category-btn:hover {
|
||||
background: #e5e7eb;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.icon-picker-category-btn.active {
|
||||
background: #991b1b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.icon-picker-grid-container {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.icon-picker-category-title {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.icon-picker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(10, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.icon-picker-emoji-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 22px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.icon-picker-emoji-btn:hover {
|
||||
background: #f3f4f6;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.icon-picker-emoji-btn.selected {
|
||||
background: #fee2e2;
|
||||
outline: 2px solid #991b1b;
|
||||
}
|
||||
|
||||
.icon-picker-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.icon-picker-reset-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.icon-picker-reset-btn:hover {
|
||||
background: #e5e7eb;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.icon-picker-cancel-btn {
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
background: white;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.icon-picker-cancel-btn:hover {
|
||||
background: #f9fafb;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
154
native/desktop/maplefile/frontend/src/components/IconPicker.jsx
Normal file
154
native/desktop/maplefile/frontend/src/components/IconPicker.jsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
// File: frontend/src/components/IconPicker.jsx
|
||||
// Icon picker component for selecting emojis or predefined icons for collections
|
||||
import React, { useState } from "react";
|
||||
import "./IconPicker.css";
|
||||
|
||||
// Comprehensive emoji collection organized by logical categories
|
||||
const EMOJI_CATEGORIES = {
|
||||
"Files & Folders": [
|
||||
"📁", "📂", "🗂️", "📑", "📄", "📃", "📋", "📝", "✏️", "🖊️",
|
||||
"📎", "📌", "🔖", "🏷️", "📰", "🗃️", "🗄️", "📦", "📥", "📤",
|
||||
],
|
||||
"Work & Business": [
|
||||
"💼", "🏢", "🏛️", "🏦", "💰", "💵", "💳", "🧾", "📊", "📈",
|
||||
"📉", "💹", "🗓️", "📅", "⏰", "⌚", "🖥️", "💻", "⌨️", "🖨️",
|
||||
],
|
||||
"Tech & Devices": [
|
||||
"📱", "📲", "☎️", "📞", "📟", "📠", "🔌", "🔋", "💾", "💿",
|
||||
"📀", "🖱️", "🖲️", "🎮", "🕹️", "🛜", "📡", "📺", "📻", "🎙️",
|
||||
],
|
||||
"Media & Creative": [
|
||||
"📷", "📸", "📹", "🎥", "🎬", "🎞️", "📽️", "🎵", "🎶", "🎤",
|
||||
"🎧", "🎼", "🎹", "🎸", "🥁", "🎨", "🖼️", "🎭", "🎪", "🎠",
|
||||
],
|
||||
"Education & Science": [
|
||||
"📚", "📖", "📕", "📗", "📘", "📙", "🎓", "🏫", "✍️", "📐",
|
||||
"📏", "🔬", "🔭", "🧪", "🧫", "🧬", "🔍", "🔎", "💡", "📡",
|
||||
],
|
||||
"Communication": [
|
||||
"💬", "💭", "🗨️", "🗯️", "📧", "📨", "📩", "📮", "📪", "📫",
|
||||
"📬", "📭", "✉️", "💌", "📯", "🔔", "🔕", "📢", "📣", "🗣️",
|
||||
],
|
||||
"Home & Life": [
|
||||
"🏠", "🏡", "🏘️", "🛏️", "🛋️", "🪑", "🚿", "🛁", "🧹", "🧺",
|
||||
"👨👩👧👦", "👪", "❤️", "💕", "💝", "💖", "🧸", "🎁", "🎀", "🎈",
|
||||
],
|
||||
"Health & Wellness": [
|
||||
"🏥", "💊", "💉", "🩺", "🩹", "🩼", "♿", "🧘", "🏃", "🚴",
|
||||
"🏋️", "🤸", "⚕️", "🩸", "🧠", "👁️", "🦷", "💪", "🧬", "🍎",
|
||||
],
|
||||
"Food & Drinks": [
|
||||
"🍕", "🍔", "🍟", "🌮", "🌯", "🍜", "🍝", "🍣", "🍱", "🥗",
|
||||
"🍰", "🎂", "🧁", "🍩", "🍪", "☕", "🍵", "🥤", "🍷", "🍺",
|
||||
],
|
||||
"Travel & Places": [
|
||||
"✈️", "🚀", "🛸", "🚁", "🚂", "🚗", "🚕", "🚌", "🚢", "⛵",
|
||||
"🗺️", "🧭", "🏖️", "🏔️", "🏕️", "🗽", "🗼", "🏰", "⛺", "🌍",
|
||||
],
|
||||
"Sports & Hobbies": [
|
||||
"⚽", "🏀", "🏈", "⚾", "🎾", "🏐", "🏓", "🏸", "🎯", "🎱",
|
||||
"🎳", "🏆", "🥇", "🥈", "🥉", "🎲", "♟️", "🧩", "🎰", "🎮",
|
||||
],
|
||||
"Nature & Weather": [
|
||||
"🌸", "🌺", "🌻", "🌹", "🌷", "🌴", "🌲", "🍀", "🌿", "🍃",
|
||||
"☀️", "🌙", "⭐", "🌈", "☁️", "🌧️", "❄️", "🔥", "💧", "🌊",
|
||||
],
|
||||
"Animals": [
|
||||
"🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🦁",
|
||||
"🐯", "🐮", "🐷", "🐸", "🐵", "🐔", "🐧", "🐦", "🦋", "🐝",
|
||||
],
|
||||
"Symbols & Status": [
|
||||
"✅", "❌", "⭕", "❗", "❓", "💯", "🔴", "🟠", "🟡", "🟢",
|
||||
"🔵", "🟣", "⚫", "⚪", "🔶", "🔷", "💠", "🔘", "🏁", "🚩",
|
||||
],
|
||||
"Security": [
|
||||
"🔒", "🔓", "🔐", "🔑", "🗝️", "🛡️", "⚔️", "🔫", "🚨", "🚔",
|
||||
"👮", "🕵️", "🦺", "🧯", "🪖", "⛑️", "🔏", "🔒", "👁️🗨️", "🛂",
|
||||
],
|
||||
"Celebration": [
|
||||
"🎉", "🎊", "🎂", "🎁", "🎀", "🎈", "🎄", "🎃", "🎆", "🎇",
|
||||
"✨", "💫", "🌟", "⭐", "🏅", "🎖️", "🏆", "🥳", "🎯", "🎪",
|
||||
],
|
||||
};
|
||||
|
||||
// Get all category keys
|
||||
const CATEGORY_KEYS = Object.keys(EMOJI_CATEGORIES);
|
||||
|
||||
const IconPicker = ({ value, onChange, onClose, isOpen }) => {
|
||||
const [activeCategory, setActiveCategory] = useState("Files & Folders");
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSelect = (emoji) => {
|
||||
onChange(emoji);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
onChange("");
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="icon-picker-overlay" onClick={onClose}>
|
||||
<div className="icon-picker-modal" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="icon-picker-header">
|
||||
<h3>Choose Icon</h3>
|
||||
<button className="icon-picker-close" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="icon-picker-content">
|
||||
{/* Category Sidebar */}
|
||||
<div className="icon-picker-sidebar">
|
||||
{CATEGORY_KEYS.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
className={`icon-picker-category-btn ${
|
||||
activeCategory === category ? "active" : ""
|
||||
}`}
|
||||
onClick={() => setActiveCategory(category)}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Emoji Grid */}
|
||||
<div className="icon-picker-grid-container">
|
||||
<h4 className="icon-picker-category-title">{activeCategory}</h4>
|
||||
<div className="icon-picker-grid">
|
||||
{EMOJI_CATEGORIES[activeCategory].map((emoji, index) => (
|
||||
<button
|
||||
key={`${emoji}-${index}`}
|
||||
className={`icon-picker-emoji-btn ${
|
||||
value === emoji ? "selected" : ""
|
||||
}`}
|
||||
onClick={() => handleSelect(emoji)}
|
||||
title={emoji}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="icon-picker-footer">
|
||||
<button className="icon-picker-reset-btn" onClick={handleReset}>
|
||||
📁 Reset to Default
|
||||
</button>
|
||||
<button className="icon-picker-cancel-btn" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconPicker;
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
.navigation {
|
||||
width: 250px;
|
||||
background-color: #2c3e50;
|
||||
color: white;
|
||||
height: 100vh;
|
||||
padding: 20px;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.nav-header {
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #34495e;
|
||||
}
|
||||
|
||||
.nav-header h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nav-menu li {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.nav-menu li a {
|
||||
color: #ecf0f1;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
padding: 12px 15px;
|
||||
border-radius: 5px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.nav-menu li a:hover {
|
||||
background-color: #34495e;
|
||||
}
|
||||
|
||||
.nav-menu li.active a {
|
||||
background-color: #3498db;
|
||||
font-weight: bold;
|
||||
}
|
||||
264
native/desktop/maplefile/frontend/src/components/Navigation.jsx
Normal file
264
native/desktop/maplefile/frontend/src/components/Navigation.jsx
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import './Navigation.css';
|
||||
import { LogoutWithOptions, GetLocalDataSize } from '../../wailsjs/go/app/Application';
|
||||
|
||||
function Navigation() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [showLogoutConfirm, setShowLogoutConfirm] = useState(false);
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
const [deleteLocalData, setDeleteLocalData] = useState(true); // Default to delete for security
|
||||
const [localDataSize, setLocalDataSize] = useState(0);
|
||||
|
||||
const isActive = (path) => {
|
||||
return location.pathname === path || location.pathname.startsWith(path + '/');
|
||||
};
|
||||
|
||||
// Format bytes to human-readable size
|
||||
const formatBytes = (bytes) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const handleLogoutClick = (e) => {
|
||||
e.preventDefault();
|
||||
// Get local data size when opening the modal
|
||||
GetLocalDataSize()
|
||||
.then((size) => {
|
||||
setLocalDataSize(size);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to get local data size:', error);
|
||||
setLocalDataSize(0);
|
||||
});
|
||||
setShowLogoutConfirm(true);
|
||||
};
|
||||
|
||||
const handleLogoutConfirm = () => {
|
||||
setIsLoggingOut(true);
|
||||
LogoutWithOptions(deleteLocalData)
|
||||
.then(() => {
|
||||
// Reset state before navigating
|
||||
setDeleteLocalData(true);
|
||||
navigate('/login');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Logout failed:', error);
|
||||
alert('Logout failed: ' + error.message);
|
||||
setIsLoggingOut(false);
|
||||
setShowLogoutConfirm(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleLogoutCancel = useCallback(() => {
|
||||
if (!isLoggingOut) {
|
||||
setShowLogoutConfirm(false);
|
||||
setDeleteLocalData(true); // Reset to default
|
||||
}
|
||||
}, [isLoggingOut]);
|
||||
|
||||
// Handle Escape key to close modal
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Escape' && showLogoutConfirm && !isLoggingOut) {
|
||||
handleLogoutCancel();
|
||||
}
|
||||
};
|
||||
|
||||
if (showLogoutConfirm) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [showLogoutConfirm, isLoggingOut, handleLogoutCancel]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="navigation">
|
||||
<div className="nav-header">
|
||||
<h2>MapleFile</h2>
|
||||
</div>
|
||||
<ul className="nav-menu">
|
||||
<li className={isActive('/dashboard') ? 'active' : ''}>
|
||||
<Link to="/dashboard">Dashboard</Link>
|
||||
</li>
|
||||
<li className={isActive('/file-manager') ? 'active' : ''}>
|
||||
<Link to="/file-manager">File Manager</Link>
|
||||
</li>
|
||||
<li className={isActive('/search') ? 'active' : ''}>
|
||||
<Link to="/search">Search</Link>
|
||||
</li>
|
||||
<li className={isActive('/tags/search') ? 'active' : ''}>
|
||||
<Link to="/tags/search">Search by Tags</Link>
|
||||
</li>
|
||||
<li className={isActive('/me') ? 'active' : ''}>
|
||||
<Link to="/me">Profile</Link>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" onClick={handleLogoutClick}>Logout</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Logout Confirmation Modal */}
|
||||
{showLogoutConfirm && (
|
||||
<div
|
||||
onClick={handleLogoutCancel}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '30px',
|
||||
maxWidth: '500px',
|
||||
width: '90%',
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.3)',
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 15px 0', color: '#2c3e50', textAlign: 'center' }}>
|
||||
Sign Out
|
||||
</h3>
|
||||
<p style={{ margin: '0 0 20px 0', color: '#666', textAlign: 'center' }}>
|
||||
You are about to sign out. You'll need to log in again next time.
|
||||
</p>
|
||||
|
||||
{/* Security Warning */}
|
||||
<div style={{
|
||||
backgroundColor: '#fef3c7',
|
||||
border: '1px solid #f59e0b',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
marginBottom: '20px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '12px' }}>
|
||||
<svg style={{ width: '20px', height: '20px', color: '#d97706', flexShrink: 0, marginTop: '2px' }} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p style={{ fontWeight: '600', color: '#d97706', margin: '0 0 4px 0', fontSize: '14px' }}>
|
||||
Security Notice
|
||||
</p>
|
||||
<p style={{ color: '#666', margin: 0, fontSize: '13px', lineHeight: '1.5' }}>
|
||||
For your security, we recommend deleting locally saved data when signing out. This includes your cached files and metadata{localDataSize > 0 ? ` (${formatBytes(localDataSize)})` : ''}. If you keep local data, anyone with access to this device may be able to view your files when you log in again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data deletion options */}
|
||||
<div style={{ marginBottom: '25px' }}>
|
||||
<label style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '12px',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '12px',
|
||||
padding: '10px',
|
||||
borderRadius: '6px',
|
||||
backgroundColor: deleteLocalData ? '#fef2f2' : 'transparent',
|
||||
border: deleteLocalData ? '1px solid #fca5a5' : '1px solid transparent',
|
||||
}}>
|
||||
<input
|
||||
type="radio"
|
||||
name="deleteLocalData"
|
||||
checked={deleteLocalData === true}
|
||||
onChange={() => setDeleteLocalData(true)}
|
||||
style={{ marginTop: '4px' }}
|
||||
/>
|
||||
<div>
|
||||
<span style={{ fontWeight: '500', color: '#2c3e50', fontSize: '14px' }}>
|
||||
Delete all local data (Recommended)
|
||||
</span>
|
||||
<p style={{ color: '#666', margin: '4px 0 0 0', fontSize: '12px' }}>
|
||||
All cached files and metadata will be removed. You'll need to re-download files from the cloud next time.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '12px',
|
||||
cursor: 'pointer',
|
||||
padding: '10px',
|
||||
borderRadius: '6px',
|
||||
backgroundColor: !deleteLocalData ? '#eff6ff' : 'transparent',
|
||||
border: !deleteLocalData ? '1px solid #93c5fd' : '1px solid transparent',
|
||||
}}>
|
||||
<input
|
||||
type="radio"
|
||||
name="deleteLocalData"
|
||||
checked={deleteLocalData === false}
|
||||
onChange={() => setDeleteLocalData(false)}
|
||||
style={{ marginTop: '4px' }}
|
||||
/>
|
||||
<div>
|
||||
<span style={{ fontWeight: '500', color: '#2c3e50', fontSize: '14px' }}>
|
||||
Keep local data for faster login
|
||||
</span>
|
||||
<p style={{ color: '#666', margin: '4px 0 0 0', fontSize: '12px' }}>
|
||||
Your cached files will be preserved. Only use this on trusted personal devices.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={handleLogoutCancel}
|
||||
disabled={isLoggingOut}
|
||||
style={{
|
||||
padding: '10px 25px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '5px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
color: '#333',
|
||||
cursor: isLoggingOut ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogoutConfirm}
|
||||
disabled={isLoggingOut}
|
||||
style={{
|
||||
padding: '10px 25px',
|
||||
border: 'none',
|
||||
borderRadius: '5px',
|
||||
backgroundColor: '#3b82f6',
|
||||
color: 'white',
|
||||
cursor: isLoggingOut ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
opacity: isLoggingOut ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{isLoggingOut ? 'Signing out...' : 'Sign Out'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Navigation;
|
||||
106
native/desktop/maplefile/frontend/src/components/Page.css
Normal file
106
native/desktop/maplefile/frontend/src/components/Page.css
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
.page {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
padding: 8px 16px;
|
||||
background-color: #95a5a6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background-color: #7f8c8d;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
background-color: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.page-content p {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.page-content label {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.page-content input[type="text"],
|
||||
.page-content input[type="email"],
|
||||
.page-content input[type="password"],
|
||||
.page-content input[type="tel"],
|
||||
.page-content textarea,
|
||||
.page-content select {
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.nav-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
padding: 12px 24px;
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: background-color 0.3s;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
background-color: #2980b9;
|
||||
}
|
||||
|
||||
.nav-button.secondary {
|
||||
background-color: #95a5a6;
|
||||
}
|
||||
|
||||
.nav-button.secondary:hover {
|
||||
background-color: #7f8c8d;
|
||||
}
|
||||
|
||||
.nav-button.success {
|
||||
background-color: #27ae60;
|
||||
}
|
||||
|
||||
.nav-button.success:hover {
|
||||
background-color: #229954;
|
||||
}
|
||||
|
||||
.nav-button.danger {
|
||||
background-color: #e74c3c;
|
||||
}
|
||||
|
||||
.nav-button.danger:hover {
|
||||
background-color: #c0392b;
|
||||
}
|
||||
24
native/desktop/maplefile/frontend/src/components/Page.jsx
Normal file
24
native/desktop/maplefile/frontend/src/components/Page.jsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
import './Page.css';
|
||||
|
||||
function Page({ title, children, showBackButton = false }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
{showBackButton && (
|
||||
<button onClick={() => navigate(-1)} className="back-button">
|
||||
← Back
|
||||
</button>
|
||||
)}
|
||||
<h1>{title}</h1>
|
||||
</div>
|
||||
<div className="page-content">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Page;
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
import { useState } from 'react';
|
||||
import { Logout, StorePasswordForSession, VerifyPassword } from '../../wailsjs/go/app/Application';
|
||||
|
||||
function PasswordPrompt({ email, onPasswordVerified }) {
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
if (!password) {
|
||||
setError('Please enter your password');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify password against stored encrypted data
|
||||
const isValid = await VerifyPassword(password);
|
||||
|
||||
if (!isValid) {
|
||||
setError('Incorrect password. Please try again.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store password in RAM
|
||||
await StorePasswordForSession(password);
|
||||
|
||||
// Notify parent component
|
||||
await onPasswordVerified(password);
|
||||
|
||||
// Success - parent will handle navigation
|
||||
} catch (err) {
|
||||
console.error('Password verification error:', err);
|
||||
setError('Failed to verify password: ' + err.message);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await Logout();
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
background: '#f8f9fa'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'white',
|
||||
padding: '40px',
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
|
||||
maxWidth: '450px',
|
||||
width: '100%'
|
||||
}}>
|
||||
<h2 style={{ marginTop: 0 }}>Welcome Back</h2>
|
||||
<p style={{ color: '#666', marginBottom: '30px' }}>
|
||||
Enter your password to unlock your encrypted files
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px' }}>Email</label>
|
||||
<input
|
||||
type="text"
|
||||
value={email}
|
||||
disabled
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
background: '#f5f5f5',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '5px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label htmlFor="password" style={{ display: 'block', marginBottom: '5px' }}>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
autoFocus
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '5px'
|
||||
}}
|
||||
/>
|
||||
<small style={{ color: '#666', fontSize: '0.9em' }}>
|
||||
Your password will be kept in memory until you close the app.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '10px',
|
||||
marginBottom: '15px',
|
||||
background: '#f8d7da',
|
||||
border: '1px solid #f5c6cb',
|
||||
borderRadius: '5px',
|
||||
color: '#721c24'
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
background: loading ? '#6c757d' : '#007bff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '5px',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
fontSize: '16px'
|
||||
}}
|
||||
>
|
||||
{loading ? 'Verifying...' : 'Unlock'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '12px 20px',
|
||||
background: 'white',
|
||||
color: '#dc3545',
|
||||
border: '1px solid #dc3545',
|
||||
borderRadius: '5px',
|
||||
cursor: loading ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div style={{
|
||||
marginTop: '20px',
|
||||
padding: '10px',
|
||||
background: '#d1ecf1',
|
||||
border: '1px solid #bee5eb',
|
||||
borderRadius: '5px',
|
||||
fontSize: '0.9em',
|
||||
color: '#0c5460'
|
||||
}}>
|
||||
<strong>🔒 Security:</strong> Your password is stored securely in memory
|
||||
and will be automatically cleared when you close the app.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PasswordPrompt;
|
||||
15
native/desktop/maplefile/frontend/src/main.jsx
Normal file
15
native/desktop/maplefile/frontend/src/main.jsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// File Path: monorepo/native/desktop/maplefile/frontend/src/main.jsx.jsx
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./style.css";
|
||||
import App from "./App";
|
||||
|
||||
const container = document.getElementById("root");
|
||||
|
||||
const root = createRoot(container);
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/Anonymous/Index/IndexPage.jsx
|
||||
import { Link } from "react-router-dom";
|
||||
import Page from "../../../components/Page";
|
||||
|
||||
function IndexPage() {
|
||||
return (
|
||||
<Page title="Welcome to MapleFile">
|
||||
<p>Secure, encrypted file storage for your important files.</p>
|
||||
<div className="nav-buttons">
|
||||
<Link to="/login" className="nav-button">
|
||||
Login
|
||||
</Link>
|
||||
<Link to="/register" className="nav-button success">
|
||||
Register
|
||||
</Link>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default IndexPage;
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/Anonymous/Login/CompleteLogin.jsx
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import Page from "../../../components/Page";
|
||||
import {
|
||||
DecryptLoginChallenge,
|
||||
CompleteLogin as CompleteLoginAPI,
|
||||
} from "../../../../wailsjs/go/app/Application";
|
||||
|
||||
function CompleteLogin() {
|
||||
const navigate = useNavigate();
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [challengeData, setChallengeData] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Get challenge data from sessionStorage
|
||||
const storedData = sessionStorage.getItem("loginChallenge");
|
||||
if (!storedData) {
|
||||
setError("Missing login challenge data. Please start login again.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(storedData);
|
||||
setChallengeData(data);
|
||||
} catch (err) {
|
||||
setError("Invalid challenge data. Please start login again.");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setMessage("");
|
||||
setLoading(true);
|
||||
|
||||
if (!challengeData) {
|
||||
setError("Missing challenge data. Please return to login.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
setError("Please enter your password.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Decrypt the login challenge using E2EE
|
||||
setMessage("Decrypting challenge with E2EE...");
|
||||
|
||||
const decryptedChallenge = await DecryptLoginChallenge(
|
||||
password,
|
||||
challengeData.salt,
|
||||
challengeData.encryptedMasterKey,
|
||||
challengeData.encryptedChallenge,
|
||||
challengeData.encryptedPrivateKey,
|
||||
challengeData.publicKey,
|
||||
// Pass KDF algorithm
|
||||
challengeData.kdfAlgorithm || "PBKDF2-SHA256",
|
||||
);
|
||||
|
||||
// Step 2: Complete login with the decrypted challenge
|
||||
setMessage("Completing login...");
|
||||
|
||||
const loginInput = {
|
||||
email: challengeData.email,
|
||||
challengeId: challengeData.challengeId,
|
||||
decryptedData: decryptedChallenge,
|
||||
password: password, // Pass password to backend for storage
|
||||
// Pass encrypted data for future password verification
|
||||
salt: challengeData.salt,
|
||||
encryptedMasterKey: challengeData.encryptedMasterKey,
|
||||
encryptedPrivateKey: challengeData.encryptedPrivateKey,
|
||||
publicKey: challengeData.publicKey,
|
||||
// Pass KDF algorithm for master key caching
|
||||
kdfAlgorithm: challengeData.kdfAlgorithm || "PBKDF2-SHA256",
|
||||
};
|
||||
|
||||
await CompleteLoginAPI(loginInput);
|
||||
|
||||
// Clear challenge data from sessionStorage
|
||||
sessionStorage.removeItem("loginChallenge");
|
||||
|
||||
setMessage("Login successful! Redirecting to dashboard...");
|
||||
|
||||
// Redirect to dashboard
|
||||
setTimeout(() => {
|
||||
navigate("/dashboard");
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
const errorMessage = err.message || err.toString();
|
||||
|
||||
// Check for wrong password error
|
||||
if (
|
||||
errorMessage.includes("wrong password") ||
|
||||
errorMessage.includes("failed to decrypt master key")
|
||||
) {
|
||||
setError("Incorrect password. Please try again.");
|
||||
} else {
|
||||
// Try to parse RFC 9457 format
|
||||
try {
|
||||
const jsonMatch = errorMessage.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
const problemDetails = JSON.parse(jsonMatch[0]);
|
||||
setError(
|
||||
problemDetails.detail || problemDetails.title || errorMessage,
|
||||
);
|
||||
} else {
|
||||
setError("Login failed: " + errorMessage);
|
||||
}
|
||||
} catch (parseErr) {
|
||||
setError("Login failed: " + errorMessage);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!challengeData && !error) {
|
||||
return (
|
||||
<Page title="Complete Login" showBackButton={true}>
|
||||
<p style={{ color: "#666" }}>Loading...</p>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Page title="Complete Login" showBackButton={true}>
|
||||
<form onSubmit={handleSubmit} style={{ maxWidth: "400px" }}>
|
||||
<p style={{ marginBottom: "20px", color: "#333" }}>
|
||||
Enter your password to decrypt your keys and complete the login
|
||||
process.
|
||||
</p>
|
||||
|
||||
{challengeData && (
|
||||
<div style={{ marginBottom: "15px" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "5px",
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={challengeData.email}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px",
|
||||
backgroundColor: "#f5f5f5",
|
||||
}}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: "15px" }}>
|
||||
<label
|
||||
htmlFor="password"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "5px",
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
style={{ width: "100%", padding: "8px" }}
|
||||
autoFocus
|
||||
minLength={8}
|
||||
/>
|
||||
<small style={{ display: "block", marginTop: "5px", color: "#666" }}>
|
||||
Your password is used to decrypt your encryption keys locally.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{error && <p style={{ color: "red", marginBottom: "15px" }}>{error}</p>}
|
||||
{message && (
|
||||
<p style={{ color: "green", marginBottom: "15px" }}>{message}</p>
|
||||
)}
|
||||
|
||||
<div className="nav-buttons">
|
||||
<button
|
||||
type="submit"
|
||||
className="nav-button success"
|
||||
disabled={loading || !challengeData}
|
||||
>
|
||||
{loading ? "Logging in..." : "Complete Login"}
|
||||
</button>
|
||||
<Link to="/login" className="nav-button secondary">
|
||||
Start Over
|
||||
</Link>
|
||||
<Link to="/recovery" className="nav-button secondary">
|
||||
Forgot Password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: "20px",
|
||||
padding: "10px",
|
||||
background: "#f0f0f0",
|
||||
borderRadius: "5px",
|
||||
}}
|
||||
>
|
||||
<strong>Security:</strong> Your password never leaves this device.
|
||||
It's used only to decrypt your keys locally using industry-standard
|
||||
cryptographic algorithms.
|
||||
</div>
|
||||
</form>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default CompleteLogin;
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/Anonymous/Login/RequestOTT.jsx
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import Page from "../../../components/Page";
|
||||
import { RequestOTT as RequestOTTAPI } from "../../../../wailsjs/go/app/Application";
|
||||
|
||||
function RequestOTT() {
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setMessage("");
|
||||
setLoading(true);
|
||||
|
||||
if (!email) {
|
||||
setError("Please enter your email address.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if Wails runtime is available
|
||||
if (!window.go || !window.go.app || !window.go.app.Application) {
|
||||
setError("Application not ready. Please wait a moment and try again.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await RequestOTTAPI(email);
|
||||
setMessage("One-time token sent! Check your email.");
|
||||
|
||||
// Redirect to verify OTT page with email
|
||||
setTimeout(() => {
|
||||
navigate(`/login/verify-ott?email=${encodeURIComponent(email)}`);
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
const errorMessage = err.message || err.toString();
|
||||
|
||||
// Try to parse RFC 9457 format
|
||||
try {
|
||||
const jsonMatch = errorMessage.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
const problemDetails = JSON.parse(jsonMatch[0]);
|
||||
setError(
|
||||
problemDetails.detail || problemDetails.title || errorMessage,
|
||||
);
|
||||
} else {
|
||||
setError("Failed to send OTT: " + errorMessage);
|
||||
}
|
||||
} catch (parseErr) {
|
||||
setError("Failed to send OTT: " + errorMessage);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Page title="Login" showBackButton={true}>
|
||||
<form onSubmit={handleSubmit} style={{ maxWidth: "400px" }}>
|
||||
<p style={{ marginBottom: "20px", color: "#333" }}>
|
||||
Enter your email to receive a one-time token.
|
||||
</p>
|
||||
|
||||
<div style={{ marginBottom: "15px" }}>
|
||||
<label
|
||||
htmlFor="email"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "5px",
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
style={{ width: "100%", padding: "8px" }}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p style={{ color: "red", marginBottom: "15px" }}>{error}</p>}
|
||||
{message && (
|
||||
<p style={{ color: "green", marginBottom: "15px" }}>{message}</p>
|
||||
)}
|
||||
|
||||
<div className="nav-buttons">
|
||||
<button type="submit" className="nav-button" disabled={loading}>
|
||||
{loading ? "Sending..." : "Send One-Time Token"}
|
||||
</button>
|
||||
<Link to="/register" className="nav-button secondary">
|
||||
Need an account?
|
||||
</Link>
|
||||
<Link to="/recovery" className="nav-button secondary">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default RequestOTT;
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/Anonymous/Login/SessionExpired.jsx
|
||||
import { Link } from "react-router-dom";
|
||||
import Page from "../../../components/Page";
|
||||
|
||||
function SessionExpired() {
|
||||
return (
|
||||
<Page title="Session Expired">
|
||||
<p>Your session has expired. Please login again.</p>
|
||||
<div className="nav-buttons">
|
||||
<Link to="/login" className="nav-button">
|
||||
Login Again
|
||||
</Link>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default SessionExpired;
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/Anonymous/Login/VerifyOTT.jsx
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link, useSearchParams, useNavigate } from "react-router-dom";
|
||||
import Page from "../../../components/Page";
|
||||
import { VerifyOTT as VerifyOTTAPI } from "../../../../wailsjs/go/app/Application";
|
||||
|
||||
function VerifyOTT() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState("");
|
||||
const [ott, setOtt] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
// Get email from URL query parameter
|
||||
const emailParam = searchParams.get("email");
|
||||
if (emailParam) {
|
||||
setEmail(emailParam);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setMessage("");
|
||||
setLoading(true);
|
||||
|
||||
if (!email) {
|
||||
setError("Email is missing. Please return to login.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ott) {
|
||||
setError("Please enter the one-time token.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify OTT - this returns the encrypted challenge and user keys
|
||||
const response = await VerifyOTTAPI(email, ott);
|
||||
setMessage("Token verified! Redirecting...");
|
||||
|
||||
// Store the challenge data in sessionStorage to pass to CompleteLogin
|
||||
sessionStorage.setItem(
|
||||
"loginChallenge",
|
||||
JSON.stringify({
|
||||
email: email,
|
||||
challengeId: response.challengeId,
|
||||
encryptedChallenge: response.encryptedChallenge,
|
||||
salt: response.salt,
|
||||
encryptedMasterKey: response.encryptedMasterKey,
|
||||
encryptedPrivateKey: response.encryptedPrivateKey,
|
||||
publicKey: response.publicKey,
|
||||
// Include KDF algorithm
|
||||
kdfAlgorithm: response.kdfAlgorithm || "PBKDF2-SHA256",
|
||||
}),
|
||||
);
|
||||
|
||||
// Redirect to complete login page
|
||||
setTimeout(() => {
|
||||
navigate("/login/complete");
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
const errorMessage = err.message || err.toString();
|
||||
|
||||
// Try to parse RFC 9457 format
|
||||
try {
|
||||
const jsonMatch = errorMessage.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
const problemDetails = JSON.parse(jsonMatch[0]);
|
||||
setError(
|
||||
problemDetails.detail || problemDetails.title || errorMessage,
|
||||
);
|
||||
} else {
|
||||
setError("Verification failed: " + errorMessage);
|
||||
}
|
||||
} catch (parseErr) {
|
||||
setError("Verification failed: " + errorMessage);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Page title="Verify Token" showBackButton={true}>
|
||||
<form onSubmit={handleSubmit} style={{ maxWidth: "400px" }}>
|
||||
<p style={{ marginBottom: "20px", color: "#333" }}>
|
||||
Enter the one-time token sent to <strong>{email}</strong>.
|
||||
</p>
|
||||
|
||||
<div style={{ marginBottom: "15px" }}>
|
||||
<label
|
||||
htmlFor="email"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "5px",
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px",
|
||||
backgroundColor: "#f5f5f5",
|
||||
}}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "15px" }}>
|
||||
<label
|
||||
htmlFor="ott"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "5px",
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
One-Time Token
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="ott"
|
||||
value={ott}
|
||||
onChange={(e) => setOtt(e.target.value)}
|
||||
placeholder="Enter token from email"
|
||||
style={{ width: "100%", padding: "8px" }}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p style={{ color: "red", marginBottom: "15px" }}>{error}</p>}
|
||||
{message && (
|
||||
<p style={{ color: "green", marginBottom: "15px" }}>{message}</p>
|
||||
)}
|
||||
|
||||
<div className="nav-buttons">
|
||||
<button type="submit" className="nav-button" disabled={loading}>
|
||||
{loading ? "Verifying..." : "Verify Token"}
|
||||
</button>
|
||||
<Link to="/login" className="nav-button secondary">
|
||||
Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: "20px",
|
||||
padding: "10px",
|
||||
background: "#f0f0f0",
|
||||
borderRadius: "5px",
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: 0, fontSize: "14px", color: "#666" }}>
|
||||
<strong>Didn't receive the token?</strong> Check your spam folder or
|
||||
return to login to request a new one.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default VerifyOTT;
|
||||
|
|
@ -0,0 +1,476 @@
|
|||
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/Anonymous/Recovery/CompleteRecovery.jsx
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import Page from "../../../components/Page";
|
||||
import { CompleteRecovery as CompleteRecoveryAPI } from "../../../../wailsjs/go/app/Application";
|
||||
|
||||
function CompleteRecovery() {
|
||||
const navigate = useNavigate();
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [recoveryPhrase, setRecoveryPhrase] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [recoveryToken, setRecoveryToken] = useState("");
|
||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Get recovery session data from sessionStorage
|
||||
const storedEmail = sessionStorage.getItem("recoveryEmail");
|
||||
const storedToken = sessionStorage.getItem("recoveryToken");
|
||||
const canReset = sessionStorage.getItem("canResetCredentials");
|
||||
|
||||
if (!storedEmail || !storedToken || canReset !== "true") {
|
||||
console.log("[CompleteRecovery] No verified recovery session, redirecting");
|
||||
navigate("/recovery/initiate");
|
||||
return;
|
||||
}
|
||||
|
||||
setEmail(storedEmail);
|
||||
setRecoveryToken(storedToken);
|
||||
}, [navigate]);
|
||||
|
||||
// Count words in recovery phrase
|
||||
const wordCount = useMemo(() => {
|
||||
if (!recoveryPhrase.trim()) return 0;
|
||||
return recoveryPhrase.trim().split(/\s+/).length;
|
||||
}, [recoveryPhrase]);
|
||||
|
||||
// Check if passwords match
|
||||
const passwordsMatch = newPassword && confirmPassword && newPassword === confirmPassword;
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Validate passwords
|
||||
if (!newPassword) {
|
||||
throw new Error("Password is required");
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
throw new Error("Password must be at least 8 characters long");
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
throw new Error("Passwords do not match");
|
||||
}
|
||||
|
||||
// Validate recovery phrase
|
||||
const words = recoveryPhrase.trim().toLowerCase().split(/\s+/);
|
||||
if (words.length !== 12) {
|
||||
throw new Error("Recovery phrase must be exactly 12 words");
|
||||
}
|
||||
|
||||
// Normalize recovery phrase
|
||||
const normalizedPhrase = words.join(" ");
|
||||
|
||||
console.log("[CompleteRecovery] Completing recovery with new password");
|
||||
|
||||
// Call backend to complete recovery
|
||||
const response = await CompleteRecoveryAPI({
|
||||
recoveryToken: recoveryToken,
|
||||
recoveryMnemonic: normalizedPhrase,
|
||||
newPassword: newPassword,
|
||||
});
|
||||
|
||||
console.log("[CompleteRecovery] Recovery completed successfully");
|
||||
|
||||
// Clear all recovery session data
|
||||
sessionStorage.removeItem("recoveryEmail");
|
||||
sessionStorage.removeItem("recoverySessionId");
|
||||
sessionStorage.removeItem("recoveryEncryptedChallenge");
|
||||
sessionStorage.removeItem("recoveryToken");
|
||||
sessionStorage.removeItem("canResetCredentials");
|
||||
|
||||
// Clear sensitive data from state
|
||||
setRecoveryPhrase("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
|
||||
// Show success and redirect to login
|
||||
alert("Account recovery completed successfully! You can now log in with your new password.");
|
||||
navigate("/login");
|
||||
} catch (err) {
|
||||
console.error("[CompleteRecovery] Recovery completion failed:", err);
|
||||
setError(err.message || "Failed to complete recovery");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackToVerify = () => {
|
||||
// Clear token but keep session
|
||||
sessionStorage.removeItem("recoveryToken");
|
||||
sessionStorage.removeItem("canResetCredentials");
|
||||
setRecoveryPhrase("");
|
||||
navigate("/recovery/verify");
|
||||
};
|
||||
|
||||
if (!email) {
|
||||
return (
|
||||
<Page title="Complete Recovery" showBackButton={true}>
|
||||
<p>Loading recovery session...</p>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Page title="Set Your New Password" showBackButton={false}>
|
||||
<div style={{ maxWidth: "600px" }}>
|
||||
{/* Progress Indicator */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: "20px",
|
||||
gap: "10px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "30px",
|
||||
height: "30px",
|
||||
borderRadius: "50%",
|
||||
background: "#27ae60",
|
||||
color: "white",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
✓
|
||||
</div>
|
||||
<span style={{ color: "#27ae60", fontWeight: "bold" }}>Email</span>
|
||||
<div style={{ width: "30px", height: "2px", background: "#27ae60" }} />
|
||||
<div
|
||||
style={{
|
||||
width: "30px",
|
||||
height: "30px",
|
||||
borderRadius: "50%",
|
||||
background: "#27ae60",
|
||||
color: "white",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
✓
|
||||
</div>
|
||||
<span style={{ color: "#27ae60", fontWeight: "bold" }}>Verify</span>
|
||||
<div style={{ width: "30px", height: "2px", background: "#27ae60" }} />
|
||||
<div
|
||||
style={{
|
||||
width: "30px",
|
||||
height: "30px",
|
||||
borderRadius: "50%",
|
||||
background: "#3498db",
|
||||
color: "white",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
3
|
||||
</div>
|
||||
<span style={{ color: "#333", fontWeight: "bold" }}>Reset</span>
|
||||
</div>
|
||||
|
||||
<p style={{ marginBottom: "20px", color: "#333" }}>
|
||||
Final step: Create a new password for <strong>{email}</strong>
|
||||
</p>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
background: "#f8d7da",
|
||||
border: "1px solid #f5c6cb",
|
||||
borderRadius: "8px",
|
||||
padding: "15px",
|
||||
marginBottom: "20px",
|
||||
color: "#721c24",
|
||||
}}
|
||||
>
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Security Notice */}
|
||||
<div
|
||||
style={{
|
||||
background: "#cce5ff",
|
||||
border: "1px solid #b8daff",
|
||||
borderRadius: "8px",
|
||||
padding: "15px",
|
||||
marginBottom: "20px",
|
||||
}}
|
||||
>
|
||||
<strong style={{ color: "#004085" }}>Why enter your recovery phrase again?</strong>
|
||||
<p style={{ margin: "10px 0 0", color: "#004085", fontSize: "14px" }}>
|
||||
We need your recovery phrase to decrypt your master key and re-encrypt
|
||||
it with your new password. This ensures continuous access to your
|
||||
encrypted files.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Recovery Phrase */}
|
||||
<div style={{ marginBottom: "20px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "5px",
|
||||
}}
|
||||
>
|
||||
<label
|
||||
htmlFor="recoveryPhrase"
|
||||
style={{ fontWeight: "bold", color: "#333" }}
|
||||
>
|
||||
Recovery Phrase (Required Again)
|
||||
</label>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color:
|
||||
wordCount === 12
|
||||
? "#27ae60"
|
||||
: wordCount > 0
|
||||
? "#f39c12"
|
||||
: "#666",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
{wordCount}/12 words
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
id="recoveryPhrase"
|
||||
value={recoveryPhrase}
|
||||
onChange={(e) => setRecoveryPhrase(e.target.value)}
|
||||
placeholder="Re-enter your 12-word recovery phrase"
|
||||
rows={3}
|
||||
required
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "12px",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "14px",
|
||||
lineHeight: "1.6",
|
||||
borderRadius: "8px",
|
||||
border:
|
||||
wordCount === 12 ? "2px solid #27ae60" : "1px solid #ccc",
|
||||
background: wordCount === 12 ? "#f0fff0" : "#fff",
|
||||
resize: "none",
|
||||
color: "#333",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* New Password Section */}
|
||||
<div
|
||||
style={{
|
||||
background: "#f0fff0",
|
||||
border: "1px solid #27ae60",
|
||||
borderRadius: "8px",
|
||||
padding: "15px",
|
||||
marginBottom: "20px",
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: "0 0 15px 0", color: "#27ae60", fontSize: "16px" }}>
|
||||
Create Your New Password
|
||||
</h3>
|
||||
|
||||
{/* New Password */}
|
||||
<div style={{ marginBottom: "15px" }}>
|
||||
<label
|
||||
htmlFor="newPassword"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "5px",
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
New Password
|
||||
</label>
|
||||
<div style={{ position: "relative" }}>
|
||||
<input
|
||||
type={showNewPassword ? "text" : "password"}
|
||||
id="newPassword"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="Enter your new password"
|
||||
required
|
||||
disabled={loading}
|
||||
minLength={8}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px",
|
||||
paddingRight: "40px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #ccc",
|
||||
color: "#333",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: "8px",
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "#666",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
{showNewPassword ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
<small style={{ display: "block", marginTop: "5px", color: "#666" }}>
|
||||
Password must be at least 8 characters long
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "5px",
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
Confirm New Password
|
||||
</label>
|
||||
<div style={{ position: "relative" }}>
|
||||
<input
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
id="confirmPassword"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Confirm your new password"
|
||||
required
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px",
|
||||
paddingRight: "40px",
|
||||
borderRadius: "4px",
|
||||
border: passwordsMatch ? "2px solid #27ae60" : "1px solid #ccc",
|
||||
background: passwordsMatch ? "#f0fff0" : "#fff",
|
||||
color: "#333",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: "8px",
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "#666",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
>
|
||||
{showConfirmPassword ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
{passwordsMatch && (
|
||||
<small style={{ display: "block", marginTop: "5px", color: "#27ae60" }}>
|
||||
✓ Passwords match
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="nav-buttons">
|
||||
<button
|
||||
type="submit"
|
||||
className="nav-button success"
|
||||
disabled={loading || wordCount !== 12 || !newPassword || !passwordsMatch}
|
||||
style={{
|
||||
opacity: wordCount === 12 && passwordsMatch ? 1 : 0.5,
|
||||
cursor: wordCount === 12 && passwordsMatch && !loading ? "pointer" : "not-allowed",
|
||||
}}
|
||||
>
|
||||
{loading ? "Setting New Password..." : "Complete Recovery"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBackToVerify}
|
||||
className="nav-button secondary"
|
||||
disabled={loading}
|
||||
>
|
||||
Back to Verification
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* What Happens Next */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: "20px",
|
||||
padding: "15px",
|
||||
background: "#e8f4fd",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #b8daff",
|
||||
}}
|
||||
>
|
||||
<strong style={{ color: "#004085" }}>What Happens Next?</strong>
|
||||
<ul style={{ margin: "10px 0 0", paddingLeft: "20px", color: "#004085", fontSize: "14px" }}>
|
||||
<li>Your master key will be decrypted using your recovery key</li>
|
||||
<li>New encryption keys will be generated</li>
|
||||
<li>All keys will be re-encrypted with your new password</li>
|
||||
<li>Your recovery phrase remains the same for future use</li>
|
||||
<li>You'll be able to log in immediately with your new password</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Security Notes */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: "15px",
|
||||
padding: "10px",
|
||||
background: "#f0f0f0",
|
||||
borderRadius: "5px",
|
||||
}}
|
||||
>
|
||||
<strong>Security Notes:</strong>
|
||||
<ul style={{ margin: "10px 0 0", paddingLeft: "20px", color: "#333", fontSize: "14px" }}>
|
||||
<li>Choose a strong, unique password</li>
|
||||
<li>Your new password will be used to encrypt your keys</li>
|
||||
<li>Keep your recovery phrase safe - it hasn't changed</li>
|
||||
<li>All your encrypted data remains accessible</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default CompleteRecovery;
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/Anonymous/Recovery/InitiateRecovery.jsx
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import Page from "../../../components/Page";
|
||||
import { InitiateRecovery as InitiateRecoveryAPI } from "../../../../wailsjs/go/app/Application";
|
||||
|
||||
function InitiateRecovery() {
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Validate email
|
||||
if (!email) {
|
||||
throw new Error("Email address is required");
|
||||
}
|
||||
|
||||
if (!email.includes("@")) {
|
||||
throw new Error("Please enter a valid email address");
|
||||
}
|
||||
|
||||
console.log("[InitiateRecovery] Starting recovery process for:", email);
|
||||
|
||||
// Call backend to initiate recovery
|
||||
const response = await InitiateRecoveryAPI(email);
|
||||
|
||||
console.log("[InitiateRecovery] Recovery initiated successfully");
|
||||
console.log("[InitiateRecovery] session_id:", response.sessionId);
|
||||
|
||||
// Check if session was actually created
|
||||
if (!response.sessionId || !response.encryptedChallenge) {
|
||||
// User not found - show generic message for security
|
||||
setError(
|
||||
"Unable to initiate recovery. Please ensure you entered the correct email address associated with your account."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store recovery session data in sessionStorage for the verify step
|
||||
sessionStorage.setItem("recoveryEmail", email);
|
||||
sessionStorage.setItem("recoverySessionId", response.sessionId);
|
||||
sessionStorage.setItem("recoveryEncryptedChallenge", response.encryptedChallenge);
|
||||
|
||||
// Navigate to verification step
|
||||
navigate("/recovery/verify");
|
||||
} catch (err) {
|
||||
console.error("[InitiateRecovery] Recovery initiation failed:", err);
|
||||
setError(err.message || "Failed to initiate recovery");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Page title="Account Recovery" showBackButton={true}>
|
||||
<div style={{ maxWidth: "500px" }}>
|
||||
{/* Warning Banner */}
|
||||
<div
|
||||
style={{
|
||||
background: "#fff3cd",
|
||||
border: "1px solid #ffc107",
|
||||
borderRadius: "8px",
|
||||
padding: "15px",
|
||||
marginBottom: "20px",
|
||||
}}
|
||||
>
|
||||
<strong style={{ color: "#856404" }}>Before You Begin</strong>
|
||||
<p style={{ margin: "10px 0 0", color: "#856404", fontSize: "14px" }}>
|
||||
You'll need your <strong>12-word recovery phrase</strong> to complete
|
||||
this process. Make sure you have it ready before proceeding.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: "15px" }}>
|
||||
<label
|
||||
htmlFor="email"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "5px",
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Enter your email address"
|
||||
required
|
||||
disabled={loading}
|
||||
style={{ width: "100%", padding: "8px" }}
|
||||
/>
|
||||
<small style={{ display: "block", marginTop: "5px", color: "#666" }}>
|
||||
We'll use this to verify your identity and guide you through recovery
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{error && <p style={{ color: "red", marginBottom: "15px" }}>{error}</p>}
|
||||
|
||||
<div className="nav-buttons">
|
||||
<button type="submit" className="nav-button" disabled={loading}>
|
||||
{loading ? "Starting Recovery..." : "Start Account Recovery"}
|
||||
</button>
|
||||
<Link to="/login" className="nav-button secondary">
|
||||
Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Security Note */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: "20px",
|
||||
padding: "10px",
|
||||
background: "#f0f0f0",
|
||||
borderRadius: "5px",
|
||||
}}
|
||||
>
|
||||
<strong>Security Note:</strong> Your recovery phrase is the only way to
|
||||
recover your account if you forget your password. It is never stored on
|
||||
our servers.
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default InitiateRecovery;
|
||||
|
|
@ -0,0 +1,366 @@
|
|||
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/Anonymous/Recovery/VerifyRecovery.jsx
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import Page from "../../../components/Page";
|
||||
import {
|
||||
DecryptRecoveryChallenge,
|
||||
VerifyRecovery as VerifyRecoveryAPI,
|
||||
} from "../../../../wailsjs/go/app/Application";
|
||||
|
||||
function VerifyRecovery() {
|
||||
const navigate = useNavigate();
|
||||
const [recoveryPhrase, setRecoveryPhrase] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [decrypting, setDecrypting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [sessionId, setSessionId] = useState("");
|
||||
const [encryptedChallenge, setEncryptedChallenge] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Get recovery session data from sessionStorage
|
||||
const storedEmail = sessionStorage.getItem("recoveryEmail");
|
||||
const storedSessionId = sessionStorage.getItem("recoverySessionId");
|
||||
const storedChallenge = sessionStorage.getItem("recoveryEncryptedChallenge");
|
||||
|
||||
if (!storedEmail || !storedSessionId || !storedChallenge) {
|
||||
console.log("[VerifyRecovery] No active recovery session, redirecting");
|
||||
navigate("/recovery/initiate");
|
||||
return;
|
||||
}
|
||||
|
||||
setEmail(storedEmail);
|
||||
setSessionId(storedSessionId);
|
||||
setEncryptedChallenge(storedChallenge);
|
||||
}, [navigate]);
|
||||
|
||||
// Count words in recovery phrase
|
||||
const wordCount = useMemo(() => {
|
||||
if (!recoveryPhrase.trim()) return 0;
|
||||
return recoveryPhrase.trim().split(/\s+/).length;
|
||||
}, [recoveryPhrase]);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
setDecrypting(true);
|
||||
|
||||
try {
|
||||
// Validate recovery phrase
|
||||
const words = recoveryPhrase.trim().toLowerCase().split(/\s+/);
|
||||
if (words.length !== 12) {
|
||||
throw new Error("Recovery phrase must be exactly 12 words");
|
||||
}
|
||||
|
||||
// Join words with single space (normalize)
|
||||
const normalizedPhrase = words.join(" ");
|
||||
|
||||
console.log("[VerifyRecovery] Decrypting challenge with recovery phrase");
|
||||
|
||||
// Decrypt the challenge using recovery phrase via backend
|
||||
const decryptResult = await DecryptRecoveryChallenge({
|
||||
recoveryMnemonic: normalizedPhrase,
|
||||
encryptedChallenge: encryptedChallenge,
|
||||
});
|
||||
|
||||
setDecrypting(false);
|
||||
|
||||
if (!decryptResult.isValid) {
|
||||
throw new Error("Invalid recovery phrase");
|
||||
}
|
||||
|
||||
console.log("[VerifyRecovery] Challenge decrypted, verifying with server");
|
||||
|
||||
// Verify the decrypted challenge with the server
|
||||
const response = await VerifyRecoveryAPI(
|
||||
sessionId,
|
||||
decryptResult.decryptedChallenge
|
||||
);
|
||||
|
||||
console.log("[VerifyRecovery] Recovery verified successfully");
|
||||
|
||||
// Store recovery token for the completion step
|
||||
sessionStorage.setItem("recoveryToken", response.recoveryToken);
|
||||
sessionStorage.setItem("canResetCredentials", response.canResetCredentials.toString());
|
||||
|
||||
// Clear the recovery phrase from memory
|
||||
setRecoveryPhrase("");
|
||||
|
||||
// Navigate to completion step
|
||||
navigate("/recovery/complete");
|
||||
} catch (err) {
|
||||
console.error("[VerifyRecovery] Recovery verification failed:", err);
|
||||
setError(err.message || "Failed to verify recovery phrase");
|
||||
setDecrypting(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
setRecoveryPhrase(text.trim());
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 3000);
|
||||
} catch (err) {
|
||||
console.error("Failed to read clipboard:", err);
|
||||
alert("Failed to paste from clipboard. Please paste manually.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartOver = () => {
|
||||
// Clear session data and go back to initiate
|
||||
sessionStorage.removeItem("recoveryEmail");
|
||||
sessionStorage.removeItem("recoverySessionId");
|
||||
sessionStorage.removeItem("recoveryEncryptedChallenge");
|
||||
setRecoveryPhrase("");
|
||||
navigate("/recovery/initiate");
|
||||
};
|
||||
|
||||
if (!email) {
|
||||
return (
|
||||
<Page title="Verify Recovery" showBackButton={true}>
|
||||
<p>Loading recovery session...</p>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Page title="Verify Your Recovery Phrase" showBackButton={false}>
|
||||
<div style={{ maxWidth: "600px" }}>
|
||||
{/* Progress Indicator */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: "20px",
|
||||
gap: "10px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "30px",
|
||||
height: "30px",
|
||||
borderRadius: "50%",
|
||||
background: "#27ae60",
|
||||
color: "white",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
✓
|
||||
</div>
|
||||
<span style={{ color: "#27ae60", fontWeight: "bold" }}>Email</span>
|
||||
<div style={{ width: "30px", height: "2px", background: "#27ae60" }} />
|
||||
<div
|
||||
style={{
|
||||
width: "30px",
|
||||
height: "30px",
|
||||
borderRadius: "50%",
|
||||
background: "#3498db",
|
||||
color: "white",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<span style={{ color: "#333", fontWeight: "bold" }}>Verify</span>
|
||||
<div style={{ width: "30px", height: "2px", background: "#ccc" }} />
|
||||
<div
|
||||
style={{
|
||||
width: "30px",
|
||||
height: "30px",
|
||||
borderRadius: "50%",
|
||||
background: "#ccc",
|
||||
color: "#666",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
3
|
||||
</div>
|
||||
<span style={{ color: "#666" }}>Reset</span>
|
||||
</div>
|
||||
|
||||
<p style={{ marginBottom: "20px", color: "#333" }}>
|
||||
Enter your 12-word recovery phrase for <strong>{email}</strong>
|
||||
</p>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
background: "#f8d7da",
|
||||
border: "1px solid #f5c6cb",
|
||||
borderRadius: "8px",
|
||||
padding: "15px",
|
||||
marginBottom: "20px",
|
||||
color: "#721c24",
|
||||
}}
|
||||
>
|
||||
<strong>Verification Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decrypting Message */}
|
||||
{decrypting && (
|
||||
<div
|
||||
style={{
|
||||
background: "#cce5ff",
|
||||
border: "1px solid #b8daff",
|
||||
borderRadius: "8px",
|
||||
padding: "15px",
|
||||
marginBottom: "20px",
|
||||
color: "#004085",
|
||||
}}
|
||||
>
|
||||
Decrypting challenge with your recovery key...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: "15px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "5px",
|
||||
}}
|
||||
>
|
||||
<label
|
||||
htmlFor="recoveryPhrase"
|
||||
style={{ fontWeight: "bold", color: "#333" }}
|
||||
>
|
||||
Recovery Phrase
|
||||
</label>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color:
|
||||
wordCount === 12
|
||||
? "#27ae60"
|
||||
: wordCount > 0
|
||||
? "#f39c12"
|
||||
: "#666",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
{wordCount}/12 words
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
id="recoveryPhrase"
|
||||
value={recoveryPhrase}
|
||||
onChange={(e) => setRecoveryPhrase(e.target.value)}
|
||||
placeholder="Enter your 12-word recovery phrase separated by spaces"
|
||||
rows={4}
|
||||
required
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "12px",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "14px",
|
||||
lineHeight: "1.6",
|
||||
borderRadius: "8px",
|
||||
border:
|
||||
wordCount === 12 ? "2px solid #27ae60" : "1px solid #ccc",
|
||||
background: wordCount === 12 ? "#f0fff0" : "#fff",
|
||||
resize: "none",
|
||||
color: "#333",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginTop: "5px",
|
||||
}}
|
||||
>
|
||||
<small style={{ color: "#666" }}>
|
||||
Enter all 12 words in the correct order, separated by spaces
|
||||
</small>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePaste}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: "4px 8px",
|
||||
fontSize: "12px",
|
||||
background: copied ? "#27ae60" : "#95a5a6",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: loading ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
{copied ? "Pasted!" : "Paste"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="nav-buttons">
|
||||
<button
|
||||
type="submit"
|
||||
className="nav-button success"
|
||||
disabled={loading || wordCount !== 12}
|
||||
style={{
|
||||
opacity: wordCount === 12 ? 1 : 0.5,
|
||||
cursor: wordCount === 12 && !loading ? "pointer" : "not-allowed",
|
||||
}}
|
||||
>
|
||||
{loading
|
||||
? decrypting
|
||||
? "Decrypting..."
|
||||
: "Verifying..."
|
||||
: "Verify Recovery Phrase"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStartOver}
|
||||
className="nav-button secondary"
|
||||
disabled={loading}
|
||||
>
|
||||
Start Over
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Security Tips */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: "20px",
|
||||
padding: "15px",
|
||||
background: "#f0f0f0",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
>
|
||||
<strong>Security Tips:</strong>
|
||||
<ul style={{ margin: "10px 0 0", paddingLeft: "20px", color: "#333" }}>
|
||||
<li>Never share your recovery phrase with anyone</li>
|
||||
<li>Make sure no one is watching your screen</li>
|
||||
<li>Your phrase is validated locally and never sent in plain text</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default VerifyRecovery;
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/Anonymous/Register/RecoveryCode.jsx
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import Page from "../../../components/Page";
|
||||
|
||||
function RecoveryCode() {
|
||||
const navigate = useNavigate();
|
||||
const [recoveryMnemonic, setRecoveryMnemonic] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
// Get recovery mnemonic from sessionStorage
|
||||
const storedMnemonic = sessionStorage.getItem("recoveryMnemonic");
|
||||
const storedEmail = sessionStorage.getItem("registrationEmail");
|
||||
|
||||
if (!storedMnemonic) {
|
||||
setError("No recovery code found. Please start registration again.");
|
||||
return;
|
||||
}
|
||||
|
||||
setRecoveryMnemonic(storedMnemonic);
|
||||
setEmail(storedEmail || "");
|
||||
}, []);
|
||||
|
||||
// Split mnemonic into words for display
|
||||
const mnemonicWords = useMemo(() => {
|
||||
if (!recoveryMnemonic) return [];
|
||||
return recoveryMnemonic.split(" ");
|
||||
}, [recoveryMnemonic]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(recoveryMnemonic);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 3000);
|
||||
} catch (err) {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = recoveryMnemonic;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textArea);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
if (!confirmed) {
|
||||
setError("Please confirm that you have saved your recovery code.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear the recovery mnemonic from sessionStorage
|
||||
sessionStorage.removeItem("recoveryMnemonic");
|
||||
|
||||
// Navigate to email verification
|
||||
navigate(`/register/verify-email?email=${encodeURIComponent(email)}`);
|
||||
};
|
||||
|
||||
if (error && !recoveryMnemonic) {
|
||||
return (
|
||||
<Page title="Recovery Code" showBackButton={true}>
|
||||
<p style={{ color: "red", marginBottom: "20px" }}>{error}</p>
|
||||
<div className="nav-buttons">
|
||||
<Link to="/register" className="nav-button">
|
||||
Start Registration
|
||||
</Link>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Page title="Save Your Recovery Code" showBackButton={false}>
|
||||
<div style={{ maxWidth: "600px" }}>
|
||||
{/* Warning Banner */}
|
||||
<div
|
||||
style={{
|
||||
background: "#fff3cd",
|
||||
border: "1px solid #ffc107",
|
||||
borderRadius: "8px",
|
||||
padding: "15px",
|
||||
marginBottom: "20px",
|
||||
}}
|
||||
>
|
||||
<strong style={{ color: "#856404" }}>
|
||||
IMPORTANT: Save This Recovery Code!
|
||||
</strong>
|
||||
<p style={{ margin: "10px 0 0", color: "#856404", fontSize: "14px" }}>
|
||||
This 12-word recovery phrase is the ONLY way to recover your account
|
||||
if you forget your password. Write it down and store it in a safe
|
||||
place. You will NOT see this again.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Recovery Words Grid */}
|
||||
<div
|
||||
style={{
|
||||
background: "#f8f9fa",
|
||||
border: "2px solid #28a745",
|
||||
borderRadius: "8px",
|
||||
padding: "20px",
|
||||
marginBottom: "20px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, 1fr)",
|
||||
gap: "10px",
|
||||
}}
|
||||
>
|
||||
{mnemonicWords.map((word, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
background: "white",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
padding: "8px 12px",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "#666", marginRight: "8px" }}>
|
||||
{index + 1}.
|
||||
</span>
|
||||
<span style={{ fontWeight: "bold" }}>{word}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Copy Button */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "12px",
|
||||
marginBottom: "20px",
|
||||
background: copied ? "#28a745" : "#007bff",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "16px",
|
||||
}}
|
||||
>
|
||||
{copied ? "Copied to Clipboard!" : "Copy Recovery Code"}
|
||||
</button>
|
||||
|
||||
{/* Security Tips */}
|
||||
<div
|
||||
style={{
|
||||
background: "#f0f0f0",
|
||||
borderRadius: "8px",
|
||||
padding: "15px",
|
||||
marginBottom: "20px",
|
||||
}}
|
||||
>
|
||||
<strong>Security Tips:</strong>
|
||||
<ul style={{ margin: "10px 0 0", paddingLeft: "20px" }}>
|
||||
<li>Write down these 12 words on paper</li>
|
||||
<li>Store in a secure location (safe, lockbox)</li>
|
||||
<li>Never share with anyone, including support staff</li>
|
||||
<li>Do not store digitally (screenshots, cloud storage)</li>
|
||||
<li>Consider storing copies in multiple secure locations</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Checkbox */}
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
cursor: "pointer",
|
||||
marginBottom: "20px",
|
||||
padding: "10px",
|
||||
background: confirmed ? "#d4edda" : "#fff",
|
||||
border: confirmed ? "1px solid #28a745" : "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(e) => {
|
||||
setConfirmed(e.target.checked);
|
||||
setError("");
|
||||
}}
|
||||
style={{ marginRight: "10px", marginTop: "3px" }}
|
||||
/>
|
||||
<span>
|
||||
I have written down my recovery code and stored it in a safe place.
|
||||
I understand that without this code, I cannot recover my account if
|
||||
I forget my password.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{error && <p style={{ color: "red", marginBottom: "15px" }}>{error}</p>}
|
||||
|
||||
{/* Continue Button */}
|
||||
<div className="nav-buttons">
|
||||
<button
|
||||
onClick={handleContinue}
|
||||
className="nav-button success"
|
||||
disabled={!confirmed}
|
||||
style={{
|
||||
opacity: confirmed ? 1 : 0.5,
|
||||
cursor: confirmed ? "pointer" : "not-allowed",
|
||||
}}
|
||||
>
|
||||
Continue to Email Verification
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Email reminder */}
|
||||
{email && (
|
||||
<p
|
||||
style={{
|
||||
marginTop: "15px",
|
||||
color: "#666",
|
||||
fontSize: "14px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Verification email will be sent to: <strong>{email}</strong>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default RecoveryCode;
|
||||
|
|
@ -0,0 +1,445 @@
|
|||
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/Anonymous/Register/Register.jsx
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import Page from "../../../components/Page";
|
||||
import {
|
||||
Register as RegisterAPI,
|
||||
GenerateRegistrationKeys,
|
||||
} from "../../../../wailsjs/go/app/Application";
|
||||
|
||||
function Register() {
|
||||
const navigate = useNavigate();
|
||||
const [formData, setFormData] = useState({
|
||||
email: "",
|
||||
password: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
phone: "",
|
||||
country: "CA",
|
||||
timezone: "America/Toronto",
|
||||
betaAccessCode: "",
|
||||
agreeTermsOfService: false,
|
||||
agreePromotions: false,
|
||||
agreeToTracking: false,
|
||||
});
|
||||
const [error, setError] = useState("");
|
||||
const [fieldErrors, setFieldErrors] = useState({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: type === "checkbox" ? checked : value,
|
||||
}));
|
||||
// Clear field error when user types
|
||||
if (fieldErrors[name]) {
|
||||
setFieldErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[name];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async (e) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setFieldErrors({});
|
||||
setMessage("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Generate production E2EE keys using backend crypto library
|
||||
const registrationKeys = await GenerateRegistrationKeys(
|
||||
formData.password,
|
||||
);
|
||||
|
||||
// Use PBKDF2-SHA256 for KDF (compatible with web frontend)
|
||||
// Parameters: 100,000 iterations, 16-byte salt, 32-byte key
|
||||
const registerInput = {
|
||||
beta_access_code: formData.betaAccessCode,
|
||||
email: formData.email,
|
||||
first_name: formData.firstName,
|
||||
last_name: formData.lastName,
|
||||
phone: formData.phone,
|
||||
country: formData.country,
|
||||
timezone: formData.timezone,
|
||||
salt: registrationKeys.salt,
|
||||
kdf_algorithm: "PBKDF2-SHA256",
|
||||
kdf_iterations: 100000,
|
||||
kdf_memory: 0, // Not used for PBKDF2
|
||||
kdf_parallelism: 0, // Not used for PBKDF2
|
||||
kdf_salt_length: 16,
|
||||
kdf_key_length: 32,
|
||||
encryptedMasterKey: registrationKeys.encryptedMasterKey,
|
||||
publicKey: registrationKeys.publicKey,
|
||||
encryptedPrivateKey: registrationKeys.encryptedPrivateKey,
|
||||
encryptedRecoveryKey: registrationKeys.encryptedRecoveryKey,
|
||||
masterKeyEncryptedWithRecoveryKey:
|
||||
registrationKeys.masterKeyEncryptedWithRecoveryKey,
|
||||
agree_terms_of_service: formData.agreeTermsOfService,
|
||||
agree_promotions: formData.agreePromotions,
|
||||
agree_to_tracking_across_third_party_apps_and_services:
|
||||
formData.agreeToTracking,
|
||||
};
|
||||
|
||||
await RegisterAPI(registerInput);
|
||||
setMessage("Registration successful! Redirecting to save recovery code...");
|
||||
|
||||
// Store recovery mnemonic in sessionStorage for the RecoveryCode page
|
||||
// This is temporary storage - it will be cleared after the user saves it
|
||||
sessionStorage.setItem("recoveryMnemonic", registrationKeys.recoveryMnemonic);
|
||||
sessionStorage.setItem("registrationEmail", formData.email);
|
||||
|
||||
// Redirect to recovery code page (user MUST save this before continuing)
|
||||
setTimeout(() => {
|
||||
navigate("/register/recovery");
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
// Handle RFC 9457 Problem Details error format
|
||||
const errorMessage = err.message || err.toString();
|
||||
|
||||
// Try to parse RFC 9457 format
|
||||
try {
|
||||
// Check if error contains JSON
|
||||
const jsonMatch = errorMessage.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
const problemDetails = JSON.parse(jsonMatch[0]);
|
||||
|
||||
// Set general error
|
||||
if (problemDetails.title || problemDetails.detail) {
|
||||
setError(problemDetails.detail || problemDetails.title);
|
||||
}
|
||||
|
||||
// Set field-specific errors
|
||||
if (problemDetails.invalid_params) {
|
||||
const errors = {};
|
||||
problemDetails.invalid_params.forEach((param) => {
|
||||
errors[param.name] = param.reason;
|
||||
});
|
||||
setFieldErrors(errors);
|
||||
}
|
||||
} else {
|
||||
setError("Registration failed: " + errorMessage);
|
||||
}
|
||||
} catch (parseErr) {
|
||||
setError("Registration failed: " + errorMessage);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Page title="Register" showBackButton={true}>
|
||||
<form onSubmit={handleRegister} style={{ maxWidth: "500px" }}>
|
||||
<div style={{ marginBottom: "15px" }}>
|
||||
<label
|
||||
htmlFor="betaAccessCode"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "5px",
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
Beta Access Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="betaAccessCode"
|
||||
name="betaAccessCode"
|
||||
value={formData.betaAccessCode}
|
||||
onChange={handleInputChange}
|
||||
style={{ width: "100%", padding: "8px" }}
|
||||
/>
|
||||
{fieldErrors.beta_access_code && (
|
||||
<small style={{ color: "red" }}>
|
||||
{fieldErrors.beta_access_code}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "15px" }}>
|
||||
<label
|
||||
htmlFor="email"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "5px",
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
style={{ width: "100%", padding: "8px" }}
|
||||
/>
|
||||
{fieldErrors.email && (
|
||||
<small style={{ color: "red" }}>{fieldErrors.email}</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "15px" }}>
|
||||
<label
|
||||
htmlFor="firstName"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "5px",
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
First Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
value={formData.firstName}
|
||||
onChange={handleInputChange}
|
||||
style={{ width: "100%", padding: "8px" }}
|
||||
/>
|
||||
{fieldErrors.first_name && (
|
||||
<small style={{ color: "red" }}>{fieldErrors.first_name}</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "15px" }}>
|
||||
<label
|
||||
htmlFor="lastName"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "5px",
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
Last Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
value={formData.lastName}
|
||||
onChange={handleInputChange}
|
||||
style={{ width: "100%", padding: "8px" }}
|
||||
/>
|
||||
{fieldErrors.last_name && (
|
||||
<small style={{ color: "red" }}>{fieldErrors.last_name}</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "15px" }}>
|
||||
<label
|
||||
htmlFor="phone"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "5px",
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
Phone (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleInputChange}
|
||||
style={{ width: "100%", padding: "8px" }}
|
||||
/>
|
||||
{fieldErrors.phone && (
|
||||
<small style={{ color: "red" }}>{fieldErrors.phone}</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "15px" }}>
|
||||
<label
|
||||
htmlFor="country"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "5px",
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
Country
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="country"
|
||||
name="country"
|
||||
value={formData.country}
|
||||
onChange={handleInputChange}
|
||||
style={{ width: "100%", padding: "8px" }}
|
||||
/>
|
||||
{fieldErrors.country && (
|
||||
<small style={{ color: "red" }}>{fieldErrors.country}</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "15px" }}>
|
||||
<label
|
||||
htmlFor="timezone"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "5px",
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
Timezone
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="timezone"
|
||||
name="timezone"
|
||||
value={formData.timezone}
|
||||
onChange={handleInputChange}
|
||||
style={{ width: "100%", padding: "8px" }}
|
||||
/>
|
||||
{fieldErrors.timezone && (
|
||||
<small style={{ color: "red" }}>{fieldErrors.timezone}</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "15px" }}>
|
||||
<label
|
||||
htmlFor="password"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "5px",
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
Password (Used for E2EE)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
minLength={8}
|
||||
style={{ width: "100%", padding: "8px" }}
|
||||
/>
|
||||
<small style={{ display: "block", marginTop: "5px", color: "#666" }}>
|
||||
Will be used to encrypt your data (not stored on server). Minimum 8
|
||||
characters.
|
||||
</small>
|
||||
{fieldErrors.password && (
|
||||
<small style={{ color: "red", display: "block" }}>
|
||||
{fieldErrors.password}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "15px" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="agreeTermsOfService"
|
||||
checked={formData.agreeTermsOfService}
|
||||
onChange={handleInputChange}
|
||||
style={{ marginRight: "8px" }}
|
||||
/>
|
||||
<span>I agree to the Terms of Service</span>
|
||||
</label>
|
||||
{fieldErrors.agree_terms_of_service && (
|
||||
<small style={{ color: "red" }}>
|
||||
{fieldErrors.agree_terms_of_service}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "15px" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="agreePromotions"
|
||||
checked={formData.agreePromotions}
|
||||
onChange={handleInputChange}
|
||||
style={{ marginRight: "8px" }}
|
||||
/>
|
||||
<span>I agree to receive promotional emails</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "15px" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="agreeToTracking"
|
||||
checked={formData.agreeToTracking}
|
||||
onChange={handleInputChange}
|
||||
style={{ marginRight: "8px" }}
|
||||
/>
|
||||
<span>
|
||||
I agree to tracking across third-party apps and services
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <p style={{ color: "red", marginBottom: "15px" }}>{error}</p>}
|
||||
{message && (
|
||||
<p style={{ color: "green", marginBottom: "15px" }}>{message}</p>
|
||||
)}
|
||||
|
||||
<div className="nav-buttons">
|
||||
<button type="submit" className="nav-button" disabled={loading}>
|
||||
{loading ? "Registering..." : "Register"}
|
||||
</button>
|
||||
<Link to="/login" className="nav-button secondary">
|
||||
Already have an account?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: "20px",
|
||||
padding: "10px",
|
||||
background: "#f0f0f0",
|
||||
borderRadius: "5px",
|
||||
}}
|
||||
>
|
||||
<strong>Security:</strong> Your password is used to generate
|
||||
encryption keys locally and is never sent to the server. All keys are
|
||||
encrypted using industry-standard PBKDF2-SHA256 and XSalsa20-Poly1305.
|
||||
</div>
|
||||
</form>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default Register;
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/Anonymous/Register/VerifyEmail.jsx
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link, useSearchParams, useNavigate } from "react-router-dom";
|
||||
import Page from "../../../components/Page";
|
||||
import { VerifyEmail as VerifyEmailAPI } from "../../../../wailsjs/go/app/Application";
|
||||
|
||||
function VerifyEmail() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState("");
|
||||
const [verificationCode, setVerificationCode] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
// Get email from URL query parameter
|
||||
const emailParam = searchParams.get("email");
|
||||
if (emailParam) {
|
||||
setEmail(emailParam);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleVerify = async (e) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setMessage("");
|
||||
setLoading(true);
|
||||
|
||||
if (!email) {
|
||||
setError("Email is missing. Please return to registration.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!verificationCode) {
|
||||
setError("Please enter the verification code.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await VerifyEmailAPI(email, verificationCode);
|
||||
setMessage("Email verified successfully! Redirecting...");
|
||||
|
||||
// Redirect to success page after 1 second
|
||||
setTimeout(() => {
|
||||
navigate("/register/verify-success");
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
const errorMessage = err.message || err.toString();
|
||||
|
||||
// Try to parse RFC 9457 format
|
||||
try {
|
||||
const jsonMatch = errorMessage.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
const problemDetails = JSON.parse(jsonMatch[0]);
|
||||
setError(
|
||||
problemDetails.detail || problemDetails.title || errorMessage,
|
||||
);
|
||||
} else {
|
||||
setError("Verification failed: " + errorMessage);
|
||||
}
|
||||
} catch (parseErr) {
|
||||
setError("Verification failed: " + errorMessage);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Page title="Verify Email" showBackButton={true}>
|
||||
<div style={{ maxWidth: "400px" }}>
|
||||
<p style={{ marginBottom: "20px", color: "#333" }}>
|
||||
Please check your email <strong>{email}</strong> and enter the
|
||||
verification code sent to you.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleVerify}>
|
||||
<div style={{ marginBottom: "15px" }}>
|
||||
<label
|
||||
htmlFor="email"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "5px",
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px",
|
||||
backgroundColor: "#f5f5f5",
|
||||
}}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "15px" }}>
|
||||
<label
|
||||
htmlFor="code"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "5px",
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
Verification Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="code"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value)}
|
||||
placeholder="Enter 8-digit code"
|
||||
style={{ width: "100%", padding: "8px" }}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p style={{ color: "red", marginBottom: "15px" }}>{error}</p>
|
||||
)}
|
||||
{message && (
|
||||
<p style={{ color: "green", marginBottom: "15px" }}>{message}</p>
|
||||
)}
|
||||
|
||||
<div className="nav-buttons">
|
||||
<button type="submit" className="nav-button" disabled={loading}>
|
||||
{loading ? "Verifying..." : "Verify Email"}
|
||||
</button>
|
||||
<Link to="/login" className="nav-button secondary">
|
||||
Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: "20px",
|
||||
padding: "10px",
|
||||
background: "#f0f0f0",
|
||||
borderRadius: "5px",
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: 0, fontSize: "14px", color: "#666" }}>
|
||||
<strong>Didn't receive the code?</strong> Check your spam folder or
|
||||
contact support.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default VerifyEmail;
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/Anonymous/Register/VerifySuccess.jsx
|
||||
import { useEffect } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import Page from "../../../components/Page";
|
||||
|
||||
function VerifySuccess() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-redirect to login after 5 seconds
|
||||
const timer = setTimeout(() => {
|
||||
navigate("/login");
|
||||
}, 5000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<Page title="Registration Successful">
|
||||
<div style={{ maxWidth: "500px", textAlign: "center", margin: "0 auto" }}>
|
||||
<div style={{ fontSize: "48px", marginBottom: "20px" }}>✅</div>
|
||||
|
||||
<h2 style={{ color: "#333", marginBottom: "15px" }}>
|
||||
Email Verified Successfully!
|
||||
</h2>
|
||||
|
||||
<p style={{ color: "#666", marginBottom: "10px", fontSize: "16px" }}>
|
||||
Your account has been successfully created and verified.
|
||||
</p>
|
||||
|
||||
<p style={{ color: "#666", marginBottom: "30px", fontSize: "16px" }}>
|
||||
You can now log in to access your MapleFile account.
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: "#f0f0f0",
|
||||
padding: "15px",
|
||||
borderRadius: "5px",
|
||||
marginBottom: "30px",
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: 0, fontSize: "14px", color: "#666" }}>
|
||||
Redirecting to login page in 5 seconds...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="nav-buttons">
|
||||
<Link to="/login" className="nav-button success">
|
||||
Login Now
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default VerifySuccess;
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
.layout {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 250px;
|
||||
flex: 1;
|
||||
min-height: 100vh;
|
||||
background-color: #ecf0f1;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #7f8c8d;
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: #2c3e50;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
|
@ -0,0 +1,495 @@
|
|||
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/Dashboard/Dashboard.jsx
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import Navigation from "../../../components/Navigation";
|
||||
import Page from "../../../components/Page";
|
||||
import { GetDashboardData } from "../../../../wailsjs/go/app/Application";
|
||||
import "./Dashboard.css";
|
||||
|
||||
function Dashboard() {
|
||||
const [dashboardData, setDashboardData] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for Wails runtime to be ready before loading data
|
||||
let attempts = 0;
|
||||
const maxAttempts = 50; // 5 seconds max (50 * 100ms)
|
||||
let isCancelled = false; // Prevent race conditions
|
||||
|
||||
const checkWailsReady = () => {
|
||||
if (isCancelled) return;
|
||||
|
||||
if (window.go && window.go.app && window.go.app.Application) {
|
||||
loadDashboardData();
|
||||
} else if (attempts < maxAttempts) {
|
||||
attempts++;
|
||||
setTimeout(checkWailsReady, 100);
|
||||
} else {
|
||||
// Timeout - show error
|
||||
if (!isCancelled) {
|
||||
setIsLoading(false);
|
||||
setError("Failed to initialize Wails runtime. Please restart the application.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkWailsReady();
|
||||
|
||||
// Cleanup function to prevent race conditions with StrictMode
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadDashboardData = async (retryCount = 0) => {
|
||||
const maxRetries = 2;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Check if Wails runtime is available
|
||||
if (!window.go || !window.go.app || !window.go.app.Application) {
|
||||
throw new Error("Wails runtime not initialized. Please refresh the page.");
|
||||
}
|
||||
|
||||
console.log("Fetching dashboard data...", retryCount > 0 ? `(retry ${retryCount})` : "");
|
||||
|
||||
// First check if we're actually logged in
|
||||
const { IsLoggedIn } = await import("../../../../wailsjs/go/app/Application");
|
||||
const loggedIn = await IsLoggedIn();
|
||||
console.log("IsLoggedIn:", loggedIn);
|
||||
|
||||
if (!loggedIn) {
|
||||
throw new Error("You are not logged in. Please log in first.");
|
||||
}
|
||||
|
||||
const data = await GetDashboardData();
|
||||
console.log("Dashboard data received:", data);
|
||||
setDashboardData(data);
|
||||
setError(null); // Clear any previous errors
|
||||
} catch (err) {
|
||||
console.error("Failed to load dashboard - Full error:", err);
|
||||
console.error("Error name:", err.name);
|
||||
console.error("Error message:", err.message);
|
||||
|
||||
// Retry logic for token refresh scenarios
|
||||
const isTokenError = err.message && (
|
||||
err.message.includes("Invalid or expired token") ||
|
||||
err.message.includes("token") ||
|
||||
err.message.includes("Unauthorized")
|
||||
);
|
||||
|
||||
if (isTokenError && retryCount < maxRetries) {
|
||||
console.log(`Token error detected, retrying in 500ms... (attempt ${retryCount + 1}/${maxRetries})`);
|
||||
// Wait a bit for token refresh to complete, then retry
|
||||
setTimeout(() => loadDashboardData(retryCount + 1), 500);
|
||||
return; // Don't set error yet, we're retrying
|
||||
}
|
||||
|
||||
// Show more detailed error message
|
||||
let errorMsg = err.message || "Failed to load dashboard data";
|
||||
if (err.toString) {
|
||||
errorMsg = err.toString();
|
||||
}
|
||||
setError(errorMsg);
|
||||
setIsLoading(false);
|
||||
} finally {
|
||||
// Only set loading to false if we're not retrying
|
||||
if (retryCount >= maxRetries || !error) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadDashboardData();
|
||||
};
|
||||
|
||||
const getTimeAgo = (dateString) => {
|
||||
if (!dateString) return "Recently";
|
||||
const now = new Date();
|
||||
const date = new Date(dateString);
|
||||
const diffInMinutes = Math.floor((now - date) / (1000 * 60));
|
||||
|
||||
if (diffInMinutes < 1) return "Just now";
|
||||
if (diffInMinutes < 60) return `${diffInMinutes} min ago`;
|
||||
if (diffInMinutes < 1440) {
|
||||
const hours = Math.floor(diffInMinutes / 60);
|
||||
return `${hours} hour${hours > 1 ? "s" : ""} ago`;
|
||||
}
|
||||
if (diffInMinutes < 2880) return "Yesterday";
|
||||
const days = Math.floor(diffInMinutes / 1440);
|
||||
return `${days} day${days > 1 ? "s" : ""} ago`;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="layout">
|
||||
<Navigation />
|
||||
<div className="main-content">
|
||||
<Page title="Dashboard">
|
||||
<div style={{ textAlign: "center", padding: "40px" }}>
|
||||
<p>Loading dashboard...</p>
|
||||
</div>
|
||||
</Page>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const isTokenError = error.includes("token") || error.includes("Unauthorized");
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<Navigation />
|
||||
<div className="main-content">
|
||||
<Page title="Dashboard">
|
||||
<div style={{ padding: "20px", color: "#e74c3c" }}>
|
||||
<h3>Error Loading Dashboard</h3>
|
||||
<p>{error}</p>
|
||||
{isTokenError && (
|
||||
<p style={{ marginTop: "10px", fontSize: "14px", color: "#7f8c8d" }}>
|
||||
Your session may have expired. Please try logging out and logging back in.
|
||||
</p>
|
||||
)}
|
||||
<div style={{ marginTop: "20px", display: "flex", gap: "10px" }}>
|
||||
<button onClick={handleRefresh} className="nav-button">
|
||||
Try Again
|
||||
</button>
|
||||
{isTokenError && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
const { Logout } = await import("../../../../wailsjs/go/app/Application");
|
||||
await Logout();
|
||||
window.location.href = "/";
|
||||
}}
|
||||
className="nav-button danger"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dashboardData) {
|
||||
return (
|
||||
<div className="layout">
|
||||
<Navigation />
|
||||
<div className="main-content">
|
||||
<Page title="Dashboard">
|
||||
<p>No dashboard data available.</p>
|
||||
</Page>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { summary, storage_usage_trend, recent_files } = dashboardData;
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<Navigation />
|
||||
<div className="main-content">
|
||||
<Page title="Dashboard">
|
||||
{/* Header with refresh button */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "20px",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2 style={{ margin: 0 }}>Welcome Back!</h2>
|
||||
<p style={{ color: "#7f8c8d", margin: "5px 0 0 0" }}>
|
||||
Here's what's happening with your files today
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={handleRefresh} className="nav-button secondary">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Summary Statistics */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))",
|
||||
gap: "20px",
|
||||
marginBottom: "30px",
|
||||
}}
|
||||
>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Total Files</div>
|
||||
<div className="stat-value">{summary.total_files}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Total Folders</div>
|
||||
<div className="stat-value">{summary.total_folders}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Storage Used</div>
|
||||
<div className="stat-value">{summary.storage_used}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Storage Limit</div>
|
||||
<div className="stat-value">{summary.storage_limit}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Storage Usage Percentage */}
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "30px",
|
||||
padding: "20px",
|
||||
background: "white",
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginTop: 0 }}>Storage Usage</h3>
|
||||
<div
|
||||
style={{
|
||||
background: "#ecf0f1",
|
||||
borderRadius: "10px",
|
||||
height: "30px",
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background:
|
||||
summary.storage_usage_percentage > 80
|
||||
? "#e74c3c"
|
||||
: summary.storage_usage_percentage > 50
|
||||
? "#f39c12"
|
||||
: "#27ae60",
|
||||
height: "100%",
|
||||
width: `${Math.min(summary.storage_usage_percentage, 100)}%`,
|
||||
transition: "width 0.3s ease",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "white",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
{summary.storage_usage_percentage}%
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
marginTop: "10px",
|
||||
fontSize: "14px",
|
||||
color: "#7f8c8d",
|
||||
}}
|
||||
>
|
||||
<span>{summary.storage_used} used</span>
|
||||
<span>{summary.storage_limit} total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Storage Usage Trend */}
|
||||
{storage_usage_trend &&
|
||||
storage_usage_trend.data_points &&
|
||||
storage_usage_trend.data_points.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "30px",
|
||||
padding: "20px",
|
||||
background: "white",
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginTop: 0 }}>
|
||||
Storage Trend ({storage_usage_trend.period})
|
||||
</h3>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${storage_usage_trend.data_points.length}, 1fr)`,
|
||||
gap: "10px",
|
||||
marginTop: "20px",
|
||||
}}
|
||||
>
|
||||
{storage_usage_trend.data_points.map((point, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{ textAlign: "center", fontSize: "12px" }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: "#7f8c8d",
|
||||
marginBottom: "5px",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
{point.usage}
|
||||
</div>
|
||||
<div style={{ color: "#95a5a6" }}>
|
||||
{new Date(point.date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Files */}
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "30px",
|
||||
padding: "20px",
|
||||
background: "white",
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "15px",
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: 0 }}>Recent Files</h3>
|
||||
<Link to="/file-manager" style={{ fontSize: "14px" }}>
|
||||
View All →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{recent_files && recent_files.length > 0 ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
|
||||
{recent_files.slice(0, 5).map((file) => {
|
||||
// Determine sync status badge
|
||||
const getSyncStatusBadge = (syncStatus) => {
|
||||
switch (syncStatus) {
|
||||
case "synced":
|
||||
return { label: "Synced", color: "#27ae60", bgColor: "#d4edda" };
|
||||
case "local_only":
|
||||
return { label: "Local", color: "#3498db", bgColor: "#d1ecf1" };
|
||||
case "cloud_only":
|
||||
return { label: "Cloud", color: "#9b59b6", bgColor: "#e2d9f3" };
|
||||
case "modified_locally":
|
||||
return { label: "Modified", color: "#f39c12", bgColor: "#fff3cd" };
|
||||
default:
|
||||
return { label: "Unknown", color: "#7f8c8d", bgColor: "#e9ecef" };
|
||||
}
|
||||
};
|
||||
|
||||
const syncBadge = getSyncStatusBadge(file.sync_status);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "12px",
|
||||
background: "#f8f9fa",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #e9ecef",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: "500",
|
||||
color: file.is_decrypted ? "#2c3e50" : "#95a5a6",
|
||||
}}
|
||||
>
|
||||
{file.is_decrypted ? file.name : "🔒 " + file.name}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "10px",
|
||||
padding: "2px 6px",
|
||||
borderRadius: "4px",
|
||||
backgroundColor: syncBadge.bgColor,
|
||||
color: syncBadge.color,
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
{syncBadge.label}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{ fontSize: "12px", color: "#7f8c8d" }}
|
||||
>
|
||||
{file.size} • {getTimeAgo(file.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
to={`/file-manager/files/${file.id}`}
|
||||
className="nav-button secondary"
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
fontSize: "12px",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ textAlign: "center", padding: "20px", color: "#7f8c8d" }}>
|
||||
<p>No recent files found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<h3>Quick Actions</h3>
|
||||
<div className="nav-buttons">
|
||||
<Link to="/file-manager" className="nav-button">
|
||||
Manage Files
|
||||
</Link>
|
||||
<Link to="/file-manager/upload" className="nav-button success">
|
||||
Upload File
|
||||
</Link>
|
||||
<Link
|
||||
to="/file-manager/collections/create"
|
||||
className="nav-button success"
|
||||
>
|
||||
Create Collection
|
||||
</Link>
|
||||
<Link to="/me" className="nav-button secondary">
|
||||
View Profile
|
||||
</Link>
|
||||
</div>
|
||||
</Page>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dashboard;
|
||||
|
|
@ -0,0 +1,474 @@
|
|||
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/FileManager/Collections/CollectionCreate.jsx
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import Navigation from "../../../../components/Navigation";
|
||||
import Page from "../../../../components/Page";
|
||||
import IconPicker from "../../../../components/IconPicker";
|
||||
import { CreateCollection, ListTags } from "../../../../../wailsjs/go/app/Application";
|
||||
import "../../Dashboard/Dashboard.css";
|
||||
|
||||
function CollectionCreate() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// Get parent collection info from navigation state
|
||||
const parentCollectionId = location.state?.parentCollectionId;
|
||||
const parentCollectionName = location.state?.parentCollectionName;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [collectionName, setCollectionName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [customIcon, setCustomIcon] = useState("");
|
||||
const [collectionType, setCollectionType] = useState("folder");
|
||||
const [isIconPickerOpen, setIsIconPickerOpen] = useState(false);
|
||||
const [availableTags, setAvailableTags] = useState([]);
|
||||
const [selectedTagIds, setSelectedTagIds] = useState([]);
|
||||
const [isLoadingTags, setIsLoadingTags] = useState(false);
|
||||
|
||||
// Load available tags
|
||||
useEffect(() => {
|
||||
const loadTags = async () => {
|
||||
setIsLoadingTags(true);
|
||||
try {
|
||||
const tags = await ListTags();
|
||||
setAvailableTags(tags || []);
|
||||
} catch (err) {
|
||||
console.error("Failed to load tags:", err);
|
||||
setAvailableTags([]);
|
||||
} finally {
|
||||
setIsLoadingTags(false);
|
||||
}
|
||||
};
|
||||
loadTags();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
// Client-side validation
|
||||
if (!collectionName || !collectionName.trim()) {
|
||||
setError("Collection name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (collectionName.trim().length < 2) {
|
||||
setError("Collection name must be at least 2 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
if (collectionName.trim().length > 100) {
|
||||
setError("Collection name must be less than 100 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const input = {
|
||||
name: collectionName.trim(),
|
||||
description: description.trim(),
|
||||
parent_id: parentCollectionId || "",
|
||||
custom_icon: customIcon,
|
||||
collection_type: collectionType,
|
||||
tag_ids: selectedTagIds.length > 0 ? selectedTagIds : undefined,
|
||||
};
|
||||
|
||||
const result = await CreateCollection(input);
|
||||
|
||||
if (result.success) {
|
||||
// Navigate back to file manager or parent collection
|
||||
if (parentCollectionId) {
|
||||
navigate(`/file-manager/collections/${parentCollectionId}`, {
|
||||
state: { refresh: true },
|
||||
});
|
||||
} else {
|
||||
navigate("/file-manager", {
|
||||
state: { refresh: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to create collection:", err);
|
||||
setError(err.message || "Failed to create collection. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getBackUrl = () => {
|
||||
if (parentCollectionId) {
|
||||
return `/file-manager/collections/${parentCollectionId}`;
|
||||
}
|
||||
return "/file-manager";
|
||||
};
|
||||
|
||||
const getBackText = () => {
|
||||
if (parentCollectionName) {
|
||||
return `Back to ${parentCollectionName}`;
|
||||
}
|
||||
return "Back to File Manager";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<Navigation />
|
||||
<div className="main-content">
|
||||
<Page title="Create New Collection">
|
||||
<button
|
||||
onClick={() => navigate(getBackUrl())}
|
||||
className="nav-button secondary"
|
||||
style={{ marginBottom: "20px" }}
|
||||
>
|
||||
← {getBackText()}
|
||||
</button>
|
||||
|
||||
{parentCollectionName && (
|
||||
<p style={{ marginBottom: "20px", color: "#666" }}>
|
||||
Creating collection inside: <strong>{parentCollectionName}</strong>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: "15px",
|
||||
background: "#ffebee",
|
||||
color: "#c62828",
|
||||
borderRadius: "4px",
|
||||
marginBottom: "20px",
|
||||
border: "1px solid #f44336",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{
|
||||
maxWidth: "600px",
|
||||
background: "white",
|
||||
padding: "30px",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #ddd",
|
||||
}}
|
||||
>
|
||||
{/* Collection Name */}
|
||||
<div style={{ marginBottom: "20px" }}>
|
||||
<label
|
||||
htmlFor="name"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "8px",
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
Collection Name <span style={{ color: "#f44336" }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
value={collectionName}
|
||||
onChange={(e) => {
|
||||
setCollectionName(e.target.value);
|
||||
if (error) setError("");
|
||||
}}
|
||||
placeholder="Enter collection name"
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 12px",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<p style={{ fontSize: "12px", color: "#666", marginTop: "5px" }}>
|
||||
A descriptive name for your collection
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Custom Icon */}
|
||||
<div style={{ marginBottom: "20px" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "8px",
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
Icon{" "}
|
||||
<span style={{ fontSize: "12px", color: "#666", fontWeight: "normal" }}>
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsIconPickerOpen(true)}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
padding: "10px 14px",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
background: "white",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
color: "#333",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.borderColor = "#999";
|
||||
e.currentTarget.style.background = "#f9f9f9";
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
e.currentTarget.style.borderColor = "#ddd";
|
||||
e.currentTarget.style.background = "white";
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "24px" }}>
|
||||
{customIcon || "📁"}
|
||||
</span>
|
||||
<span>{customIcon ? "Change Icon" : "Choose Icon"}</span>
|
||||
</button>
|
||||
<p style={{ fontSize: "12px", color: "#666", marginTop: "5px" }}>
|
||||
Select an emoji to customize your collection
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Collection Type */}
|
||||
<div style={{ marginBottom: "20px" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "8px",
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
Type
|
||||
</label>
|
||||
<div style={{ display: "flex", gap: "10px" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollectionType("folder")}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "8px",
|
||||
padding: "12px",
|
||||
border: collectionType === "folder" ? "2px solid #991b1b" : "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
background: collectionType === "folder" ? "#fef2f2" : "white",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
color: "#333",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "20px" }}>📁</span>
|
||||
<span>Folder</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollectionType("album")}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "8px",
|
||||
padding: "12px",
|
||||
border: collectionType === "album" ? "2px solid #991b1b" : "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
background: collectionType === "album" ? "#fef2f2" : "white",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
color: "#333",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "20px" }}>🖼️</span>
|
||||
<span>Album</span>
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ fontSize: "12px", color: "#666", marginTop: "5px" }}>
|
||||
Folders are for general files, albums are optimized for photos and media
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tag Selection */}
|
||||
<div style={{ marginBottom: "20px" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "8px",
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
Tags{" "}
|
||||
<span style={{ fontSize: "12px", color: "#666", fontWeight: "normal" }}>
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
{isLoadingTags ? (
|
||||
<div style={{ padding: "10px", color: "#666", fontSize: "14px" }}>
|
||||
Loading tags...
|
||||
</div>
|
||||
) : availableTags.length > 0 ? (
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
padding: "10px",
|
||||
maxHeight: "150px",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{availableTags.map((tag) => (
|
||||
<label
|
||||
key={tag.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "6px",
|
||||
cursor: "pointer",
|
||||
borderRadius: "4px",
|
||||
transition: "background-color 0.15s ease",
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#f9f9f9";
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "transparent";
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedTagIds.includes(tag.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedTagIds([...selectedTagIds, tag.id]);
|
||||
} else {
|
||||
setSelectedTagIds(selectedTagIds.filter((id) => id !== tag.id));
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
style={{ marginRight: "8px", cursor: "pointer" }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: tag.color || "#666",
|
||||
marginRight: "8px",
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: "14px", color: "#333" }}>{tag.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px",
|
||||
color: "#666",
|
||||
fontSize: "14px",
|
||||
fontStyle: "italic",
|
||||
}}
|
||||
>
|
||||
No tags available. Create tags first to organize your collections.
|
||||
</div>
|
||||
)}
|
||||
<p style={{ fontSize: "12px", color: "#666", marginTop: "5px" }}>
|
||||
Select tags to organize this collection
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description (Optional) */}
|
||||
<div style={{ marginBottom: "30px" }}>
|
||||
<label
|
||||
htmlFor="description"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "8px",
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
Description{" "}
|
||||
<span style={{ fontSize: "12px", color: "#666", fontWeight: "normal" }}>
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Enter a description (optional)"
|
||||
disabled={isLoading}
|
||||
rows="4"
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 12px",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
fontSize: "14px",
|
||||
fontFamily: "inherit",
|
||||
resize: "vertical",
|
||||
}}
|
||||
/>
|
||||
<p style={{ fontSize: "12px", color: "#666", marginTop: "5px" }}>
|
||||
Additional details about this collection
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Submit Buttons */}
|
||||
<div className="nav-buttons" style={{ marginTop: "30px" }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !collectionName.trim()}
|
||||
className="nav-button success"
|
||||
>
|
||||
{isLoading ? "Creating..." : "Create Collection"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(getBackUrl())}
|
||||
disabled={isLoading}
|
||||
className="nav-button secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Page>
|
||||
</div>
|
||||
|
||||
{/* Icon Picker Modal */}
|
||||
<IconPicker
|
||||
value={customIcon}
|
||||
onChange={setCustomIcon}
|
||||
onClose={() => setIsIconPickerOpen(false)}
|
||||
isOpen={isIconPickerOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CollectionCreate;
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,452 @@
|
|||
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/FileManager/Collections/CollectionEdit.jsx
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import Navigation from "../../../../components/Navigation";
|
||||
import Page from "../../../../components/Page";
|
||||
import IconPicker from "../../../../components/IconPicker";
|
||||
import {
|
||||
GetCollection,
|
||||
UpdateCollection,
|
||||
DeleteCollection,
|
||||
} from "../../../../../wailsjs/go/app/Application";
|
||||
import "../../Dashboard/Dashboard.css";
|
||||
|
||||
function CollectionEdit() {
|
||||
const navigate = useNavigate();
|
||||
const { collectionId } = useParams();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const [collection, setCollection] = useState(null);
|
||||
const [name, setName] = useState("");
|
||||
const [customIcon, setCustomIcon] = useState("");
|
||||
const [collectionType, setCollectionType] = useState("folder");
|
||||
const [isIconPickerOpen, setIsIconPickerOpen] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
// Load collection details
|
||||
const loadCollection = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
console.log("Loading collection details...", collectionId);
|
||||
const collectionData = await GetCollection(collectionId);
|
||||
console.log("Collection loaded:", collectionData);
|
||||
setCollection(collectionData);
|
||||
setName(collectionData.name || "");
|
||||
setCustomIcon(collectionData.custom_icon || "");
|
||||
setCollectionType(collectionData.collection_type || "folder");
|
||||
} catch (err) {
|
||||
console.error("Failed to load collection:", err);
|
||||
setError(err.message || "Failed to load collection");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [collectionId]);
|
||||
|
||||
// Initial load - wait for Wails to be ready
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && window.go && window.go.app && window.go.app.Application) {
|
||||
loadCollection();
|
||||
} else {
|
||||
const checkWails = setInterval(() => {
|
||||
if (window.go && window.go.app && window.go.app.Application) {
|
||||
clearInterval(checkWails);
|
||||
loadCollection();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => clearInterval(checkWails), 10000);
|
||||
return () => clearInterval(checkWails);
|
||||
}
|
||||
}, [loadCollection]);
|
||||
|
||||
// Handle save
|
||||
const handleSave = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!name.trim()) {
|
||||
setError("Collection name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
|
||||
try {
|
||||
console.log("Updating collection...", collectionId, name, customIcon, collectionType);
|
||||
await UpdateCollection(collectionId, {
|
||||
name: name.trim(),
|
||||
custom_icon: customIcon,
|
||||
collection_type: collectionType,
|
||||
});
|
||||
setSuccess("Collection updated successfully!");
|
||||
|
||||
// Navigate back to collection details after a short delay
|
||||
setTimeout(() => {
|
||||
navigate(`/file-manager/collections/${collectionId}`, {
|
||||
state: { refresh: true }
|
||||
});
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
console.error("Failed to update collection:", err);
|
||||
setError(err.message || "Failed to update collection");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle delete
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
console.log("Deleting collection...", collectionId);
|
||||
await DeleteCollection(collectionId);
|
||||
|
||||
// Navigate to file manager after deletion
|
||||
navigate("/file-manager", {
|
||||
state: { refresh: true }
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to delete collection:", err);
|
||||
setError(err.message || "Failed to delete collection");
|
||||
setIsDeleting(false);
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle cancel
|
||||
const handleCancel = () => {
|
||||
navigate(`/file-manager/collections/${collectionId}`);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="layout">
|
||||
<Navigation />
|
||||
<div className="main-content">
|
||||
<Page title="Loading...">
|
||||
<div style={{ textAlign: "center", padding: "40px" }}>
|
||||
<div>Loading collection...</div>
|
||||
</div>
|
||||
</Page>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<Navigation />
|
||||
<div className="main-content">
|
||||
<Page title={`Edit Collection`}>
|
||||
{/* Back Button */}
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="nav-button secondary"
|
||||
style={{ marginBottom: "20px" }}
|
||||
>
|
||||
← Back to Collection
|
||||
</button>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: "15px",
|
||||
background: "#ffebee",
|
||||
color: "#c62828",
|
||||
borderRadius: "4px",
|
||||
marginBottom: "20px",
|
||||
border: "1px solid #f44336",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Display */}
|
||||
{success && (
|
||||
<div
|
||||
style={{
|
||||
padding: "15px",
|
||||
background: "#e8f5e9",
|
||||
color: "#2e7d32",
|
||||
borderRadius: "4px",
|
||||
marginBottom: "20px",
|
||||
border: "1px solid #4caf50",
|
||||
}}
|
||||
>
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Form */}
|
||||
<div
|
||||
style={{
|
||||
background: "white",
|
||||
padding: "30px",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #ddd",
|
||||
marginBottom: "30px",
|
||||
}}
|
||||
>
|
||||
<form onSubmit={handleSave}>
|
||||
<div style={{ marginBottom: "20px" }}>
|
||||
<label
|
||||
htmlFor="name"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "8px",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
Collection Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter collection name"
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "12px",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
fontSize: "16px",
|
||||
}}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Custom Icon */}
|
||||
<div style={{ marginBottom: "20px" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "8px",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
Icon{" "}
|
||||
<span style={{ fontSize: "12px", color: "#666", fontWeight: "normal" }}>
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsIconPickerOpen(true)}
|
||||
disabled={isSaving}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
padding: "10px 14px",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
background: "white",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
color: "#333",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.borderColor = "#999";
|
||||
e.currentTarget.style.background = "#f9f9f9";
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
e.currentTarget.style.borderColor = "#ddd";
|
||||
e.currentTarget.style.background = "white";
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "24px" }}>
|
||||
{customIcon || "📁"}
|
||||
</span>
|
||||
<span>{customIcon ? "Change Icon" : "Choose Icon"}</span>
|
||||
</button>
|
||||
<p style={{ fontSize: "12px", color: "#666", marginTop: "5px" }}>
|
||||
Select an emoji to customize your collection
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Collection Type */}
|
||||
<div style={{ marginBottom: "20px" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "8px",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
Type
|
||||
</label>
|
||||
<div style={{ display: "flex", gap: "10px" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollectionType("folder")}
|
||||
disabled={isSaving}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "8px",
|
||||
padding: "12px",
|
||||
border: collectionType === "folder" ? "2px solid #991b1b" : "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
background: collectionType === "folder" ? "#fef2f2" : "white",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
color: "#333",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "20px" }}>📁</span>
|
||||
<span>Folder</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollectionType("album")}
|
||||
disabled={isSaving}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "8px",
|
||||
padding: "12px",
|
||||
border: collectionType === "album" ? "2px solid #991b1b" : "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
background: collectionType === "album" ? "#fef2f2" : "white",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
color: "#333",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "20px" }}>🖼️</span>
|
||||
<span>Album</span>
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ fontSize: "12px", color: "#666", marginTop: "5px" }}>
|
||||
Folders are for general files, albums are optimized for photos and media
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{collection && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "20px",
|
||||
padding: "15px",
|
||||
background: "#f5f5f5",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "14px", color: "#666" }}>
|
||||
<div><strong>Collection ID:</strong> {collection.id}</div>
|
||||
<div><strong>Files:</strong> {collection.file_count || 0}</div>
|
||||
{collection.parent_id && (
|
||||
<div><strong>Parent Collection:</strong> {collection.parent_id}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="nav-buttons">
|
||||
<button
|
||||
type="submit"
|
||||
className="nav-button success"
|
||||
disabled={isSaving || !name.trim()}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="nav-button secondary"
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div
|
||||
style={{
|
||||
background: "#fff5f5",
|
||||
padding: "20px",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #f44336",
|
||||
}}
|
||||
>
|
||||
<h3 style={{ color: "#c62828", marginBottom: "15px" }}>
|
||||
Danger Zone
|
||||
</h3>
|
||||
<p style={{ marginBottom: "15px", color: "#666" }}>
|
||||
Deleting a collection will move it to trash. This action can be
|
||||
reversed from the trash within 30 days.
|
||||
</p>
|
||||
|
||||
{!showDeleteConfirm ? (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="nav-button danger"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Delete Collection
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
background: "#ffebee",
|
||||
padding: "15px",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
<p style={{ marginBottom: "15px", fontWeight: "bold" }}>
|
||||
Are you sure you want to delete "{collection?.name}"?
|
||||
</p>
|
||||
<div className="nav-buttons">
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="nav-button danger"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Yes, Delete"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="nav-button secondary"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Page>
|
||||
</div>
|
||||
|
||||
{/* Icon Picker Modal */}
|
||||
<IconPicker
|
||||
value={customIcon}
|
||||
onChange={setCustomIcon}
|
||||
onClose={() => setIsIconPickerOpen(false)}
|
||||
isOpen={isIconPickerOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CollectionEdit;
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/FileManager/Collections/CollectionShare.jsx
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import Navigation from "../../../../components/Navigation";
|
||||
import Page from "../../../../components/Page";
|
||||
import "../../Dashboard/Dashboard.css";
|
||||
|
||||
function CollectionShare() {
|
||||
const { collectionId } = useParams();
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<Navigation />
|
||||
<div className="main-content">
|
||||
<Page
|
||||
title={`Share Collection (${collectionId})`}
|
||||
showBackButton={true}
|
||||
>
|
||||
<p>Share this collection with other users.</p>
|
||||
<h3>Sharing Options</h3>
|
||||
<div className="nav-buttons">
|
||||
<Link
|
||||
to={`/file-manager/collections/${collectionId}`}
|
||||
className="nav-button success"
|
||||
>
|
||||
Share (Read Only)
|
||||
</Link>
|
||||
<Link
|
||||
to={`/file-manager/collections/${collectionId}`}
|
||||
className="nav-button success"
|
||||
>
|
||||
Share (Read/Write)
|
||||
</Link>
|
||||
<Link
|
||||
to={`/file-manager/collections/${collectionId}`}
|
||||
className="nav-button success"
|
||||
>
|
||||
Share (Admin)
|
||||
</Link>
|
||||
<Link
|
||||
to={`/file-manager/collections/${collectionId}`}
|
||||
className="nav-button secondary"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</Page>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CollectionShare;
|
||||
|
|
@ -0,0 +1,604 @@
|
|||
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/FileManager/FileManagerIndex.jsx
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import Navigation from "../../../components/Navigation";
|
||||
import Page from "../../../components/Page";
|
||||
import {
|
||||
ListCollections,
|
||||
GetSyncStatus,
|
||||
TriggerSync,
|
||||
ListTags,
|
||||
} from "../../../../wailsjs/go/app/Application";
|
||||
import "../Dashboard/Dashboard.css";
|
||||
|
||||
function FileManagerIndex() {
|
||||
const navigate = useNavigate();
|
||||
const [collections, setCollections] = useState([]);
|
||||
const [syncStatus, setSyncStatus] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [filterType, setFilterType] = useState("root"); // root, all, owned, shared
|
||||
const [availableTags, setAvailableTags] = useState([]);
|
||||
const [selectedTagIds, setSelectedTagIds] = useState([]);
|
||||
const [showTagFilter, setShowTagFilter] = useState(false);
|
||||
|
||||
// Load collections from local storage (synced from backend)
|
||||
const loadCollections = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
console.log("🔄 Loading collections...");
|
||||
const collectionsData = await ListCollections();
|
||||
console.log("✅ Collections loaded:", collectionsData);
|
||||
console.log("📊 Collections count:", collectionsData ? collectionsData.length : 0);
|
||||
if (collectionsData && collectionsData.length > 0) {
|
||||
console.log("🔍 First collection data:", JSON.stringify(collectionsData[0], null, 2));
|
||||
}
|
||||
setCollections(collectionsData || []);
|
||||
} catch (err) {
|
||||
console.error("❌ Failed to load collections:", err);
|
||||
setError(err.message || "Failed to load collections");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load sync status
|
||||
const loadSyncStatus = useCallback(async () => {
|
||||
try {
|
||||
const status = await GetSyncStatus();
|
||||
setSyncStatus(status);
|
||||
} catch (err) {
|
||||
console.error("Failed to load sync status:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load available tags
|
||||
const loadTags = useCallback(async () => {
|
||||
try {
|
||||
const tags = await ListTags();
|
||||
setAvailableTags(tags || []);
|
||||
} catch (err) {
|
||||
console.error("Failed to load tags:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle manual sync trigger
|
||||
const handleManualSync = async () => {
|
||||
try {
|
||||
await TriggerSync();
|
||||
// Reload sync status after triggering
|
||||
setTimeout(() => {
|
||||
loadSyncStatus();
|
||||
loadCollections();
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
console.error("Failed to trigger sync:", err);
|
||||
setError(err.message || "Failed to trigger sync");
|
||||
}
|
||||
};
|
||||
|
||||
// Initial load - wait for Wails to be ready
|
||||
useEffect(() => {
|
||||
// Check if Wails bindings are available
|
||||
if (typeof window !== 'undefined' && window.go && window.go.app && window.go.app.Application) {
|
||||
loadCollections();
|
||||
loadSyncStatus();
|
||||
loadTags();
|
||||
} else {
|
||||
// Wait for Wails to be ready
|
||||
const checkWails = setInterval(() => {
|
||||
if (window.go && window.go.app && window.go.app.Application) {
|
||||
clearInterval(checkWails);
|
||||
loadCollections();
|
||||
loadSyncStatus();
|
||||
loadTags();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Cleanup timeout after 10 seconds
|
||||
setTimeout(() => clearInterval(checkWails), 10000);
|
||||
|
||||
return () => clearInterval(checkWails);
|
||||
}
|
||||
}, [loadCollections, loadSyncStatus, loadTags]);
|
||||
|
||||
// Auto-refresh sync status every 5 seconds
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (window.go && window.go.app && window.go.app.Application) {
|
||||
loadSyncStatus();
|
||||
}
|
||||
}, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadSyncStatus]);
|
||||
|
||||
// Helper to check if a collection is a root collection (no parent)
|
||||
const isRootCollection = (collection) => {
|
||||
return !collection.parent_id || collection.parent_id === "00000000-0000-0000-0000-000000000000";
|
||||
};
|
||||
|
||||
// Filter and search collections
|
||||
const filteredCollections = collections.filter((collection) => {
|
||||
// Apply filter type
|
||||
if (filterType === "owned" && collection.is_shared) return false;
|
||||
if (filterType === "shared" && !collection.is_shared) return false;
|
||||
if (filterType === "root" && !isRootCollection(collection)) return false; // Only show root collections
|
||||
|
||||
// Apply tag filter (multi-tag filtering with AND logic)
|
||||
if (selectedTagIds.length > 0) {
|
||||
const collectionTagIds = (collection.tags || []).map(tag => tag.id);
|
||||
// Check if collection has ALL selected tags
|
||||
const hasAllTags = selectedTagIds.every(tagId => collectionTagIds.includes(tagId));
|
||||
if (!hasAllTags) return false;
|
||||
}
|
||||
|
||||
// Apply search query
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return collection.name.toLowerCase().includes(query) ||
|
||||
(collection.description && collection.description.toLowerCase().includes(query));
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
console.log("🔍 Filtered collections:", filteredCollections);
|
||||
console.log("📊 Total collections:", collections.length, "Filtered:", filteredCollections.length);
|
||||
|
||||
// Get time ago helper
|
||||
const getTimeAgo = (dateString) => {
|
||||
if (!dateString) return "Recently";
|
||||
const now = new Date();
|
||||
const date = new Date(dateString);
|
||||
const diffInMinutes = Math.floor((now - date) / (1000 * 60));
|
||||
|
||||
if (diffInMinutes < 60) return "Just now";
|
||||
if (diffInMinutes < 1440) return "Today";
|
||||
if (diffInMinutes < 2880) return "Yesterday";
|
||||
const diffInDays = Math.floor(diffInMinutes / 1440);
|
||||
if (diffInDays < 7) return `${diffInDays} days ago`;
|
||||
if (diffInDays < 30) return `${Math.floor(diffInDays / 7)} weeks ago`;
|
||||
return `${Math.floor(diffInDays / 30)} months ago`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<Navigation />
|
||||
<div className="main-content">
|
||||
<Page title="File Manager">
|
||||
{/* Sync Status Section */}
|
||||
{syncStatus && (
|
||||
<div
|
||||
style={{
|
||||
padding: "15px",
|
||||
background: syncStatus.last_sync_success ? "#e8f5e9" : "#ffebee",
|
||||
borderRadius: "4px",
|
||||
marginBottom: "20px",
|
||||
border: `1px solid ${syncStatus.last_sync_success ? "#4caf50" : "#f44336"}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<div>
|
||||
<strong>Sync Status:</strong>{" "}
|
||||
{syncStatus.is_syncing ? (
|
||||
<span>⏳ Syncing...</span>
|
||||
) : syncStatus.last_sync_success ? (
|
||||
<span>✓ Up to date</span>
|
||||
) : (
|
||||
<span>✗ Error</span>
|
||||
)}
|
||||
<div style={{ fontSize: "12px", marginTop: "5px", color: "#666" }}>
|
||||
{syncStatus.last_sync_time && (
|
||||
<div>Last sync: {getTimeAgo(syncStatus.last_sync_time)}</div>
|
||||
)}
|
||||
{syncStatus.collections_synced > 0 && (
|
||||
<div>
|
||||
Collections: {syncStatus.collections_synced}, Files: {syncStatus.files_synced}
|
||||
</div>
|
||||
)}
|
||||
{syncStatus.last_sync_error && (
|
||||
<div style={{ color: "#f44336", marginTop: "5px" }}>
|
||||
Error: {syncStatus.last_sync_error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleManualSync}
|
||||
disabled={syncStatus.is_syncing}
|
||||
className="nav-button"
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
{syncStatus.is_syncing ? "Syncing..." : "Sync Now"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: "15px",
|
||||
background: "#ffebee",
|
||||
color: "#c62828",
|
||||
borderRadius: "4px",
|
||||
marginBottom: "20px",
|
||||
border: "1px solid #f44336",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search and Filter Bar */}
|
||||
<div style={{ marginBottom: "20px" }}>
|
||||
<div style={{ display: "flex", gap: "10px", marginBottom: "10px" }}>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search collections..."
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "8px 12px",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={filterType}
|
||||
onChange={(e) => setFilterType(e.target.value)}
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
background: "white",
|
||||
}}
|
||||
>
|
||||
<option value="all">All Collections</option>
|
||||
<option value="root">Root Collections</option>
|
||||
<option value="owned">My Collections</option>
|
||||
<option value="shared">Shared with Me</option>
|
||||
</select>
|
||||
{availableTags.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowTagFilter(!showTagFilter)}
|
||||
className="nav-button"
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
background: selectedTagIds.length > 0 ? "#1976d2" : "white",
|
||||
color: selectedTagIds.length > 0 ? "white" : "#333",
|
||||
}}
|
||||
>
|
||||
🏷️ Tags {selectedTagIds.length > 0 && `(${selectedTagIds.length})`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tag Filter Panel */}
|
||||
{showTagFilter && availableTags.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: "15px",
|
||||
background: "#f9f9f9",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
marginTop: "10px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "10px",
|
||||
}}
|
||||
>
|
||||
<strong>Filter by Tags</strong>
|
||||
{selectedTagIds.length > 0 && (
|
||||
<button
|
||||
onClick={() => setSelectedTagIds([])}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "#1976d2",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
{availableTags.map((tag) => {
|
||||
const isSelected = selectedTagIds.includes(tag.id);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
setSelectedTagIds(selectedTagIds.filter((id) => id !== tag.id));
|
||||
} else {
|
||||
setSelectedTagIds([...selectedTagIds, tag.id]);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "6px 12px",
|
||||
borderRadius: "16px",
|
||||
fontSize: "13px",
|
||||
backgroundColor: isSelected
|
||||
? tag.color || "#666"
|
||||
: "white",
|
||||
color: isSelected ? "white" : tag.color || "#666",
|
||||
border: `2px solid ${tag.color || "#666"}`,
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: isSelected ? "white" : tag.color || "#666",
|
||||
}}
|
||||
/>
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "#666",
|
||||
marginTop: "10px",
|
||||
}}
|
||||
>
|
||||
{selectedTagIds.length === 0
|
||||
? "Select one or more tags to filter collections"
|
||||
: `Showing collections with ${selectedTagIds.length === 1 ? "this tag" : "all selected tags"}`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div style={{ marginBottom: "30px" }}>
|
||||
<h3>Quick Actions</h3>
|
||||
<div className="nav-buttons">
|
||||
<Link
|
||||
to="/file-manager/collections/create"
|
||||
className="nav-button success"
|
||||
>
|
||||
+ Create Collection
|
||||
</Link>
|
||||
<Link to="/file-manager/upload" className="nav-button success">
|
||||
↑ Upload File
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Collections Grid */}
|
||||
<h3>
|
||||
{filterType === "all" && "All Collections"}
|
||||
{filterType === "root" && "Root Collections"}
|
||||
{filterType === "owned" && "My Collections"}
|
||||
{filterType === "shared" && "Shared with Me"}
|
||||
{searchQuery && ` (${filteredCollections.length} results)`}
|
||||
</h3>
|
||||
|
||||
{isLoading ? (
|
||||
<div style={{ textAlign: "center", padding: "40px" }}>
|
||||
<div>Loading collections...</div>
|
||||
</div>
|
||||
) : filteredCollections.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "40px",
|
||||
background: "#f5f5f5",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "48px", marginBottom: "10px" }}>📁</div>
|
||||
<h4>
|
||||
{searchQuery
|
||||
? "No collections found"
|
||||
: filterType === "shared"
|
||||
? "No shared collections"
|
||||
: "No collections yet"}
|
||||
</h4>
|
||||
<p style={{ color: "#666" }}>
|
||||
{searchQuery
|
||||
? `No collections match "${searchQuery}"`
|
||||
: filterType === "shared"
|
||||
? "When someone shares a collection with you, it will appear here"
|
||||
: "Create your first collection to start organizing your files"}
|
||||
</p>
|
||||
{!searchQuery && filterType !== "shared" && (
|
||||
<Link
|
||||
to="/file-manager/collections/create"
|
||||
className="nav-button success"
|
||||
style={{ marginTop: "20px", display: "inline-block" }}
|
||||
>
|
||||
+ Create Your First Collection
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="dashboard-grid">
|
||||
{filteredCollections.map((collection) => (
|
||||
<div
|
||||
key={collection.id}
|
||||
className="dashboard-card"
|
||||
style={{ cursor: "pointer", position: "relative" }}
|
||||
onClick={() => navigate(`/file-manager/collections/${collection.id}`)}
|
||||
>
|
||||
{/* Collection Icon */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: "48px",
|
||||
marginBottom: "10px",
|
||||
}}
|
||||
>
|
||||
{collection.custom_icon
|
||||
? collection.custom_icon
|
||||
: collection.is_shared
|
||||
? "🔗"
|
||||
: collection.collection_type === "album"
|
||||
? "🖼️"
|
||||
: "📁"}
|
||||
</div>
|
||||
|
||||
{/* Collection Name */}
|
||||
<h3 style={{ marginBottom: "5px", color: "#333" }}>
|
||||
{collection.name || "Unnamed Collection"}
|
||||
</h3>
|
||||
|
||||
{/* Collection Description */}
|
||||
{collection.description && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "#666",
|
||||
marginBottom: "10px",
|
||||
}}
|
||||
>
|
||||
{collection.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Collection Stats */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: "14px",
|
||||
color: "#666",
|
||||
marginTop: "10px",
|
||||
paddingTop: "10px",
|
||||
borderTop: "1px solid #eee",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{collection.file_count || 0}{" "}
|
||||
{collection.file_count === 1 ? "file" : "files"}
|
||||
</div>
|
||||
{/* Collection type badge */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
color: "#888",
|
||||
marginTop: "5px",
|
||||
textTransform: "capitalize",
|
||||
}}
|
||||
>
|
||||
{collection.collection_type || "folder"}
|
||||
</div>
|
||||
{collection.is_shared && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "#1976d2",
|
||||
marginTop: "5px",
|
||||
}}
|
||||
>
|
||||
Shared
|
||||
</div>
|
||||
)}
|
||||
{!isRootCollection(collection) && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
color: "#9c27b0",
|
||||
marginTop: "5px",
|
||||
}}
|
||||
>
|
||||
Sub-collection
|
||||
</div>
|
||||
)}
|
||||
{collection.modified_at && (
|
||||
<div style={{ fontSize: "12px", marginTop: "5px" }}>
|
||||
{getTimeAgo(collection.modified_at)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{collection.tags && collection.tags.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "10px",
|
||||
paddingTop: "10px",
|
||||
borderTop: "1px solid #eee",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "5px",
|
||||
}}
|
||||
>
|
||||
{collection.tags.slice(0, 3).map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
padding: "2px 8px",
|
||||
borderRadius: "12px",
|
||||
fontSize: "11px",
|
||||
backgroundColor: tag.color ? `${tag.color}20` : "#e0e0e0",
|
||||
color: tag.color || "#666",
|
||||
border: `1px solid ${tag.color || "#ccc"}`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: "6px",
|
||||
height: "6px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: tag.color || "#666",
|
||||
}}
|
||||
/>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
{collection.tags.length > 3 && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
color: "#666",
|
||||
padding: "2px 8px",
|
||||
}}
|
||||
>
|
||||
+{collection.tags.length - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Page>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileManagerIndex;
|
||||
|
|
@ -0,0 +1,762 @@
|
|||
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/FileManager/Files/FileDetails.jsx
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import Navigation from "../../../../components/Navigation";
|
||||
import Page from "../../../../components/Page";
|
||||
import {
|
||||
GetFile,
|
||||
DeleteFile,
|
||||
DownloadFile,
|
||||
OnloadFile,
|
||||
OpenFile,
|
||||
OffloadFile,
|
||||
ListTags,
|
||||
AssignTagToFile,
|
||||
UnassignTagFromFile,
|
||||
} from "../../../../../wailsjs/go/app/Application";
|
||||
import "../../Dashboard/Dashboard.css";
|
||||
|
||||
function FileDetails() {
|
||||
const navigate = useNavigate();
|
||||
const { fileId } = useParams();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [file, setFile] = useState(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [isOnloading, setIsOnloading] = useState(false);
|
||||
const [isOpening, setIsOpening] = useState(false);
|
||||
const [isOffloading, setIsOffloading] = useState(false);
|
||||
const [showTagEditor, setShowTagEditor] = useState(false);
|
||||
const [allTags, setAllTags] = useState([]);
|
||||
const [isLoadingTags, setIsLoadingTags] = useState(false);
|
||||
const [isUpdatingTags, setIsUpdatingTags] = useState(false);
|
||||
|
||||
// Load file details
|
||||
const loadFile = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
console.log("Loading file details...", fileId);
|
||||
const fileData = await GetFile(fileId);
|
||||
console.log("File loaded:", fileData);
|
||||
setFile(fileData);
|
||||
} catch (err) {
|
||||
console.error("Failed to load file:", err);
|
||||
setError(err.message || "Failed to load file");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [fileId]);
|
||||
|
||||
// Initial load - wait for Wails to be ready
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && window.go && window.go.app && window.go.app.Application) {
|
||||
loadFile();
|
||||
} else {
|
||||
const checkWails = setInterval(() => {
|
||||
if (window.go && window.go.app && window.go.app.Application) {
|
||||
clearInterval(checkWails);
|
||||
loadFile();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => clearInterval(checkWails), 10000);
|
||||
return () => clearInterval(checkWails);
|
||||
}
|
||||
}, [loadFile]);
|
||||
|
||||
// Helper functions
|
||||
const formatFileSize = (bytes) => {
|
||||
if (!bytes) return "0 B";
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 2 : 0)} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return "Unknown";
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const getTimeAgo = (dateString) => {
|
||||
if (!dateString) return "Recently";
|
||||
const now = new Date();
|
||||
const date = new Date(dateString);
|
||||
const diffInMinutes = Math.floor((now - date) / (1000 * 60));
|
||||
|
||||
if (diffInMinutes < 60) return "Just now";
|
||||
if (diffInMinutes < 1440) return "Today";
|
||||
if (diffInMinutes < 2880) return "Yesterday";
|
||||
const diffInDays = Math.floor(diffInMinutes / 1440);
|
||||
if (diffInDays < 7) return `${diffInDays} days ago`;
|
||||
if (diffInDays < 30) return `${Math.floor(diffInDays / 7)} weeks ago`;
|
||||
return `${Math.floor(diffInDays / 30)} months ago`;
|
||||
};
|
||||
|
||||
const getStateIcon = (state) => {
|
||||
switch (state) {
|
||||
case "active":
|
||||
return "[A]";
|
||||
case "archived":
|
||||
return "[R]";
|
||||
case "deleted":
|
||||
return "[D]";
|
||||
default:
|
||||
return "[?]";
|
||||
}
|
||||
};
|
||||
|
||||
const getStateLabel = (state) => {
|
||||
switch (state) {
|
||||
case "active":
|
||||
return "Active";
|
||||
case "archived":
|
||||
return "Archived";
|
||||
case "deleted":
|
||||
return "Deleted";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
};
|
||||
|
||||
// Get sync status icon and color
|
||||
const getSyncStatusInfo = (syncStatus) => {
|
||||
switch (syncStatus) {
|
||||
case "cloud_only":
|
||||
return { icon: "☁️", color: "#2196f3", label: "Cloud Only", tooltip: "File is in the cloud, not downloaded locally" };
|
||||
case "local_only":
|
||||
return { icon: "💻", color: "#9c27b0", label: "Local Only", tooltip: "File exists only locally, not uploaded to cloud" };
|
||||
case "synced":
|
||||
return { icon: "✓", color: "#4caf50", label: "Synced", tooltip: "File is synchronized between local and cloud" };
|
||||
case "modified_locally":
|
||||
return { icon: "⬆️", color: "#ff9800", label: "Modified", tooltip: "Local changes pending upload to cloud" };
|
||||
default:
|
||||
return { icon: "☁️", color: "#2196f3", label: "Cloud Only", tooltip: "File is in the cloud" };
|
||||
}
|
||||
};
|
||||
|
||||
// Handle onload file (download for offline access)
|
||||
const handleOnload = async () => {
|
||||
setIsOnloading(true);
|
||||
setError("");
|
||||
try {
|
||||
console.log("Onloading file for offline access:", fileId);
|
||||
const result = await OnloadFile(fileId);
|
||||
console.log("File onloaded:", result);
|
||||
// Refresh file details to update sync status
|
||||
await loadFile();
|
||||
} catch (err) {
|
||||
console.error("Failed to onload file:", err);
|
||||
setError(err.message || "Failed to download file for offline access");
|
||||
} finally {
|
||||
setIsOnloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle open file
|
||||
const handleOpen = async () => {
|
||||
setIsOpening(true);
|
||||
setError("");
|
||||
try {
|
||||
console.log("Opening file:", fileId);
|
||||
await OpenFile(fileId);
|
||||
console.log("File opened");
|
||||
} catch (err) {
|
||||
console.error("Failed to open file:", err);
|
||||
setError(err.message || "Failed to open file");
|
||||
} finally {
|
||||
setIsOpening(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle offload file (remove local copy, keep in cloud)
|
||||
const handleOffload = async () => {
|
||||
setIsOffloading(true);
|
||||
setError("");
|
||||
try {
|
||||
console.log("Offloading file to cloud-only:", fileId);
|
||||
await OffloadFile(fileId);
|
||||
console.log("File offloaded");
|
||||
// Refresh file details to update sync status
|
||||
await loadFile();
|
||||
} catch (err) {
|
||||
console.error("Failed to offload file:", err);
|
||||
setError(err.message || "Failed to offload file");
|
||||
} finally {
|
||||
setIsOffloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
setIsDownloading(true);
|
||||
setError("");
|
||||
try {
|
||||
console.log("Starting file download and decryption for:", fileId);
|
||||
const savePath = await DownloadFile(fileId);
|
||||
console.log("File downloaded and decrypted to:", savePath);
|
||||
|
||||
// Show success message if user selected a location (didn't cancel)
|
||||
if (savePath) {
|
||||
// Could add a success toast/notification here
|
||||
console.log("File saved successfully to:", savePath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to download file:", err);
|
||||
setError(err.message || "Failed to download file");
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
console.log("Deleting file:", fileId);
|
||||
await DeleteFile(fileId);
|
||||
console.log("File deleted successfully");
|
||||
|
||||
// Navigate back to the collection
|
||||
if (file?.collection_id) {
|
||||
navigate(`/file-manager/collections/${file.collection_id}`, { state: { refresh: true } });
|
||||
} else {
|
||||
navigate("/file-manager", { state: { refresh: true } });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to delete file:", err);
|
||||
setError(err.message || "Failed to delete file");
|
||||
setIsDeleting(false);
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load all available tags for the tag editor
|
||||
const loadAllTags = async () => {
|
||||
setIsLoadingTags(true);
|
||||
try {
|
||||
console.log("Loading all tags...");
|
||||
const tags = await ListTags();
|
||||
console.log("All tags loaded:", tags);
|
||||
setAllTags(tags || []);
|
||||
} catch (err) {
|
||||
console.error("Failed to load tags:", err);
|
||||
setAllTags([]);
|
||||
} finally {
|
||||
setIsLoadingTags(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Open tag editor modal
|
||||
const handleOpenTagEditor = async () => {
|
||||
await loadAllTags();
|
||||
setShowTagEditor(true);
|
||||
};
|
||||
|
||||
// Check if a tag is assigned to the current file
|
||||
const isTagAssigned = (tagId) => {
|
||||
return file?.tags?.some((t) => t.id === tagId) || false;
|
||||
};
|
||||
|
||||
// Handle tag toggle (assign or unassign)
|
||||
const handleTagToggle = async (tagId) => {
|
||||
setIsUpdatingTags(true);
|
||||
try {
|
||||
if (isTagAssigned(tagId)) {
|
||||
console.log("Unassigning tag from file:", tagId, fileId);
|
||||
await UnassignTagFromFile(tagId, fileId);
|
||||
} else {
|
||||
console.log("Assigning tag to file:", tagId, fileId);
|
||||
await AssignTagToFile(tagId, fileId);
|
||||
}
|
||||
// Refresh file to update tags
|
||||
await loadFile();
|
||||
} catch (err) {
|
||||
console.error("Failed to update tag:", err);
|
||||
setError(err.message || "Failed to update tag");
|
||||
} finally {
|
||||
setIsUpdatingTags(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getBackUrl = () => {
|
||||
if (file?.collection_id) {
|
||||
return `/file-manager/collections/${file.collection_id}`;
|
||||
}
|
||||
return "/file-manager";
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="layout">
|
||||
<Navigation />
|
||||
<div className="main-content">
|
||||
<Page title="Loading...">
|
||||
<div style={{ textAlign: "center", padding: "40px" }}>
|
||||
<div>Loading file details...</div>
|
||||
</div>
|
||||
</Page>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<Navigation />
|
||||
<div className="main-content">
|
||||
<Page title={file?.filename || "File Details"}>
|
||||
{/* Back Button */}
|
||||
<button
|
||||
onClick={() => navigate(getBackUrl())}
|
||||
className="nav-button secondary"
|
||||
style={{ marginBottom: "20px" }}
|
||||
>
|
||||
Back to Collection
|
||||
</button>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: "15px",
|
||||
background: "#ffebee",
|
||||
color: "#c62828",
|
||||
borderRadius: "4px",
|
||||
marginBottom: "20px",
|
||||
border: "1px solid #f44336",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File Info Card */}
|
||||
{file && (
|
||||
<div
|
||||
style={{
|
||||
background: "white",
|
||||
padding: "20px",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #ddd",
|
||||
marginBottom: "30px",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: "20px" }}>
|
||||
<div style={{
|
||||
width: "80px",
|
||||
height: "80px",
|
||||
background: "#f5f5f5",
|
||||
borderRadius: "8px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold",
|
||||
color: "#666",
|
||||
textTransform: "uppercase",
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{file.mime_type?.split("/")[1]?.substring(0, 4) || "FILE"}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h2 style={{ margin: 0, marginBottom: "10px", wordBreak: "break-word", color: "#333" }}>
|
||||
{file.filename}
|
||||
</h2>
|
||||
|
||||
{/* File Details Grid */}
|
||||
<div style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))",
|
||||
gap: "15px",
|
||||
marginTop: "15px"
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ color: "#666", fontSize: "12px", marginBottom: "4px" }}>SIZE</div>
|
||||
<div style={{ fontWeight: "500", color: "#333" }}>{formatFileSize(file.size)}</div>
|
||||
<div style={{ fontSize: "12px", color: "#888" }}>({file.size?.toLocaleString()} bytes)</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ color: "#666", fontSize: "12px", marginBottom: "4px" }}>ENCRYPTED SIZE</div>
|
||||
<div style={{ fontWeight: "500", color: "#333" }}>{formatFileSize(file.encrypted_file_size_in_bytes)}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ color: "#666", fontSize: "12px", marginBottom: "4px" }}>TYPE</div>
|
||||
<div style={{ fontWeight: "500", color: "#333" }}>{file.mime_type || "Unknown"}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ color: "#666", fontSize: "12px", marginBottom: "4px" }}>STATUS</div>
|
||||
<div style={{ fontWeight: "500" }}>
|
||||
<span style={{
|
||||
display: "inline-block",
|
||||
padding: "2px 8px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "12px",
|
||||
background: file.state === "active" ? "#e8f5e9" : file.state === "archived" ? "#fff3e0" : "#ffebee",
|
||||
color: file.state === "active" ? "#2e7d32" : file.state === "archived" ? "#ef6c00" : "#c62828"
|
||||
}}>
|
||||
{getStateIcon(file.state)} {getStateLabel(file.state)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ color: "#666", fontSize: "12px", marginBottom: "4px" }}>SYNC STATUS</div>
|
||||
<div style={{ fontWeight: "500" }}>
|
||||
{(() => {
|
||||
const syncInfo = getSyncStatusInfo(file.sync_status);
|
||||
return (
|
||||
<span
|
||||
title={syncInfo.tooltip}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "4px 10px",
|
||||
borderRadius: "12px",
|
||||
fontSize: "13px",
|
||||
backgroundColor: `${syncInfo.color}20`,
|
||||
color: syncInfo.color,
|
||||
fontWeight: "500",
|
||||
cursor: "help",
|
||||
}}
|
||||
>
|
||||
{syncInfo.icon} {syncInfo.label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{file.has_local_content && file.local_file_path && (
|
||||
<div>
|
||||
<div style={{ color: "#666", fontSize: "12px", marginBottom: "4px" }}>LOCAL PATH</div>
|
||||
<div style={{
|
||||
fontWeight: "500",
|
||||
fontSize: "11px",
|
||||
fontFamily: "monospace",
|
||||
wordBreak: "break-all",
|
||||
color: "#4caf50"
|
||||
}}>
|
||||
{file.local_file_path}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div style={{ color: "#666", fontSize: "12px", marginBottom: "4px" }}>CREATED</div>
|
||||
<div style={{ fontWeight: "500", color: "#333" }}>{formatDate(file.created_at)}</div>
|
||||
<div style={{ fontSize: "12px", color: "#888" }}>{getTimeAgo(file.created_at)}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ color: "#666", fontSize: "12px", marginBottom: "4px" }}>MODIFIED</div>
|
||||
<div style={{ fontWeight: "500", color: "#333" }}>{formatDate(file.modified_at)}</div>
|
||||
<div style={{ fontSize: "12px", color: "#888" }}>{getTimeAgo(file.modified_at)}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ color: "#666", fontSize: "12px", marginBottom: "4px" }}>VERSION</div>
|
||||
<div style={{ fontWeight: "500", color: "#333" }}>{file.version}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ color: "#666", fontSize: "12px", marginBottom: "4px" }}>FILE ID</div>
|
||||
<div style={{
|
||||
fontWeight: "500",
|
||||
fontSize: "11px",
|
||||
fontFamily: "monospace",
|
||||
wordBreak: "break-all",
|
||||
color: "#333"
|
||||
}}>
|
||||
{file.id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags Section */}
|
||||
<div style={{ marginTop: "20px", paddingTop: "15px", borderTop: "1px solid #eee" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "10px" }}>
|
||||
<div style={{ color: "#666", fontSize: "12px", fontWeight: "bold" }}>TAGS</div>
|
||||
<button
|
||||
onClick={handleOpenTagEditor}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
padding: "4px 10px",
|
||||
fontSize: "12px",
|
||||
cursor: "pointer",
|
||||
color: "#666",
|
||||
}}
|
||||
>
|
||||
Edit Tags
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px" }}>
|
||||
{file.tags && file.tags.length > 0 ? (
|
||||
file.tags.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "4px 10px",
|
||||
borderRadius: "16px",
|
||||
fontSize: "13px",
|
||||
backgroundColor: tag.color ? `${tag.color}20` : "#e3f2fd",
|
||||
color: tag.color || "#1976d2",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span style={{ color: "#999", fontSize: "13px", fontStyle: "italic" }}>
|
||||
No tags assigned
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ marginBottom: "30px" }}>
|
||||
<h3 style={{ color: "#333" }}>Actions</h3>
|
||||
<div className="nav-buttons">
|
||||
{/* Show Onload button for cloud-only files */}
|
||||
{file?.sync_status === "cloud_only" && (
|
||||
<button
|
||||
onClick={handleOnload}
|
||||
className="nav-button success"
|
||||
disabled={isOnloading}
|
||||
title="Download file from cloud for offline access"
|
||||
>
|
||||
{isOnloading ? "Downloading..." : "☁️⬇️ Onload"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Show Open button for files with local content */}
|
||||
{(file?.sync_status === "synced" || file?.has_local_content) && (
|
||||
<button
|
||||
onClick={handleOpen}
|
||||
className="nav-button"
|
||||
disabled={isOpening}
|
||||
title="Open file with default application"
|
||||
>
|
||||
{isOpening ? "Opening..." : "📂 Open"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Show Offload button for synced files */}
|
||||
{(file?.sync_status === "synced" || file?.has_local_content) && (
|
||||
<button
|
||||
onClick={handleOffload}
|
||||
className="nav-button secondary"
|
||||
disabled={isOffloading}
|
||||
title="Remove local copy, keep in cloud only"
|
||||
>
|
||||
{isOffloading ? "Offloading..." : "☁️⬆️ Offload"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Show Push button for modified files */}
|
||||
{file?.sync_status === "modified_locally" && (
|
||||
<button
|
||||
onClick={() => {
|
||||
// TODO: Implement push to cloud
|
||||
console.log("Push changes for:", fileId);
|
||||
}}
|
||||
className="nav-button success"
|
||||
title="Push local changes to cloud"
|
||||
>
|
||||
⬆️ Push to Cloud
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Save As button - always available */}
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="nav-button secondary"
|
||||
disabled={isDownloading}
|
||||
title="Save file to a specific location"
|
||||
>
|
||||
{isDownloading ? "Saving..." : "💾 Save As"}
|
||||
</button>
|
||||
|
||||
{/* Delete button */}
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="nav-button danger"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
🗑️ Delete File
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteConfirm && (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: "rgba(0, 0, 0, 0.5)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onClick={() => !isDeleting && setShowDeleteConfirm(false)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "white",
|
||||
padding: "30px",
|
||||
borderRadius: "8px",
|
||||
maxWidth: "400px",
|
||||
width: "90%",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 style={{ margin: "0 0 15px 0", color: "#333" }}>Delete File?</h3>
|
||||
<p style={{ color: "#666", marginBottom: "20px" }}>
|
||||
Are you sure you want to delete "{file?.filename}"? This action cannot be undone.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: "10px", justifyContent: "flex-end" }}>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="nav-button secondary"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="nav-button danger"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tag Editor Modal */}
|
||||
{showTagEditor && (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: "rgba(0, 0, 0, 0.5)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onClick={() => !isUpdatingTags && setShowTagEditor(false)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "white",
|
||||
padding: "30px",
|
||||
borderRadius: "8px",
|
||||
maxWidth: "450px",
|
||||
width: "90%",
|
||||
maxHeight: "80vh",
|
||||
overflow: "auto",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 style={{ margin: "0 0 15px 0", color: "#333" }}>Manage Tags</h3>
|
||||
<p style={{ color: "#666", marginBottom: "20px", fontSize: "14px" }}>
|
||||
Click on a tag to add or remove it from this file.
|
||||
</p>
|
||||
|
||||
{isLoadingTags ? (
|
||||
<div style={{ textAlign: "center", padding: "20px", color: "#666" }}>
|
||||
Loading tags...
|
||||
</div>
|
||||
) : allTags.length === 0 ? (
|
||||
<div style={{ textAlign: "center", padding: "20px" }}>
|
||||
<p style={{ color: "#666", marginBottom: "15px" }}>No tags found.</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowTagEditor(false);
|
||||
navigate("/tags");
|
||||
}}
|
||||
className="nav-button"
|
||||
>
|
||||
Create Tags
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "10px", marginBottom: "20px" }}>
|
||||
{allTags.map((tag) => {
|
||||
const assigned = isTagAssigned(tag.id);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => handleTagToggle(tag.id)}
|
||||
disabled={isUpdatingTags}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "8px 14px",
|
||||
borderRadius: "20px",
|
||||
fontSize: "14px",
|
||||
border: assigned ? `2px solid ${tag.color || "#1976d2"}` : "2px solid #ddd",
|
||||
backgroundColor: assigned ? (tag.color ? `${tag.color}20` : "#e3f2fd") : "white",
|
||||
color: tag.color || "#1976d2",
|
||||
fontWeight: "500",
|
||||
cursor: isUpdatingTags ? "not-allowed" : "pointer",
|
||||
opacity: isUpdatingTags ? 0.6 : 1,
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
>
|
||||
{assigned && <span>✓</span>}
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", gap: "10px", justifyContent: "flex-end" }}>
|
||||
<button
|
||||
onClick={() => setShowTagEditor(false)}
|
||||
className="nav-button secondary"
|
||||
disabled={isUpdatingTags}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Page>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileDetails;
|
||||
|
|
@ -0,0 +1,539 @@
|
|||
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/FileManager/Files/FileUpload.jsx
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import Navigation from "../../../../components/Navigation";
|
||||
import Page from "../../../../components/Page";
|
||||
import {
|
||||
SelectFile,
|
||||
UploadFile,
|
||||
ListCollections,
|
||||
ListTags,
|
||||
} from "../../../../../wailsjs/go/app/Application";
|
||||
import "../../Dashboard/Dashboard.css";
|
||||
|
||||
function FileUpload() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// Get collection ID from URL params if provided
|
||||
const preselectedCollectionId = searchParams.get("collection");
|
||||
|
||||
const [selectedFilePath, setSelectedFilePath] = useState("");
|
||||
const [selectedCollectionId, setSelectedCollectionId] = useState(preselectedCollectionId || "");
|
||||
const [collections, setCollections] = useState([]);
|
||||
const [isLoadingCollections, setIsLoadingCollections] = useState(true);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [uploadResult, setUploadResult] = useState(null);
|
||||
const [availableTags, setAvailableTags] = useState([]);
|
||||
const [selectedTagIds, setSelectedTagIds] = useState([]);
|
||||
const [isLoadingTags, setIsLoadingTags] = useState(false);
|
||||
|
||||
// Load available collections and tags
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const collectionsData = await ListCollections();
|
||||
setCollections(collectionsData || []);
|
||||
} catch (err) {
|
||||
console.error("Failed to load collections:", err);
|
||||
setError("Failed to load collections");
|
||||
} finally {
|
||||
setIsLoadingCollections(false);
|
||||
}
|
||||
|
||||
// Load tags
|
||||
setIsLoadingTags(true);
|
||||
try {
|
||||
const tags = await ListTags();
|
||||
setAvailableTags(tags || []);
|
||||
} catch (err) {
|
||||
console.error("Failed to load tags:", err);
|
||||
setAvailableTags([]);
|
||||
} finally {
|
||||
setIsLoadingTags(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Wait for Wails
|
||||
if (typeof window !== 'undefined' && window.go && window.go.app && window.go.app.Application) {
|
||||
loadData();
|
||||
} else {
|
||||
const checkWails = setInterval(() => {
|
||||
if (window.go && window.go.app && window.go.app.Application) {
|
||||
clearInterval(checkWails);
|
||||
loadData();
|
||||
}
|
||||
}, 100);
|
||||
setTimeout(() => clearInterval(checkWails), 10000);
|
||||
return () => clearInterval(checkWails);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSelectFile = async () => {
|
||||
try {
|
||||
setError("");
|
||||
const filePath = await SelectFile();
|
||||
if (filePath) {
|
||||
setSelectedFilePath(filePath);
|
||||
console.log("File selected:", filePath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to select file:", err);
|
||||
setError(err.message || "Failed to select file");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFilePath) {
|
||||
setError("Please select a file to upload");
|
||||
return;
|
||||
}
|
||||
if (!selectedCollectionId) {
|
||||
setError("Please select a collection");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setError("");
|
||||
setUploadProgress("Preparing upload...");
|
||||
setUploadResult(null);
|
||||
|
||||
try {
|
||||
console.log("Starting upload:", {
|
||||
filePath: selectedFilePath,
|
||||
collectionId: selectedCollectionId,
|
||||
});
|
||||
|
||||
setUploadProgress("Encrypting and uploading file...");
|
||||
|
||||
const result = await UploadFile({
|
||||
file_path: selectedFilePath,
|
||||
collection_id: selectedCollectionId,
|
||||
tag_ids: selectedTagIds.length > 0 ? selectedTagIds : undefined,
|
||||
});
|
||||
|
||||
console.log("Upload result:", result);
|
||||
|
||||
if (result.success) {
|
||||
setUploadResult(result);
|
||||
setUploadProgress("Upload complete!");
|
||||
} else {
|
||||
setError(result.message || "Upload failed");
|
||||
setUploadProgress("");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Upload failed:", err);
|
||||
setError(err.message || "Failed to upload file");
|
||||
setUploadProgress("");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadAnother = () => {
|
||||
setSelectedFilePath("");
|
||||
setUploadResult(null);
|
||||
setUploadProgress("");
|
||||
setError("");
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
if (!bytes) return "0 B";
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 2 : 0)} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const getFileName = (filePath) => {
|
||||
if (!filePath) return "";
|
||||
const parts = filePath.split(/[/\\]/);
|
||||
return parts[parts.length - 1];
|
||||
};
|
||||
|
||||
const getBackUrl = () => {
|
||||
if (preselectedCollectionId) {
|
||||
return `/file-manager/collections/${preselectedCollectionId}`;
|
||||
}
|
||||
return "/file-manager";
|
||||
};
|
||||
|
||||
// Success state
|
||||
if (uploadResult) {
|
||||
return (
|
||||
<div className="layout">
|
||||
<Navigation />
|
||||
<div className="main-content">
|
||||
<Page title="Upload Complete">
|
||||
<div
|
||||
style={{
|
||||
background: "#e8f5e9",
|
||||
padding: "30px",
|
||||
borderRadius: "8px",
|
||||
textAlign: "center",
|
||||
marginBottom: "30px",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "64px", marginBottom: "15px" }}>
|
||||
[OK]
|
||||
</div>
|
||||
<h2 style={{ margin: "0 0 10px 0", color: "#2e7d32" }}>
|
||||
File Uploaded Successfully!
|
||||
</h2>
|
||||
<p style={{ color: "#666", margin: "0" }}>
|
||||
Your file has been encrypted and uploaded to the cloud.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: "white",
|
||||
padding: "20px",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #ddd",
|
||||
marginBottom: "30px",
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginTop: 0 }}>Upload Details</h3>
|
||||
<div style={{ display: "grid", gap: "10px" }}>
|
||||
<div>
|
||||
<span style={{ color: "#666" }}>Filename: </span>
|
||||
<strong>{uploadResult.filename}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: "#666" }}>Size: </span>
|
||||
<strong>{formatFileSize(uploadResult.size)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: "#666" }}>File ID: </span>
|
||||
<code style={{ fontSize: "12px" }}>{uploadResult.file_id}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="nav-buttons">
|
||||
<button
|
||||
onClick={() => navigate(`/file-manager/files/${uploadResult.file_id}`)}
|
||||
className="nav-button success"
|
||||
>
|
||||
View File
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUploadAnother}
|
||||
className="nav-button"
|
||||
>
|
||||
Upload Another
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(getBackUrl(), { state: { refresh: true } })}
|
||||
className="nav-button secondary"
|
||||
>
|
||||
Back to Collection
|
||||
</button>
|
||||
</div>
|
||||
</Page>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<Navigation />
|
||||
<div className="main-content">
|
||||
<Page title="Upload File">
|
||||
{/* Back Button */}
|
||||
<button
|
||||
onClick={() => navigate(getBackUrl())}
|
||||
className="nav-button secondary"
|
||||
style={{ marginBottom: "20px" }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: "15px",
|
||||
background: "#ffebee",
|
||||
color: "#c62828",
|
||||
borderRadius: "4px",
|
||||
marginBottom: "20px",
|
||||
border: "1px solid #f44336",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Form */}
|
||||
<div
|
||||
style={{
|
||||
background: "white",
|
||||
padding: "30px",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #ddd",
|
||||
marginBottom: "30px",
|
||||
}}
|
||||
>
|
||||
{/* Step 1: Select Collection */}
|
||||
<div style={{ marginBottom: "25px" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
fontWeight: "bold",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
1. Select Collection
|
||||
</label>
|
||||
{isLoadingCollections ? (
|
||||
<div style={{ color: "#666" }}>Loading collections...</div>
|
||||
) : (
|
||||
<select
|
||||
value={selectedCollectionId}
|
||||
onChange={(e) => setSelectedCollectionId(e.target.value)}
|
||||
disabled={isUploading}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 12px",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
fontSize: "14px",
|
||||
backgroundColor: isUploading ? "#f5f5f5" : "white",
|
||||
}}
|
||||
>
|
||||
<option value="">-- Select a collection --</option>
|
||||
{collections.map((col) => (
|
||||
<option key={col.id} value={col.id}>
|
||||
{col.name || "Unnamed Collection"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{collections.length === 0 && !isLoadingCollections && (
|
||||
<p style={{ color: "#666", fontSize: "14px", marginTop: "8px" }}>
|
||||
No collections found.{" "}
|
||||
<button
|
||||
onClick={() => navigate("/file-manager/collections/create")}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "#1976d2",
|
||||
cursor: "pointer",
|
||||
textDecoration: "underline",
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
Create one first
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step 2: Select File */}
|
||||
<div style={{ marginBottom: "25px" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
fontWeight: "bold",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
2. Select File
|
||||
</label>
|
||||
<div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
|
||||
<button
|
||||
onClick={handleSelectFile}
|
||||
disabled={isUploading}
|
||||
className="nav-button"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
Browse...
|
||||
</button>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "10px 12px",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
background: "#f9f9f9",
|
||||
color: selectedFilePath ? "#333" : "#999",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{selectedFilePath ? getFileName(selectedFilePath) : "No file selected"}
|
||||
</div>
|
||||
</div>
|
||||
{selectedFilePath && (
|
||||
<div style={{ fontSize: "12px", color: "#666", marginTop: "5px" }}>
|
||||
Full path: {selectedFilePath}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step 3: Select Tags (Optional) */}
|
||||
<div style={{ marginBottom: "25px" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
fontWeight: "bold",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
3. Tags{" "}
|
||||
<span style={{ fontSize: "12px", fontWeight: "normal", color: "#666" }}>
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
{isLoadingTags ? (
|
||||
<div style={{ color: "#666" }}>Loading tags...</div>
|
||||
) : availableTags.length > 0 ? (
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
padding: "10px",
|
||||
maxHeight: "150px",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{availableTags.map((tag) => (
|
||||
<label
|
||||
key={tag.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "6px",
|
||||
cursor: "pointer",
|
||||
borderRadius: "4px",
|
||||
transition: "background-color 0.15s ease",
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#f9f9f9";
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "transparent";
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedTagIds.includes(tag.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedTagIds([...selectedTagIds, tag.id]);
|
||||
} else {
|
||||
setSelectedTagIds(selectedTagIds.filter((id) => id !== tag.id));
|
||||
}
|
||||
}}
|
||||
disabled={isUploading}
|
||||
style={{ marginRight: "8px", cursor: "pointer" }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: tag.color || "#666",
|
||||
marginRight: "8px",
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: "14px", color: "#333" }}>{tag.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px",
|
||||
color: "#666",
|
||||
fontSize: "14px",
|
||||
fontStyle: "italic",
|
||||
}}
|
||||
>
|
||||
No tags available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload Progress */}
|
||||
{uploadProgress && (
|
||||
<div
|
||||
style={{
|
||||
padding: "15px",
|
||||
background: "#e3f2fd",
|
||||
color: "#1565c0",
|
||||
borderRadius: "4px",
|
||||
marginBottom: "20px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "20px",
|
||||
height: "20px",
|
||||
border: "2px solid #1565c0",
|
||||
borderTopColor: "transparent",
|
||||
borderRadius: "50%",
|
||||
animation: "spin 1s linear infinite",
|
||||
}}
|
||||
/>
|
||||
{uploadProgress}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Button */}
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading || !selectedFilePath || !selectedCollectionId}
|
||||
className="nav-button success"
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "15px",
|
||||
fontSize: "16px",
|
||||
opacity: (isUploading || !selectedFilePath || !selectedCollectionId) ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{isUploading ? "Uploading..." : "Encrypt & Upload File"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div
|
||||
style={{
|
||||
background: "#f5f5f5",
|
||||
padding: "20px",
|
||||
borderRadius: "8px",
|
||||
fontSize: "14px",
|
||||
color: "#666",
|
||||
}}
|
||||
>
|
||||
<h4 style={{ marginTop: 0, color: "#333" }}>How it works</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: "20px" }}>
|
||||
<li>Your file is encrypted locally before being uploaded</li>
|
||||
<li>Only you and people you share with can decrypt it</li>
|
||||
<li>The server never sees your unencrypted data</li>
|
||||
<li>Files are stored securely in the cloud</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Inline CSS for spinner animation */}
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</Page>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileUpload;
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/FileManager/SearchResults.jsx
|
||||
import { Link } from "react-router-dom";
|
||||
import Navigation from "../../../components/Navigation";
|
||||
import Page from "../../../components/Page";
|
||||
import "../Dashboard/Dashboard.css";
|
||||
|
||||
function SearchResults() {
|
||||
return (
|
||||
<div className="layout">
|
||||
<Navigation />
|
||||
<div className="main-content">
|
||||
<Page title="Search Files" showBackButton={true}>
|
||||
<p>Search your encrypted files.</p>
|
||||
<h3>Search Results (Demo)</h3>
|
||||
<div className="nav-buttons">
|
||||
<Link to="/file-manager/files/abc" className="nav-button secondary">
|
||||
Result 1: document.pdf
|
||||
</Link>
|
||||
<Link to="/file-manager/files/def" className="nav-button secondary">
|
||||
Result 2: image.png
|
||||
</Link>
|
||||
<Link to="/file-manager/files/ghi" className="nav-button secondary">
|
||||
Result 3: report.docx
|
||||
</Link>
|
||||
</div>
|
||||
</Page>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchResults;
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/FileManager/TrashView.jsx
|
||||
import { Link } from "react-router-dom";
|
||||
import Navigation from "../../../components/Navigation";
|
||||
import Page from "../../../components/Page";
|
||||
import "../Dashboard/Dashboard.css";
|
||||
|
||||
function TrashView() {
|
||||
return (
|
||||
<div className="layout">
|
||||
<Navigation />
|
||||
<div className="main-content">
|
||||
<Page title="Trash" showBackButton={true}>
|
||||
<p>Deleted files and collections.</p>
|
||||
<h3>Deleted Items (Demo)</h3>
|
||||
<div className="nav-buttons">
|
||||
<Link to="/file-manager" className="nav-button secondary">
|
||||
Restore: old_file.pdf
|
||||
</Link>
|
||||
<Link to="/file-manager" className="nav-button secondary">
|
||||
Restore: archive.zip
|
||||
</Link>
|
||||
</div>
|
||||
<h3>Actions</h3>
|
||||
<div className="nav-buttons">
|
||||
<Link to="/file-manager" className="nav-button danger">
|
||||
Empty Trash
|
||||
</Link>
|
||||
</div>
|
||||
</Page>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TrashView;
|
||||
|
|
@ -0,0 +1,339 @@
|
|||
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/Me/BlockedUsers.jsx
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import Navigation from "../../../components/Navigation";
|
||||
import Page from "../../../components/Page";
|
||||
import { GetBlockedEmails, AddBlockedEmail, RemoveBlockedEmail } from "../../../../wailsjs/go/app/Application";
|
||||
|
||||
function BlockedUsers() {
|
||||
const [blockedEmails, setBlockedEmails] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
// Add form state
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [addLoading, setAddLoading] = useState(false);
|
||||
const [addError, setAddError] = useState("");
|
||||
|
||||
// Delete state
|
||||
const [deleteLoading, setDeleteLoading] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for Wails runtime to be ready
|
||||
let attempts = 0;
|
||||
const maxAttempts = 50;
|
||||
let isCancelled = false;
|
||||
|
||||
const checkWailsReady = () => {
|
||||
if (isCancelled) return;
|
||||
|
||||
if (window.go && window.go.app && window.go.app.Application) {
|
||||
loadBlockedEmails();
|
||||
} else if (attempts < maxAttempts) {
|
||||
attempts++;
|
||||
setTimeout(checkWailsReady, 100);
|
||||
} else {
|
||||
if (!isCancelled) {
|
||||
setIsLoading(false);
|
||||
setError("Failed to initialize. Please refresh the page.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkWailsReady();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadBlockedEmails = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
if (!window.go || !window.go.app || !window.go.app.Application) {
|
||||
throw new Error("Application not ready");
|
||||
}
|
||||
|
||||
console.log("Loading blocked emails...");
|
||||
const emails = await GetBlockedEmails();
|
||||
console.log("Blocked emails loaded:", emails);
|
||||
|
||||
setBlockedEmails(emails || []);
|
||||
} catch (err) {
|
||||
console.error("Failed to load blocked emails:", err);
|
||||
setError(err.message || "Failed to load blocked users");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddEmail = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!newEmail.trim()) return;
|
||||
|
||||
setAddLoading(true);
|
||||
setAddError("");
|
||||
setSuccess("");
|
||||
|
||||
try {
|
||||
console.log("Adding blocked email:", newEmail);
|
||||
await AddBlockedEmail(newEmail.trim(), "Blocked via UI");
|
||||
setSuccess(`${newEmail} has been blocked successfully.`);
|
||||
setNewEmail("");
|
||||
await loadBlockedEmails();
|
||||
} catch (err) {
|
||||
console.error("Failed to add blocked email:", err);
|
||||
setAddError(err.message || "Failed to block email");
|
||||
} finally {
|
||||
setAddLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveEmail = async (email) => {
|
||||
setDeleteLoading(email);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
|
||||
try {
|
||||
console.log("Removing blocked email:", email);
|
||||
await RemoveBlockedEmail(email);
|
||||
setSuccess(`${email} has been unblocked.`);
|
||||
await loadBlockedEmails();
|
||||
} catch (err) {
|
||||
console.error("Failed to remove blocked email:", err);
|
||||
setError(err.message || "Failed to unblock email");
|
||||
} finally {
|
||||
setDeleteLoading("");
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return "N/A";
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return "Invalid Date";
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
} catch (error) {
|
||||
return "Invalid Date";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<Navigation />
|
||||
<div className="main-content">
|
||||
<Page title="Blocked Users">
|
||||
{/* Breadcrumb */}
|
||||
<div style={{ marginBottom: "20px", fontSize: "14px", color: "#6b7280" }}>
|
||||
<Link to="/dashboard" style={{ color: "#3b82f6", textDecoration: "none" }}>Dashboard</Link>
|
||||
<span style={{ margin: "0 8px" }}>›</span>
|
||||
<Link to="/me" style={{ color: "#3b82f6", textDecoration: "none" }}>My Profile</Link>
|
||||
<span style={{ margin: "0 8px" }}>›</span>
|
||||
<span style={{ color: "#2c3e50", fontWeight: "500" }}>Blocked Users</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
background: "white",
|
||||
padding: "30px",
|
||||
borderRadius: "12px",
|
||||
marginBottom: "20px",
|
||||
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<div style={{
|
||||
width: "50px",
|
||||
height: "50px",
|
||||
borderRadius: "12px",
|
||||
background: "#fee2e2",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginRight: "15px",
|
||||
}}>
|
||||
<span style={{ fontSize: "24px" }}>🚫</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 style={{ margin: 0, color: "#2c3e50", fontSize: "24px" }}>Blocked Users</h1>
|
||||
<p style={{ margin: "5px 0 0 0", color: "#7f8c8d" }}>
|
||||
Manage users who cannot share folders with you
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success message */}
|
||||
{success && (
|
||||
<div style={{
|
||||
marginBottom: "20px",
|
||||
padding: "15px",
|
||||
background: "#d4edda",
|
||||
border: "1px solid #c3e6cb",
|
||||
borderRadius: "8px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<span style={{ fontSize: "20px", marginRight: "10px" }}>✓</span>
|
||||
<p style={{ margin: 0, color: "#155724" }}>{success}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div style={{
|
||||
marginBottom: "20px",
|
||||
padding: "15px",
|
||||
background: "#f8d7da",
|
||||
border: "1px solid #f5c6cb",
|
||||
borderRadius: "8px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<span style={{ fontSize: "20px", marginRight: "10px" }}>⚠️</span>
|
||||
<p style={{ margin: 0, color: "#721c24" }}>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Email Form */}
|
||||
<div style={{
|
||||
background: "white",
|
||||
padding: "30px",
|
||||
borderRadius: "12px",
|
||||
marginBottom: "20px",
|
||||
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
|
||||
}}>
|
||||
<h2 style={{ marginTop: 0, color: "#2c3e50", fontSize: "18px" }}>Block a User</h2>
|
||||
<p style={{ color: "#7f8c8d", fontSize: "14px", marginBottom: "15px" }}>
|
||||
Enter the email address of a user you want to block. They will not be able to share folders with you.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleAddEmail} style={{ display: "flex", gap: "10px" }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<input
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={(e) => {
|
||||
setNewEmail(e.target.value);
|
||||
if (addError) setAddError("");
|
||||
}}
|
||||
placeholder="Enter email address to block"
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #ddd",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
disabled={addLoading}
|
||||
required
|
||||
/>
|
||||
{addError && (
|
||||
<p style={{ margin: "5px 0 0 0", fontSize: "14px", color: "#e74c3c" }}>{addError}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="nav-button"
|
||||
disabled={addLoading || !newEmail.trim()}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{addLoading ? "Adding..." : "➕ Block"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Blocked Emails List */}
|
||||
<div style={{
|
||||
background: "white",
|
||||
padding: "30px",
|
||||
borderRadius: "12px",
|
||||
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
|
||||
}}>
|
||||
<h2 style={{ marginTop: 0, color: "#2c3e50", fontSize: "18px" }}>
|
||||
Blocked Users ({blockedEmails.length})
|
||||
</h2>
|
||||
|
||||
{isLoading ? (
|
||||
<div style={{ textAlign: "center", padding: "40px" }}>
|
||||
<p style={{ color: "#7f8c8d" }}>Loading blocked users...</p>
|
||||
</div>
|
||||
) : blockedEmails.length === 0 ? (
|
||||
<div style={{ textAlign: "center", padding: "40px" }}>
|
||||
<span style={{ fontSize: "48px" }}>🚫</span>
|
||||
<p style={{ color: "#7f8c8d", margin: "10px 0 0 0" }}>No blocked users</p>
|
||||
<p style={{ fontSize: "14px", color: "#95a5a6", margin: "5px 0 0 0" }}>
|
||||
Users you block won't be able to share folders with you.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
|
||||
{blockedEmails.map((blocked) => (
|
||||
<div
|
||||
key={blocked.blocked_email}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "15px",
|
||||
background: "#f8f9fa",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #e9ecef",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<span style={{ fontSize: "20px", marginRight: "12px" }}>✉️</span>
|
||||
<div>
|
||||
<p style={{ margin: 0, fontWeight: "500", color: "#2c3e50" }}>
|
||||
{blocked.blocked_email}
|
||||
</p>
|
||||
<p style={{ margin: "3px 0 0 0", fontSize: "12px", color: "#7f8c8d" }}>
|
||||
Blocked on {formatDate(blocked.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveEmail(blocked.blocked_email)}
|
||||
className="nav-button danger"
|
||||
disabled={deleteLoading === blocked.blocked_email}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{deleteLoading === blocked.blocked_email ? "Removing..." : "🗑️ Unblock"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info section */}
|
||||
<div style={{
|
||||
marginTop: "20px",
|
||||
padding: "20px",
|
||||
background: "#e3f2fd",
|
||||
border: "1px solid #bbdefb",
|
||||
borderRadius: "8px",
|
||||
}}>
|
||||
<h3 style={{ marginTop: 0, fontSize: "16px", color: "#1565c0" }}>
|
||||
How blocking works
|
||||
</h3>
|
||||
<ul style={{ margin: 0, paddingLeft: "20px", color: "#1976d2", fontSize: "14px" }}>
|
||||
<li>Blocked users cannot share folders or files with you</li>
|
||||
<li>You can still share folders with blocked users</li>
|
||||
<li>Blocking is private - users are not notified when blocked</li>
|
||||
<li>Existing shares are not affected when you block someone</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Page>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BlockedUsers;
|
||||
|
|
@ -0,0 +1,621 @@
|
|||
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/Me/DeleteAccount.jsx
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import Navigation from "../../../components/Navigation";
|
||||
import Page from "../../../components/Page";
|
||||
import { GetUserProfile, DeleteAccount as DeleteAccountAPI } from "../../../../wailsjs/go/app/Application";
|
||||
|
||||
function DeleteAccount() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// State management
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmText, setConfirmText] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [userEmail, setUserEmail] = useState("");
|
||||
const [acknowledgments, setAcknowledgments] = useState({
|
||||
permanentDeletion: false,
|
||||
dataLoss: false,
|
||||
gdprRights: false,
|
||||
});
|
||||
|
||||
// Load user info
|
||||
useEffect(() => {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 50;
|
||||
let isCancelled = false;
|
||||
|
||||
const checkWailsReady = () => {
|
||||
if (isCancelled) return;
|
||||
|
||||
if (window.go && window.go.app && window.go.app.Application) {
|
||||
loadUserInfo();
|
||||
} else if (attempts < maxAttempts) {
|
||||
attempts++;
|
||||
setTimeout(checkWailsReady, 100);
|
||||
}
|
||||
};
|
||||
|
||||
checkWailsReady();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadUserInfo = async () => {
|
||||
try {
|
||||
const user = await GetUserProfile();
|
||||
setUserEmail(user.email);
|
||||
} catch (err) {
|
||||
console.error("Failed to load user info:", err);
|
||||
setError("Failed to load your account information.");
|
||||
}
|
||||
};
|
||||
|
||||
// Check if all acknowledgments are checked
|
||||
const allAcknowledged =
|
||||
acknowledgments.permanentDeletion &&
|
||||
acknowledgments.dataLoss &&
|
||||
acknowledgments.gdprRights;
|
||||
|
||||
// Check if confirmation text matches
|
||||
const confirmationMatches = confirmText === "DELETE MY ACCOUNT";
|
||||
|
||||
// Handle checkbox change
|
||||
const handleAcknowledgmentChange = (key) => {
|
||||
setAcknowledgments((prev) => ({
|
||||
...prev,
|
||||
[key]: !prev[key],
|
||||
}));
|
||||
};
|
||||
|
||||
// Handle back button
|
||||
const handleBack = () => {
|
||||
if (currentStep === 1) {
|
||||
navigate("/me");
|
||||
} else {
|
||||
setCurrentStep(currentStep - 1);
|
||||
setError("");
|
||||
}
|
||||
};
|
||||
|
||||
// Handle next step
|
||||
const handleNext = () => {
|
||||
setError("");
|
||||
setCurrentStep(currentStep + 1);
|
||||
};
|
||||
|
||||
// Handle account deletion
|
||||
const handleDeleteAccount = async () => {
|
||||
if (!password) {
|
||||
setError("Please enter your password to confirm deletion.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allAcknowledged) {
|
||||
setError("Please acknowledge all statements before proceeding.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirmationMatches) {
|
||||
setError('Please type "DELETE MY ACCOUNT" exactly to confirm.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
await DeleteAccountAPI(password);
|
||||
setCurrentStep(4); // Success step
|
||||
|
||||
// Wait 3 seconds then redirect to login
|
||||
setTimeout(() => {
|
||||
navigate("/login");
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
console.error("Account deletion failed:", err);
|
||||
const errorMessage = err.message || err.toString();
|
||||
|
||||
if (errorMessage.includes("Invalid") || errorMessage.includes("password")) {
|
||||
setError("Invalid password. Please try again.");
|
||||
} else if (errorMessage.includes("permission")) {
|
||||
setError("You do not have permission to delete this account.");
|
||||
} else {
|
||||
setError("Failed to delete account. Please try again or contact support.");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Render Step 1: Warning and Information
|
||||
const renderStep1 = () => (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
|
||||
{/* Warning Banner */}
|
||||
<div style={{
|
||||
background: "#fee2e2",
|
||||
borderLeft: "4px solid #ef4444",
|
||||
padding: "20px",
|
||||
borderRadius: "8px",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "start" }}>
|
||||
<span style={{ fontSize: "24px", marginRight: "15px" }}>⚠️</span>
|
||||
<div>
|
||||
<h3 style={{ margin: "0 0 10px 0", color: "#991b1b", fontSize: "18px" }}>
|
||||
This action is permanent and cannot be undone
|
||||
</h3>
|
||||
<p style={{ margin: 0, color: "#7f1d1d" }}>
|
||||
Once you delete your account, all your data will be permanently removed from our servers. This includes all files, collections, and personal information.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* What will be deleted */}
|
||||
<div style={{
|
||||
background: "white",
|
||||
padding: "25px",
|
||||
borderRadius: "12px",
|
||||
border: "1px solid #e5e7eb",
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
|
||||
}}>
|
||||
<h3 style={{ margin: "0 0 20px 0", color: "#2c3e50", fontSize: "18px", display: "flex", alignItems: "center" }}>
|
||||
<span style={{ marginRight: "10px" }}>🗑️</span>
|
||||
What will be deleted
|
||||
</h3>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "15px" }}>
|
||||
{[
|
||||
{ title: "All your files", desc: "Every file you've uploaded will be permanently deleted from our servers" },
|
||||
{ title: "All your collections", desc: "All folders and collections you own will be permanently removed" },
|
||||
{ title: "Personal information", desc: "Your profile, email, and all associated data will be deleted" },
|
||||
{ title: "Shared access", desc: "You'll be removed from any collections shared with you" },
|
||||
{ title: "Storage usage history", desc: "All your storage metrics and usage history will be deleted" }
|
||||
].map((item, idx) => (
|
||||
<div key={idx} style={{ display: "flex", alignItems: "start" }}>
|
||||
<span style={{ fontSize: "20px", color: "#ef4444", marginRight: "12px" }}>✗</span>
|
||||
<div>
|
||||
<strong style={{ color: "#2c3e50" }}>{item.title}</strong>
|
||||
<p style={{ margin: "3px 0 0 0", fontSize: "14px", color: "#6b7280" }}>{item.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GDPR Information */}
|
||||
<div style={{
|
||||
background: "#dbeafe",
|
||||
border: "1px solid #93c5fd",
|
||||
padding: "20px",
|
||||
borderRadius: "8px",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "start" }}>
|
||||
<span style={{ fontSize: "20px", marginRight: "12px" }}>ℹ️</span>
|
||||
<div>
|
||||
<h4 style={{ margin: "0 0 8px 0", fontSize: "14px", fontWeight: "600", color: "#1e3a8a" }}>
|
||||
Your Right to Erasure (GDPR Article 17)
|
||||
</h4>
|
||||
<p style={{ margin: 0, fontSize: "14px", color: "#1e40af" }}>
|
||||
This deletion process complies with GDPR regulations. All your personal data will be permanently erased from our systems within moments of confirmation. This action cannot be reversed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", paddingTop: "15px" }}>
|
||||
<button onClick={handleBack} className="nav-button secondary">
|
||||
← Cancel
|
||||
</button>
|
||||
<button onClick={handleNext} className="nav-button danger">
|
||||
Continue to Confirmation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render Step 2: Acknowledgments
|
||||
const renderStep2 = () => (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
|
||||
<div style={{
|
||||
background: "#fef3c7",
|
||||
borderLeft: "4px solid #f59e0b",
|
||||
padding: "20px",
|
||||
borderRadius: "8px",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "start" }}>
|
||||
<span style={{ fontSize: "24px", marginRight: "15px" }}>🛡️</span>
|
||||
<div>
|
||||
<h3 style={{ margin: "0 0 10px 0", color: "#78350f", fontSize: "18px" }}>
|
||||
Please confirm you understand
|
||||
</h3>
|
||||
<p style={{ margin: 0, color: "#92400e" }}>
|
||||
Before proceeding, you must acknowledge the following statements about your account deletion.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Acknowledgment Checkboxes */}
|
||||
<div style={{
|
||||
background: "white",
|
||||
padding: "25px",
|
||||
borderRadius: "12px",
|
||||
border: "1px solid #e5e7eb",
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "20px",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "start" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="ack-permanent"
|
||||
checked={acknowledgments.permanentDeletion}
|
||||
onChange={() => handleAcknowledgmentChange("permanentDeletion")}
|
||||
style={{ marginTop: "3px", marginRight: "12px", width: "18px", height: "18px" }}
|
||||
/>
|
||||
<label htmlFor="ack-permanent" style={{ fontSize: "14px", color: "#374151", cursor: "pointer" }}>
|
||||
<strong style={{ color: "#2c3e50" }}>
|
||||
I understand that this deletion is permanent and irreversible.
|
||||
</strong>{" "}
|
||||
Once deleted, my account and all associated data cannot be recovered.
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "start" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="ack-data"
|
||||
checked={acknowledgments.dataLoss}
|
||||
onChange={() => handleAcknowledgmentChange("dataLoss")}
|
||||
style={{ marginTop: "3px", marginRight: "12px", width: "18px", height: "18px" }}
|
||||
/>
|
||||
<label htmlFor="ack-data" style={{ fontSize: "14px", color: "#374151", cursor: "pointer" }}>
|
||||
<strong style={{ color: "#2c3e50" }}>
|
||||
I understand that all my files and collections will be permanently deleted.
|
||||
</strong>{" "}
|
||||
I have downloaded or backed up any important data I wish to keep.
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "start" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="ack-gdpr"
|
||||
checked={acknowledgments.gdprRights}
|
||||
onChange={() => handleAcknowledgmentChange("gdprRights")}
|
||||
style={{ marginTop: "3px", marginRight: "12px", width: "18px", height: "18px" }}
|
||||
/>
|
||||
<label htmlFor="ack-gdpr" style={{ fontSize: "14px", color: "#374151", cursor: "pointer" }}>
|
||||
<strong style={{ color: "#2c3e50" }}>
|
||||
I am exercising my right to erasure under GDPR Article 17.
|
||||
</strong>{" "}
|
||||
I understand that this will result in the immediate and complete deletion of all my personal data from MapleFile servers.
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Account */}
|
||||
<div style={{
|
||||
background: "#f9fafb",
|
||||
border: "1px solid #e5e7eb",
|
||||
padding: "15px",
|
||||
borderRadius: "8px",
|
||||
}}>
|
||||
<p style={{ margin: 0, fontSize: "14px", color: "#6b7280" }}>
|
||||
Account to be deleted:{" "}
|
||||
<strong style={{ color: "#2c3e50" }}>{userEmail}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", paddingTop: "15px" }}>
|
||||
<button onClick={handleBack} className="nav-button secondary">
|
||||
← Back
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={!allAcknowledged}
|
||||
className="nav-button danger"
|
||||
style={{ opacity: allAcknowledged ? 1 : 0.5, cursor: allAcknowledged ? "pointer" : "not-allowed" }}
|
||||
>
|
||||
Continue to Final Step
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render Step 3: Final Confirmation
|
||||
const renderStep3 = () => (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
|
||||
<div style={{
|
||||
background: "#fee2e2",
|
||||
border: "2px solid #ef4444",
|
||||
padding: "20px",
|
||||
borderRadius: "12px",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "start" }}>
|
||||
<span style={{ fontSize: "32px", marginRight: "15px" }}>⚠️</span>
|
||||
<div>
|
||||
<h3 style={{ margin: "0 0 10px 0", color: "#7f1d1d", fontSize: "20px" }}>
|
||||
Final Confirmation Required
|
||||
</h3>
|
||||
<p style={{ margin: 0, color: "#991b1b" }}>
|
||||
This is your last chance to cancel. After clicking "Delete My Account", your data will be permanently erased.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password Confirmation */}
|
||||
<div style={{
|
||||
background: "white",
|
||||
padding: "25px",
|
||||
borderRadius: "12px",
|
||||
border: "1px solid #e5e7eb",
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "20px",
|
||||
}}>
|
||||
<div>
|
||||
<label htmlFor="password" style={{ display: "block", fontSize: "14px", fontWeight: "500", color: "#374151", marginBottom: "8px" }}>
|
||||
<span style={{ marginRight: "8px" }}>🔒</span>
|
||||
Enter your password to confirm
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: "8px",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
placeholder="Your account password"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirm-text" style={{ display: "block", fontSize: "14px", fontWeight: "500", color: "#374151", marginBottom: "8px" }}>
|
||||
Type <strong>"DELETE MY ACCOUNT"</strong> to confirm (exactly as shown)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="confirm-text"
|
||||
value={confirmText}
|
||||
onChange={(e) => {
|
||||
setConfirmText(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: "8px",
|
||||
fontSize: "14px",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
placeholder="DELETE MY ACCOUNT"
|
||||
disabled={loading}
|
||||
/>
|
||||
{confirmText && !confirmationMatches && (
|
||||
<p style={{ margin: "5px 0 0 0", fontSize: "14px", color: "#dc2626" }}>
|
||||
Text must match exactly: "DELETE MY ACCOUNT"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div style={{
|
||||
background: "#fee2e2",
|
||||
border: "1px solid #fecaca",
|
||||
padding: "15px",
|
||||
borderRadius: "8px",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "start" }}>
|
||||
<span style={{ fontSize: "20px", color: "#dc2626", marginRight: "12px" }}>✗</span>
|
||||
<p style={{ margin: 0, fontSize: "14px", color: "#991b1b" }}>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", paddingTop: "15px" }}>
|
||||
<button
|
||||
onClick={handleBack}
|
||||
disabled={loading}
|
||||
className="nav-button secondary"
|
||||
style={{ opacity: loading ? 0.5 : 1 }}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteAccount}
|
||||
disabled={loading || !password || !confirmationMatches || !allAcknowledged}
|
||||
className="nav-button danger"
|
||||
style={{
|
||||
opacity: (loading || !password || !confirmationMatches || !allAcknowledged) ? 0.5 : 1,
|
||||
cursor: (loading || !password || !confirmationMatches || !allAcknowledged) ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
{loading ? "🔄 Deleting Account..." : "🗑️ Delete My Account"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render Step 4: Success
|
||||
const renderStep4 = () => (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
|
||||
<div style={{
|
||||
background: "#d1fae5",
|
||||
border: "2px solid #10b981",
|
||||
padding: "40px",
|
||||
borderRadius: "12px",
|
||||
textAlign: "center",
|
||||
}}>
|
||||
<span style={{ fontSize: "64px" }}>✓</span>
|
||||
<h3 style={{ margin: "15px 0 10px 0", color: "#065f46", fontSize: "24px" }}>
|
||||
Account Deleted Successfully
|
||||
</h3>
|
||||
<p style={{ margin: "0 0 15px 0", color: "#047857" }}>
|
||||
Your account and all associated data have been permanently deleted from our servers.
|
||||
</p>
|
||||
<p style={{ margin: 0, fontSize: "14px", color: "#059669" }}>
|
||||
You will be redirected to the login page in a few seconds...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: "#dbeafe",
|
||||
border: "1px solid #93c5fd",
|
||||
padding: "20px",
|
||||
borderRadius: "8px",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "start" }}>
|
||||
<span style={{ fontSize: "20px", marginRight: "12px" }}>ℹ️</span>
|
||||
<div style={{ fontSize: "14px", color: "#1e40af" }}>
|
||||
<strong style={{ color: "#1e3a8a" }}>Thank you for using MapleFile.</strong>
|
||||
<p style={{ margin: "8px 0 0 0" }}>
|
||||
If you have any feedback or concerns, please contact our support team. You're always welcome to create a new account in the future.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Progress indicator
|
||||
const renderProgressIndicator = () => {
|
||||
if (currentStep === 4) return null;
|
||||
|
||||
const steps = [
|
||||
{ number: 1, label: "Warning" },
|
||||
{ number: 2, label: "Acknowledgment" },
|
||||
{ number: 3, label: "Confirmation" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: "30px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.number} style={{ display: "flex", alignItems: "center", flex: index < steps.length - 1 ? 1 : "initial" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center" }}>
|
||||
<div style={{
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
borderRadius: "50%",
|
||||
border: `2px solid ${currentStep >= step.number ? "#dc2626" : "#d1d5db"}`,
|
||||
background: currentStep >= step.number ? "#dc2626" : "white",
|
||||
color: currentStep >= step.number ? "white" : "#9ca3af",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "16px",
|
||||
fontWeight: "600",
|
||||
}}>
|
||||
{currentStep > step.number ? "✓" : step.number}
|
||||
</div>
|
||||
<span style={{
|
||||
marginTop: "8px",
|
||||
fontSize: "12px",
|
||||
fontWeight: currentStep >= step.number ? "600" : "normal",
|
||||
color: currentStep >= step.number ? "#dc2626" : "#6b7280",
|
||||
whiteSpace: "nowrap",
|
||||
}}>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div style={{
|
||||
flex: 1,
|
||||
height: "2px",
|
||||
background: currentStep > step.number ? "#dc2626" : "#d1d5db",
|
||||
marginBottom: "24px",
|
||||
marginLeft: "15px",
|
||||
marginRight: "15px",
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<Navigation />
|
||||
<div className="main-content">
|
||||
<Page title="Delete Account">
|
||||
<div style={{ maxWidth: "800px", margin: "0 auto" }}>
|
||||
{/* Breadcrumb */}
|
||||
<div style={{ marginBottom: "20px", fontSize: "14px", color: "#6b7280" }}>
|
||||
<Link to="/dashboard" style={{ color: "#3b82f6", textDecoration: "none" }}>Dashboard</Link>
|
||||
<span style={{ margin: "0 8px" }}>›</span>
|
||||
<Link to="/me" style={{ color: "#3b82f6", textDecoration: "none" }}>My Profile</Link>
|
||||
<span style={{ margin: "0 8px" }}>›</span>
|
||||
<span style={{ color: "#2c3e50", fontWeight: "500" }}>Delete Account</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: "30px" }}>
|
||||
<h1 style={{ margin: 0, color: "#2c3e50", fontSize: "28px", display: "flex", alignItems: "center" }}>
|
||||
<span style={{ fontSize: "32px", marginRight: "12px" }}>🗑️</span>
|
||||
Delete Account
|
||||
</h1>
|
||||
<p style={{ margin: "8px 0 0 0", color: "#6b7280" }}>
|
||||
Permanently delete your MapleFile account and all associated data
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Progress Indicator */}
|
||||
{renderProgressIndicator()}
|
||||
|
||||
{/* Main Content */}
|
||||
<div style={{
|
||||
background: "white",
|
||||
padding: "30px",
|
||||
borderRadius: "12px",
|
||||
boxShadow: "0 4px 6px rgba(0,0,0,0.1)",
|
||||
}}>
|
||||
{currentStep === 1 && renderStep1()}
|
||||
{currentStep === 2 && renderStep2()}
|
||||
{currentStep === 3 && renderStep3()}
|
||||
{currentStep === 4 && renderStep4()}
|
||||
</div>
|
||||
|
||||
{/* Footer Notice */}
|
||||
{currentStep !== 4 && (
|
||||
<div style={{ marginTop: "20px", textAlign: "center", fontSize: "14px", color: "#6b7280" }}>
|
||||
<p style={{ margin: 0 }}>
|
||||
Need help? Contact our support team at{" "}
|
||||
<a href="mailto:support@maplefile.com" style={{ color: "#3b82f6", textDecoration: "none" }}>
|
||||
support@maplefile.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Page>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeleteAccount;
|
||||
1378
native/desktop/maplefile/frontend/src/pages/User/Me/MeDetail.jsx
Normal file
1378
native/desktop/maplefile/frontend/src/pages/User/Me/MeDetail.jsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,391 @@
|
|||
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/Search/FullTextSearch.jsx
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Navigation from "../../../components/Navigation";
|
||||
import Page from "../../../components/Page";
|
||||
|
||||
// Utility function to format file sizes
|
||||
const formatFileSize = (bytes) => {
|
||||
if (!bytes) return "0 B";
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${Math.round(bytes / Math.pow(1024, i))} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const getTimeAgo = (dateString) => {
|
||||
if (!dateString) return "Recently";
|
||||
const now = new Date();
|
||||
const date = new Date(dateString);
|
||||
const diffInMinutes = Math.floor((now - date) / (1000 * 60));
|
||||
|
||||
if (diffInMinutes < 60) return "Just now";
|
||||
if (diffInMinutes < 1440) return "Today";
|
||||
if (diffInMinutes < 2880) return "Yesterday";
|
||||
const diffInDays = Math.floor(diffInMinutes / 1440);
|
||||
if (diffInDays < 7) return `${diffInDays} days ago`;
|
||||
if (diffInDays < 30) return `${Math.floor(diffInDays / 7)} weeks ago`;
|
||||
return `${Math.floor(diffInDays / 30)} months ago`;
|
||||
};
|
||||
|
||||
function FullTextSearch() {
|
||||
const navigate = useNavigate();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState(null);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSearch = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!searchQuery.trim()) {
|
||||
setError("Please enter a search query");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSearching(true);
|
||||
setError("");
|
||||
setSearchResults(null);
|
||||
|
||||
const { Search } = await import("../../../../wailsjs/go/app/Application");
|
||||
const results = await Search({
|
||||
query: searchQuery.trim(),
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
setSearchResults(results);
|
||||
|
||||
if (results.total_files === 0 && results.total_collections === 0) {
|
||||
setError(`No results found for "${searchQuery}"`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Search failed:", err);
|
||||
setError(err.message || "Search failed. Please try again.");
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileClick = (fileId) => {
|
||||
navigate(`/file-manager/files/${fileId}`);
|
||||
};
|
||||
|
||||
const handleCollectionClick = (collectionId) => {
|
||||
navigate(`/file-manager/collections/${collectionId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<Navigation />
|
||||
<div className="main-content">
|
||||
<Page title="Full-Text Search">
|
||||
{/* Search Form */}
|
||||
<div style={{ marginBottom: "30px" }}>
|
||||
<form onSubmit={handleSearch}>
|
||||
<div style={{ display: "flex", gap: "12px" }}>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search files and collections..."
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "12px 16px",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
fontSize: "14px",
|
||||
color: "#333",
|
||||
}}
|
||||
disabled={isSearching}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSearching || !searchQuery.trim()}
|
||||
className="nav-button success"
|
||||
style={{ padding: "12px 24px" }}
|
||||
>
|
||||
{isSearching ? "Searching..." : "🔍 Search"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search Tips */}
|
||||
<div style={{ marginTop: "10px", fontSize: "13px", color: "#666" }}>
|
||||
<p style={{ margin: 0 }}>
|
||||
<strong>Search tips:</strong> Use quotes for exact phrases (e.g., "project report"),
|
||||
+ for AND logic, - to exclude terms
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: "15px",
|
||||
background: "#ffebee",
|
||||
color: "#c62828",
|
||||
borderRadius: "4px",
|
||||
marginBottom: "20px",
|
||||
border: "1px solid #f44336",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Results */}
|
||||
{searchResults && (
|
||||
<>
|
||||
{/* Results Summary */}
|
||||
<div
|
||||
style={{
|
||||
padding: "15px 20px",
|
||||
background: "#e3f2fd",
|
||||
color: "#1976d2",
|
||||
borderRadius: "4px",
|
||||
marginBottom: "30px",
|
||||
border: "1px solid #90caf9",
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: 0, fontWeight: "500" }}>
|
||||
Found <strong>{searchResults.total_files}</strong> file(s) and{" "}
|
||||
<strong>{searchResults.total_collections}</strong> collection(s)
|
||||
{searchResults.total_hits > 0 && (
|
||||
<span> ({searchResults.total_hits} total matches)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Files Section */}
|
||||
{searchResults.files && searchResults.files.length > 0 && (
|
||||
<div style={{ marginBottom: "40px" }}>
|
||||
<h3 style={{ color: "#333", marginBottom: "15px" }}>
|
||||
Files ({searchResults.total_files})
|
||||
</h3>
|
||||
<div
|
||||
style={{
|
||||
background: "white",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #ddd",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead style={{ background: "#f5f5f5", borderBottom: "1px solid #ddd" }}>
|
||||
<tr>
|
||||
<th style={{ padding: "12px", textAlign: "left", fontWeight: "bold", color: "#333" }}>
|
||||
File Name
|
||||
</th>
|
||||
<th style={{ padding: "12px", textAlign: "left", fontWeight: "bold", color: "#333" }}>
|
||||
Collection
|
||||
</th>
|
||||
<th style={{ padding: "12px", textAlign: "left", fontWeight: "bold", color: "#333" }}>
|
||||
Size
|
||||
</th>
|
||||
<th style={{ padding: "12px", textAlign: "left", fontWeight: "bold", color: "#333" }}>
|
||||
Created
|
||||
</th>
|
||||
<th style={{ padding: "12px", textAlign: "left", fontWeight: "bold", color: "#333" }}>
|
||||
Tags
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{searchResults.files.map((file) => (
|
||||
<tr
|
||||
key={file.id}
|
||||
style={{
|
||||
borderBottom: "1px solid #eee",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => handleFileClick(file.id)}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = "#f9f9f9"}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = "white"}
|
||||
>
|
||||
<td style={{ padding: "12px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
|
||||
<span style={{ fontSize: "24px" }}>📄</span>
|
||||
<span style={{ color: "#333" }}>{file.filename}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: "12px" }}>
|
||||
{file.collection_name ? (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "6px", color: "#666" }}>
|
||||
<svg style={{ width: "16px", height: "16px" }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
<span>{file.collection_name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ color: "#ccc" }}>-</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: "12px", color: "#666" }}>
|
||||
{formatFileSize(file.size)}
|
||||
</td>
|
||||
<td style={{ padding: "12px", color: "#666" }}>
|
||||
{getTimeAgo(file.created_at)}
|
||||
</td>
|
||||
<td style={{ padding: "12px" }}>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "4px" }}>
|
||||
{file.tags && file.tags.length > 0 ? (
|
||||
file.tags.slice(0, 3).map((tag, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "2px 8px",
|
||||
borderRadius: "12px",
|
||||
fontSize: "11px",
|
||||
backgroundColor: "#e3f2fd",
|
||||
color: "#1976d2",
|
||||
fontWeight: "500",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span style={{ color: "#ccc", fontSize: "11px" }}>-</span>
|
||||
)}
|
||||
{file.tags && file.tags.length > 3 && (
|
||||
<span style={{ color: "#999", fontSize: "11px" }}>
|
||||
+{file.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collections Section */}
|
||||
{searchResults.collections && searchResults.collections.length > 0 && (
|
||||
<div style={{ marginBottom: "40px" }}>
|
||||
<h3 style={{ color: "#333", marginBottom: "15px" }}>
|
||||
Collections ({searchResults.total_collections})
|
||||
</h3>
|
||||
<div
|
||||
style={{
|
||||
background: "white",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #ddd",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead style={{ background: "#f5f5f5", borderBottom: "1px solid #ddd" }}>
|
||||
<tr>
|
||||
<th style={{ padding: "12px", textAlign: "left", fontWeight: "bold", color: "#333" }}>
|
||||
Collection Name
|
||||
</th>
|
||||
<th style={{ padding: "12px", textAlign: "center", fontWeight: "bold", color: "#333" }}>
|
||||
Files
|
||||
</th>
|
||||
<th style={{ padding: "12px", textAlign: "left", fontWeight: "bold", color: "#333" }}>
|
||||
Created
|
||||
</th>
|
||||
<th style={{ padding: "12px", textAlign: "left", fontWeight: "bold", color: "#333" }}>
|
||||
Tags
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{searchResults.collections.map((collection) => (
|
||||
<tr
|
||||
key={collection.id}
|
||||
style={{
|
||||
borderBottom: "1px solid #eee",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => handleCollectionClick(collection.id)}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = "#f9f9f9"}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = "white"}
|
||||
>
|
||||
<td style={{ padding: "12px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
|
||||
<span style={{ fontSize: "24px" }}>📁</span>
|
||||
<span style={{ color: "#333" }}>{collection.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: "12px", textAlign: "center", color: "#666" }}>
|
||||
{collection.file_count}
|
||||
</td>
|
||||
<td style={{ padding: "12px", color: "#666" }}>
|
||||
{getTimeAgo(collection.created_at)}
|
||||
</td>
|
||||
<td style={{ padding: "12px" }}>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "4px" }}>
|
||||
{collection.tags && collection.tags.length > 0 ? (
|
||||
collection.tags.slice(0, 3).map((tag, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "2px 8px",
|
||||
borderRadius: "12px",
|
||||
fontSize: "11px",
|
||||
backgroundColor: "#e3f2fd",
|
||||
color: "#1976d2",
|
||||
fontWeight: "500",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span style={{ color: "#ccc", fontSize: "11px" }}>-</span>
|
||||
)}
|
||||
{collection.tags && collection.tags.length > 3 && (
|
||||
<span style={{ color: "#999", fontSize: "11px" }}>
|
||||
+{collection.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Empty State - No Search Yet */}
|
||||
{!searchResults && !error && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "60px 20px",
|
||||
background: "#f5f5f5",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "64px", marginBottom: "15px" }}>
|
||||
🔍
|
||||
</div>
|
||||
<h3 style={{ marginBottom: "10px", color: "#333" }}>
|
||||
Search Your Files and Collections
|
||||
</h3>
|
||||
<p style={{ color: "#666", maxWidth: "500px", margin: "0 auto" }}>
|
||||
Enter a search query above to find files and collections. You can search by filename,
|
||||
collection name, or tags.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Page>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FullTextSearch;
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/Tags/TagCreate.jsx
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Navigation from "../../../components/Navigation";
|
||||
import Page from "../../../components/Page";
|
||||
import {
|
||||
CreateTag,
|
||||
} from "../../../../wailsjs/go/app/Application";
|
||||
|
||||
function TagCreate() {
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
color: "#3B82F6"
|
||||
});
|
||||
const [formErrors, setFormErrors] = useState({});
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate
|
||||
const errors = {};
|
||||
if (!formData.name.trim()) {
|
||||
errors.name = "Tag name is required";
|
||||
}
|
||||
if (!formData.color) {
|
||||
errors.color = "Tag color is required";
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setFormErrors(errors);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
setFormErrors({});
|
||||
|
||||
await CreateTag(formData.name.trim(), formData.color);
|
||||
|
||||
// Navigate back to profile page with tags tab selected
|
||||
navigate("/me?tab=tags");
|
||||
} catch (err) {
|
||||
console.error("Failed to create tag:", err);
|
||||
setError(err.message || "Failed to create tag");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Navigation />
|
||||
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "20px" }}>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: "30px" }}>
|
||||
<button
|
||||
onClick={() => navigate("/me?tab=tags")}
|
||||
className="nav-button secondary"
|
||||
style={{ marginBottom: "16px" }}
|
||||
>
|
||||
← Back to Tags
|
||||
</button>
|
||||
<h1 style={{ margin: "0 0 8px 0", fontSize: "28px", fontWeight: "600" }}>
|
||||
Create Tag
|
||||
</h1>
|
||||
<p style={{ margin: 0, color: "#666" }}>
|
||||
Create a new tag to organize your files and collections
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: "12px",
|
||||
marginBottom: "20px",
|
||||
backgroundColor: "#fee",
|
||||
border: "1px solid #fcc",
|
||||
borderRadius: "4px",
|
||||
color: "#c00"
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<div style={{
|
||||
backgroundColor: "#fff",
|
||||
padding: "24px",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #e0e0e0",
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,0.1)"
|
||||
}}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Tag Name */}
|
||||
<div style={{ marginBottom: "20px" }}>
|
||||
<label
|
||||
htmlFor="tag_name"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "8px",
|
||||
fontWeight: "500",
|
||||
fontSize: "14px"
|
||||
}}
|
||||
>
|
||||
Tag Name <span style={{ color: "#c00" }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="tag_name"
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, name: e.target.value });
|
||||
setFormErrors({ ...formErrors, name: "" });
|
||||
}}
|
||||
placeholder="e.g., Important, Work, Personal"
|
||||
maxLength={50}
|
||||
required
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 12px",
|
||||
fontSize: "14px",
|
||||
border: formErrors.name ? "1px solid #c00" : "1px solid #ccc",
|
||||
borderRadius: "4px",
|
||||
outline: "none",
|
||||
boxSizing: "border-box"
|
||||
}}
|
||||
/>
|
||||
{formErrors.name && (
|
||||
<p style={{ margin: "6px 0 0 0", fontSize: "13px", color: "#c00" }}>
|
||||
{formErrors.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tag Color */}
|
||||
<div style={{ marginBottom: "24px" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "8px",
|
||||
fontWeight: "500",
|
||||
fontSize: "14px"
|
||||
}}
|
||||
>
|
||||
Tag Color <span style={{ color: "#c00" }}>*</span>
|
||||
</label>
|
||||
<div style={{ display: "flex", gap: "16px", alignItems: "center" }}>
|
||||
<input
|
||||
type="color"
|
||||
value={formData.color}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, color: e.target.value });
|
||||
setFormErrors({ ...formErrors, color: "" });
|
||||
}}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
width: "80px",
|
||||
height: "48px",
|
||||
border: "2px solid #ccc",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer"
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.color}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, color: e.target.value });
|
||||
setFormErrors({ ...formErrors, color: "" });
|
||||
}}
|
||||
placeholder="#3B82F6"
|
||||
maxLength={7}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 12px",
|
||||
fontSize: "14px",
|
||||
border: formErrors.color ? "1px solid #c00" : "1px solid #ccc",
|
||||
borderRadius: "4px",
|
||||
outline: "none",
|
||||
fontFamily: "monospace",
|
||||
boxSizing: "border-box"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{formErrors.color && (
|
||||
<p style={{ margin: "6px 0 0 0", fontSize: "13px", color: "#c00" }}>
|
||||
{formErrors.color}
|
||||
</p>
|
||||
)}
|
||||
<p style={{ margin: "8px 0 0 0", fontSize: "12px", color: "#666" }}>
|
||||
Choose a color to identify this tag visually
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div style={{ display: "flex", gap: "12px", paddingTop: "16px", borderTop: "1px solid #e0e0e0" }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="nav-button"
|
||||
style={{
|
||||
padding: "10px 20px",
|
||||
cursor: isLoading ? "wait" : "pointer"
|
||||
}}
|
||||
>
|
||||
{isLoading ? "Creating..." : "Create Tag"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/me?tab=tags")}
|
||||
disabled={isLoading}
|
||||
className="nav-button secondary"
|
||||
style={{
|
||||
padding: "10px 20px"
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default TagCreate;
|
||||
|
|
@ -0,0 +1,281 @@
|
|||
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/Tags/TagEdit.jsx
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import Navigation from "../../../components/Navigation";
|
||||
import Page from "../../../components/Page";
|
||||
import {
|
||||
ListTags,
|
||||
UpdateTag,
|
||||
} from "../../../../wailsjs/go/app/Application";
|
||||
|
||||
function TagEdit() {
|
||||
const navigate = useNavigate();
|
||||
const { tagId } = useParams();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
color: "#3B82F6"
|
||||
});
|
||||
const [formErrors, setFormErrors] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
loadTag();
|
||||
}, [tagId]);
|
||||
|
||||
const loadTag = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
// Fetch all tags and find the one we need
|
||||
const tags = await ListTags();
|
||||
const tag = tags.find((t) => t.id === tagId);
|
||||
|
||||
if (!tag) {
|
||||
setError("Tag not found");
|
||||
setTimeout(() => navigate("/me?tab=tags"), 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
setFormData({
|
||||
name: tag.name,
|
||||
color: tag.color
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to load tag:", err);
|
||||
setError(err.message || "Failed to load tag");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate
|
||||
const errors = {};
|
||||
if (!formData.name.trim()) {
|
||||
errors.name = "Tag name is required";
|
||||
}
|
||||
if (!formData.color) {
|
||||
errors.color = "Tag color is required";
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setFormErrors(errors);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setError("");
|
||||
setFormErrors({});
|
||||
|
||||
await UpdateTag(tagId, formData.name.trim(), formData.color);
|
||||
|
||||
// Navigate back to profile page with tags tab selected
|
||||
navigate("/me?tab=tags");
|
||||
} catch (err) {
|
||||
console.error("Failed to update tag:", err);
|
||||
setError(err.message || "Failed to update tag");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Navigation />
|
||||
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "20px" }}>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: "30px" }}>
|
||||
<button
|
||||
onClick={() => navigate("/me?tab=tags")}
|
||||
className="nav-button secondary"
|
||||
style={{ marginBottom: "16px" }}
|
||||
>
|
||||
← Back to Tags
|
||||
</button>
|
||||
<h1 style={{ margin: "0 0 8px 0", fontSize: "28px", fontWeight: "600" }}>
|
||||
Edit Tag
|
||||
</h1>
|
||||
<p style={{ margin: 0, color: "#666" }}>
|
||||
Update tag information
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: "12px",
|
||||
marginBottom: "20px",
|
||||
backgroundColor: "#fee",
|
||||
border: "1px solid #fcc",
|
||||
borderRadius: "4px",
|
||||
color: "#c00"
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading or Form */}
|
||||
{isLoading ? (
|
||||
<div style={{
|
||||
backgroundColor: "#fff",
|
||||
padding: "40px",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #e0e0e0",
|
||||
textAlign: "center"
|
||||
}}>
|
||||
<p>Loading tag...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
backgroundColor: "#fff",
|
||||
padding: "24px",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #e0e0e0",
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,0.1)"
|
||||
}}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Tag Name */}
|
||||
<div style={{ marginBottom: "20px" }}>
|
||||
<label
|
||||
htmlFor="tag_name"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "8px",
|
||||
fontWeight: "500",
|
||||
fontSize: "14px"
|
||||
}}
|
||||
>
|
||||
Tag Name <span style={{ color: "#c00" }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="tag_name"
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, name: e.target.value });
|
||||
setFormErrors({ ...formErrors, name: "" });
|
||||
}}
|
||||
placeholder="e.g., Important, Work, Personal"
|
||||
maxLength={50}
|
||||
required
|
||||
disabled={isSaving}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 12px",
|
||||
fontSize: "14px",
|
||||
border: formErrors.name ? "1px solid #c00" : "1px solid #ccc",
|
||||
borderRadius: "4px",
|
||||
outline: "none",
|
||||
boxSizing: "border-box"
|
||||
}}
|
||||
/>
|
||||
{formErrors.name && (
|
||||
<p style={{ margin: "6px 0 0 0", fontSize: "13px", color: "#c00" }}>
|
||||
{formErrors.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tag Color */}
|
||||
<div style={{ marginBottom: "24px" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: "8px",
|
||||
fontWeight: "500",
|
||||
fontSize: "14px"
|
||||
}}
|
||||
>
|
||||
Tag Color <span style={{ color: "#c00" }}>*</span>
|
||||
</label>
|
||||
<div style={{ display: "flex", gap: "16px", alignItems: "center" }}>
|
||||
<input
|
||||
type="color"
|
||||
value={formData.color}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, color: e.target.value });
|
||||
setFormErrors({ ...formErrors, color: "" });
|
||||
}}
|
||||
disabled={isSaving}
|
||||
style={{
|
||||
width: "80px",
|
||||
height: "48px",
|
||||
border: "2px solid #ccc",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer"
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.color}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, color: e.target.value });
|
||||
setFormErrors({ ...formErrors, color: "" });
|
||||
}}
|
||||
placeholder="#3B82F6"
|
||||
maxLength={7}
|
||||
disabled={isSaving}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 12px",
|
||||
fontSize: "14px",
|
||||
border: formErrors.color ? "1px solid #c00" : "1px solid #ccc",
|
||||
borderRadius: "4px",
|
||||
outline: "none",
|
||||
fontFamily: "monospace",
|
||||
boxSizing: "border-box"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{formErrors.color && (
|
||||
<p style={{ margin: "6px 0 0 0", fontSize: "13px", color: "#c00" }}>
|
||||
{formErrors.color}
|
||||
</p>
|
||||
)}
|
||||
<p style={{ margin: "8px 0 0 0", fontSize: "12px", color: "#666" }}>
|
||||
Choose a color to identify this tag visually
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div style={{ display: "flex", gap: "12px", paddingTop: "16px", borderTop: "1px solid #e0e0e0" }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="nav-button"
|
||||
style={{
|
||||
padding: "10px 20px",
|
||||
cursor: isSaving ? "wait" : "pointer"
|
||||
}}
|
||||
>
|
||||
{isSaving ? "Updating..." : "Update Tag"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/me?tab=tags")}
|
||||
disabled={isSaving}
|
||||
className="nav-button secondary"
|
||||
style={{
|
||||
padding: "10px 20px"
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default TagEdit;
|
||||
|
|
@ -0,0 +1,476 @@
|
|||
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/Tags/TagSearch.jsx
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Navigation from "../../../components/Navigation";
|
||||
import Page from "../../../components/Page";
|
||||
|
||||
function TagSearch() {
|
||||
const navigate = useNavigate();
|
||||
const [availableTags, setAvailableTags] = useState([]);
|
||||
const [selectedTagIds, setSelectedTagIds] = useState([]);
|
||||
const [searchResults, setSearchResults] = useState(null);
|
||||
const [decryptedCollections, setDecryptedCollections] = useState([]);
|
||||
const [decryptedFiles, setDecryptedFiles] = useState([]);
|
||||
const [isLoadingTags, setIsLoadingTags] = useState(true);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isDecrypting, setIsDecrypting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const loadTags = async () => {
|
||||
try {
|
||||
setIsLoadingTags(true);
|
||||
setError("");
|
||||
const { ListTags } = await import("../../../../wailsjs/go/app/Application");
|
||||
const fetchedTags = await ListTags();
|
||||
setAvailableTags(fetchedTags || []);
|
||||
} catch (err) {
|
||||
console.error("Failed to load tags:", err);
|
||||
setError(err.message || "Failed to load tags");
|
||||
} finally {
|
||||
setIsLoadingTags(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for Wails runtime to be ready before loading data
|
||||
let attempts = 0;
|
||||
const maxAttempts = 50; // 5 seconds max (50 * 100ms)
|
||||
let isCancelled = false; // Prevent race conditions
|
||||
|
||||
const checkWailsReady = () => {
|
||||
if (isCancelled) return;
|
||||
|
||||
if (window.go && window.go.app && window.go.app.Application) {
|
||||
loadTags();
|
||||
} else if (attempts < maxAttempts) {
|
||||
attempts++;
|
||||
setTimeout(checkWailsReady, 100);
|
||||
} else {
|
||||
// Timeout - show error
|
||||
if (!isCancelled) {
|
||||
setError("Failed to initialize application. Please reload.");
|
||||
setIsLoadingTags(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkWailsReady();
|
||||
|
||||
// Cleanup function to prevent race conditions with StrictMode
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleTagToggle = (tagId) => {
|
||||
if (selectedTagIds.includes(tagId)) {
|
||||
setSelectedTagIds(selectedTagIds.filter((id) => id !== tagId));
|
||||
} else {
|
||||
setSelectedTagIds([...selectedTagIds, tagId]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (selectedTagIds.length === 0) {
|
||||
setError("Please select at least one tag");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSearching(true);
|
||||
setError("");
|
||||
setDecryptedCollections([]);
|
||||
setDecryptedFiles([]);
|
||||
|
||||
const { SearchByTags, GetCollection, GetFile } = await import("../../../../wailsjs/go/app/Application");
|
||||
const results = await SearchByTags(selectedTagIds, 50); // Limit to 50 results
|
||||
setSearchResults(results);
|
||||
|
||||
// Fetch and decrypt collection details
|
||||
if (results.collection_ids && results.collection_ids.length > 0) {
|
||||
setIsDecrypting(true);
|
||||
const collectionPromises = results.collection_ids.map(async (id) => {
|
||||
try {
|
||||
return await GetCollection(id);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch collection:", id, error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const collections = (await Promise.all(collectionPromises)).filter(Boolean);
|
||||
setDecryptedCollections(collections);
|
||||
}
|
||||
|
||||
// Fetch and decrypt file details
|
||||
if (results.file_ids && results.file_ids.length > 0) {
|
||||
setIsDecrypting(true);
|
||||
const filePromises = results.file_ids.map(async (id) => {
|
||||
try {
|
||||
return await GetFile(id);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch file:", id, error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const files = (await Promise.all(filePromises)).filter(Boolean);
|
||||
setDecryptedFiles(files);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to search by tags:", err);
|
||||
setError(err.message || "Failed to search by tags");
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
setIsDecrypting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearSelection = () => {
|
||||
setSelectedTagIds([]);
|
||||
setSearchResults(null);
|
||||
setDecryptedCollections([]);
|
||||
setDecryptedFiles([]);
|
||||
setError("");
|
||||
};
|
||||
|
||||
const handleCollectionClick = (collectionId) => {
|
||||
navigate(`/file-manager/collections/${collectionId}`);
|
||||
};
|
||||
|
||||
const handleFileClick = (fileId) => {
|
||||
navigate(`/file-manager/files/${fileId}`);
|
||||
};
|
||||
|
||||
if (isLoadingTags) {
|
||||
return (
|
||||
<div className="layout">
|
||||
<Navigation />
|
||||
<div className="main-content">
|
||||
<Page title="Search by Tags">
|
||||
<div style={{ padding: "20px", textAlign: "center" }}>
|
||||
<p>Loading tags...</p>
|
||||
</div>
|
||||
</Page>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !searchResults) {
|
||||
return (
|
||||
<div className="layout">
|
||||
<Navigation />
|
||||
<div className="main-content">
|
||||
<Page title="Search by Tags">
|
||||
<div style={{ padding: "20px", textAlign: "center" }}>
|
||||
<p style={{ color: "#d32f2f" }}>{error}</p>
|
||||
<button
|
||||
onClick={loadTags}
|
||||
style={{
|
||||
marginTop: "10px",
|
||||
padding: "8px 16px",
|
||||
background: "#1976d2",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</Page>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<Navigation />
|
||||
<div className="main-content">
|
||||
<Page title="Search by Tags">
|
||||
<div style={{ padding: "20px" }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "20px",
|
||||
}}
|
||||
>
|
||||
<h1 style={{ margin: 0 }}>Search by Tags</h1>
|
||||
<button
|
||||
onClick={() => navigate("/tags")}
|
||||
className="nav-button"
|
||||
style={{ padding: "8px 16px" }}
|
||||
>
|
||||
← Back to Tags
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tag Selection */}
|
||||
<div
|
||||
style={{
|
||||
background: "#f5f5f5",
|
||||
padding: "15px",
|
||||
borderRadius: "4px",
|
||||
marginBottom: "20px",
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginTop: 0 }}>
|
||||
Select Tags ({selectedTagIds.length} selected)
|
||||
</h3>
|
||||
{availableTags.length === 0 ? (
|
||||
<p>No tags available. Create tags first to use search.</p>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "10px",
|
||||
marginBottom: "15px",
|
||||
}}
|
||||
>
|
||||
{availableTags.map((tag) => {
|
||||
const isSelected = selectedTagIds.includes(tag.id);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => handleTagToggle(tag.id)}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
border: `2px solid ${tag.color || "#666"}`,
|
||||
borderRadius: "16px",
|
||||
background: isSelected ? tag.color || "#666" : "white",
|
||||
color: isSelected ? "white" : tag.color || "#666",
|
||||
cursor: "pointer",
|
||||
fontWeight: "500",
|
||||
transition: "all 0.2s",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
borderRadius: "50%",
|
||||
background: isSelected ? "white" : tag.color || "#666",
|
||||
}}
|
||||
/>
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", gap: "10px" }}>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={selectedTagIds.length === 0 || isSearching}
|
||||
style={{
|
||||
padding: "10px 20px",
|
||||
background:
|
||||
selectedTagIds.length === 0 || isSearching
|
||||
? "#ccc"
|
||||
: "#1976d2",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor:
|
||||
selectedTagIds.length === 0 || isSearching
|
||||
? "not-allowed"
|
||||
: "pointer",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
{isSearching ? "Searching..." : "Search"}
|
||||
</button>
|
||||
{selectedTagIds.length > 0 && (
|
||||
<button
|
||||
onClick={handleClearSelection}
|
||||
style={{
|
||||
padding: "10px 20px",
|
||||
background: "white",
|
||||
color: "#666",
|
||||
border: "1px solid #ccc",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Clear Selection
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Results */}
|
||||
{searchResults && (
|
||||
<div>
|
||||
<h2 style={{ marginBottom: "15px" }}>
|
||||
Search Results - {searchResults.collection_count} Collections,{" "}
|
||||
{searchResults.file_count} Files
|
||||
</h2>
|
||||
|
||||
{searchResults.collection_count === 0 &&
|
||||
searchResults.file_count === 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: "40px",
|
||||
textAlign: "center",
|
||||
background: "#f9f9f9",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
<p style={{ fontSize: "16px", color: "#666" }}>
|
||||
No collections or files found with the selected tags.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decrypting Indicator */}
|
||||
{isDecrypting && (
|
||||
<div
|
||||
style={{
|
||||
padding: "20px",
|
||||
textAlign: "center",
|
||||
background: "#f9f9f9",
|
||||
borderRadius: "4px",
|
||||
marginBottom: "20px",
|
||||
}}
|
||||
>
|
||||
<p style={{ fontSize: "14px", color: "#666" }}>
|
||||
Decrypting results...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collections Section */}
|
||||
{decryptedCollections.length > 0 && (
|
||||
<div style={{ marginBottom: "30px" }}>
|
||||
<h3 style={{ marginBottom: "10px" }}>
|
||||
Collections ({decryptedCollections.length})
|
||||
</h3>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
|
||||
{decryptedCollections.map((collection) => (
|
||||
<div
|
||||
key={collection.id}
|
||||
onClick={() => handleCollectionClick(collection.id)}
|
||||
style={{
|
||||
padding: "15px",
|
||||
background: "white",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "#f5f5f5";
|
||||
e.currentTarget.style.borderColor = "#1976d2";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "white";
|
||||
e.currentTarget.style.borderColor = "#ddd";
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "24px" }}>📁</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: "600", fontSize: "15px", color: "#333", marginBottom: "4px" }}>
|
||||
{collection.name}
|
||||
</div>
|
||||
{collection.description && (
|
||||
<div style={{ fontSize: "13px", color: "#666", marginBottom: "4px" }}>
|
||||
{collection.description}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: "12px", color: "#999" }}>
|
||||
{collection.file_count || 0} files • Created {new Date(collection.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Files Section */}
|
||||
{decryptedFiles.length > 0 && (
|
||||
<div>
|
||||
<h3 style={{ marginBottom: "10px" }}>
|
||||
Files ({decryptedFiles.length})
|
||||
</h3>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
|
||||
{decryptedFiles.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
onClick={() => handleFileClick(file.id)}
|
||||
style={{
|
||||
padding: "15px",
|
||||
background: "white",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "#f5f5f5";
|
||||
e.currentTarget.style.borderColor = "#1976d2";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "white";
|
||||
e.currentTarget.style.borderColor = "#ddd";
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "24px" }}>📄</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: "600", fontSize: "15px", color: "#333", marginBottom: "4px" }}>
|
||||
{file.filename}
|
||||
</div>
|
||||
<div style={{ fontSize: "12px", color: "#999" }}>
|
||||
{formatFileSize(file.size)} • Uploaded {new Date(file.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Page>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to format file size
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i];
|
||||
}
|
||||
|
||||
export default TagSearch;
|
||||
|
|
@ -0,0 +1,303 @@
|
|||
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/Tags/TagsList.jsx
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ListTags,
|
||||
DeleteTag,
|
||||
} from "../../../../wailsjs/go/app/Application";
|
||||
|
||||
function TagsList() {
|
||||
const navigate = useNavigate();
|
||||
const [tags, setTags] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [deleteError, setDeleteError] = useState("");
|
||||
const [deletingTagId, setDeletingTagId] = useState(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(null); // { tagId, tagName }
|
||||
|
||||
useEffect(() => {
|
||||
loadTags();
|
||||
}, []);
|
||||
|
||||
const loadTags = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
const fetchedTags = await ListTags();
|
||||
setTags(fetchedTags || []);
|
||||
} catch (err) {
|
||||
console.error("Failed to load tags:", err);
|
||||
setError(err.message || "Failed to load tags");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = (tagId, tagName) => {
|
||||
console.log("[TagsList] Delete button clicked:", tagId, tagName);
|
||||
setConfirmDelete({ tagId, tagName });
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!confirmDelete) return;
|
||||
|
||||
const { tagId, tagName } = confirmDelete;
|
||||
|
||||
try {
|
||||
setDeletingTagId(tagId);
|
||||
setDeleteError("");
|
||||
setConfirmDelete(null);
|
||||
|
||||
console.log("[TagsList] Deleting tag:", tagId, tagName);
|
||||
const result = await DeleteTag(tagId);
|
||||
console.log("[TagsList] Delete result:", result);
|
||||
|
||||
// Reload tags after successful deletion
|
||||
console.log("[TagsList] Reloading tags after delete");
|
||||
await loadTags();
|
||||
console.log("[TagsList] Tags reloaded successfully");
|
||||
} catch (err) {
|
||||
console.error("[TagsList] Failed to delete tag:", err);
|
||||
console.error("[TagsList] Error details:", {
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
error: err
|
||||
});
|
||||
setDeleteError(err.message || "Failed to delete tag");
|
||||
} finally {
|
||||
setDeletingTagId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCancel = () => {
|
||||
console.log("[TagsList] Delete cancelled");
|
||||
setConfirmDelete(null);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ textAlign: "center", padding: "40px" }}>
|
||||
<p>Loading tags...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header with Buttons */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "20px" }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: "24px", fontWeight: "600" }}>Tags</h2>
|
||||
<p style={{ margin: "5px 0 0 0", color: "#666", fontSize: "14px" }}>
|
||||
Create and organize tags for your files and collections
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "10px", marginLeft: "auto" }}>
|
||||
<button
|
||||
onClick={() => navigate("/tags/search")}
|
||||
className="nav-button"
|
||||
style={{ background: "#1976d2", color: "white" }}
|
||||
>
|
||||
🔍 Search by Tags
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate("/me/tags/create")}
|
||||
className="nav-button"
|
||||
>
|
||||
+ New Tag
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Errors */}
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: "12px",
|
||||
marginBottom: "16px",
|
||||
backgroundColor: "#fee",
|
||||
border: "1px solid #fcc",
|
||||
borderRadius: "4px",
|
||||
color: "#c00"
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deleteError && (
|
||||
<div style={{
|
||||
padding: "12px",
|
||||
marginBottom: "16px",
|
||||
backgroundColor: "#fee",
|
||||
border: "1px solid #fcc",
|
||||
borderRadius: "4px",
|
||||
color: "#c00"
|
||||
}}>
|
||||
{deleteError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags List */}
|
||||
{tags.length === 0 ? (
|
||||
<div style={{
|
||||
textAlign: "center",
|
||||
padding: "60px 20px",
|
||||
backgroundColor: "#f9f9f9",
|
||||
borderRadius: "8px",
|
||||
border: "2px dashed #ddd"
|
||||
}}>
|
||||
<h3 style={{ margin: "0 0 10px 0", color: "#666" }}>No tags yet</h3>
|
||||
<p style={{ margin: "0 0 20px 0", color: "#999" }}>
|
||||
Create your first tag to organize your files and collections
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate("/me/tags/create")}
|
||||
className="nav-button"
|
||||
>
|
||||
+ Create Your First Tag
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
||||
gap: "16px"
|
||||
}}>
|
||||
{tags.map((tag) => (
|
||||
<div
|
||||
key={tag.id}
|
||||
style={{
|
||||
padding: "16px",
|
||||
backgroundColor: "#fff",
|
||||
border: "1px solid #e0e0e0",
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
|
||||
transition: "box-shadow 0.2s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.boxShadow = "0 4px 8px rgba(0,0,0,0.15)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.boxShadow = "0 1px 3px rgba(0,0,0,0.1)";
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px", marginBottom: "8px" }}>
|
||||
<div
|
||||
style={{
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: tag.color,
|
||||
flexShrink: 0,
|
||||
border: "2px solid rgba(0,0,0,0.1)"
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<h4 style={{
|
||||
margin: 0,
|
||||
fontSize: "16px",
|
||||
fontWeight: "600",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap"
|
||||
}}>
|
||||
{tag.name}
|
||||
</h4>
|
||||
<p style={{
|
||||
margin: "2px 0 0 0",
|
||||
fontSize: "12px",
|
||||
color: "#888",
|
||||
fontFamily: "monospace"
|
||||
}}>
|
||||
{tag.color}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "8px", marginTop: "12px" }}>
|
||||
<button
|
||||
onClick={() => navigate(`/me/tags/${tag.id}/edit`)}
|
||||
className="nav-button secondary"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "6px 12px",
|
||||
fontSize: "13px"
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteClick(tag.id, tag.name)}
|
||||
disabled={deletingTagId === tag.id}
|
||||
className="nav-button secondary"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "6px 12px",
|
||||
fontSize: "13px",
|
||||
backgroundColor: deletingTagId === tag.id ? "#ccc" : undefined,
|
||||
cursor: deletingTagId === tag.id ? "wait" : "pointer"
|
||||
}}
|
||||
>
|
||||
{deletingTagId === tag.id ? "Deleting..." : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{confirmDelete && (
|
||||
<div style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: "#fff",
|
||||
padding: "24px",
|
||||
borderRadius: "8px",
|
||||
maxWidth: "400px",
|
||||
width: "90%",
|
||||
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)"
|
||||
}}>
|
||||
<h3 style={{ margin: "0 0 16px 0", fontSize: "18px", fontWeight: "600" }}>
|
||||
Delete Tag
|
||||
</h3>
|
||||
<p style={{ margin: "0 0 20px 0", color: "#666" }}>
|
||||
Are you sure you want to delete tag "{confirmDelete.tagName}"? This action cannot be undone.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: "12px", justifyContent: "flex-end" }}>
|
||||
<button
|
||||
onClick={handleDeleteCancel}
|
||||
className="nav-button secondary"
|
||||
style={{ padding: "8px 16px" }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteConfirm}
|
||||
className="nav-button"
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
backgroundColor: "#dc2626",
|
||||
borderColor: "#dc2626"
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TagsList;
|
||||
26
native/desktop/maplefile/frontend/src/style.css
Normal file
26
native/desktop/maplefile/frontend/src/style.css
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
html {
|
||||
background-color: rgba(27, 38, 54, 1);
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: white;
|
||||
font-family: "Nunito", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
|
||||
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Nunito";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: local(""),
|
||||
url("assets/fonts/nunito-v16-latin-regular.woff2") format("woff2");
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100vh;
|
||||
text-align: center;
|
||||
}
|
||||
7
native/desktop/maplefile/frontend/vite.config.js
Normal file
7
native/desktop/maplefile/frontend/vite.config.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import {defineConfig} from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()]
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue