Initial commit: Open sourcing all of the Maple Open Technologies code.

This commit is contained in:
Bartlomiej Mika 2025-12-02 14:33:08 -05:00
commit 755d54a99d
2010 changed files with 448675 additions and 0 deletions

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

File diff suppressed because it is too large Load diff

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

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

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

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

After

Width:  |  Height:  |  Size: 136 KiB

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

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

View file

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

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

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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" }}
>
&larr; 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;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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