This commit is contained in:
Rodolfo Martinez 2025-12-05 12:24:07 -05:00
parent 81f60acd06
commit 8380cb8e6a
36 changed files with 5342 additions and 4434 deletions

View file

@ -16,10 +16,12 @@ import {
* *
* @param {string} className - Additional CSS classes * @param {string} className - Additional CSS classes
* @param {string} containerClassName - Additional classes for the outer container * @param {string} containerClassName - Additional classes for the outer container
* @param {boolean} showSecurityFeatures - Whether to show the security features section (default: true)
*/ */
const GDPRFooter = memo(function GDPRFooter({ const GDPRFooter = memo(function GDPRFooter({
className = "", className = "",
containerClassName = "" containerClassName = "",
showSecurityFeatures = true,
}) { }) {
const { getThemeClasses } = useUIXTheme(); const { getThemeClasses } = useUIXTheme();
@ -36,29 +38,31 @@ const GDPRFooter = memo(function GDPRFooter({
<div className={`flex-shrink-0 border-t ${themeClasses.borderSecondary} ${themeClasses.bgCard}/50 backdrop-blur-sm relative z-10 ${containerClassName}`}> <div className={`flex-shrink-0 border-t ${themeClasses.borderSecondary} ${themeClasses.bgCard}/50 backdrop-blur-sm relative z-10 ${containerClassName}`}>
<div className={`max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 ${className}`}> <div className={`max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 ${className}`}>
{/* Security Features */} {/* Security Features */}
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-8 text-sm"> {showSecurityFeatures && (
<div className="flex items-center space-x-2"> <div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-8 text-sm">
<ShieldCheckIcon className={`h-4 w-4 ${getThemeClasses("icon-success")}`} /> <div className="flex items-center space-x-2">
<span className={getThemeClasses("text-secondary")}> <ShieldCheckIcon className={`h-4 w-4 ${getThemeClasses("icon-success")}`} />
ChaCha20-Poly1305 Encryption <span className={getThemeClasses("text-secondary")}>
</span> ChaCha20-Poly1305 Encryption
</span>
</div>
<div className="flex items-center space-x-2">
<ServerIcon className={`h-4 w-4 ${getThemeClasses("icon-info")}`} />
<span className={getThemeClasses("text-secondary")}>Canadian Hosted</span>
</div>
<div className="flex items-center space-x-2">
<GlobeAltIcon className={`h-4 w-4 ${getThemeClasses("icon-privacy")}`} />
<span className={getThemeClasses("text-secondary")}>Privacy First</span>
</div>
<div className="flex items-center space-x-2">
<HeartIcon className={`h-4 w-4 ${getThemeClasses("icon-featured")}`} />
<span className={getThemeClasses("text-secondary")}>Made in Canada</span>
</div>
</div> </div>
<div className="flex items-center space-x-2"> )}
<ServerIcon className={`h-4 w-4 ${getThemeClasses("icon-info")}`} />
<span className={getThemeClasses("text-secondary")}>Canadian Hosted</span>
</div>
<div className="flex items-center space-x-2">
<GlobeAltIcon className={`h-4 w-4 ${getThemeClasses("icon-privacy")}`} />
<span className={getThemeClasses("text-secondary")}>Privacy First</span>
</div>
<div className="flex items-center space-x-2">
<HeartIcon className={`h-4 w-4 ${getThemeClasses("icon-featured")}`} />
<span className={getThemeClasses("text-secondary")}>Made in Canada</span>
</div>
</div>
{/* GDPR Information */} {/* GDPR Information */}
<div className={`mt-4 text-center text-xs ${getThemeClasses("text-secondary")} space-y-2`}> <div className={`${showSecurityFeatures ? 'mt-4' : ''} text-center text-xs ${getThemeClasses("text-secondary")} space-y-2`}>
<p> <p>
<strong>Data Controller:</strong> Maple Open Tech Inc. |{" "} <strong>Data Controller:</strong> Maple Open Tech Inc. |{" "}
<strong>Location:</strong> Canada (Adequate protection under GDPR Art. 45) <strong>Location:</strong> Canada (Adequate protection under GDPR Art. 45)

View file

@ -227,6 +227,45 @@ const getThemeConfigs = () => {
"success-bg": "bg-green-50", "success-bg": "bg-green-50",
"success-border": "border-green-200", "success-border": "border-green-200",
"success-text": "text-green-800", "success-text": "text-green-800",
// Help page section colors
"help-section-blue-text": "text-blue-600",
"help-section-blue-bg": "bg-blue-50",
"help-section-green-text": "text-green-600",
"help-section-green-bg": "bg-green-50",
"help-section-purple-text": "text-purple-600",
"help-section-purple-bg": "bg-purple-50",
"help-section-pink-text": "text-pink-600",
"help-section-pink-bg": "bg-pink-50",
"help-section-red-text": "text-red-600",
"help-section-red-bg": "bg-red-50",
// Export page section colors - Success (green)
"export-section-success-bg": "bg-green-50",
"export-section-success-border": "border-green-200",
"export-section-success-icon": "text-green-600",
"export-section-success-title": "text-green-900",
"export-section-success-text": "text-green-800",
"export-section-success-muted": "text-green-700",
// Export page section colors - Info (blue)
"export-section-info-bg": "bg-blue-50",
"export-section-info-border": "border-blue-200",
"export-section-info-icon": "text-blue-600",
"export-section-info-title": "text-blue-900",
"export-section-info-text": "text-blue-800",
// Export page section colors - Warning (yellow)
"export-section-warning-bg": "bg-yellow-50",
"export-section-warning-border": "border-yellow-200",
"export-section-warning-icon": "text-yellow-600",
"export-section-warning-title": "text-yellow-900",
"export-section-warning-text": "text-yellow-800",
"export-section-warning-muted": "text-yellow-700",
"export-section-warning-code": "bg-yellow-100",
// Muted background
"bg-muted": "bg-gray-50",
}, },
}, },
@ -434,6 +473,45 @@ const getThemeConfigs = () => {
"success-bg": "bg-green-50", "success-bg": "bg-green-50",
"success-border": "border-green-200", "success-border": "border-green-200",
"success-text": "text-green-800", "success-text": "text-green-800",
// Help page section colors
"help-section-blue-text": "text-blue-600",
"help-section-blue-bg": "bg-blue-50",
"help-section-green-text": "text-green-600",
"help-section-green-bg": "bg-green-50",
"help-section-purple-text": "text-purple-600",
"help-section-purple-bg": "bg-purple-50",
"help-section-pink-text": "text-pink-600",
"help-section-pink-bg": "bg-pink-50",
"help-section-red-text": "text-red-600",
"help-section-red-bg": "bg-red-50",
// Export page section colors - Success (green)
"export-section-success-bg": "bg-green-50",
"export-section-success-border": "border-green-200",
"export-section-success-icon": "text-green-600",
"export-section-success-title": "text-green-900",
"export-section-success-text": "text-green-800",
"export-section-success-muted": "text-green-700",
// Export page section colors - Info (blue)
"export-section-info-bg": "bg-blue-50",
"export-section-info-border": "border-blue-200",
"export-section-info-icon": "text-blue-600",
"export-section-info-title": "text-blue-900",
"export-section-info-text": "text-blue-800",
// Export page section colors - Warning (yellow)
"export-section-warning-bg": "bg-yellow-50",
"export-section-warning-border": "border-yellow-200",
"export-section-warning-icon": "text-yellow-600",
"export-section-warning-title": "text-yellow-900",
"export-section-warning-text": "text-yellow-800",
"export-section-warning-muted": "text-yellow-700",
"export-section-warning-code": "bg-yellow-100",
// Muted background
"bg-muted": "bg-gray-50",
}, },
}, },
@ -642,6 +720,45 @@ const getThemeConfigs = () => {
"success-bg": "bg-green-50", "success-bg": "bg-green-50",
"success-border": "border-green-200", "success-border": "border-green-200",
"success-text": "text-green-800", "success-text": "text-green-800",
// Help page section colors
"help-section-blue-text": "text-blue-600",
"help-section-blue-bg": "bg-blue-50",
"help-section-green-text": "text-green-600",
"help-section-green-bg": "bg-green-50",
"help-section-purple-text": "text-purple-600",
"help-section-purple-bg": "bg-purple-50",
"help-section-pink-text": "text-pink-600",
"help-section-pink-bg": "bg-pink-50",
"help-section-red-text": "text-red-600",
"help-section-red-bg": "bg-red-50",
// Export page section colors - Success (green)
"export-section-success-bg": "bg-green-50",
"export-section-success-border": "border-green-200",
"export-section-success-icon": "text-green-600",
"export-section-success-title": "text-green-900",
"export-section-success-text": "text-green-800",
"export-section-success-muted": "text-green-700",
// Export page section colors - Info (blue)
"export-section-info-bg": "bg-blue-50",
"export-section-info-border": "border-blue-200",
"export-section-info-icon": "text-blue-600",
"export-section-info-title": "text-blue-900",
"export-section-info-text": "text-blue-800",
// Export page section colors - Warning (yellow)
"export-section-warning-bg": "bg-yellow-50",
"export-section-warning-border": "border-yellow-200",
"export-section-warning-icon": "text-yellow-600",
"export-section-warning-title": "text-yellow-900",
"export-section-warning-text": "text-yellow-800",
"export-section-warning-muted": "text-yellow-700",
"export-section-warning-code": "bg-yellow-100",
// Muted background
"bg-muted": "bg-gray-50",
}, },
}, },
@ -849,6 +966,45 @@ const getThemeConfigs = () => {
"success-bg": "bg-green-50", "success-bg": "bg-green-50",
"success-border": "border-green-200", "success-border": "border-green-200",
"success-text": "text-green-800", "success-text": "text-green-800",
// Help page section colors
"help-section-blue-text": "text-blue-600",
"help-section-blue-bg": "bg-blue-50",
"help-section-green-text": "text-green-600",
"help-section-green-bg": "bg-green-50",
"help-section-purple-text": "text-purple-600",
"help-section-purple-bg": "bg-purple-50",
"help-section-pink-text": "text-pink-600",
"help-section-pink-bg": "bg-pink-50",
"help-section-red-text": "text-red-600",
"help-section-red-bg": "bg-red-50",
// Export page section colors - Success (green)
"export-section-success-bg": "bg-green-50",
"export-section-success-border": "border-green-200",
"export-section-success-icon": "text-green-600",
"export-section-success-title": "text-green-900",
"export-section-success-text": "text-green-800",
"export-section-success-muted": "text-green-700",
// Export page section colors - Info (blue)
"export-section-info-bg": "bg-blue-50",
"export-section-info-border": "border-blue-200",
"export-section-info-icon": "text-blue-600",
"export-section-info-title": "text-blue-900",
"export-section-info-text": "text-blue-800",
// Export page section colors - Warning (yellow)
"export-section-warning-bg": "bg-yellow-50",
"export-section-warning-border": "border-yellow-200",
"export-section-warning-icon": "text-yellow-600",
"export-section-warning-title": "text-yellow-900",
"export-section-warning-text": "text-yellow-800",
"export-section-warning-muted": "text-yellow-700",
"export-section-warning-code": "bg-yellow-100",
// Muted background
"bg-muted": "bg-gray-50",
}, },
}, },
@ -1056,6 +1212,45 @@ const getThemeConfigs = () => {
"success-bg": "bg-green-50", "success-bg": "bg-green-50",
"success-border": "border-green-200", "success-border": "border-green-200",
"success-text": "text-green-800", "success-text": "text-green-800",
// Help page section colors
"help-section-blue-text": "text-blue-600",
"help-section-blue-bg": "bg-blue-50",
"help-section-green-text": "text-green-600",
"help-section-green-bg": "bg-green-50",
"help-section-purple-text": "text-purple-600",
"help-section-purple-bg": "bg-purple-50",
"help-section-pink-text": "text-pink-600",
"help-section-pink-bg": "bg-pink-50",
"help-section-red-text": "text-red-600",
"help-section-red-bg": "bg-red-50",
// Export page section colors - Success (green)
"export-section-success-bg": "bg-green-50",
"export-section-success-border": "border-green-200",
"export-section-success-icon": "text-green-600",
"export-section-success-title": "text-green-900",
"export-section-success-text": "text-green-800",
"export-section-success-muted": "text-green-700",
// Export page section colors - Info (blue)
"export-section-info-bg": "bg-blue-50",
"export-section-info-border": "border-blue-200",
"export-section-info-icon": "text-blue-600",
"export-section-info-title": "text-blue-900",
"export-section-info-text": "text-blue-800",
// Export page section colors - Warning (yellow)
"export-section-warning-bg": "bg-yellow-50",
"export-section-warning-border": "border-yellow-200",
"export-section-warning-icon": "text-yellow-600",
"export-section-warning-title": "text-yellow-900",
"export-section-warning-text": "text-yellow-800",
"export-section-warning-muted": "text-yellow-700",
"export-section-warning-code": "bg-yellow-100",
// Muted background
"bg-muted": "bg-gray-50",
}, },
}, },
}; };

View file

@ -33,6 +33,7 @@ function LoginPageUIX() {
const authManager = useAuthManager(); const authManager = useAuthManager();
const navigate = useNavigate(); const navigate = useNavigate();
const emailInputRef = useRef(null); const emailInputRef = useRef(null);
const isMountedRef = useRef(true);
// UIX Theme support - defaults to blue theme // UIX Theme support - defaults to blue theme
const { getThemeClasses } = useUIXTheme(); const { getThemeClasses } = useUIXTheme();
@ -47,6 +48,14 @@ function LoginPageUIX() {
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [hasInitialized, setHasInitialized] = useState(false); const [hasInitialized, setHasInitialized] = useState(false);
// Cleanup on unmount - prevents state updates after unmount
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
// Split useEffect 1: Basic initialization // Split useEffect 1: Basic initialization
useEffect(() => { useEffect(() => {
if (hasInitialized) return; if (hasInitialized) return;
@ -145,9 +154,12 @@ function LoginPageUIX() {
return newErrors; return newErrors;
}; };
const handleSubmit = async (e) => { const handleSubmit = useCallback(async (e) => {
e.preventDefault(); e.preventDefault();
// Prevent state updates if component is unmounted
if (!isMountedRef.current) return;
const validationErrors = validateForm(); const validationErrors = validateForm();
if (Object.keys(validationErrors).length > 0) { if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors); setErrors(validationErrors);
@ -177,6 +189,8 @@ function LoginPageUIX() {
password: formData.password, password: formData.password,
}); });
if (!isMountedRef.current) return;
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
console.log("LoginPage: Login successful"); console.log("LoginPage: Login successful");
} }
@ -203,6 +217,8 @@ function LoginPageUIX() {
navigate("/login/2fa"); navigate("/login/2fa");
} }
} catch (error) { } catch (error) {
if (!isMountedRef.current) return;
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
console.error("LoginPage: Login failed", error); console.error("LoginPage: Login failed", error);
} }
@ -215,16 +231,18 @@ function LoginPageUIX() {
form?.classList.add("animate-shake"); form?.classList.add("animate-shake");
setTimeout(() => form?.classList.remove("animate-shake"), 500); setTimeout(() => form?.classList.remove("animate-shake"), 500);
} finally { } finally {
setLoading(false); if (isMountedRef.current) {
setLoading(false);
}
} }
}; }, [formData, rememberMe, authManager, navigate, validateForm]);
// Handle Enter key submission - memoized to prevent Input component re-renders // Handle Enter key submission - memoized to prevent Input component re-renders
const handleKeyDown = useCallback((e) => { const handleKeyDown = useCallback((e) => {
if (e.key === "Enter" && !loading) { if (e.key === "Enter" && !loading) {
handleSubmit(e); handleSubmit(e);
} }
}, [loading]); // Depends on loading state to disable during submission }, [loading, handleSubmit]); // Depends on loading state and handleSubmit
// Memoized password toggle button - prevents Input re-renders // Memoized password toggle button - prevents Input re-renders
const passwordSuffix = useMemo(() => ( const passwordSuffix = useMemo(() => (
@ -350,7 +368,7 @@ function LoginPageUIX() {
<Checkbox <Checkbox
label="Remember me" label="Remember me"
checked={rememberMe} checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)} onChange={(checked) => setRememberMe(checked)}
disabled={loading} disabled={loading}
className="text-sm" className="text-sm"
/> />

View file

@ -1,5 +1,5 @@
// File Path: monorepo/web/frontend/src/pages/Anonymous/TwoFA/ValidationPage.jsx // File Path: monorepo/web/frontend/src/pages/Anonymous/TwoFA/ValidationPage.jsx
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback, useRef } from "react";
import { Link, useNavigate } from "react-router"; import { Link, useNavigate } from "react-router";
import { import {
useAuthManager, useAuthManager,
@ -11,6 +11,7 @@ import {
useUIXTheme, useUIXTheme,
useMobileOptimizations, useMobileOptimizations,
OTPInput, OTPInput,
Button,
} from "../../../components/UIX"; } from "../../../components/UIX";
import { import {
ShieldCheckIcon, ShieldCheckIcon,
@ -47,6 +48,15 @@ function TwoFAValidationPageContent() {
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [token, setToken] = useState(""); const [token, setToken] = useState("");
const isMountedRef = useRef(true);
// Cleanup on unmount - prevents state updates after unmount
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
//// ////
//// Event handling. //// Event handling.
@ -62,6 +72,9 @@ function TwoFAValidationPageContent() {
const handleSubmit = useCallback(async (e) => { const handleSubmit = useCallback(async (e) => {
e.preventDefault(); e.preventDefault();
// Prevent state updates if component is unmounted
if (!isMountedRef.current) return;
// Clear previous errors // Clear previous errors
setErrors({}); setErrors({});
@ -89,6 +102,8 @@ function TwoFAValidationPageContent() {
onUnauthorized, onUnauthorized,
); );
if (!isMountedRef.current) return;
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
console.log("TwoFAValidationPage: OTP validation successful"); console.log("TwoFAValidationPage: OTP validation successful");
} }
@ -110,13 +125,17 @@ function TwoFAValidationPageContent() {
navigate("/dashboard"); navigate("/dashboard");
} }
} catch (error) { } catch (error) {
if (!isMountedRef.current) return;
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
console.error("TwoFAValidationPage: OTP validation failed", error); console.error("TwoFAValidationPage: OTP validation failed", error);
} }
setErrors(error); setErrors(error);
window.scrollTo(0, 0); window.scrollTo(0, 0);
} finally { } finally {
setIsLoading(false); if (isMountedRef.current) {
setIsLoading(false);
}
} }
}, [token, twoFactorAuthManager, onUnauthorized, navigate]); }, [token, twoFactorAuthManager, onUnauthorized, navigate]);
@ -258,27 +277,21 @@ function TwoFAValidationPageContent() {
Use Backup Code Use Backup Code
</Link> </Link>
<button <Button
type="submit" type="submit"
variant="primary"
disabled={isLoading || token.length !== 6} disabled={isLoading || token.length !== 6}
className={`flex items-center justify-center px-8 py-3 rounded-lg transition-all duration-200 font-medium ${ loading={isLoading}
isLoading || token.length !== 6 className="px-8"
? `${getThemeClasses("bg-muted")} ${getThemeClasses("text-muted-foreground")} cursor-not-allowed`
: getThemeClasses("button-primary")
}`}
> >
{isLoading ? ( {!isLoading && (
<> <span className="inline-flex items-center gap-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current mr-2"></div> <span>Continue</span>
Verifying... <ArrowRightIcon className="h-4 w-4" />
</> </span>
) : (
<>
Continue
<ArrowRightIcon className="h-4 w-4 ml-2" />
</>
)} )}
</button> {isLoading && "Verifying..."}
</Button>
</div> </div>
{/* Back to Login */} {/* Back to Login */}
@ -328,7 +341,7 @@ function TwoFAValidationPageContent() {
{/* Copyright */} {/* Copyright */}
<div className="text-center mt-6 sm:mt-8"> <div className="text-center mt-6 sm:mt-8">
<p className={`text-sm ${getThemeClasses("text-secondary")}`}> <p className={`text-sm ${getThemeClasses("text-secondary")}`}>
© 2024 Flashpoint Training © 2025 Flashpoint Training
</p> </p>
</div> </div>
</div> </div>

View file

@ -1,7 +1,7 @@
// File: monorepo/web/maplefile-frontend/src/pages/Anonymous/Download/DownloadPage.jsx // File: monorepo/web/maplefile-frontend/src/pages/Anonymous/Download/DownloadPage.jsx
// Simple download page for MapleFile desktop application // Simple download page for MapleFile desktop application
import { Link } from "react-router"; import { Link } from "react-router";
import { Button, Card } from "../../../components/UIX"; import { Button, Card, useUIXTheme } from "../../../components/UIX";
import { import {
ArrowRightIcon, ArrowRightIcon,
ArrowDownTrayIcon, ArrowDownTrayIcon,
@ -9,6 +9,7 @@ import {
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
function DownloadPage() { function DownloadPage() {
const { getThemeClasses } = useUIXTheme();
const currentVersion = "1.0.0"; const currentVersion = "1.0.0";
const downloads = [ const downloads = [
@ -20,30 +21,30 @@ function DownloadPage() {
]; ];
return ( return (
<div className="min-h-screen bg-white"> <div className={`min-h-screen ${getThemeClasses("bg-gradient-primary")}`}>
{/* Navigation */} {/* Navigation */}
<nav className="relative z-50 bg-white/95 backdrop-blur-sm border-b border-gray-100"> <nav className={`relative z-50 ${getThemeClasses("bg-card")}/95 backdrop-blur-sm border-b ${getThemeClasses("border-muted")}`}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-4"> <div className="flex justify-between items-center py-4">
<Link to="/" className="flex items-center group"> <Link to="/" className="flex items-center group">
<div className="flex items-center justify-center h-10 w-10 bg-gradient-to-br from-red-800 to-red-900 rounded-lg mr-3 group-hover:scale-105 transition-transform duration-200"> <div className={`flex items-center justify-center h-10 w-10 ${getThemeClasses("bg-gradient-secondary")} rounded-lg mr-3 group-hover:scale-105 transition-transform duration-200`}>
<LockClosedIcon className="h-6 w-6 text-white" /> <LockClosedIcon className="h-6 w-6 text-white" />
</div> </div>
<span className="text-2xl font-bold bg-gradient-to-r from-gray-900 to-red-800 bg-clip-text text-transparent"> <span className={`text-2xl font-bold ${getThemeClasses("text-primary")}`}>
MapleFile MapleFile
</span> </span>
</Link> </Link>
<div className="hidden md:flex items-center space-x-6"> <div className="hidden md:flex items-center space-x-6">
<Link to="/#how-it-works" className="text-gray-600 hover:text-gray-900 font-medium"> <Link to="/#how-it-works" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} font-medium`}>
How It Works How It Works
</Link> </Link>
<Link to="/#security" className="text-gray-600 hover:text-gray-900 font-medium"> <Link to="/#security" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} font-medium`}>
Security Security
</Link> </Link>
<Link to="/#pricing" className="text-gray-600 hover:text-gray-900 font-medium"> <Link to="/#pricing" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} font-medium`}>
Pricing Pricing
</Link> </Link>
<Link to="/#faq" className="text-gray-600 hover:text-gray-900 font-medium"> <Link to="/#faq" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} font-medium`}>
FAQ FAQ
</Link> </Link>
</div> </div>
@ -67,21 +68,21 @@ function DownloadPage() {
{/* Content */} {/* Content */}
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-16"> <div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<div className="text-center mb-12"> <div className="text-center mb-12">
<h1 className="text-4xl font-black text-gray-900 mb-4"> <h1 className={`text-4xl font-black ${getThemeClasses("text-primary")} mb-4`}>
Download MapleFile Download MapleFile
</h1> </h1>
<p className="text-lg text-gray-600"> <p className={`text-lg ${getThemeClasses("text-secondary")}`}>
Desktop app with offline support and automatic sync. Desktop app with offline support and automatic sync.
</p> </p>
</div> </div>
{/* Downloads List */} {/* Downloads List */}
<Card className="border border-gray-200 mb-8"> <Card className={`border ${getThemeClasses("border-muted")} mb-8`}>
<div className="divide-y divide-gray-100"> <div className={`divide-y ${getThemeClasses("divide-muted")}`}>
{downloads.map((item, index) => ( {downloads.map((item, index) => (
<div key={index} className="flex items-center justify-between px-6 py-4"> <div key={index} className="flex items-center justify-between px-6 py-4">
<div> <div>
<span className="font-medium text-gray-900">{item.platform}</span> <span className={`font-medium ${getThemeClasses("text-primary")}`}>{item.platform}</span>
</div> </div>
{item.available ? ( {item.available ? (
<Button variant="secondary" size="sm"> <Button variant="secondary" size="sm">
@ -89,26 +90,26 @@ function DownloadPage() {
Download Download
</Button> </Button>
) : ( ) : (
<span className="text-sm text-gray-400 font-medium">Coming Soon</span> <span className={`text-sm ${getThemeClasses("text-secondary")} font-medium`}>Coming Soon</span>
)} )}
</div> </div>
))} ))}
</div> </div>
</Card> </Card>
<p className="text-center text-gray-500 text-sm mb-12"> <p className={`text-center ${getThemeClasses("text-secondary")} text-sm mb-12`}>
<Link to="/register" className="text-red-800 hover:text-red-900 font-medium"> <Link to="/register" className={`${getThemeClasses("link-primary")} font-medium`}>
Sign up Sign up
</Link> </Link>
{" "}to be notified when downloads are available. {" "}to be notified when downloads are available.
</p> </p>
{/* Web App CTA */} {/* Web App CTA */}
<Card className="border-2 border-red-800 bg-gradient-to-br from-red-50 to-white p-8 text-center"> <Card className={`border-2 ${getThemeClasses("border-accent")} ${getThemeClasses("bg-accent-light")} p-8 text-center`}>
<h2 className="text-2xl font-bold text-gray-900 mb-2"> <h2 className={`text-2xl font-bold ${getThemeClasses("text-primary")} mb-2`}>
Use the Web App Now Use the Web App Now
</h2> </h2>
<p className="text-gray-600 mb-6"> <p className={`${getThemeClasses("text-secondary")} mb-6`}>
No download required. Get 10 GB free. No download required. Get 10 GB free.
</p> </p>
<Link to="/register"> <Link to="/register">
@ -123,17 +124,17 @@ function DownloadPage() {
</div> </div>
{/* Footer */} {/* Footer */}
<footer className="bg-gray-900 text-white py-8"> <footer className={`${getThemeClasses("bg-card")} border-t ${getThemeClasses("border-muted")} py-8`}>
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex flex-col md:flex-row md:items-center md:justify-between"> <div className="flex flex-col md:flex-row md:items-center md:justify-between">
<div className="flex items-center mb-4 md:mb-0"> <div className="flex items-center mb-4 md:mb-0">
<LockClosedIcon className="h-5 w-5 text-red-500 mr-2" /> <LockClosedIcon className={`h-5 w-5 ${getThemeClasses("text-accent")} mr-2`} />
<span className="font-bold">MapleFile</span> <span className={`font-bold ${getThemeClasses("text-primary")}`}>MapleFile</span>
<span className="ml-3 text-gray-400 text-sm">Made with in Canada</span> <span className={`ml-3 ${getThemeClasses("text-secondary")} text-sm`}>Made with in Canada</span>
</div> </div>
<div className="flex space-x-6 text-sm text-gray-400"> <div className={`flex space-x-6 text-sm ${getThemeClasses("text-secondary")}`}>
<Link to="/" className="hover:text-white">Home</Link> <Link to="/" className={getThemeClasses("hover:text-accent")}>Home</Link>
<Link to="/register" className="hover:text-white">Register</Link> <Link to="/register" className={getThemeClasses("hover:text-accent")}>Register</Link>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,8 +1,8 @@
// File: monorepo/web/maplefile-frontend/src/pages/Anonymous/Index/IndexPage.jsx // File: monorepo/web/maplefile-frontend/src/pages/Anonymous/Index/IndexPage.jsx
// UIX version - Red theme landing page with UIX components // UIX version - Red theme landing page with UIX components
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { Button, Card, Alert } from "../../../components/UIX"; import { Button, Card, Alert, useUIXTheme } from "../../../components/UIX";
import { import {
ArrowRightIcon, ArrowRightIcon,
PlayIcon, PlayIcon,
@ -22,8 +22,22 @@ import {
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
function IndexPage() { function IndexPage() {
const { getThemeClasses } = useUIXTheme();
const [authMessage, setAuthMessage] = useState(""); const [authMessage, setAuthMessage] = useState("");
const [openFaq, setOpenFaq] = useState(null); const [openFaq, setOpenFaq] = useState(null);
const timerRef = useRef(null);
const isMountedRef = useRef(true);
// Cleanup on unmount
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, []);
useEffect(() => { useEffect(() => {
// Check for auth redirect message // Check for auth redirect message
@ -33,11 +47,11 @@ function IndexPage() {
sessionStorage.removeItem("auth_redirect_message"); sessionStorage.removeItem("auth_redirect_message");
// Clear message after 10 seconds // Clear message after 10 seconds
const timer = setTimeout(() => { timerRef.current = setTimeout(() => {
setAuthMessage(""); if (isMountedRef.current) {
setAuthMessage("");
}
}, 10000); }, 10000);
return () => clearTimeout(timer);
} }
}, []); }, []);
@ -175,28 +189,28 @@ function IndexPage() {
)} )}
{/* Navigation */} {/* Navigation */}
<nav className="relative z-50 bg-white/95 backdrop-blur-sm border-b border-gray-100"> <nav className={`relative z-50 ${getThemeClasses("bg-card")}/95 backdrop-blur-sm border-b ${getThemeClasses("border-muted")}`}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-4"> <div className="flex justify-between items-center py-4">
<div className="flex items-center group"> <div className="flex items-center group">
<div className="flex items-center justify-center h-10 w-10 bg-gradient-to-br from-red-800 to-red-900 rounded-lg mr-3 group-hover:scale-105 transition-transform duration-200"> <div className={`flex items-center justify-center h-10 w-10 ${getThemeClasses("bg-gradient-secondary")} rounded-lg mr-3 group-hover:scale-105 transition-transform duration-200`}>
<LockClosedIcon className="h-6 w-6 text-white" /> <LockClosedIcon className="h-6 w-6 text-white" />
</div> </div>
<span className="text-2xl font-bold bg-gradient-to-r from-gray-900 to-red-800 bg-clip-text text-transparent"> <span className={`text-2xl font-bold ${getThemeClasses("text-primary")}`}>
MapleFile MapleFile
</span> </span>
</div> </div>
<div className="hidden md:flex items-center space-x-6"> <div className="hidden md:flex items-center space-x-6">
<a href="#how-it-works" className="text-gray-600 hover:text-gray-900 font-medium"> <a href="#how-it-works" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-primary")} font-medium`}>
How It Works How It Works
</a> </a>
<a href="#security" className="text-gray-600 hover:text-gray-900 font-medium"> <a href="#security" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-primary")} font-medium`}>
Security Security
</a> </a>
<a href="#pricing" className="text-gray-600 hover:text-gray-900 font-medium"> <a href="#pricing" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-primary")} font-medium`}>
Pricing Pricing
</a> </a>
<a href="#faq" className="text-gray-600 hover:text-gray-900 font-medium"> <a href="#faq" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-primary")} font-medium`}>
FAQ FAQ
</a> </a>
</div> </div>
@ -220,32 +234,32 @@ function IndexPage() {
</nav> </nav>
{/* Hero Section */} {/* Hero Section */}
<div className="relative bg-gradient-to-br from-gray-50 via-white to-red-50 overflow-hidden"> <div className={`relative ${getThemeClasses("bg-gradient-primary")} overflow-hidden`}>
<div className="absolute inset-0 bg-grid-pattern opacity-5"></div> <div className="absolute inset-0 bg-grid-pattern opacity-5"></div>
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-16 pb-24"> <div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-16 pb-24">
<div className="text-center"> <div className="text-center">
{/* Trust Badge */} {/* Trust Badge */}
<div className="flex justify-center mb-6 animate-fade-in"> <div className="flex justify-center mb-6 animate-fade-in">
<div className="inline-flex items-center bg-red-50 border border-red-200 rounded-full px-4 py-2 text-sm"> <div className={`inline-flex items-center ${getThemeClasses("bg-accent-light")} border ${getThemeClasses("border-accent")} rounded-full px-4 py-2 text-sm`}>
<span className="text-lg mr-2">🍁</span> <span className="text-lg mr-2">🍁</span>
<span className="text-red-800 font-medium">Proudly Canadian 100% Encrypted Open Source</span> <span className={`${getThemeClasses("text-accent")} font-medium`}>Proudly Canadian 100% Encrypted Open Source</span>
</div> </div>
</div> </div>
<h1 className="text-4xl md:text-6xl lg:text-7xl tracking-tight font-black text-gray-900 mb-6"> <h1 className={`text-4xl md:text-6xl lg:text-7xl tracking-tight font-black ${getThemeClasses("text-primary")} mb-6`}>
<span className="block animate-fade-in-up"> <span className="block animate-fade-in-up">
Your Files. Your Privacy. Your Files. Your Privacy.
</span> </span>
<span className="block bg-gradient-to-r from-red-800 via-red-700 to-red-900 bg-clip-text text-transparent animate-fade-in-up-delay"> <span className={`block ${getThemeClasses("text-accent")} animate-fade-in-up-delay`}>
Zero Compromises. Zero Compromises.
</span> </span>
</h1> </h1>
<p className="mt-6 max-w-2xl mx-auto text-lg md:text-xl text-gray-600 leading-relaxed animate-fade-in-up-delay-2"> <p className={`mt-6 max-w-2xl mx-auto text-lg md:text-xl ${getThemeClasses("text-secondary")} leading-relaxed animate-fade-in-up-delay-2`}>
Unlike Dropbox or Google Drive, MapleFile uses{" "} Unlike Dropbox or Google Drive, MapleFile uses{" "}
<span className="font-semibold text-gray-900">true end-to-end encryption</span>. <span className={`font-semibold ${getThemeClasses("text-primary")}`}>true end-to-end encryption</span>.
{" "}Your files are encrypted before they leave your device. {" "}Your files are encrypted before they leave your device.
{" "}<span className="font-semibold text-red-800">We can't see them. Nobody can.</span> {" "}<span className={`font-semibold ${getThemeClasses("text-accent")}`}>We can't see them. Nobody can.</span>
</p> </p>
<div className="mt-10 flex flex-col sm:flex-row gap-4 justify-center items-center animate-fade-in-up-delay-3"> <div className="mt-10 flex flex-col sm:flex-row gap-4 justify-center items-center animate-fade-in-up-delay-3">
@ -264,7 +278,7 @@ function IndexPage() {
</a> </a>
</div> </div>
<p className="mt-6 text-sm text-gray-500 animate-fade-in-up-delay-3"> <p className={`mt-6 text-sm ${getThemeClasses("text-secondary")} animate-fade-in-up-delay-3`}>
No credit card required Setup in 2 minutes Cancel anytime No credit card required Setup in 2 minutes Cancel anytime
</p> </p>
</div> </div>
@ -272,7 +286,7 @@ function IndexPage() {
</div> </div>
{/* Problem/Solution Section */} {/* Problem/Solution Section */}
<div className="bg-gray-900 py-16"> <div className="bg-gray-900 dark:bg-gray-950 py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12"> <div className="text-center mb-12">
<h2 className="text-3xl font-black text-white mb-4"> <h2 className="text-3xl font-black text-white mb-4">
@ -287,13 +301,13 @@ function IndexPage() {
{problemsWeSolve.map((item, index) => ( {problemsWeSolve.map((item, index) => (
<div key={index} className="text-center"> <div key={index} className="text-center">
<div className="mb-4"> <div className="mb-4">
<span className="inline-block bg-red-900/50 text-red-300 text-sm font-medium px-3 py-1 rounded-full mb-4"> <span className={`inline-block ${getThemeClasses("bg-error-light")} ${getThemeClasses("text-error")} text-sm font-medium px-3 py-1 rounded-full mb-4`}>
The Problem The Problem
</span> </span>
<p className="text-gray-400 text-lg">{item.problem}</p> <p className="text-gray-400 text-lg">{item.problem}</p>
</div> </div>
<div className="border-t border-gray-700 pt-4 mt-4"> <div className="border-t border-gray-700 pt-4 mt-4">
<span className="inline-block bg-green-900/50 text-green-300 text-sm font-medium px-3 py-1 rounded-full mb-4"> <span className={`inline-block ${getThemeClasses("bg-success-light")} ${getThemeClasses("text-success")} text-sm font-medium px-3 py-1 rounded-full mb-4`}>
Our Solution Our Solution
</span> </span>
<p className="text-white font-semibold text-lg">{item.solution}</p> <p className="text-white font-semibold text-lg">{item.solution}</p>
@ -305,39 +319,39 @@ function IndexPage() {
</div> </div>
{/* Stats Section */} {/* Stats Section */}
<div className="bg-gradient-to-r from-red-800 to-red-900 py-12"> <div className={`${getThemeClasses("bg-gradient-secondary")} py-12`}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 text-center"> <div className="grid grid-cols-2 md:grid-cols-4 gap-8 text-center">
<div className="text-white"> <div className="text-white">
<div className="text-4xl font-black mb-2">256-bit</div> <div className="text-4xl font-black mb-2">256-bit</div>
<div className="text-red-100 font-medium"> <div className="text-white/80 font-medium">
Encryption Strength Encryption Strength
</div> </div>
</div> </div>
<div className="text-white"> <div className="text-white">
<div className="text-4xl font-black mb-2">100%</div> <div className="text-4xl font-black mb-2">100%</div>
<div className="text-red-100 font-medium">Canadian Hosted</div> <div className="text-white/80 font-medium">Canadian Hosted</div>
</div> </div>
<div className="text-white"> <div className="text-white">
<div className="text-4xl font-black mb-2">Zero</div> <div className="text-4xl font-black mb-2">Zero</div>
<div className="text-red-100 font-medium">Knowledge Access</div> <div className="text-white/80 font-medium">Knowledge Access</div>
</div> </div>
<div className="text-white"> <div className="text-white">
<div className="text-4xl font-black mb-2">10 GB</div> <div className="text-4xl font-black mb-2">10 GB</div>
<div className="text-red-100 font-medium">Free Forever</div> <div className="text-white/80 font-medium">Free Forever</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* How It Works Section */} {/* How It Works Section */}
<div id="how-it-works" className="bg-white py-20"> <div id="how-it-works" className={`${getThemeClasses("bg-card")} py-20`}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16"> <div className="text-center mb-16">
<h2 className="text-4xl font-black text-gray-900 mb-4"> <h2 className={`text-4xl font-black ${getThemeClasses("text-primary")} mb-4`}>
How MapleFile Works How MapleFile Works
</h2> </h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> <p className={`text-xl ${getThemeClasses("text-secondary")} max-w-2xl mx-auto`}>
Secure file storage shouldn't be complicated. Get started in three simple steps. Secure file storage shouldn't be complicated. Get started in three simple steps.
</p> </p>
</div> </div>
@ -346,20 +360,20 @@ function IndexPage() {
{howItWorks.map((item, index) => ( {howItWorks.map((item, index) => (
<div key={index} className="text-center relative"> <div key={index} className="text-center relative">
{index < howItWorks.length - 1 && ( {index < howItWorks.length - 1 && (
<div className="hidden md:block absolute top-12 left-1/2 w-full h-0.5 bg-gradient-to-r from-red-200 to-red-100"></div> <div className={`hidden md:block absolute top-12 left-1/2 w-full h-0.5 ${getThemeClasses("bg-accent-light")}`}></div>
)} )}
<div className="relative z-10"> <div className="relative z-10">
<div className="inline-flex items-center justify-center h-24 w-24 bg-gradient-to-br from-red-800 to-red-900 rounded-2xl mb-6 shadow-lg"> <div className={`inline-flex items-center justify-center h-24 w-24 ${getThemeClasses("bg-gradient-secondary")} rounded-2xl mb-6 shadow-lg`}>
<item.icon className="h-12 w-12 text-white" /> <item.icon className="h-12 w-12 text-white" />
</div> </div>
<div className="absolute -top-2 -right-2 md:right-auto md:-top-2 md:-left-2 h-8 w-8 bg-white border-2 border-red-800 rounded-full flex items-center justify-center text-red-800 font-bold text-sm shadow-md"> <div className={`absolute -top-2 -right-2 md:right-auto md:-top-2 md:-left-2 h-8 w-8 ${getThemeClasses("bg-card")} border-2 ${getThemeClasses("border-accent")} rounded-full flex items-center justify-center ${getThemeClasses("text-accent")} font-bold text-sm shadow-md`}>
{item.step} {item.step}
</div> </div>
</div> </div>
<h3 className="text-xl font-bold text-gray-900 mb-3"> <h3 className={`text-xl font-bold ${getThemeClasses("text-primary")} mb-3`}>
{item.title} {item.title}
</h3> </h3>
<p className="text-gray-600 leading-relaxed"> <p className={`${getThemeClasses("text-secondary")} leading-relaxed`}>
{item.description} {item.description}
</p> </p>
</div> </div>
@ -380,13 +394,13 @@ function IndexPage() {
</div> </div>
{/* Security Features Section */} {/* Security Features Section */}
<div id="security" className="bg-gradient-to-br from-gray-50 to-white py-20"> <div id="security" className={`${getThemeClasses("bg-gradient-primary")} py-20`}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16"> <div className="text-center mb-16">
<h2 className="text-4xl font-black text-gray-900 mb-4"> <h2 className={`text-4xl font-black ${getThemeClasses("text-primary")} mb-4`}>
Security That's Actually Secure Security That's Actually Secure
</h2> </h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> <p className={`text-xl ${getThemeClasses("text-secondary")} max-w-2xl mx-auto`}>
We use the same cryptographic standards trusted by governments and security professionals worldwide. We use the same cryptographic standards trusted by governments and security professionals worldwide.
</p> </p>
</div> </div>
@ -395,20 +409,20 @@ function IndexPage() {
{securityFeatures.map((feature, index) => ( {securityFeatures.map((feature, index) => (
<Card <Card
key={index} key={index}
className="group bg-white border-gray-200 shadow-lg hover:shadow-xl transform hover:scale-[1.02] transition-all duration-300" className={`group ${getThemeClasses("border-muted")} shadow-lg hover:shadow-xl transform hover:scale-[1.02] transition-all duration-300`}
> >
<div className="p-8"> <div className="p-8">
<div className="flex items-start"> <div className="flex items-start">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<div className="inline-flex items-center justify-center h-14 w-14 bg-gradient-to-br from-red-800 to-red-900 rounded-xl group-hover:scale-110 transition-transform duration-200"> <div className={`inline-flex items-center justify-center h-14 w-14 ${getThemeClasses("bg-gradient-secondary")} rounded-xl group-hover:scale-110 transition-transform duration-200`}>
<feature.icon className="h-7 w-7 text-white" /> <feature.icon className="h-7 w-7 text-white" />
</div> </div>
</div> </div>
<div className="ml-6"> <div className="ml-6">
<h3 className="text-xl font-bold text-gray-900 mb-2"> <h3 className={`text-xl font-bold ${getThemeClasses("text-primary")} mb-2`}>
{feature.title} {feature.title}
</h3> </h3>
<p className="text-gray-600 leading-relaxed"> <p className={`${getThemeClasses("text-secondary")} leading-relaxed`}>
{feature.description} {feature.description}
</p> </p>
</div> </div>
@ -421,44 +435,44 @@ function IndexPage() {
</div> </div>
{/* Comparison Section */} {/* Comparison Section */}
<div className="bg-white py-20"> <div className={`${getThemeClasses("bg-card")} py-20`}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16"> <div className="text-center mb-16">
<h2 className="text-4xl font-black text-gray-900 mb-4"> <h2 className={`text-4xl font-black ${getThemeClasses("text-primary")} mb-4`}>
MapleFile vs. Traditional Cloud Storage MapleFile vs. Traditional Cloud Storage
</h2> </h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> <p className={`text-xl ${getThemeClasses("text-secondary")} max-w-2xl mx-auto`}>
See why privacy-conscious users are switching to MapleFile See why privacy-conscious users are switching to MapleFile
</p> </p>
</div> </div>
<div className="max-w-3xl mx-auto"> <div className="max-w-3xl mx-auto">
<Card className="overflow-hidden shadow-xl"> <Card className="overflow-hidden shadow-xl">
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200"> <div className={`${getThemeClasses("bg-muted")} px-6 py-4 border-b ${getThemeClasses("border-muted")}`}>
<div className="grid grid-cols-3 gap-4 text-center font-bold"> <div className="grid grid-cols-3 gap-4 text-center font-bold">
<div className="text-gray-600">Feature</div> <div className={getThemeClasses("text-secondary")}>Feature</div>
<div className="text-red-800">MapleFile</div> <div className={getThemeClasses("text-accent")}>MapleFile</div>
<div className="text-gray-500">Others</div> <div className={getThemeClasses("text-secondary")}>Others</div>
</div> </div>
</div> </div>
<div className="divide-y divide-gray-100"> <div className={`divide-y ${getThemeClasses("divide-muted")}`}>
{comparisonFeatures.map((item, index) => ( {comparisonFeatures.map((item, index) => (
<div key={index} className="grid grid-cols-3 gap-4 px-6 py-4 text-center items-center hover:bg-gray-50 transition-colors"> <div key={index} className={`grid grid-cols-3 gap-4 px-6 py-4 text-center items-center ${getThemeClasses("hover:bg-muted")} transition-colors`}>
<div className="text-gray-700 font-medium text-left">{item.feature}</div> <div className={`${getThemeClasses("text-primary")} font-medium text-left`}>{item.feature}</div>
<div> <div>
{item.maplefile === true ? ( {item.maplefile === true ? (
<CheckIcon className="h-6 w-6 text-green-500 mx-auto" /> <CheckIcon className={`h-6 w-6 ${getThemeClasses("text-success")} mx-auto`} />
) : ( ) : (
<span className="text-gray-400"></span> <span className={getThemeClasses("text-secondary")}></span>
)} )}
</div> </div>
<div> <div>
{item.others === true ? ( {item.others === true ? (
<CheckIcon className="h-6 w-6 text-green-500 mx-auto" /> <CheckIcon className={`h-6 w-6 ${getThemeClasses("text-success")} mx-auto`} />
) : item.others === false ? ( ) : item.others === false ? (
<span className="text-red-400 font-medium">No</span> <span className={`${getThemeClasses("text-error")} font-medium`}>No</span>
) : ( ) : (
<span className="text-gray-400 text-sm">{item.others}</span> <span className={`${getThemeClasses("text-secondary")} text-sm`}>{item.others}</span>
)} )}
</div> </div>
</div> </div>
@ -470,13 +484,13 @@ function IndexPage() {
</div> </div>
{/* Use Cases Section */} {/* Use Cases Section */}
<div className="bg-gradient-to-br from-gray-50 to-white py-20"> <div className={`${getThemeClasses("bg-gradient-primary")} py-20`}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16"> <div className="text-center mb-16">
<h2 className="text-4xl font-black text-gray-900 mb-4"> <h2 className={`text-4xl font-black ${getThemeClasses("text-primary")} mb-4`}>
Perfect For Perfect For
</h2> </h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> <p className={`text-xl ${getThemeClasses("text-secondary")} max-w-2xl mx-auto`}>
Whether you're protecting client files or personal documents, MapleFile has you covered. Whether you're protecting client files or personal documents, MapleFile has you covered.
</p> </p>
</div> </div>
@ -485,16 +499,16 @@ function IndexPage() {
{useCases.map((useCase, index) => ( {useCases.map((useCase, index) => (
<Card <Card
key={index} key={index}
className="group bg-white border-gray-200 shadow-lg hover:shadow-xl transform hover:scale-105 transition-all duration-300" className={`group ${getThemeClasses("border-muted")} shadow-lg hover:shadow-xl transform hover:scale-105 transition-all duration-300`}
> >
<div className="p-6 text-center"> <div className="p-6 text-center">
<div className="inline-flex items-center justify-center h-14 w-14 bg-red-50 rounded-xl mb-4 group-hover:bg-red-100 transition-colors duration-200"> <div className={`inline-flex items-center justify-center h-14 w-14 ${getThemeClasses("bg-accent-light")} rounded-xl mb-4 group-hover:opacity-80 transition-colors duration-200`}>
<useCase.icon className="h-7 w-7 text-red-800" /> <useCase.icon className={`h-7 w-7 ${getThemeClasses("text-accent")}`} />
</div> </div>
<h3 className="text-lg font-bold text-gray-900 mb-2"> <h3 className={`text-lg font-bold ${getThemeClasses("text-primary")} mb-2`}>
{useCase.title} {useCase.title}
</h3> </h3>
<p className="text-gray-600 text-sm leading-relaxed"> <p className={`${getThemeClasses("text-secondary")} text-sm leading-relaxed`}>
{useCase.description} {useCase.description}
</p> </p>
</div> </div>
@ -505,34 +519,34 @@ function IndexPage() {
</div> </div>
{/* Pricing Section */} {/* Pricing Section */}
<div id="pricing" className="bg-gradient-to-br from-gray-50 to-white py-20"> <div id="pricing" className={`${getThemeClasses("bg-gradient-primary")} py-20`}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16"> <div className="text-center mb-16">
<h2 className="text-4xl font-black text-gray-900 mb-4"> <h2 className={`text-4xl font-black ${getThemeClasses("text-primary")} mb-4`}>
Simple, Transparent Pricing Simple, Transparent Pricing
</h2> </h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> <p className={`text-xl ${getThemeClasses("text-secondary")} max-w-2xl mx-auto`}>
Start free with 10 GB. Upgrade when you need more. Start free with 10 GB. Upgrade when you need more.
</p> </p>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-6xl mx-auto"> <div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-6xl mx-auto">
{/* Free Plan */} {/* Free Plan */}
<Card className="border-2 border-gray-200 shadow-lg hover:shadow-xl transition-shadow duration-300"> <Card className={`border-2 ${getThemeClasses("border-muted")} shadow-lg hover:shadow-xl transition-shadow duration-300`}>
<div className="p-8"> <div className="p-8">
<div className="text-center"> <div className="text-center">
<h3 className="text-2xl font-black text-gray-900 mb-2"> <h3 className={`text-2xl font-black ${getThemeClasses("text-primary")} mb-2`}>
Personal Personal
</h3> </h3>
<p className="text-gray-600 mb-8"> <p className={`${getThemeClasses("text-secondary")} mb-8`}>
Everything you need to get started Everything you need to get started
</p> </p>
<div className="mb-8"> <div className="mb-8">
<span className="text-5xl font-black text-gray-900"> <span className={`text-5xl font-black ${getThemeClasses("text-primary")}`}>
Free Free
</span> </span>
<span className="text-lg text-gray-600 ml-2">forever</span> <span className={`text-lg ${getThemeClasses("text-secondary")} ml-2`}>forever</span>
</div> </div>
<div className="space-y-4 mb-8 text-left"> <div className="space-y-4 mb-8 text-left">
@ -545,8 +559,8 @@ function IndexPage() {
"Community support", "Community support",
].map((feature, idx) => ( ].map((feature, idx) => (
<div key={idx} className="flex items-start"> <div key={idx} className="flex items-start">
<CheckIcon className="h-5 w-5 text-green-500 mr-3 mt-0.5 flex-shrink-0" /> <CheckIcon className={`h-5 w-5 ${getThemeClasses("text-success")} mr-3 mt-0.5 flex-shrink-0`} />
<span className="text-gray-700 text-sm font-medium"> <span className={`${getThemeClasses("text-primary")} text-sm font-medium`}>
{feature} {feature}
</span> </span>
</div> </div>
@ -558,7 +572,7 @@ function IndexPage() {
Get Started Free Get Started Free
</Button> </Button>
</Link> </Link>
<p className="mt-3 text-sm text-gray-500"> <p className={`mt-3 text-sm ${getThemeClasses("text-secondary")}`}>
No credit card required No credit card required
</p> </p>
</div> </div>
@ -567,26 +581,26 @@ function IndexPage() {
{/* Pro Plan */} {/* Pro Plan */}
<div className="relative"> <div className="relative">
<div className="absolute -inset-4 bg-gradient-to-r from-red-800 to-red-900 rounded-3xl blur opacity-20"></div> <div className={`absolute -inset-4 ${getThemeClasses("bg-gradient-secondary")} rounded-3xl blur opacity-20`}></div>
<Card className="relative bg-gradient-to-br from-white to-gray-50 border-2 border-red-800 shadow-2xl"> <Card className={`relative border-2 ${getThemeClasses("border-accent")} shadow-2xl`}>
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2 bg-gray-400 text-white text-sm font-bold py-2 px-6 rounded-full shadow-lg"> <div className={`absolute -top-4 left-1/2 transform -translate-x-1/2 ${getThemeClasses("bg-secondary")} text-white text-sm font-bold py-2 px-6 rounded-full shadow-lg`}>
COMING SOON COMING SOON
</div> </div>
<div className="p-8 pt-12"> <div className="p-8 pt-12">
<div className="text-center"> <div className="text-center">
<h3 className="text-2xl font-black text-gray-900 mb-2"> <h3 className={`text-2xl font-black ${getThemeClasses("text-primary")} mb-2`}>
Pro Pro
</h3> </h3>
<p className="text-gray-600 mb-8"> <p className={`${getThemeClasses("text-secondary")} mb-8`}>
For power users and professionals For power users and professionals
</p> </p>
<div className="mb-8"> <div className="mb-8">
<span className="text-5xl font-black text-gray-900"> <span className={`text-5xl font-black ${getThemeClasses("text-primary")}`}>
$9.99 $9.99
</span> </span>
<span className="text-lg text-gray-600 ml-2"> <span className={`text-lg ${getThemeClasses("text-secondary")} ml-2`}>
CAD/month CAD/month
</span> </span>
</div> </div>
@ -601,8 +615,8 @@ function IndexPage() {
"Early access to features", "Early access to features",
].map((feature, idx) => ( ].map((feature, idx) => (
<div key={idx} className="flex items-start"> <div key={idx} className="flex items-start">
<CheckIcon className="h-5 w-5 text-green-500 mr-3 mt-0.5 flex-shrink-0" /> <CheckIcon className={`h-5 w-5 ${getThemeClasses("text-success")} mr-3 mt-0.5 flex-shrink-0`} />
<span className="text-gray-700 text-sm font-medium"> <span className={`${getThemeClasses("text-primary")} text-sm font-medium`}>
{feature} {feature}
</span> </span>
</div> </div>
@ -617,7 +631,7 @@ function IndexPage() {
</span> </span>
</Button> </Button>
</Link> </Link>
<p className="mt-3 text-sm text-gray-500"> <p className={`mt-3 text-sm ${getThemeClasses("text-secondary")}`}>
Then $9.99 CAD/month Then $9.99 CAD/month
</p> </p>
</div> </div>
@ -626,24 +640,24 @@ function IndexPage() {
</div> </div>
{/* Team Plan */} {/* Team Plan */}
<Card className="border-2 border-gray-200 shadow-lg hover:shadow-xl transition-shadow duration-300"> <Card className={`border-2 ${getThemeClasses("border-muted")} shadow-lg hover:shadow-xl transition-shadow duration-300`}>
<div className="p-8"> <div className="p-8">
<div className="text-center"> <div className="text-center">
<h3 className="text-2xl font-black text-gray-900 mb-2"> <h3 className={`text-2xl font-black ${getThemeClasses("text-primary")} mb-2`}>
Team Team
</h3> </h3>
<p className="text-gray-600 mb-8"> <p className={`${getThemeClasses("text-secondary")} mb-8`}>
For teams and organizations For teams and organizations
</p> </p>
<div className="mb-8"> <div className="mb-8">
<span className="text-5xl font-black text-gray-900"> <span className={`text-5xl font-black ${getThemeClasses("text-primary")}`}>
$49.99 $49.99
</span> </span>
<span className="text-lg text-gray-600 ml-2"> <span className={`text-lg ${getThemeClasses("text-secondary")} ml-2`}>
CAD/month CAD/month
</span> </span>
<p className="text-sm text-gray-500 mt-1">Up to 10 users</p> <p className={`text-sm ${getThemeClasses("text-secondary")} mt-1`}>Up to 10 users</p>
</div> </div>
<div className="space-y-4 mb-8 text-left"> <div className="space-y-4 mb-8 text-left">
@ -656,8 +670,8 @@ function IndexPage() {
"Dedicated support", "Dedicated support",
].map((feature, idx) => ( ].map((feature, idx) => (
<div key={idx} className="flex items-start"> <div key={idx} className="flex items-start">
<CheckIcon className="h-5 w-5 text-green-500 mr-3 mt-0.5 flex-shrink-0" /> <CheckIcon className={`h-5 w-5 ${getThemeClasses("text-success")} mr-3 mt-0.5 flex-shrink-0`} />
<span className="text-gray-700 text-sm font-medium"> <span className={`${getThemeClasses("text-primary")} text-sm font-medium`}>
{feature} {feature}
</span> </span>
</div> </div>
@ -677,13 +691,13 @@ function IndexPage() {
</div> </div>
{/* FAQ Section */} {/* FAQ Section */}
<div id="faq" className="bg-gradient-to-br from-gray-50 to-white py-20"> <div id="faq" className={`${getThemeClasses("bg-gradient-primary")} py-20`}>
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16"> <div className="text-center mb-16">
<h2 className="text-4xl font-black text-gray-900 mb-4"> <h2 className={`text-4xl font-black ${getThemeClasses("text-primary")} mb-4`}>
Frequently Asked Questions Frequently Asked Questions
</h2> </h2>
<p className="text-xl text-gray-600"> <p className={`text-xl ${getThemeClasses("text-secondary")}`}>
Have questions? We've got answers. Have questions? We've got answers.
</p> </p>
</div> </div>
@ -692,7 +706,7 @@ function IndexPage() {
{faqs.map((faq, index) => ( {faqs.map((faq, index) => (
<Card <Card
key={index} key={index}
className={`border border-gray-200 shadow-sm overflow-hidden transition-all duration-300 ${ className={`border ${getThemeClasses("border-muted")} shadow-sm overflow-hidden transition-all duration-300 ${
openFaq === index ? "shadow-lg" : "hover:shadow-md" openFaq === index ? "shadow-lg" : "hover:shadow-md"
}`} }`}
> >
@ -700,16 +714,16 @@ function IndexPage() {
onClick={() => setOpenFaq(openFaq === index ? null : index)} onClick={() => setOpenFaq(openFaq === index ? null : index)}
className="w-full px-6 py-5 text-left flex items-center justify-between" className="w-full px-6 py-5 text-left flex items-center justify-between"
> >
<span className="font-bold text-gray-900">{faq.question}</span> <span className={`font-bold ${getThemeClasses("text-primary")}`}>{faq.question}</span>
<ChevronDownIcon <ChevronDownIcon
className={`h-5 w-5 text-gray-500 transition-transform duration-300 ${ className={`h-5 w-5 ${getThemeClasses("text-secondary")} transition-transform duration-300 ${
openFaq === index ? "rotate-180" : "" openFaq === index ? "rotate-180" : ""
}`} }`}
/> />
</button> </button>
{openFaq === index && ( {openFaq === index && (
<div className="px-6 pb-5"> <div className="px-6 pb-5">
<p className="text-gray-600 leading-relaxed">{faq.answer}</p> <p className={`${getThemeClasses("text-secondary")} leading-relaxed`}>{faq.answer}</p>
</div> </div>
)} )}
</Card> </Card>
@ -719,12 +733,12 @@ function IndexPage() {
</div> </div>
{/* CTA Section */} {/* CTA Section */}
<div className="bg-gradient-to-r from-red-800 to-red-900 py-20"> <div className={`${getThemeClasses("bg-gradient-secondary")} py-20`}>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 className="text-4xl font-black text-white mb-6"> <h2 className="text-4xl font-black text-white mb-6">
Ready to Take Control of Your Privacy? Ready to Take Control of Your Privacy?
</h2> </h2>
<p className="text-xl text-red-100 mb-10 max-w-2xl mx-auto"> <p className="text-xl text-white/80 mb-10 max-w-2xl mx-auto">
Join thousands of Canadians who trust MapleFile with their most sensitive files. Join thousands of Canadians who trust MapleFile with their most sensitive files.
Get started free with 10 GB of encrypted storage. Get started free with 10 GB of encrypted storage.
</p> </p>
@ -733,7 +747,7 @@ function IndexPage() {
<Button <Button
variant="secondary" variant="secondary"
size="lg" size="lg"
className="bg-white text-red-800 hover:bg-gray-100 shadow-xl hover:shadow-2xl transform hover:scale-105 px-8" className="shadow-xl hover:shadow-2xl transform hover:scale-105 px-8"
> >
<span className="flex items-center whitespace-nowrap"> <span className="flex items-center whitespace-nowrap">
Get Started Free Get Started Free
@ -755,43 +769,43 @@ function IndexPage() {
</div> </div>
{/* Footer */} {/* Footer */}
<footer className="bg-gray-900 text-white py-16"> <footer className={`${getThemeClasses("bg-card")} border-t ${getThemeClasses("border-muted")} py-16`}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8 mb-12"> <div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8 mb-12">
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<div className="flex items-center mb-6"> <div className="flex items-center mb-6">
<div className="flex items-center justify-center h-10 w-10 bg-gradient-to-br from-red-800 to-red-900 rounded-lg mr-3"> <div className={`flex items-center justify-center h-10 w-10 ${getThemeClasses("bg-gradient-secondary")} rounded-lg mr-3`}>
<LockClosedIcon className="h-6 w-6 text-white" /> <LockClosedIcon className="h-6 w-6 text-white" />
</div> </div>
<span className="text-2xl font-bold text-white">MapleFile</span> <span className={`text-2xl font-bold ${getThemeClasses("text-primary")}`}>MapleFile</span>
</div> </div>
<p className="text-gray-400 mb-6 max-w-md"> <p className={`${getThemeClasses("text-secondary")} mb-6 max-w-md`}>
Secure, encrypted file storage built in Canada. Your data stays private Secure, encrypted file storage built in Canada. Your data stays private
with military-grade encryption and zero-knowledge architecture. with military-grade encryption and zero-knowledge architecture.
</p> </p>
<div className="flex items-center space-x-2 text-sm text-gray-400"> <div className={`flex items-center space-x-2 text-sm ${getThemeClasses("text-secondary")}`}>
<span className="text-lg">🍁</span> <span className="text-lg">🍁</span>
<span>Proudly Canadian</span> <span>Proudly Canadian</span>
</div> </div>
</div> </div>
<div> <div>
<h3 className="text-sm font-bold text-gray-300 tracking-wider uppercase mb-4"> <h3 className={`text-sm font-bold ${getThemeClasses("text-secondary")} tracking-wider uppercase mb-4`}>
Product Product
</h3> </h3>
<ul className="space-y-3"> <ul className="space-y-3">
<li> <li>
<a href="#security" className="text-gray-400 hover:text-white transition-colors duration-200"> <a href="#security" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} transition-colors duration-200`}>
Security Security
</a> </a>
</li> </li>
<li> <li>
<a href="#pricing" className="text-gray-400 hover:text-white transition-colors duration-200"> <a href="#pricing" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} transition-colors duration-200`}>
Pricing Pricing
</a> </a>
</li> </li>
<li> <li>
<a href="#faq" className="text-gray-400 hover:text-white transition-colors duration-200"> <a href="#faq" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} transition-colors duration-200`}>
FAQ FAQ
</a> </a>
</li> </li>
@ -799,22 +813,22 @@ function IndexPage() {
</div> </div>
<div> <div>
<h3 className="text-sm font-bold text-gray-300 tracking-wider uppercase mb-4"> <h3 className={`text-sm font-bold ${getThemeClasses("text-secondary")} tracking-wider uppercase mb-4`}>
Account Account
</h3> </h3>
<ul className="space-y-3"> <ul className="space-y-3">
<li> <li>
<Link to="/login" className="text-gray-400 hover:text-white transition-colors duration-200"> <Link to="/login" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} transition-colors duration-200`}>
Sign In Sign In
</Link> </Link>
</li> </li>
<li> <li>
<Link to="/register" className="text-gray-400 hover:text-white transition-colors duration-200"> <Link to="/register" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} transition-colors duration-200`}>
Create Account Create Account
</Link> </Link>
</li> </li>
<li> <li>
<Link to="/recovery" className="text-gray-400 hover:text-white transition-colors duration-200"> <Link to="/recovery" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} transition-colors duration-200`}>
Account Recovery Account Recovery
</Link> </Link>
</li> </li>
@ -822,19 +836,19 @@ function IndexPage() {
</div> </div>
</div> </div>
<div className="border-t border-gray-800 pt-8"> <div className={`border-t ${getThemeClasses("border-muted")} pt-8`}>
<div className="md:flex md:items-center md:justify-between"> <div className="md:flex md:items-center md:justify-between">
<p className="text-gray-400 text-sm"> <p className={`${getThemeClasses("text-secondary")} text-sm`}>
&copy; 2025 MapleFile Inc. All rights reserved. Made with in Canada. &copy; 2025 MapleFile Inc. All rights reserved. Made with in Canada.
</p> </p>
<div className="mt-4 md:mt-0 flex space-x-6 text-sm"> <div className="mt-4 md:mt-0 flex space-x-6 text-sm">
<a href="mailto:privacy@maplefile.com" className="text-gray-400 hover:text-white transition-colors duration-200"> <a href="mailto:privacy@maplefile.com" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} transition-colors duration-200`}>
Privacy Policy Privacy Policy
</a> </a>
<a href="mailto:legal@maplefile.com" className="text-gray-400 hover:text-white transition-colors duration-200"> <a href="mailto:legal@maplefile.com" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} transition-colors duration-200`}>
Terms of Service Terms of Service
</a> </a>
<a href="mailto:support@maplefile.com" className="text-gray-400 hover:text-white transition-colors duration-200"> <a href="mailto:support@maplefile.com" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} transition-colors duration-200`}>
Contact Contact
</a> </a>
</div> </div>

View file

@ -1,6 +1,6 @@
// File: monorepo/web/maplefile-frontend/src/pages/Anonymous/Login/CompleteLogin.jsx // File: monorepo/web/maplefile-frontend/src/pages/Anonymous/Login/CompleteLogin.jsx
import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useNavigate, Link } from "react-router"; import { useNavigate, Link } from "react-router"; // Link still used for "Forgot password?"
import { useServices } from "../../../services/Services"; import { useServices } from "../../../services/Services";
import { import {
ArrowRightIcon, ArrowRightIcon,
@ -15,12 +15,18 @@ import {
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
// UIX Components // UIX Components
import Input from "../../../components/UIX/Input/Input"; import {
import Button from "../../../components/UIX/Button/Button"; Input,
import Checkbox from "../../../components/UIX/Checkbox/Checkbox"; Button,
import Alert from "../../../components/UIX/Alert/Alert"; Checkbox,
import Card from "../../../components/UIX/Card/Card"; Alert,
import GDPRFooter from "../../../components/UIX/GDPRFooter/GDPRFooter"; Card,
GDPRFooter,
PageContainer,
Navigation,
ProgressIndicator,
PageHeader,
} from "../../../components/UIX";
import { useUIXTheme } from "../../../components/UIX/themes/useUIXTheme"; import { useUIXTheme } from "../../../components/UIX/themes/useUIXTheme";
const CompleteLogin = () => { const CompleteLogin = () => {
@ -114,18 +120,34 @@ const CompleteLogin = () => {
} }
} }
// Try to get verification data // Try to get verification data with schema validation
try { try {
const sessionData = sessionStorage.getItem("otpVerificationResult"); const sessionData = sessionStorage.getItem("otpVerificationResult");
if (sessionData) { if (sessionData) {
storedVerificationData = JSON.parse(sessionData); const parsed = JSON.parse(sessionData);
if (import.meta.env.DEV) { // Validate expected structure to prevent injection attacks
console.log( if (
"[CompleteLogin] Using verification data from sessionStorage", parsed &&
); typeof parsed === 'object' &&
(typeof parsed.challengeId === 'string' || typeof parsed.challenge_id === 'string')
) {
storedVerificationData = parsed;
if (import.meta.env.DEV) {
console.log(
"[CompleteLogin] Using verification data from sessionStorage",
);
}
} else {
// Invalid structure - clear corrupted data
sessionStorage.removeItem("otpVerificationResult");
if (import.meta.env.DEV) {
console.warn("[CompleteLogin] Invalid verification data structure, cleared");
}
} }
} }
} catch (err) { } catch (err) {
// Parse error - clear corrupted data
sessionStorage.removeItem("otpVerificationResult");
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
console.warn( console.warn(
"[CompleteLogin] Could not parse verification data from sessionStorage:", "[CompleteLogin] Could not parse verification data from sessionStorage:",
@ -480,11 +502,27 @@ const CompleteLogin = () => {
// Memoize display email // Memoize display email
const displayEmail = useMemo(() => email || "", [email]); const displayEmail = useMemo(() => email || "", [email]);
// Memoize progress steps
const progressSteps = useMemo(
() => [
{ label: "Email", completed: true },
{ label: "Verify", completed: true },
{ label: "Access", completed: false },
],
[],
);
// Memoize navigation links
const navLinks = useMemo(
() => [{ to: "/register", text: "Create account", variant: "secondary" }],
[],
);
// Early return if verification data is not available // Early return if verification data is not available
if (!verificationData) { if (!verificationData) {
return ( return (
<div className={`min-h-screen ${getThemeClasses("bg-gradient-primary")} flex items-center justify-center`}> <PageContainer>
<Card className="text-center p-8"> <Card className="text-center p-8 max-w-md mx-auto mt-20">
<h2 className={`text-2xl font-bold ${getThemeClasses("text-primary")} mb-4`}> <h2 className={`text-2xl font-bold ${getThemeClasses("text-primary")} mb-4`}>
Loading... Loading...
</h2> </h2>
@ -492,86 +530,28 @@ const CompleteLogin = () => {
Preparing secure login... Preparing secure login...
</p> </p>
</Card> </Card>
</div> </PageContainer>
); );
} }
return ( return (
<div className={`min-h-screen ${getThemeClasses("bg-gradient-primary")} flex flex-col`}> <PageContainer showBlobs>
{/* Decorative Background Blobs */} {/* Navigation */}
<div className={`${getThemeClasses("decorative-blob-1")}`}></div> <Navigation icon={LockClosedIcon} logoText="MapleFile" links={navLinks} />
<div className={`${getThemeClasses("decorative-blob-2")}`}></div>
<div className={`${getThemeClasses("decorative-blob-3")}`}></div>
{/* Header */}
<div className="flex-shrink-0 relative z-10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<Link to="/" className="inline-flex items-center space-x-3 group">
<div className="relative">
<div className={`absolute inset-0 ${getThemeClasses("bg-gradient-secondary")} rounded-xl opacity-0 group-hover:opacity-100 blur-xl transition-opacity duration-300`}></div>
<div className={`relative flex items-center justify-center h-10 w-10 ${getThemeClasses("bg-gradient-secondary")} rounded-xl shadow-md group-hover:shadow-lg transform group-hover:scale-105 transition-all duration-200`}>
<LockClosedIcon className="h-5 w-5 text-white" />
</div>
</div>
<span className={`text-xl font-bold ${getThemeClasses("text-primary")} transition-colors duration-200`}>
MapleFile
</span>
</Link>
</div>
</div>
{/* Main Content */} {/* Main Content */}
<div className="flex-1 flex items-center justify-center px-4 sm:px-6 lg:px-8 py-12 relative z-10"> <div className="flex-1 flex items-center justify-center px-4 sm:px-6 lg:px-8 py-12 relative z-10">
<div className="w-full max-w-md space-y-8"> <div className="w-full max-w-md space-y-8">
{/* Progress Indicator */} {/* Progress Indicator */}
<div className="flex items-center justify-center"> <ProgressIndicator steps={progressSteps} currentStep={3} />
<div className="flex items-center space-x-4">
<div className="flex items-center">
<div className="flex items-center justify-center w-8 h-8 bg-green-600 rounded-full text-white text-sm font-bold">
<CheckIcon className="h-4 w-4" />
</div>
<span className={`ml-2 text-sm font-semibold ${getThemeClasses("text-success")}`}>
Email
</span>
</div>
<div className="w-12 h-0.5 bg-green-600"></div>
<div className="flex items-center">
<div className="flex items-center justify-center w-8 h-8 bg-green-600 rounded-full text-white text-sm font-bold">
<CheckIcon className="h-4 w-4" />
</div>
<span className={`ml-2 text-sm font-semibold ${getThemeClasses("text-success")}`}>
Verify
</span>
</div>
<div className="w-12 h-0.5 bg-green-600"></div>
<div className="flex items-center">
<div className={`flex items-center justify-center w-8 h-8 ${getThemeClasses("pagination-active")} rounded-full text-sm font-bold shadow-lg`}>
3
</div>
<span className={`ml-2 text-sm font-semibold ${getThemeClasses("text-primary")}`}>
Access
</span>
</div>
</div>
</div>
{/* Header */} {/* Header */}
<div className="text-center"> <PageHeader
<div className="flex justify-center mb-6"> icon={KeyIcon}
<div className="relative"> title="Unlock Your Account"
<div className={`absolute inset-0 ${getThemeClasses("bg-gradient-secondary")} rounded-2xl blur-2xl opacity-20 animate-pulse`}></div> subtitle="Enter your master password to decrypt your data."
<div className={`relative h-16 w-16 ${getThemeClasses("bg-gradient-secondary")} rounded-2xl flex items-center justify-center shadow-xl`}> className="text-center"
<KeyIcon className="h-8 w-8 text-white" /> />
</div>
</div>
</div>
<h1 className={`text-3xl font-bold ${getThemeClasses("text-primary")}`}>
Unlock Your Account
</h1>
<p className={`mt-2 ${getThemeClasses("text-secondary")}`}>
Enter your master password to decrypt your data.
</p>
</div>
{/* GDPR Notice - Master Password Privacy */} {/* GDPR Notice - Master Password Privacy */}
<Alert type="info"> <Alert type="info">
@ -748,7 +728,7 @@ const CompleteLogin = () => {
{/* Footer - GDPR Rights */} {/* Footer - GDPR Rights */}
<GDPRFooter /> <GDPRFooter />
</div> </PageContainer>
); );
}; };

View file

@ -1,26 +1,46 @@
// File: src/pages/Anonymous/Login/SessionExpired.jsx // File: src/pages/Anonymous/Login/SessionExpired.jsx
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useRef, useCallback } from "react";
import { useNavigate, useLocation, Link } from "react-router"; import { useNavigate, useLocation, Link } from "react-router";
import {
Button,
Card,
Alert,
useUIXTheme,
} from "../../../components/UIX";
import { import {
ClockIcon, ClockIcon,
LockClosedIcon, LockClosedIcon,
ShieldCheckIcon, ShieldCheckIcon,
ArrowRightIcon, ArrowRightIcon,
ExclamationTriangleIcon,
InformationCircleIcon, InformationCircleIcon,
CheckCircleIcon, CheckCircleIcon,
HomeIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
const SessionExpired = () => { const SessionExpired = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { getThemeClasses } = useUIXTheme();
const [countdown, setCountdown] = useState(30); const [countdown, setCountdown] = useState(30);
const timerRef = useRef(null);
const isMountedRef = useRef(true);
// Get the reason and message from location state // Get the reason and message from location state
const { reason, message, from } = location.state || {}; const { reason, from } = location.state || {};
// Cleanup on unmount
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
}, []);
// Determine the appropriate message and icon based on reason // Determine the appropriate message and icon based on reason
const getSessionInfo = () => { const getSessionInfo = useCallback(() => {
switch (reason) { switch (reason) {
case "inactivity_timeout": case "inactivity_timeout":
return { return {
@ -29,8 +49,7 @@ const SessionExpired = () => {
description: description:
"For your security, we automatically log you out after 60 minutes of inactivity. This helps protect your encrypted files from unauthorized access.", "For your security, we automatically log you out after 60 minutes of inactivity. This helps protect your encrypted files from unauthorized access.",
icon: ClockIcon, icon: ClockIcon,
iconColor: "text-amber-600", type: "warning",
iconBg: "bg-amber-100",
}; };
case "manual_clear": case "manual_clear":
return { return {
@ -39,8 +58,7 @@ const SessionExpired = () => {
description: description:
"Your session has been cleared. This might have happened if you logged out from another tab or if there was a security-related action.", "Your session has been cleared. This might have happened if you logged out from another tab or if there was a security-related action.",
icon: ShieldCheckIcon, icon: ShieldCheckIcon,
iconColor: "text-blue-600", type: "info",
iconBg: "bg-blue-100",
}; };
default: default:
return { return {
@ -49,17 +67,18 @@ const SessionExpired = () => {
description: description:
"For your security, your session has expired. Please sign in again to continue accessing your encrypted files.", "For your security, your session has expired. Please sign in again to continue accessing your encrypted files.",
icon: LockClosedIcon, icon: LockClosedIcon,
iconColor: "text-red-600", type: "error",
iconBg: "bg-red-100",
}; };
} }
}; }, [reason]);
const sessionInfo = getSessionInfo(); const sessionInfo = getSessionInfo();
// Auto-redirect countdown // Auto-redirect countdown
useEffect(() => { useEffect(() => {
const timer = setInterval(() => { timerRef.current = setInterval(() => {
if (!isMountedRef.current) return;
setCountdown((prev) => { setCountdown((prev) => {
if (prev <= 1) { if (prev <= 1) {
navigate("/login", { navigate("/login", {
@ -75,46 +94,50 @@ const SessionExpired = () => {
}); });
}, 1000); }, 1000);
return () => clearInterval(timer); return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
}, [navigate, from]); }, [navigate, from]);
const handleSignInNow = () => { const handleSignInNow = useCallback(() => {
navigate("/login", { navigate("/login", {
state: { state: {
from: from, from: from,
reason: "session_expired", reason: "session_expired",
}, },
}); });
}; }, [navigate, from]);
const handleGoHome = () => { const handleGoHome = useCallback(() => {
navigate("/"); navigate("/");
}; }, [navigate]);
return ( return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-red-50 flex flex-col"> <div className={`min-h-screen ${getThemeClasses("bg-gradient-primary")} flex flex-col`}>
{/* Navigation */} {/* Navigation */}
<nav className="bg-white/95 backdrop-blur-sm border-b border-gray-100"> <nav className={`${getThemeClasses("bg-card")}/95 backdrop-blur-sm border-b ${getThemeClasses("border-muted")}`}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-4"> <div className="flex justify-between items-center py-4">
<Link to="/" className="flex items-center group"> <Link to="/" className="flex items-center group">
<div className="flex items-center justify-center h-10 w-10 bg-gradient-to-br from-red-800 to-red-900 rounded-lg mr-3 group-hover:scale-105 transition-transform duration-200"> <div className={`flex items-center justify-center h-10 w-10 ${getThemeClasses("bg-gradient-secondary")} rounded-lg mr-3 group-hover:scale-105 transition-transform duration-200`}>
<LockClosedIcon className="h-6 w-6 text-white" /> <LockClosedIcon className="h-6 w-6 text-white" />
</div> </div>
<span className="text-2xl font-bold bg-gradient-to-r from-gray-900 to-red-800 bg-clip-text text-transparent"> <span className={`text-2xl font-bold ${getThemeClasses("text-primary")}`}>
MapleFile MapleFile
</span> </span>
</Link> </Link>
<div className="flex items-center space-x-6"> <div className="flex items-center space-x-6">
<Link <Link
to="/register" to="/register"
className="text-base font-medium text-gray-700 hover:text-red-800 transition-colors duration-200" className={`text-base font-medium ${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} transition-colors duration-200`}
> >
Need an account? Need an account?
</Link> </Link>
<Link <Link
to="/recovery" to="/recovery"
className="text-base font-medium text-gray-700 hover:text-red-800 transition-colors duration-200" className={`text-base font-medium ${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} transition-colors duration-200`}
> >
Forgot password? Forgot password?
</Link> </Link>
@ -130,107 +153,106 @@ const SessionExpired = () => {
<div className="text-center animate-fade-in-up"> <div className="text-center animate-fade-in-up">
<div className="flex justify-center mb-6"> <div className="flex justify-center mb-6">
<div className="relative"> <div className="relative">
<div <div className={`flex items-center justify-center h-16 w-16 ${
className={`flex items-center justify-center h-16 w-16 ${sessionInfo.iconBg} rounded-2xl shadow-lg`} sessionInfo.type === "warning" ? getThemeClasses("bg-warning-light") :
> sessionInfo.type === "info" ? getThemeClasses("bg-info-light") :
<sessionInfo.icon getThemeClasses("bg-error-light")
className={`h-8 w-8 ${sessionInfo.iconColor}`} } rounded-2xl shadow-lg`}>
/> <sessionInfo.icon className={`h-8 w-8 ${
sessionInfo.type === "warning" ? getThemeClasses("text-warning") :
sessionInfo.type === "info" ? getThemeClasses("text-info") :
getThemeClasses("text-error")
}`} />
</div> </div>
<div className="absolute -inset-1 bg-gradient-to-r from-red-800 to-red-900 rounded-2xl blur opacity-20"></div> <div className={`absolute -inset-1 ${getThemeClasses("bg-gradient-secondary")} rounded-2xl blur opacity-20`}></div>
</div> </div>
</div> </div>
<h2 className="text-3xl font-black text-gray-900 mb-2"> <h2 className={`text-3xl font-black ${getThemeClasses("text-primary")} mb-2`}>
{sessionInfo.title} {sessionInfo.title}
</h2> </h2>
<p className="text-gray-600 mb-4">{sessionInfo.subtitle}</p> <p className={`${getThemeClasses("text-secondary")} mb-4`}>{sessionInfo.subtitle}</p>
</div> </div>
{/* Session Info Card */} {/* Session Info Card */}
<div className="bg-white rounded-2xl shadow-2xl border border-gray-100 p-8 animate-fade-in-up-delay"> <Card className="shadow-2xl p-8 animate-fade-in-up-delay">
{/* Description */} {/* Description */}
<div className="mb-6 p-4 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-lg border border-blue-100"> <Alert type="info" className="mb-6">
<div className="flex items-start"> <div>
<InformationCircleIcon className="h-5 w-5 text-blue-500 mr-3 flex-shrink-0 mt-0.5" /> <strong className="font-semibold">What happened?</strong>
<div> <p className="mt-1">{sessionInfo.description}</p>
<h3 className="text-sm font-semibold text-blue-900 mb-2">
What happened?
</h3>
<p className="text-sm text-blue-800">
{sessionInfo.description}
</p>
</div>
</div> </div>
</div> </Alert>
{/* Security Info */} {/* Security Info */}
<div className="mb-6 p-4 bg-gradient-to-r from-green-50 to-emerald-50 rounded-lg border border-green-100"> <Alert type="success" className="mb-6">
<div className="flex items-start"> <div>
<CheckCircleIcon className="h-5 w-5 text-green-500 mr-3 flex-shrink-0 mt-0.5" /> <strong className="font-semibold">Your data is secure</strong>
<div> <ul className="mt-1 space-y-1">
<h3 className="text-sm font-semibold text-green-900 mb-2"> <li> Your files remain encrypted and protected</li>
Your data is secure <li> No one can access your data without your password</li>
</h3> <li> Session expiry is a security feature, not a problem</li>
<ul className="text-sm text-green-800 space-y-1"> </ul>
<li> Your files remain encrypted and protected</li>
<li> No one can access your data without your password</li>
<li>
Session expiry is a security feature, not a problem
</li>
</ul>
</div>
</div> </div>
</div> </Alert>
{/* Auto-redirect notice */} {/* Auto-redirect notice */}
<div className="mb-6 p-4 bg-gradient-to-r from-amber-50 to-yellow-50 rounded-lg border border-amber-100"> <Alert type="warning" className="mb-6">
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
<ClockIcon className="h-5 w-5 text-amber-500 mr-3" /> <ClockIcon className="h-5 w-5 mr-3 flex-shrink-0" />
<p className="text-sm text-amber-800"> <p>
Automatically redirecting to sign in page in{" "} Automatically redirecting to sign in page in{" "}
<span className="font-bold text-amber-900">{countdown}</span>{" "} <span className="font-bold">{countdown}</span>{" "}
seconds seconds
</p> </p>
</div> </div>
</div> </Alert>
{/* Action Buttons */} {/* Action Buttons */}
<div className="space-y-4"> <div className="space-y-4">
<button <Button
onClick={handleSignInNow} onClick={handleSignInNow}
className="group w-full flex justify-center items-center py-3 px-4 border border-transparent text-base font-semibold rounded-lg text-white bg-gradient-to-r from-red-800 to-red-900 hover:from-red-900 hover:to-red-950 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transform hover:scale-105 transition-all duration-200 shadow-lg hover:shadow-xl" variant="primary"
fullWidth
className="py-3"
> >
Sign In Now <span className="inline-flex items-center gap-2">
<ArrowRightIcon className="ml-2 h-4 w-4 group-hover:translate-x-1 transition-transform duration-200" /> <span>Sign In Now</span>
</button> <ArrowRightIcon className="h-4 w-4" />
</span>
</Button>
<button <Button
onClick={handleGoHome} onClick={handleGoHome}
className="w-full flex justify-center items-center py-3 px-4 border-2 border-gray-300 text-base font-semibold rounded-lg text-gray-700 bg-white hover:bg-gray-50 hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transform hover:scale-105 transition-all duration-200 shadow-lg hover:shadow-xl" variant="secondary"
fullWidth
className="py-3"
> >
Go to Homepage <span className="inline-flex items-center gap-2">
</button> <HomeIcon className="h-4 w-4" />
<span>Go to Homepage</span>
</span>
</Button>
</div> </div>
{/* Alternative Actions */} {/* Alternative Actions */}
<div className="mt-6 flex flex-col sm:flex-row justify-between items-center space-y-2 sm:space-y-0 text-sm"> <div className="mt-6 flex flex-col sm:flex-row justify-between items-center space-y-2 sm:space-y-0 text-sm">
<Link <Link
to="/register" to="/register"
className="text-red-600 hover:text-red-700 font-medium hover:underline transition-colors duration-200" className={`${getThemeClasses("link-primary")} font-medium hover:underline transition-colors duration-200`}
> >
Create new account Create new account
</Link> </Link>
<Link <Link
to="/recovery" to="/recovery"
className="text-gray-600 hover:text-gray-700 font-medium hover:underline transition-colors duration-200" className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")} font-medium hover:underline transition-colors duration-200`}
> >
Forgot your password? Forgot your password?
</Link> </Link>
</div> </div>
</div> </Card>
{/* Additional Info */} {/* Additional Info */}
<div className="text-center text-sm text-gray-500 animate-fade-in-up-delay-2"> <div className={`text-center text-sm ${getThemeClasses("text-secondary")} animate-fade-in-up-delay-2`}>
<p>Session timeout: 60 minutes of inactivity</p> <p>Session timeout: 60 minutes of inactivity</p>
<p className="mt-1">This helps keep your encrypted files secure</p> <p className="mt-1">This helps keep your encrypted files secure</p>
</div> </div>
@ -238,26 +260,26 @@ const SessionExpired = () => {
</div> </div>
{/* Footer */} {/* Footer */}
<footer className="bg-white border-t border-gray-100 py-8"> <footer className={`${getThemeClasses("bg-card")} border-t ${getThemeClasses("border-muted")} py-8`}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center text-sm text-gray-500"> <div className={`text-center text-sm ${getThemeClasses("text-secondary")}`}>
<p>&copy; 2025 MapleFile Inc. All rights reserved.</p> <p>&copy; 2025 MapleFile Inc. All rights reserved.</p>
<div className="mt-2 space-x-4"> <div className="mt-2 space-x-4">
<Link <Link
to="#" to="/privacy"
className="hover:text-gray-700 transition-colors duration-200" className={`${getThemeClasses("hover:text-accent")} transition-colors duration-200`}
> >
Privacy Policy Privacy Policy
</Link> </Link>
<Link <Link
to="#" to="/terms"
className="hover:text-gray-700 transition-colors duration-200" className={`${getThemeClasses("hover:text-accent")} transition-colors duration-200`}
> >
Terms of Service Terms of Service
</Link> </Link>
<Link <Link
to="#" to="/support"
className="hover:text-gray-700 transition-colors duration-200" className={`${getThemeClasses("hover:text-accent")} transition-colors duration-200`}
> >
Support Support
</Link> </Link>

View file

@ -401,9 +401,11 @@ const VerifyOTT = () => {
const handleOttChange = useCallback((value) => { const handleOttChange = useCallback((value) => {
setOtt(value); setOtt(value);
if (generalError) setGeneralError(""); // Clear errors only once when user starts typing - uses functional updates
if (Object.keys(fieldErrors).length > 0) setFieldErrors({}); // to avoid dependencies on error state values
}, [generalError, fieldErrors]); setGeneralError((prev) => prev ? "" : prev);
setFieldErrors((prev) => Object.keys(prev).length > 0 ? {} : prev);
}, []);
// Memoize display email // Memoize display email
const displayEmail = useMemo(() => email || "", [email]); const displayEmail = useMemo(() => email || "", [email]);

View file

@ -1,7 +1,15 @@
// File: monorepo/web/maplefile-frontend/src/pages/Anonymous/Recovery/CompleteRecovery.jsx // File: monorepo/web/maplefile-frontend/src/pages/Anonymous/Recovery/CompleteRecovery.jsx
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { useNavigate, Link } from "react-router"; import { useNavigate, Link } from "react-router";
import { useServices } from "../../../services/Services"; import { useServices } from "../../../services/Services";
import {
Button,
Input,
Alert,
Card,
Spinner,
useUIXTheme,
} from "../../../components/UIX";
import { import {
ArrowRightIcon, ArrowRightIcon,
ArrowLeftIcon, ArrowLeftIcon,
@ -13,16 +21,17 @@ import {
KeyIcon, KeyIcon,
EyeIcon, EyeIcon,
EyeSlashIcon, EyeSlashIcon,
DocumentTextIcon,
CheckCircleIcon, CheckCircleIcon,
ArrowPathIcon, ArrowPathIcon,
LockOpenIcon, LockOpenIcon,
ServerIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
const CompleteRecovery = () => { const CompleteRecovery = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { recoveryManager } = useServices(); const { recoveryManager } = useServices();
const { getThemeClasses } = useUIXTheme();
const isMountedRef = useRef(true);
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [recoveryPhrase, setRecoveryPhrase] = useState(""); const [recoveryPhrase, setRecoveryPhrase] = useState("");
@ -31,7 +40,14 @@ const CompleteRecovery = () => {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [showNewPassword, setShowNewPassword] = useState(false); const [showNewPassword, setShowNewPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [showRecoveryPhrase, setShowRecoveryPhrase] = useState(false);
// Cleanup on unmount
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
useEffect(() => { useEffect(() => {
// Check if we have completed verification // Check if we have completed verification
@ -39,9 +55,9 @@ const CompleteRecovery = () => {
const isVerified = recoveryManager.isVerificationComplete(); const isVerified = recoveryManager.isVerificationComplete();
if (!recoveryEmail || !isVerified) { if (!recoveryEmail || !isVerified) {
console.log( if (import.meta.env.DEV) {
"[CompleteRecovery] No verified recovery session, redirecting", console.log("[CompleteRecovery] No verified recovery session, redirecting");
); }
navigate("/recovery/initiate"); navigate("/recovery/initiate");
return; return;
} }
@ -49,8 +65,11 @@ const CompleteRecovery = () => {
setEmail(recoveryEmail); setEmail(recoveryEmail);
}, [navigate, recoveryManager]); }, [navigate, recoveryManager]);
const handleSubmit = async (e) => { const handleSubmit = useCallback(async (e) => {
e.preventDefault(); e.preventDefault();
if (!isMountedRef.current) return;
setLoading(true); setLoading(true);
setError(""); setError("");
@ -77,15 +96,21 @@ const CompleteRecovery = () => {
// Join words with single space // Join words with single space
const normalizedPhrase = words.join(" "); const normalizedPhrase = words.join(" ");
console.log("[CompleteRecovery] Completing recovery with new password"); if (import.meta.env.DEV) {
console.log("[CompleteRecovery] Completing recovery with new password");
}
// Complete recovery with both recovery phrase and new password // Complete recovery with both recovery phrase and new password
const response = await recoveryManager.completeRecoveryWithPhrase( await recoveryManager.completeRecoveryWithPhrase(
normalizedPhrase, normalizedPhrase,
newPassword, newPassword,
); );
console.log("[CompleteRecovery] Recovery completed successfully"); if (!isMountedRef.current) return;
if (import.meta.env.DEV) {
console.log("[CompleteRecovery] Recovery completed successfully");
}
// Show success modal or alert // Show success modal or alert
alert( alert(
@ -94,50 +119,88 @@ const CompleteRecovery = () => {
// Navigate to login // Navigate to login
navigate("/login"); navigate("/login");
} catch (error) { } catch (err) {
console.error("[CompleteRecovery] Recovery completion failed:", error); if (!isMountedRef.current) return;
setError(error.message);
} finally {
setLoading(false);
}
};
const handleBackToVerify = () => { if (import.meta.env.DEV) {
console.error("[CompleteRecovery] Recovery completion failed:", err);
}
setError(err.message);
} finally {
if (isMountedRef.current) {
setLoading(false);
}
}
}, [newPassword, confirmPassword, recoveryPhrase, recoveryManager, navigate]);
const handleBackToVerify = useCallback(() => {
navigate("/recovery/verify"); navigate("/recovery/verify");
}; }, [navigate]);
// Memoize word count calculation
const wordCount = useMemo(() => {
return recoveryPhrase.trim() ? recoveryPhrase.trim().split(/\s+/).length : 0;
}, [recoveryPhrase]);
// Memoize password toggle buttons
const newPasswordSuffix = useMemo(() => (
<button
type="button"
onClick={() => setShowNewPassword(!showNewPassword)}
className="flex items-center touch-manipulation"
tabIndex={-1}
>
{showNewPassword ? (
<EyeSlashIcon className={`h-5 w-5 ${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")}`} />
) : (
<EyeIcon className={`h-5 w-5 ${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")}`} />
)}
</button>
), [showNewPassword, getThemeClasses]);
const confirmPasswordSuffix = useMemo(() => (
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="flex items-center touch-manipulation"
tabIndex={-1}
>
{showConfirmPassword ? (
<EyeSlashIcon className={`h-5 w-5 ${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")}`} />
) : (
<EyeIcon className={`h-5 w-5 ${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-accent")}`} />
)}
</button>
), [showConfirmPassword, getThemeClasses]);
if (!email) { if (!email) {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-red-50 flex items-center justify-center"> <div className={`min-h-screen ${getThemeClasses("bg-gradient-primary")} flex items-center justify-center`}>
<div className="text-center"> <div className="text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Loading...</h2> <Spinner size="lg" className="mx-auto mb-4" />
<p className="text-gray-600">Checking recovery session...</p> <h2 className={`text-2xl font-bold ${getThemeClasses("text-primary")} mb-4`}>Loading...</h2>
<p className={getThemeClasses("text-secondary")}>Checking recovery session...</p>
</div> </div>
</div> </div>
); );
} }
// Count words in recovery phrase
const wordCount = recoveryPhrase.trim()
? recoveryPhrase.trim().split(/\s+/).length
: 0;
return ( return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-red-50 flex flex-col"> <div className={`min-h-screen ${getThemeClasses("bg-gradient-primary")} flex flex-col`}>
{/* Navigation */} {/* Navigation */}
<nav className="bg-white/95 backdrop-blur-sm border-b border-gray-100"> <nav className={`${getThemeClasses("bg-card")}/95 backdrop-blur-sm border-b ${getThemeClasses("border-muted")}`}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-4"> <div className="flex justify-between items-center py-4">
<Link to="/" className="flex items-center group"> <Link to="/" className="flex items-center group">
<div className="flex items-center justify-center h-10 w-10 bg-gradient-to-br from-red-800 to-red-900 rounded-lg mr-3 group-hover:scale-105 transition-transform duration-200"> <div className={`flex items-center justify-center h-10 w-10 ${getThemeClasses("bg-gradient-secondary")} rounded-lg mr-3 group-hover:scale-105 transition-transform duration-200`}>
<LockClosedIcon className="h-6 w-6 text-white" /> <LockClosedIcon className="h-6 w-6 text-white" />
</div> </div>
<span className="text-2xl font-bold bg-gradient-to-r from-gray-900 to-red-800 bg-clip-text text-transparent"> <span className={`text-2xl font-bold ${getThemeClasses("text-primary")}`}>
MapleFile MapleFile
</span> </span>
</Link> </Link>
<div className="flex items-center space-x-6"> <div className="flex items-center space-x-6">
<span className="text-base font-medium text-gray-500"> <span className={`text-base font-medium ${getThemeClasses("text-secondary")}`}>
Step 3 of 3 Step 3 of 3
</span> </span>
</div> </div>
@ -152,28 +215,28 @@ const CompleteRecovery = () => {
<div className="flex items-center justify-center mb-8"> <div className="flex items-center justify-center mb-8">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="flex items-center"> <div className="flex items-center">
<div className="flex items-center justify-center w-8 h-8 bg-green-500 rounded-full text-white text-sm font-bold"> <div className={`flex items-center justify-center w-8 h-8 ${getThemeClasses("bg-success")} rounded-full text-white text-sm font-bold`}>
<CheckIcon className="h-4 w-4" /> <CheckIcon className="h-4 w-4" />
</div> </div>
<span className="ml-2 text-sm font-semibold text-green-600"> <span className={`ml-2 text-sm font-semibold ${getThemeClasses("text-success")}`}>
Email Email
</span> </span>
</div> </div>
<div className="w-12 h-0.5 bg-green-500"></div> <div className={`w-12 h-0.5 ${getThemeClasses("bg-success")}`}></div>
<div className="flex items-center"> <div className="flex items-center">
<div className="flex items-center justify-center w-8 h-8 bg-green-500 rounded-full text-white text-sm font-bold"> <div className={`flex items-center justify-center w-8 h-8 ${getThemeClasses("bg-success")} rounded-full text-white text-sm font-bold`}>
<CheckIcon className="h-4 w-4" /> <CheckIcon className="h-4 w-4" />
</div> </div>
<span className="ml-2 text-sm font-semibold text-green-600"> <span className={`ml-2 text-sm font-semibold ${getThemeClasses("text-success")}`}>
Verify Verify
</span> </span>
</div> </div>
<div className="w-12 h-0.5 bg-green-500"></div> <div className={`w-12 h-0.5 ${getThemeClasses("bg-success")}`}></div>
<div className="flex items-center"> <div className="flex items-center">
<div className="flex items-center justify-center w-8 h-8 bg-gradient-to-r from-red-800 to-red-900 rounded-full text-white text-sm font-bold"> <div className={`flex items-center justify-center w-8 h-8 ${getThemeClasses("bg-gradient-secondary")} rounded-full text-white text-sm font-bold`}>
3 3
</div> </div>
<span className="ml-2 text-sm font-semibold text-gray-900"> <span className={`ml-2 text-sm font-semibold ${getThemeClasses("text-primary")}`}>
Reset Reset
</span> </span>
</div> </div>
@ -184,20 +247,20 @@ const CompleteRecovery = () => {
<div className="text-center animate-fade-in-up"> <div className="text-center animate-fade-in-up">
<div className="flex justify-center mb-6"> <div className="flex justify-center mb-6">
<div className="relative"> <div className="relative">
<div className="flex items-center justify-center h-16 w-16 bg-gradient-to-br from-red-800 to-red-900 rounded-2xl shadow-lg animate-pulse"> <div className={`flex items-center justify-center h-16 w-16 ${getThemeClasses("bg-gradient-secondary")} rounded-2xl shadow-lg animate-pulse`}>
<LockOpenIcon className="h-8 w-8 text-white" /> <LockOpenIcon className="h-8 w-8 text-white" />
</div> </div>
<div className="absolute -inset-1 bg-gradient-to-r from-red-800 to-red-900 rounded-2xl blur opacity-20 animate-pulse"></div> <div className={`absolute -inset-1 ${getThemeClasses("bg-gradient-secondary")} rounded-2xl blur opacity-20 animate-pulse`}></div>
</div> </div>
</div> </div>
<h2 className="text-3xl font-black text-gray-900 mb-2"> <h2 className={`text-3xl font-black ${getThemeClasses("text-primary")} mb-2`}>
Set Your New Password Set Your New Password
</h2> </h2>
<p className="text-gray-600 mb-2"> <p className={`${getThemeClasses("text-secondary")} mb-2`}>
Final step: Create a new password for {email} Final step: Create a new password for {email}
</p> </p>
<div className="flex items-center justify-center space-x-2 text-sm text-gray-500"> <div className={`flex items-center justify-center space-x-2 text-sm ${getThemeClasses("text-secondary")}`}>
<ArrowPathIcon className="h-4 w-4 text-green-600" /> <ArrowPathIcon className={`h-4 w-4 ${getThemeClasses("text-success")}`} />
<span> <span>
Your encryption keys will be re-encrypted with the new password Your encryption keys will be re-encrypted with the new password
</span> </span>
@ -205,38 +268,33 @@ const CompleteRecovery = () => {
</div> </div>
{/* Form Card */} {/* Form Card */}
<div className="bg-white rounded-2xl shadow-2xl border border-gray-100 p-8 animate-fade-in-up-delay"> <Card className="shadow-2xl p-8 animate-fade-in-up-delay">
{/* Error Message */} {/* Error Message */}
{error && ( {error && (
<div className="mb-6 p-4 rounded-lg bg-red-50 border border-red-200 animate-fade-in"> <Alert
<div className="flex items-center"> type="error"
<ExclamationTriangleIcon className="h-5 w-5 text-red-500 mr-3 flex-shrink-0" /> dismissible
<div> onDismiss={() => setError("")}
<h3 className="text-sm font-semibold text-red-800"> className="mb-6 animate-fade-in"
Recovery Error >
</h3> <div>
<p className="text-sm text-red-700 mt-1">{error}</p> <strong className="font-semibold">Recovery Error</strong>
</div> <p className="mt-1">{error}</p>
</div> </div>
</div> </Alert>
)} )}
{/* Security Notice */} {/* Security Notice */}
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg"> <Alert type="info" className="mb-6">
<div className="flex items-start"> <div>
<InformationCircleIcon className="h-5 w-5 text-blue-600 mr-3 flex-shrink-0 mt-0.5" /> <strong className="font-semibold">Why enter your recovery phrase again?</strong>
<div className="flex-1"> <p className="mt-1">
<h3 className="text-sm font-semibold text-blue-800 mb-1"> We need your recovery phrase to decrypt your master key and
Why enter your recovery phrase again? re-encrypt it with your new password. This ensures
</h3> continuous access to your encrypted files.
<p className="text-sm text-blue-700"> </p>
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>
</div> </div>
</div> </Alert>
{/* Form */} {/* Form */}
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
@ -245,17 +303,17 @@ const CompleteRecovery = () => {
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<label <label
htmlFor="recoveryPhrase" htmlFor="recoveryPhrase"
className="block text-sm font-semibold text-gray-700" className={`block text-sm font-semibold ${getThemeClasses("text-primary")}`}
> >
Recovery Phrase (Required Again) Recovery Phrase (Required Again)
</label> </label>
<span <span
className={`text-xs font-medium ${ className={`text-xs font-medium ${
wordCount === 12 wordCount === 12
? "text-green-600" ? getThemeClasses("text-success")
: wordCount > 0 : wordCount > 0
? "text-amber-600" ? getThemeClasses("text-warning")
: "text-gray-500" : getThemeClasses("text-secondary")
}`} }`}
> >
{wordCount}/12 words {wordCount}/12 words
@ -270,112 +328,56 @@ const CompleteRecovery = () => {
rows={3} rows={3}
required required
disabled={loading} disabled={loading}
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-all duration-200 disabled:bg-gray-50 disabled:cursor-not-allowed text-gray-900 placeholder-gray-500 font-mono text-sm leading-relaxed resize-none ${ className={`w-full px-4 py-3 border rounded-lg focus:ring-2 ${getThemeClasses("focus:ring-accent")} ${getThemeClasses("focus:border-accent")} transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed ${getThemeClasses("text-primary")} ${getThemeClasses("placeholder-secondary")} font-mono text-sm leading-relaxed resize-none ${getThemeClasses("bg-card")} ${
wordCount === 12 wordCount === 12
? "border-green-300 bg-green-50" ? `${getThemeClasses("border-success")} ${getThemeClasses("bg-success-light")}`
: "border-gray-300" : getThemeClasses("border-muted")
}`} }`}
/> />
{showRecoveryPhrase && (
<button
type="button"
onClick={() => setShowRecoveryPhrase(!showRecoveryPhrase)}
className="absolute top-3 right-3"
>
<EyeSlashIcon className="h-5 w-5 text-gray-400 hover:text-gray-600" />
</button>
)}
</div> </div>
</div> </div>
{/* New Password Section */} {/* New Password Section */}
<div className="space-y-4 p-4 bg-gradient-to-r from-green-50 to-emerald-50 rounded-lg border border-green-100"> <div className={`space-y-4 p-4 ${getThemeClasses("bg-success-light")} rounded-lg border ${getThemeClasses("border-success")}`}>
<h3 className="text-sm font-semibold text-green-900 flex items-center"> <h3 className={`text-sm font-semibold ${getThemeClasses("text-primary")} flex items-center`}>
<KeyIcon className="h-4 w-4 mr-2" /> <KeyIcon className="h-4 w-4 mr-2" />
Create Your New Password Create Your New Password
</h3> </h3>
{/* New Password */} {/* New Password */}
<div> <Input
<label label="New Password"
htmlFor="newPassword" type={showNewPassword ? "text" : "password"}
className="block text-sm font-semibold text-gray-700 mb-2" name="newPassword"
> placeholder="Enter your new password"
New Password value={newPassword}
</label> onChange={(value) => setNewPassword(value)}
<div className="relative"> disabled={loading}
<input required
type={showNewPassword ? "text" : "password"} autoComplete="new-password"
id="newPassword" icon={LockClosedIcon}
value={newPassword} suffix={newPasswordSuffix}
onChange={(e) => setNewPassword(e.target.value)} helperText="Password must be at least 8 characters long"
placeholder="Enter your new password" error={error && error.includes("password") ? error : null}
required />
disabled={loading}
autoComplete="new-password"
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-all duration-200 text-gray-900 placeholder-gray-500 pr-12 ${
error && error.includes("password")
? "border-red-300"
: "border-gray-300"
}`}
/>
<button
type="button"
onClick={() => setShowNewPassword(!showNewPassword)}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
>
{showNewPassword ? (
<EyeSlashIcon className="h-5 w-5 text-gray-400 hover:text-gray-600" />
) : (
<EyeIcon className="h-5 w-5 text-gray-400 hover:text-gray-600" />
)}
</button>
</div>
<p className="mt-1 text-xs text-gray-500">
Password must be at least 8 characters long
</p>
</div>
{/* Confirm Password */} {/* Confirm Password */}
<div> <div>
<label <Input
htmlFor="confirmPassword" label="Confirm New Password"
className="block text-sm font-semibold text-gray-700 mb-2" type={showConfirmPassword ? "text" : "password"}
> name="confirmPassword"
Confirm New Password placeholder="Confirm your new password"
</label> value={confirmPassword}
<div className="relative"> onChange={(value) => setConfirmPassword(value)}
<input disabled={loading}
type={showConfirmPassword ? "text" : "password"} required
id="confirmPassword" autoComplete="new-password"
value={confirmPassword} icon={LockClosedIcon}
onChange={(e) => setConfirmPassword(e.target.value)} suffix={confirmPasswordSuffix}
placeholder="Confirm your new password" />
required
disabled={loading}
autoComplete="new-password"
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-all duration-200 text-gray-900 placeholder-gray-500 pr-12 ${
confirmPassword && newPassword === confirmPassword
? "border-green-300 bg-green-50"
: "border-gray-300"
}`}
/>
<button
type="button"
onClick={() =>
setShowConfirmPassword(!showConfirmPassword)
}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
>
{showConfirmPassword ? (
<EyeSlashIcon className="h-5 w-5 text-gray-400 hover:text-gray-600" />
) : (
<EyeIcon className="h-5 w-5 text-gray-400 hover:text-gray-600" />
)}
</button>
</div>
{confirmPassword && newPassword === confirmPassword && ( {confirmPassword && newPassword === confirmPassword && (
<p className="mt-1 text-xs text-green-600 flex items-center"> <p className={`mt-1 text-xs ${getThemeClasses("text-success")} flex items-center`}>
<CheckIcon className="h-3 w-3 mr-1" /> <CheckIcon className="h-3 w-3 mr-1" />
Passwords match Passwords match
</p> </p>
@ -384,99 +386,82 @@ const CompleteRecovery = () => {
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<button <Button
type="submit" type="submit"
variant="primary"
fullWidth
disabled={ disabled={
loading || loading ||
wordCount !== 12 || wordCount !== 12 ||
!newPassword || !newPassword ||
newPassword !== confirmPassword newPassword !== confirmPassword
} }
className="group w-full flex justify-center items-center py-3 px-4 border border-transparent text-base font-semibold rounded-lg text-white bg-gradient-to-r from-red-800 to-red-900 hover:from-red-900 hover:to-red-950 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:from-gray-400 disabled:to-gray-500 disabled:cursor-not-allowed transform hover:scale-105 transition-all duration-200 shadow-lg hover:shadow-xl" loading={loading}
className="py-3"
> >
{loading ? ( {!loading && (
<> <span className="inline-flex items-center gap-2">
<svg <CheckCircleIcon className="h-4 w-4" />
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" <span>Complete Recovery</span>
xmlns="http://www.w3.org/2000/svg" <ArrowRightIcon className="h-4 w-4" />
fill="none" </span>
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Setting New Password...
</>
) : (
<>
<CheckCircleIcon className="mr-2 h-4 w-4" />
Complete Recovery
<ArrowRightIcon className="ml-2 h-4 w-4 group-hover:translate-x-1 transition-transform duration-200" />
</>
)} )}
</button> {loading && "Setting New Password..."}
</Button>
<button <Button
type="button" type="button"
variant="secondary"
fullWidth
onClick={handleBackToVerify} onClick={handleBackToVerify}
disabled={loading} disabled={loading}
className="w-full flex justify-center items-center py-2 px-4 border border-gray-300 text-sm font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 disabled:bg-gray-50 disabled:cursor-not-allowed transition-all duration-200"
> >
<ArrowLeftIcon className="mr-2 h-4 w-4" /> <span className="inline-flex items-center gap-2">
Back to Verification <ArrowLeftIcon className="h-4 w-4" />
</button> <span>Back to Verification</span>
</span>
</Button>
</div> </div>
</form> </form>
</div> </Card>
{/* What Happens Next */} {/* What Happens Next */}
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-lg border border-blue-100 p-6 animate-fade-in-up-delay-2"> <div className={`${getThemeClasses("bg-info-light")} rounded-lg border ${getThemeClasses("border-info")} p-6 animate-fade-in-up-delay-2`}>
<h3 className="text-sm font-semibold text-blue-900 mb-3 flex items-center"> <h3 className={`text-sm font-semibold ${getThemeClasses("text-primary")} mb-3 flex items-center`}>
<InformationCircleIcon className="h-4 w-4 mr-2" /> <InformationCircleIcon className={`h-4 w-4 mr-2 ${getThemeClasses("text-info")}`} />
What Happens Next? What Happens Next?
</h3> </h3>
<ul className="text-sm text-blue-800 space-y-2"> <ul className={`text-sm ${getThemeClasses("text-secondary")} space-y-2`}>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-blue-500 mr-2 mt-0.5"></span> <span className={`${getThemeClasses("text-accent")} mr-2 mt-0.5`}></span>
Your master key will be decrypted using your recovery key Your master key will be decrypted using your recovery key
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-blue-500 mr-2 mt-0.5"></span> <span className={`${getThemeClasses("text-accent")} mr-2 mt-0.5`}></span>
New encryption keys will be generated New encryption keys will be generated
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-blue-500 mr-2 mt-0.5"></span> <span className={`${getThemeClasses("text-accent")} mr-2 mt-0.5`}></span>
All keys will be re-encrypted with your new password All keys will be re-encrypted with your new password
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-blue-500 mr-2 mt-0.5"></span> <span className={`${getThemeClasses("text-accent")} mr-2 mt-0.5`}></span>
Your recovery phrase remains the same for future use Your recovery phrase remains the same for future use
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-blue-500 mr-2 mt-0.5"></span> <span className={`${getThemeClasses("text-accent")} mr-2 mt-0.5`}></span>
You'll be able to log in immediately with your new password You'll be able to log in immediately with your new password
</li> </li>
</ul> </ul>
</div> </div>
{/* Security Notes */} {/* Security Notes */}
<div className="bg-gray-50 rounded-lg border border-gray-200 p-4 animate-fade-in-up-delay-3"> <div className={`${getThemeClasses("bg-muted")} rounded-lg border ${getThemeClasses("border-muted")} p-4 animate-fade-in-up-delay-3`}>
<h4 className="text-xs font-semibold text-gray-700 mb-2 flex items-center"> <h4 className={`text-xs font-semibold ${getThemeClasses("text-primary")} mb-2 flex items-center`}>
<ShieldCheckIcon className="h-4 w-4 mr-1" /> <ShieldCheckIcon className="h-4 w-4 mr-1" />
Security Notes Security Notes
</h4> </h4>
<div className="text-xs text-gray-600 space-y-1"> <div className={`text-xs ${getThemeClasses("text-secondary")} space-y-1`}>
<p> Choose a strong, unique password</p> <p> Choose a strong, unique password</p>
<p> Your new password will be used to encrypt your keys</p> <p> Your new password will be used to encrypt your keys</p>
<p> Keep your recovery phrase safe - it hasn't changed</p> <p> Keep your recovery phrase safe - it hasn't changed</p>
@ -487,26 +472,26 @@ const CompleteRecovery = () => {
</div> </div>
{/* Footer */} {/* Footer */}
<footer className="bg-white border-t border-gray-100 py-8"> <footer className={`${getThemeClasses("bg-card")} border-t ${getThemeClasses("border-muted")} py-8`}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center text-sm text-gray-500"> <div className={`text-center text-sm ${getThemeClasses("text-secondary")}`}>
<p>&copy; 2025 MapleFile Inc. All rights reserved.</p> <p>&copy; 2025 MapleFile Inc. All rights reserved.</p>
<div className="mt-2 space-x-4"> <div className="mt-2 space-x-4">
<Link <Link
to="/privacy" to="/privacy"
className="hover:text-gray-700 transition-colors duration-200" className={`${getThemeClasses("hover:text-accent")} transition-colors duration-200`}
> >
Privacy Policy Privacy Policy
</Link> </Link>
<Link <Link
to="/terms" to="/terms"
className="hover:text-gray-700 transition-colors duration-200" className={`${getThemeClasses("hover:text-accent")} transition-colors duration-200`}
> >
Terms of Service Terms of Service
</Link> </Link>
<Link <Link
to="/support" to="/support"
className="hover:text-gray-700 transition-colors duration-200" className={`${getThemeClasses("hover:text-accent")} transition-colors duration-200`}
> >
Support Support
</Link> </Link>

View file

@ -161,7 +161,7 @@ const VerifyRecovery = () => {
if (!email) { if (!email) {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-red-50 flex items-center justify-center"> <div className={`min-h-screen ${getThemeClasses("bg-gradient-primary")} flex items-center justify-center`}>
<div className="text-center"> <div className="text-center">
<h2 className={`text-2xl font-bold ${getThemeClasses("text-primary")} mb-4`}>Loading...</h2> <h2 className={`text-2xl font-bold ${getThemeClasses("text-primary")} mb-4`}>Loading...</h2>
<p className={getThemeClasses("text-secondary")}>Checking recovery session...</p> <p className={getThemeClasses("text-secondary")}>Checking recovery session...</p>
@ -176,16 +176,16 @@ const VerifyRecovery = () => {
: 0; : 0;
return ( return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-red-50 flex flex-col"> <div className={`min-h-screen ${getThemeClasses("bg-gradient-primary")} flex flex-col`}>
{/* Navigation */} {/* Navigation */}
<nav className={`${getThemeClasses("bg-card")} backdrop-blur-sm ${getThemeClasses("border")}`}> <nav className={`${getThemeClasses("bg-card")}/95 backdrop-blur-sm border-b ${getThemeClasses("border-muted")}`}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-4"> <div className="flex justify-between items-center py-4">
<Link to="/" className="flex items-center group"> <Link to="/" className="flex items-center group">
<div className="flex items-center justify-center h-10 w-10 bg-gradient-to-br from-red-800 to-red-900 rounded-lg mr-3 group-hover:scale-105 transition-transform duration-200"> <div className={`flex items-center justify-center h-10 w-10 ${getThemeClasses("bg-gradient-secondary")} rounded-lg mr-3 group-hover:scale-105 transition-transform duration-200`}>
<LockClosedIcon className="h-6 w-6 text-white" /> <LockClosedIcon className="h-6 w-6 text-white" />
</div> </div>
<span className="text-2xl font-bold bg-gradient-to-r from-gray-900 to-red-800 bg-clip-text text-transparent"> <span className={`text-2xl font-bold ${getThemeClasses("text-primary")}`}>
MapleFile MapleFile
</span> </span>
</Link> </Link>
@ -205,16 +205,16 @@ const VerifyRecovery = () => {
<div className="flex items-center justify-center mb-8"> <div className="flex items-center justify-center mb-8">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="flex items-center"> <div className="flex items-center">
<div className={`flex items-center justify-center w-8 h-8 ${getThemeClasses("badge-success")} rounded-full text-white text-sm font-bold`}> <div className={`flex items-center justify-center w-8 h-8 ${getThemeClasses("bg-success")} rounded-full text-white text-sm font-bold`}>
<CheckIcon className="h-4 w-4" /> <CheckIcon className="h-4 w-4" />
</div> </div>
<span className={`ml-2 text-sm font-semibold ${getThemeClasses("text-success")}`}> <span className={`ml-2 text-sm font-semibold ${getThemeClasses("text-success")}`}>
Email Email
</span> </span>
</div> </div>
<div className={`w-12 h-0.5 ${getThemeClasses("badge-success")}`}></div> <div className={`w-12 h-0.5 ${getThemeClasses("bg-success")}`}></div>
<div className="flex items-center"> <div className="flex items-center">
<div className="flex items-center justify-center w-8 h-8 bg-gradient-to-r from-red-800 to-red-900 rounded-full text-white text-sm font-bold"> <div className={`flex items-center justify-center w-8 h-8 ${getThemeClasses("bg-gradient-secondary")} rounded-full text-white text-sm font-bold`}>
2 2
</div> </div>
<span className={`ml-2 text-sm font-semibold ${getThemeClasses("text-primary")}`}> <span className={`ml-2 text-sm font-semibold ${getThemeClasses("text-primary")}`}>
@ -235,10 +235,10 @@ const VerifyRecovery = () => {
<div className="text-center animate-fade-in-up"> <div className="text-center animate-fade-in-up">
<div className="flex justify-center mb-6"> <div className="flex justify-center mb-6">
<div className="relative"> <div className="relative">
<div className="flex items-center justify-center h-16 w-16 bg-gradient-to-br from-red-800 to-red-900 rounded-2xl shadow-lg animate-pulse"> <div className={`flex items-center justify-center h-16 w-16 ${getThemeClasses("bg-gradient-secondary")} rounded-2xl shadow-lg animate-pulse`}>
<DocumentTextIcon className="h-8 w-8 text-white" /> <DocumentTextIcon className="h-8 w-8 text-white" />
</div> </div>
<div className="absolute -inset-1 bg-gradient-to-r from-red-800 to-red-900 rounded-2xl blur opacity-20 animate-pulse"></div> <div className={`absolute -inset-1 ${getThemeClasses("bg-gradient-secondary")} rounded-2xl blur opacity-20 animate-pulse`}></div>
</div> </div>
</div> </div>
<h2 className={`text-3xl font-black ${getThemeClasses("text-primary")} mb-2`}> <h2 className={`text-3xl font-black ${getThemeClasses("text-primary")} mb-2`}>
@ -325,10 +325,10 @@ const VerifyRecovery = () => {
rows={4} rows={4}
required required
disabled={loading} disabled={loading}
className={`w-full px-4 py-3 border-2 rounded-xl ${getThemeClasses("input-focus-ring")} transition-all duration-200 ${getThemeClasses("bg-disabled")} disabled:cursor-not-allowed ${getThemeClasses("text-primary")} placeholder-gray-400 font-mono text-sm leading-relaxed resize-none ${ className={`w-full px-4 py-3 border-2 rounded-xl focus:ring-2 ${getThemeClasses("focus:ring-accent")} ${getThemeClasses("focus:border-accent")} transition-all duration-200 ${getThemeClasses("bg-card")} disabled:opacity-50 disabled:cursor-not-allowed ${getThemeClasses("text-primary")} ${getThemeClasses("placeholder-secondary")} font-mono text-sm leading-relaxed resize-none ${
wordCount === 12 wordCount === 12
? "border-green-300 bg-green-50" ? `${getThemeClasses("border-success")} ${getThemeClasses("bg-success-light")}`
: getThemeClasses("input-border") : getThemeClasses("border-muted")
}`} }`}
/> />
<div className="mt-2 flex items-center justify-between"> <div className="mt-2 flex items-center justify-between">

View file

@ -177,112 +177,200 @@ const RecoveryCode = () => {
}, [recoveryMnemonic]); }, [recoveryMnemonic]);
const handlePrint = useCallback(() => { const handlePrint = useCallback(() => {
// HTML escape function to prevent XSS const printWindow = window.open("", "_blank");
const escapeHtml = (text) => { if (!printWindow) {
const div = document.createElement('div'); // Popup blocked - fall back to alert
div.textContent = text; alert("Please allow popups to print your recovery phrase.");
return div.innerHTML; return;
}
const doc = printWindow.document;
// Build document using safe DOM manipulation (no document.write)
// Create style element
const style = doc.createElement("style");
style.textContent = `
body {
font-family: Arial, sans-serif;
padding: 20px;
line-height: 1.6;
}
.header {
text-align: center;
margin-bottom: 30px;
}
.warning {
background: #fff3cd;
border: 1px solid #ffeaa7;
padding: 15px;
margin: 20px 0;
border-radius: 4px;
}
.mnemonic {
background: #f8f9fa;
border: 2px solid #dee2e6;
padding: 20px;
margin: 20px 0;
border-radius: 4px;
font-family: monospace;
font-size: 16px;
text-align: center;
line-height: 2;
}
.word {
display: inline-block;
margin: 5px;
padding: 5px 10px;
background: white;
border: 1px solid #ccc;
border-radius: 3px;
}
.footer {
margin-top: 30px;
font-size: 12px;
color: #666;
}
.privacy-notice {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #ddd;
}
.privacy-text {
font-size: 10px;
color: #666;
}
`;
doc.head.appendChild(style);
// Set title safely
doc.title = "MapleFile Recovery Phrase";
// Build body content using textContent for user data (XSS-safe)
const body = doc.body;
// Header section
const header = doc.createElement("div");
header.className = "header";
const h1 = doc.createElement("h1");
h1.textContent = "MapleFile Recovery Phrase";
header.appendChild(h1);
const accountP = doc.createElement("p");
const accountStrong = doc.createElement("strong");
accountStrong.textContent = "Account: ";
accountP.appendChild(accountStrong);
accountP.appendChild(doc.createTextNode(email)); // Safe: textContent equivalent
header.appendChild(accountP);
const dateP = doc.createElement("p");
const dateStrong = doc.createElement("strong");
dateStrong.textContent = "Generated: ";
dateP.appendChild(dateStrong);
dateP.appendChild(doc.createTextNode(new Date().toLocaleString()));
header.appendChild(dateP);
body.appendChild(header);
// Warning section
const warning = doc.createElement("div");
warning.className = "warning";
const warningH3 = doc.createElement("h3");
warningH3.textContent = "⚠️ IMPORTANT SECURITY NOTICE";
warning.appendChild(warningH3);
const warningP = doc.createElement("p");
warningP.textContent = "This recovery phrase is the ONLY way to recover your account if you forget your password. Keep it safe and never share it with anyone.";
warning.appendChild(warningP);
body.appendChild(warning);
// Mnemonic words section
const mnemonic = doc.createElement("div");
mnemonic.className = "mnemonic";
recoveryMnemonic.split(" ").forEach((word, index) => {
const wordSpan = doc.createElement("span");
wordSpan.className = "word";
wordSpan.textContent = `${index + 1}. ${word}`; // Safe: textContent
mnemonic.appendChild(wordSpan);
});
body.appendChild(mnemonic);
// Footer section
const footer = doc.createElement("div");
footer.className = "footer";
const tipsP = doc.createElement("p");
const tipsStrong = doc.createElement("strong");
tipsStrong.textContent = "Security Tips:";
tipsP.appendChild(tipsStrong);
footer.appendChild(tipsP);
const tipsList = doc.createElement("ul");
const tips = [
"Store this phrase in a secure location (safe, safety deposit box)",
"Consider making multiple copies and storing them separately",
"Never store this digitally (computer files, cloud storage, photos)",
"Never share this phrase with anyone, including MapleFile support",
"Write clearly and double-check each word"
];
tips.forEach((tip) => {
const li = doc.createElement("li");
li.textContent = tip;
tipsList.appendChild(li);
});
footer.appendChild(tipsList);
// Privacy notice
const privacyDiv = doc.createElement("div");
privacyDiv.className = "privacy-notice";
const privacyTitleP = doc.createElement("p");
const privacyStrong = doc.createElement("strong");
privacyStrong.textContent = "Privacy Notice:";
privacyTitleP.appendChild(privacyStrong);
privacyDiv.appendChild(privacyTitleP);
const privacyTextP = doc.createElement("p");
privacyTextP.className = "privacy-text";
privacyTextP.textContent = "This recovery phrase is your personal cryptographic data. Data Controller: Maple Open Tech Inc. (Canada). Your GDPR rights: Access, rectification, erasure, restriction, portability, objection, and complaint to supervisory authority. Contact: privacy@mapleopentech.ca | This document was generated locally and contains no tracking.";
privacyDiv.appendChild(privacyTextP);
footer.appendChild(privacyDiv);
body.appendChild(footer);
doc.close();
// Track if print has been triggered to prevent double printing
let printTriggered = false;
const triggerPrint = () => {
if (printTriggered || printWindow.closed) return;
printTriggered = true;
printWindow.focus();
// Close window after print dialog is dismissed (print or cancel)
printWindow.onafterprint = () => {
printWindow.close();
};
printWindow.print();
// Fallback: close after delay if onafterprint isn't supported
setTimeout(() => {
if (!printWindow.closed) {
printWindow.close();
}
}, 500);
}; };
const printWindow = window.open("", "_blank"); // Trigger print once content is ready
if (printWindow.document.readyState === 'complete') {
// Sanitize user-controlled data to prevent XSS triggerPrint();
const safeEmail = escapeHtml(email); } else {
const safeDate = escapeHtml(new Date().toLocaleString()); printWindow.onload = triggerPrint;
const safeWords = recoveryMnemonic // Fallback timeout in case onload doesn't fire
.split(" ") setTimeout(triggerPrint, 250);
.map((word, index) => }
`<span class="word">${index + 1}. ${escapeHtml(word)}</span>`
)
.join("");
printWindow.document.write(`
<html>
<head>
<title>MapleFile Recovery Phrase</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 20px;
line-height: 1.6;
}
.header {
text-align: center;
margin-bottom: 30px;
}
.warning {
background: #fff3cd;
border: 1px solid #ffeaa7;
padding: 15px;
margin: 20px 0;
border-radius: 4px;
}
.mnemonic {
background: #f8f9fa;
border: 2px solid #dee2e6;
padding: 20px;
margin: 20px 0;
border-radius: 4px;
font-family: monospace;
font-size: 16px;
text-align: center;
line-height: 2;
}
.word {
display: inline-block;
margin: 5px;
padding: 5px 10px;
background: white;
border: 1px solid #ccc;
border-radius: 3px;
}
.footer {
margin-top: 30px;
font-size: 12px;
color: #666;
}
</style>
</head>
<body>
<div class="header">
<h1>MapleFile Recovery Phrase</h1>
<p><strong>Account:</strong> ${safeEmail}</p>
<p><strong>Generated:</strong> ${safeDate}</p>
</div>
<div class="warning">
<h3> IMPORTANT SECURITY NOTICE</h3>
<p>This recovery phrase is the ONLY way to recover your account if you forget your password. Keep it safe and never share it with anyone.</p>
</div>
<div class="mnemonic">
${safeWords}
</div>
<div class="footer">
<p><strong>Security Tips:</strong></p>
<ul>
<li>Store this phrase in a secure location (safe, safety deposit box)</li>
<li>Consider making multiple copies and storing them separately</li>
<li>Never store this digitally (computer files, cloud storage, photos)</li>
<li>Never share this phrase with anyone, including MapleFile support</li>
<li>Write clearly and double-check each word</li>
</ul>
<div style="margin-top: 20px; padding-top: 20px; border-top: 1px solid #ddd;">
<p><strong>Privacy Notice:</strong></p>
<p style="font-size: 10px; color: #666;">
This recovery phrase is your personal cryptographic data. Data Controller: Maple Open Tech Inc. (Canada).
Your GDPR rights: Access, rectification, erasure, restriction, portability, objection, and complaint to supervisory authority.
Contact: privacy@mapleopentech.ca | This document was generated locally and contains no tracking.
</p>
</div>
</div>
</body>
</html>
`);
printWindow.document.close();
printWindow.print();
}, [email, recoveryMnemonic]); }, [email, recoveryMnemonic]);
const handleCheckboxChange = useCallback((checked) => { const handleCheckboxChange = useCallback((checked) => {

View file

@ -125,9 +125,14 @@ const VerifyEmail = () => {
const sanitized = value.replace(/\D/g, ""); // Only allow digits const sanitized = value.replace(/\D/g, ""); // Only allow digits
if (sanitized.length <= 8) { if (sanitized.length <= 8) {
setVerificationCode(sanitized); setVerificationCode(sanitized);
// Clear errors when user types // Clear errors when user types - use functional updates to avoid unnecessary re-renders
setGeneralError(""); setGeneralError((prev) => prev ? "" : prev);
setFieldErrors((prev) => ({ ...prev, code: "" })); setFieldErrors((prev) => {
if (prev.code) {
return { ...prev, code: "" };
}
return prev;
});
} }
}, []); }, []);
@ -341,42 +346,16 @@ const VerifyEmail = () => {
</span> </span>
</div> </div>
{/* GDPR Notice */}
<Alert type="info">
<p className="text-xs">
We process your email and verification code for account verification (legal basis: contract); email retained for account duration, codes expire after 72 hours; contact privacy@mapleopentech.ca for GDPR rights.
</p>
</Alert>
{/* Form Card */} {/* Form Card */}
<Card className="shadow-2xl animate-fade-in-up-delay"> <Card className="shadow-2xl animate-fade-in-up-delay">
{/* Error Message Box with Field Errors */} {/* Error Message Box - only show after form submission attempt */}
{(generalError || Object.keys(fieldErrors).length > 0) && ( {generalError && (
<Alert type="error" className="mb-6"> <Alert type="error" className="mb-6">
<div> <div>
<h3 className="text-base font-bold mb-2"> <h3 className="text-base font-bold mb-2">
Verification Failed Verification Failed
</h3> </h3>
{generalError && ( <p className="text-sm">{generalError}</p>
<p className="text-sm mb-3">{generalError}</p>
)}
{Object.keys(fieldErrors).length > 0 && (
<div>
<p className="text-sm font-semibold mb-2">
Please fix the following errors:
</p>
<ul className="list-disc list-inside space-y-1.5 text-sm">
{Object.entries(fieldErrors).map(([field, message]) => (
<li key={field} className="ml-1">
<span className="font-medium">
{field.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}:
</span>{' '}
{message}
</li>
))}
</ul>
</div>
)}
</div> </div>
</Alert> </Alert>
)} )}
@ -436,8 +415,7 @@ const VerifyEmail = () => {
) : ( ) : (
<> <>
<CheckIcon className="mr-2 h-4 w-4 inline-block" /> <CheckIcon className="mr-2 h-4 w-4 inline-block" />
<span className="inline-block">Verify Email & Complete</span> <span className="inline-block">Submit</span>
<ArrowRightIcon className="ml-2 h-4 w-4 inline-block" />
</> </>
)} )}
</Button> </Button>
@ -488,8 +466,7 @@ const VerifyEmail = () => {
{/* Help Section */} {/* Help Section */}
<Alert type="info" className="animate-fade-in-up-delay-2"> <Alert type="info" className="animate-fade-in-up-delay-2">
<div> <div>
<h3 className="text-sm font-semibold mb-3 flex items-center"> <h3 className="text-sm font-semibold mb-3">
<InformationCircleIcon className="h-4 w-4 mr-2" />
Having trouble? Having trouble?
</h3> </h3>
<ul className="text-sm space-y-2"> <ul className="text-sm space-y-2">

View file

@ -94,7 +94,6 @@ const VerifySuccess = () => {
timerRef.current = setInterval(() => { timerRef.current = setInterval(() => {
setCountdown((prev) => { setCountdown((prev) => {
if (prev <= 1) { if (prev <= 1) {
navigate("/login");
return 0; return 0;
} }
return prev - 1; return prev - 1;
@ -106,8 +105,18 @@ const VerifySuccess = () => {
clearInterval(timerRef.current); clearInterval(timerRef.current);
} }
}; };
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []);
// navigate is stable but we only want this to run once on mount
// Separate effect to handle navigation when countdown reaches 0
useEffect(() => {
if (countdown === 0) {
// Clear session storage before redirecting
sessionStorage.removeItem("registrationResult");
sessionStorage.removeItem("registeredEmail");
sessionStorage.removeItem("userRole");
navigate("/login");
}
}, [countdown, navigate]);
const getUserRoleText = useCallback((role) => { const getUserRoleText = useCallback((role) => {
switch (role) { switch (role) {
@ -362,10 +371,11 @@ const VerifySuccess = () => {
onClick={handleGoToLogin} onClick={handleGoToLogin}
variant="primary" variant="primary"
fullWidth fullWidth
className="whitespace-nowrap"
> >
<LockClosedIcon className="mr-2 h-5 w-5" /> <LockClosedIcon className="mr-2 h-5 w-5 inline-block" />
Sign In Now <span className="inline-block">Sign In Now</span>
<ArrowRightIcon className="ml-2 h-4 w-4" /> <ArrowRightIcon className="ml-2 h-4 w-4 inline-block" />
</Button> </Button>
</div> </div>
</div> </div>

View file

@ -11,16 +11,7 @@ import {
} from "../../../services/Services"; } from "../../../services/Services";
import withPasswordProtection from "../../../hocs/withPasswordProtection"; import withPasswordProtection from "../../../hocs/withPasswordProtection";
import Layout from "../../../components/Layout/Layout"; import Layout from "../../../components/Layout/Layout";
import { Card, Button, Alert, useUIXTheme } from "../../../components/UIX"; import { Card, Button, Alert, Spinner, useUIXTheme } from "../../../components/UIX";
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { import {
CloudArrowUpIcon, CloudArrowUpIcon,
FolderIcon, FolderIcon,
@ -29,10 +20,19 @@ import {
ArrowPathIcon, ArrowPathIcon,
ArrowTrendingUpIcon, ArrowTrendingUpIcon,
ClockIcon, ClockIcon,
ChartBarIcon,
SparklesIcon, SparklesIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
// Cache the dynamic import to avoid repeated imports
let FileCryptoServiceCache = null;
const getFileCryptoService = async () => {
if (!FileCryptoServiceCache) {
const module = await import("../../../services/Crypto/FileCryptoService.js");
FileCryptoServiceCache = module.default;
}
return FileCryptoServiceCache;
};
const Dashboard = () => { const Dashboard = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@ -47,6 +47,7 @@ const Dashboard = () => {
const isMountedRef = useRef(true); const isMountedRef = useRef(true);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isInitialized, setIsInitialized] = useState(false);
const [generalError, setGeneralError] = useState(""); const [generalError, setGeneralError] = useState("");
const [fieldErrors, setFieldErrors] = useState({}); const [fieldErrors, setFieldErrors] = useState({});
const [dashboardData, setDashboardData] = useState(null); const [dashboardData, setDashboardData] = useState(null);
@ -77,38 +78,38 @@ const Dashboard = () => {
async (files) => { async (files) => {
if (!files || files.length === 0) return []; if (!files || files.length === 0) return [];
const { default: FileCryptoService } = await import( // Use cached import for better performance
"../../../services/Crypto/FileCryptoService.js" const FileCryptoService = await getFileCryptoService();
);
const decryptedFiles = [];
for (const file of files) { // Process files in parallel for better performance
try { const decryptedFiles = await Promise.all(
const collectionKey = CollectionCryptoService.getCachedCollectionKey( files.map(async (file) => {
file.collection_id, try {
); const collectionKey = CollectionCryptoService.getCachedCollectionKey(
if (!collectionKey) { file.collection_id,
decryptedFiles.push({ );
if (!collectionKey) {
return {
...file,
name: "Locked File",
_isDecrypted: false,
};
}
const decryptedFile = await FileCryptoService.decryptFileFromAPI(
file,
collectionKey,
);
return decryptedFile;
} catch {
return {
...file, ...file,
name: "Locked File", name: "Locked File",
_isDecrypted: false, _isDecrypted: false,
}); };
continue;
} }
})
const decryptedFile = await FileCryptoService.decryptFileFromAPI( );
file,
collectionKey,
);
decryptedFiles.push(decryptedFile);
} catch {
decryptedFiles.push({
...file,
name: "Locked File",
_isDecrypted: false,
});
}
}
return decryptedFiles; return decryptedFiles;
}, },
@ -149,6 +150,7 @@ const Dashboard = () => {
if (isMountedRef.current) { if (isMountedRef.current) {
setDashboardData(data); setDashboardData(data);
setIsInitialized(true);
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
console.log("[Dashboard] Dashboard loaded successfully"); console.log("[Dashboard] Dashboard loaded successfully");
} }
@ -170,6 +172,7 @@ const Dashboard = () => {
} else { } else {
setGeneralError("Could not load your dashboard. Please try again."); setGeneralError("Could not load your dashboard. Please try again.");
} }
setIsInitialized(true);
} finally { } finally {
if (isMountedRef.current) { if (isMountedRef.current) {
setIsLoading(false); setIsLoading(false);
@ -212,19 +215,21 @@ const Dashboard = () => {
useEffect(() => { useEffect(() => {
isMountedRef.current = true; isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
if (dashboardManager && authManager?.isAuthenticated()) { // Initial load when managers become available
useEffect(() => {
if (dashboardManager && authManager?.isAuthenticated() && !isInitialized) {
// Always force refresh on page load to ensure fresh data // Always force refresh on page load to ensure fresh data
// This handles cases where sharing/unsharing happened in other tabs // This handles cases where sharing/unsharing happened in other tabs
// or when the user was removed from a shared collection // or when the user was removed from a shared collection
clearDashboardCache(); clearDashboardCache();
loadDashboardData(true); loadDashboardData(true);
} }
}, [dashboardManager, authManager, isInitialized, loadDashboardData, clearDashboardCache]);
return () => {
isMountedRef.current = false;
};
}, [dashboardManager, authManager, loadDashboardData, clearDashboardCache]);
useEffect(() => { useEffect(() => {
if (createFileManager) { if (createFileManager) {
@ -379,18 +384,6 @@ const Dashboard = () => {
bgColor: getThemeClasses("stat-folders-bg"), bgColor: getThemeClasses("stat-folders-bg"),
textColor: getThemeClasses("stat-folders-text"), textColor: getThemeClasses("stat-folders-text"),
}, },
{
label: "Current Storage",
value:
dashboardManager?.formatStorageValue(
dashboardData.summary?.storage_used,
) || "0 B",
subtitle: "in use now",
icon: ChartBarIcon,
gradient: getThemeClasses("stat-storage-gradient"),
bgColor: getThemeClasses("stat-storage-bg"),
textColor: getThemeClasses("stat-storage-text"),
},
{ {
label: "Storage Limit", label: "Storage Limit",
value: value:
@ -408,23 +401,6 @@ const Dashboard = () => {
[dashboardData, dashboardManager, getThemeClasses] [dashboardData, dashboardManager, getThemeClasses]
); );
const chartData = useMemo(() =>
dashboardData?.storage_usage_trend?.data_points?.map((point) => ({
name: new Date(point.date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
}),
usage: Math.round(point.usage?.value || 0),
unit: point.usage?.unit || "GB",
})) || [],
[dashboardData]
);
const chartColors = useMemo(() => ({
primary: getThemeClasses("chart-primary"),
primaryLight: getThemeClasses("chart-primary-light"),
}), [getThemeClasses]);
const recentFilesDisplay = useMemo(() => const recentFilesDisplay = useMemo(() =>
dashboardData?.recent_files?.slice(0, 5) || [], dashboardData?.recent_files?.slice(0, 5) || [],
[dashboardData] [dashboardData]
@ -475,7 +451,7 @@ const Dashboard = () => {
{isLoading && !dashboardData && ( {isLoading && !dashboardData && (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className="text-center"> <div className="text-center">
<div className={`animate-spin rounded-full h-8 w-8 border-b-2 ${getThemeClasses("border-primary")} mx-auto mb-4`}></div> <Spinner size="lg" className="mx-auto mb-4" />
<p className={getThemeClasses("text-secondary")}>Loading your dashboard...</p> <p className={getThemeClasses("text-secondary")}>Loading your dashboard...</p>
</div> </div>
</div> </div>
@ -513,10 +489,12 @@ const Dashboard = () => {
{dashboardData && ( {dashboardData && (
<> <>
{/* Stats Grid */} {/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8" role="list" aria-label="Dashboard statistics">
{stats.map((stat, index) => ( {stats.map((stat, index) => (
<Card <Card
key={stat.label} key={stat.label}
role="listitem"
aria-label={`${stat.label}: ${stat.value}${stat.subtitle ? `, ${stat.subtitle}` : ""}`}
className="hover:shadow-lg transform hover:-translate-y-1 transition-all duration-300 animate-fade-in-up overflow-hidden" className="hover:shadow-lg transform hover:-translate-y-1 transition-all duration-300 animate-fade-in-up overflow-hidden"
style={{ animationDelay: `${index * 100}ms` }} style={{ animationDelay: `${index * 100}ms` }}
> >
@ -544,140 +522,6 @@ const Dashboard = () => {
))} ))}
</div> </div>
{/* Charts Section */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
{/* Storage Usage Chart */}
<Card className="lg:col-span-2 animate-fade-in-up">
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className={`text-lg font-semibold ${getThemeClasses("text-primary")}`}>
Storage Usage Trend
</h2>
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>Last 7 days</p>
</div>
<div className="flex items-center space-x-2">
<div className={`h-3 w-3 ${getThemeClasses("bg-gradient-secondary")} rounded-full`}></div>
<span className={`text-sm ${getThemeClasses("text-secondary")}`}>
Storage (GB)
</span>
</div>
</div>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={chartData}
margin={{ top: 5, right: 20, bottom: 5, left: 0 }}
>
<defs>
<linearGradient
id="colorUsage"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor={chartColors.primary}
stopOpacity={0.3}
/>
<stop
offset="95%"
stopColor={chartColors.primary}
stopOpacity={0}
/>
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke="#f3f4f6"
/>
<XAxis dataKey="name" stroke="#9ca3af" fontSize={12} />
<YAxis
unit={` ${dashboardData.storage_usage_trend.data_points[0]?.usage?.unit || "GB"}`}
stroke="#9ca3af"
fontSize={12}
tickFormatter={(value) => Math.round(value)}
/>
<Tooltip
contentStyle={{
backgroundColor: "#ffffff",
border: "1px solid #e5e7eb",
borderRadius: "0.5rem",
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1)",
}}
formatter={(value, name, props) => [
`${Math.round(value)} ${props.payload?.unit || "GB"}`,
"Storage Used",
]}
/>
<Area
type="monotone"
dataKey="usage"
stroke={chartColors.primary}
strokeWidth={2}
fill="url(#colorUsage)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
</Card>
{/* Storage Summary */}
<Card
className="animate-fade-in-up"
style={{ animationDelay: "200ms" }}
>
<div className="p-6">
<h2 className={`text-lg font-semibold ${getThemeClasses("text-primary")} mb-6`}>
Storage Overview
</h2>
<div className="space-y-6">
<div>
<div className="flex items-center justify-between mb-2">
<span className={`text-sm font-medium ${getThemeClasses("text-primary")}`}>
Used Storage
</span>
<span className={`text-sm font-semibold ${getThemeClasses("text-primary")}`}>
{dashboardData.summary?.storage_usage_percentage || 0}
%
</span>
</div>
<div className="relative">
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
<div
className={`${getThemeClasses("bg-gradient-secondary")} h-3 rounded-full transition-all duration-1000 ease-out`}
style={{
width: `${Math.min(dashboardData.summary?.storage_usage_percentage || 0, 100)}%`,
}}
></div>
</div>
</div>
<div className="flex items-center justify-between mt-2">
<span className={`text-xs ${getThemeClasses("text-secondary")}`}>
{dashboardManager?.formatStorageValue(
dashboardData.summary?.storage_used,
) || "0 GB"}{" "}
used
</span>
<span className={`text-xs ${getThemeClasses("text-secondary")}`}>
{dashboardManager?.formatStorageValue(
dashboardData.summary?.storage_limit,
) || "0 GB"}{" "}
total
</span>
</div>
</div>
</div>
</div>
</Card>
</div>
{/* Recent Files */} {/* Recent Files */}
<Card <Card
className="animate-fade-in-up" className="animate-fade-in-up"
@ -698,15 +542,24 @@ const Dashboard = () => {
</div> </div>
{recentFilesDisplay.length > 0 ? ( {recentFilesDisplay.length > 0 ? (
<div className="divide-y divide-gray-100"> <div role="list" aria-label="Recent files" className="divide-y divide-gray-100">
{recentFilesDisplay.map((file) => ( {recentFilesDisplay.map((file) => (
<div <div
key={file.id} key={file.id}
className="p-4 hover:bg-gray-50 transition-colors duration-200 group" role="listitem"
tabIndex={0}
aria-label={`${file.name || "Locked File"}, ${formatFileSize(file.size)}, ${getTimeAgo(file.created_at)}`}
className="p-4 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 transition-colors duration-200 group"
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && file._isDecrypted && !downloadingFiles.has(file.id)) {
e.preventDefault();
handleDownloadFile(file.id);
}
}}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="h-10 w-10 bg-gray-50 rounded-lg flex items-center justify-center group-hover:bg-gray-100 transition-colors duration-200"> <div className="h-10 w-10 bg-gray-50 rounded-lg flex items-center justify-center group-hover:bg-gray-100 transition-colors duration-200" aria-hidden="true">
{getFileIcon(file.name)} {getFileIcon(file.name)}
</div> </div>
<div> <div>
@ -717,27 +570,30 @@ const Dashboard = () => {
<span className={`text-xs ${getThemeClasses("text-secondary")}`}> <span className={`text-xs ${getThemeClasses("text-secondary")}`}>
{formatFileSize(file.size)} {formatFileSize(file.size)}
</span> </span>
<span className="text-xs text-gray-400"></span> <span className="text-xs text-gray-400" aria-hidden="true"></span>
<span className={`text-xs ${getThemeClasses("text-secondary")} flex items-center`}> <span className={`text-xs ${getThemeClasses("text-secondary")} flex items-center`}>
<ClockIcon className="h-3 w-3 mr-1" /> <ClockIcon className="h-3 w-3 mr-1" aria-hidden="true" />
{getTimeAgo(file.created_at)} {getTimeAgo(file.created_at)}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
<button <Button
onClick={() => handleDownloadFile(file.id)} onClick={() => handleDownloadFile(file.id)}
disabled={ disabled={
!file._isDecrypted || downloadingFiles.has(file.id) !file._isDecrypted || downloadingFiles.has(file.id)
} }
className="opacity-0 group-hover:opacity-100 p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-all duration-200 disabled:opacity-50" variant="ghost"
size="sm"
className="opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity duration-200"
aria-label={`Download ${file.name || 'file'}`}
> >
{downloadingFiles.has(file.id) ? ( {downloadingFiles.has(file.id) ? (
<ArrowPathIcon className="h-4 w-4 animate-spin" /> <ArrowPathIcon className="h-4 w-4 animate-spin" />
) : ( ) : (
<ArrowDownTrayIcon className="h-4 w-4" /> <ArrowDownTrayIcon className="h-4 w-4" />
)} )}
</button> </Button>
</div> </div>
</div> </div>
))} ))}
@ -758,40 +614,8 @@ const Dashboard = () => {
</Card> </Card>
</> </>
)} )}
</div> </div>
{/* CSS Animations */}
<style>{`
@keyframes fade-in-down {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-down {
animation: fade-in-down 0.6s ease-out;
}
.animate-fade-in-up {
animation: fade-in-up 0.6s ease-out;
}
`}</style>
</Layout> </Layout>
); );
}; };

View file

@ -420,7 +420,7 @@ const CollectionCreate = () => {
className={`relative flex cursor-pointer rounded-lg border p-4 transition-all ${ className={`relative flex cursor-pointer rounded-lg border p-4 transition-all ${
isSelected isSelected
? `${getThemeClasses("input-border")} ${getThemeClasses("alert-info-bg")}` ? `${getThemeClasses("input-border")} ${getThemeClasses("alert-info-bg")}`
: `${getThemeClasses("border-secondary")} hover:bg-gray-50` : `${getThemeClasses("border-secondary")} ${getThemeClasses("hover:bg-muted")}`
}`} }`}
> >
<input <input
@ -474,7 +474,7 @@ const CollectionCreate = () => {
</span> </span>
</span> </span>
</label> </label>
<div className={`flex items-center p-4 rounded-lg border ${getThemeClasses("border-secondary")} bg-gray-50`}> <div className={`flex items-center p-4 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-muted")}`}>
<CollectionIconPreview <CollectionIconPreview
customIcon={customIcon} customIcon={customIcon}
collectionType={collectionType} collectionType={collectionType}
@ -495,7 +495,7 @@ const CollectionCreate = () => {
type="button" type="button"
onClick={handleOpenIconPicker} onClick={handleOpenIconPicker}
disabled={isLoading} disabled={isLoading}
className={`px-3 py-1.5 text-sm font-medium rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("text-primary")} hover:bg-gray-100 transition-colors disabled:opacity-50`} className={`px-3 py-1.5 text-sm font-medium rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("text-primary")} ${getThemeClasses("hover:bg-muted")} transition-colors disabled:opacity-50`}
> >
{customIcon ? "Change Icon" : "Choose Icon"} {customIcon ? "Change Icon" : "Choose Icon"}
</button> </button>
@ -504,7 +504,7 @@ const CollectionCreate = () => {
type="button" type="button"
onClick={() => setCustomIcon("")} onClick={() => setCustomIcon("")}
disabled={isLoading} disabled={isLoading}
className={`px-3 py-1.5 text-sm font-medium rounded-lg ${getThemeClasses("text-secondary")} hover:text-gray-700 transition-colors disabled:opacity-50`} className={`px-3 py-1.5 text-sm font-medium rounded-lg ${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-primary")} transition-colors disabled:opacity-50`}
> >
Reset Reset
</button> </button>

View file

@ -107,6 +107,14 @@ const CollectionShare = () => {
}, },
], []); ], []);
// Sanitize filename to prevent path traversal and special character issues
const sanitizeFilename = useCallback((filename) => {
return filename
.replace(/[^a-z0-9_-]/gi, '_') // Replace non-alphanumeric with underscore
.replace(/_+/g, '_') // Collapse multiple underscores
.substring(0, 50); // Limit length
}, []);
// Export members list (GDPR Article 20 - Data Portability) // Export members list (GDPR Article 20 - Data Portability)
const handleExportMembers = useCallback(() => { const handleExportMembers = useCallback(() => {
const exportData = { const exportData = {
@ -124,12 +132,14 @@ const CollectionShare = () => {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `${collection?.name || 'folder'}_sharing_${Date.now()}.json`; // Sanitize collection name for safe filename
const safeName = sanitizeFilename(collection?.name || 'folder');
a.download = `${safeName}_sharing_${Date.now()}.json`;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}, [collection, ownerEmail, collectionMembers]); }, [collection, ownerEmail, collectionMembers, sanitizeFilename]);
useEffect(() => { useEffect(() => {
if (collectionId && getCollectionManager && shareCollectionManager) { if (collectionId && getCollectionManager && shareCollectionManager) {

View file

@ -1,6 +1,6 @@
// File: monorepo/web/maplefile-frontend/src/pages/User/FileManager/FileManagerIndex.jsx // File: monorepo/web/maplefile-frontend/src/pages/User/FileManager/FileManagerIndex.jsx
// UIX version - Theme-aware File Manager with Layout // UIX version - Theme-aware File Manager with Layout
import React, { useState, useEffect, useCallback, useRef } from "react"; import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useNavigate, useLocation } from "react-router"; import { useNavigate, useLocation } from "react-router";
import { useFiles, useCrypto, useAuth, useTags } from "../../../services/Services"; import { useFiles, useCrypto, useAuth, useTags } from "../../../services/Services";
import withPasswordProtection from "../../../hocs/withPasswordProtection"; import withPasswordProtection from "../../../hocs/withPasswordProtection";
@ -10,6 +10,10 @@ import {
Input, Input,
Alert, Alert,
Modal, Modal,
Checkbox,
Spinner,
Card,
Breadcrumb,
useUIXTheme, useUIXTheme,
CollectionIcon, CollectionIcon,
} from "../../../components/UIX"; } from "../../../components/UIX";
@ -31,8 +35,19 @@ import {
ShareIcon, ShareIcon,
TrashIcon, TrashIcon,
TagIcon, TagIcon,
HomeIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
// Cache the dynamic import to avoid repeated imports in loops
let CollectionCryptoServiceClassCache = null;
const getCollectionCryptoServiceClass = async () => {
if (!CollectionCryptoServiceClassCache) {
const module = await import("../../../services/Crypto/CollectionCryptoService.js");
CollectionCryptoServiceClassCache = module.default;
}
return CollectionCryptoServiceClassCache;
};
const FileManagerIndex = () => { const FileManagerIndex = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@ -50,6 +65,7 @@ const FileManagerIndex = () => {
const isMountedRef = useRef(true); const isMountedRef = useRef(true);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isInitialized, setIsInitialized] = useState(false);
const [generalError, setGeneralError] = useState(""); const [generalError, setGeneralError] = useState("");
const [collectionTagsMap, setCollectionTagsMap] = useState({}); const [collectionTagsMap, setCollectionTagsMap] = useState({});
const [fieldErrors, setFieldErrors] = useState({}); const [fieldErrors, setFieldErrors] = useState({});
@ -105,9 +121,8 @@ const FileManagerIndex = () => {
if (!Array.isArray(rawCollections) || rawCollections.length === 0) if (!Array.isArray(rawCollections) || rawCollections.length === 0)
return []; return [];
const processedCollections = []; // Process single collection - extracted for parallel execution
const processSingleCollection = async (collection) => {
for (const collection of rawCollections) {
try { try {
let processedCollection = { ...collection }; let processedCollection = { ...collection };
@ -140,9 +155,7 @@ const FileManagerIndex = () => {
if (collectionKey) { if (collectionKey) {
try { try {
const { default: CollectionCryptoServiceClass } = await import( const CollectionCryptoServiceClass = await getCollectionCryptoServiceClass();
"../../../services/Crypto/CollectionCryptoService.js"
);
const decryptedCollection = const decryptedCollection =
await CollectionCryptoServiceClass.decryptCollectionFromAPI( await CollectionCryptoServiceClass.decryptCollectionFromAPI(
@ -180,9 +193,9 @@ const FileManagerIndex = () => {
processedCollection.isOwned = processedCollection.isOwned =
collection._isOwned !== undefined ? collection._isOwned : true; collection._isOwned !== undefined ? collection._isOwned : true;
processedCollections.push(processedCollection); return processedCollection;
} catch (error) { } catch (error) {
processedCollections.push({ return {
...collection, ...collection,
name: "Locked Folder", name: "Locked Folder",
type: "folder", type: "folder",
@ -190,9 +203,14 @@ const FileManagerIndex = () => {
_isDecrypted: false, _isDecrypted: false,
isShared: false, isShared: false,
isOwned: true, isOwned: true,
}); };
} }
} };
// Process all collections in parallel for better performance
const processedCollections = await Promise.all(
rawCollections.map(processSingleCollection)
);
return processedCollections; return processedCollections;
}, },
@ -283,6 +301,7 @@ const FileManagerIndex = () => {
if (isMountedRef.current) { if (isMountedRef.current) {
setCollections(processedCollections); setCollections(processedCollections);
setIsInitialized(true);
// Load tags for the collections // Load tags for the collections
loadCollectionTags(processedCollections); loadCollectionTags(processedCollections);
} }
@ -303,6 +322,7 @@ const FileManagerIndex = () => {
} else { } else {
setGeneralError("Could not load your folders. Please try again."); setGeneralError("Could not load your folders. Please try again.");
} }
setIsInitialized(true);
} }
} finally { } finally {
if (isMountedRef.current) { if (isMountedRef.current) {
@ -444,6 +464,28 @@ const FileManagerIndex = () => {
setShowDeleteConfirm(true); setShowDeleteConfirm(true);
}, []); }, []);
// Memoized handler for collection card navigation
const handleCollectionClick = useCallback((collectionId) => {
navigate(`/file-manager/collections/${collectionId}`);
}, [navigate]);
// Memoized handler for collection card keyboard navigation
const handleCollectionKeyDown = useCallback((e, collectionId) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
navigate(`/file-manager/collections/${collectionId}`);
}
}, [navigate]);
// Memoized handlers for hover state
const handleCollectionMouseEnter = useCallback((collectionId) => {
setHoveredCollection(collectionId);
}, []);
const handleCollectionMouseLeave = useCallback(() => {
setHoveredCollection(null);
}, []);
const handleCancelDelete = useCallback(() => { const handleCancelDelete = useCallback(() => {
setCollectionToDelete(null); setCollectionToDelete(null);
setShowDeleteConfirm(false); setShowDeleteConfirm(false);
@ -536,11 +578,12 @@ const FileManagerIndex = () => {
loadCollections, loadCollections,
]); ]);
// Initial load when managers become available
useEffect(() => { useEffect(() => {
if (listCollectionManager && authManager?.isAuthenticated()) { if (listCollectionManager && authManager?.isAuthenticated() && !isInitialized) {
loadCollections(); loadCollections();
} }
}, [listCollectionManager, authManager, loadCollections]); }, [listCollectionManager, authManager, isInitialized, loadCollections]);
useEffect(() => { useEffect(() => {
const handleCollectionEvent = () => { const handleCollectionEvent = () => {
@ -617,29 +660,33 @@ const FileManagerIndex = () => {
location.pathname, location.pathname,
]); ]);
const filteredCollections = collections.filter((collection) => { // Memoize filtered collections to prevent recalculation on every render
// Filter by search query const filteredCollections = useMemo(() => {
if (searchQuery) { return collections.filter((collection) => {
const query = searchQuery.toLowerCase(); // Filter by search query
if (!(collection.name || "Locked Folder").toLowerCase().includes(query)) { if (searchQuery) {
return false; const query = searchQuery.toLowerCase();
if (!(collection.name || "Locked Folder").toLowerCase().includes(query)) {
return false;
}
} }
}
// Filter by selected tags (collection must have ALL selected tags) // Filter by selected tags (collection must have ALL selected tags)
if (selectedTagIds.length > 0) { if (selectedTagIds.length > 0) {
const collectionTags = collectionTagsMap[collection.id] || []; const collectionTags = collectionTagsMap[collection.id] || [];
const collectionTagIdSet = new Set(collectionTags.map(tag => tag.id)); const collectionTagIdSet = new Set(collectionTags.map(tag => tag.id));
const hasAllSelectedTags = selectedTagIds.every(tagId => collectionTagIdSet.has(tagId)); const hasAllSelectedTags = selectedTagIds.every(tagId => collectionTagIdSet.has(tagId));
if (!hasAllSelectedTags) { if (!hasAllSelectedTags) {
return false; return false;
}
} }
}
return true; return true;
}); });
}, [collections, searchQuery, selectedTagIds, collectionTagsMap]);
const filterTypes = [ // Memoize filter types to prevent recreation on every render
const filterTypes = useMemo(() => [
{ {
key: "owned", key: "owned",
label: "My Folders", label: "My Folders",
@ -661,77 +708,102 @@ const FileManagerIndex = () => {
description: "All folders you can access", description: "All folders you can access",
count: collectionCounts.all, count: collectionCounts.all,
}, },
]; ], [collectionCounts]);
const currentFilter = filterTypes.find((f) => f.key === filterType); const currentFilter = useMemo(
() => filterTypes.find((f) => f.key === filterType),
[filterTypes, filterType]
);
// Memoize breadcrumb items (static)
const breadcrumbItems = useMemo(() => [
{
label: "My Files",
icon: HomeIcon,
isActive: true,
},
], []);
return ( return (
<Layout> <Layout>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */} {/* Breadcrumb Navigation */}
<div className="mb-8"> <Breadcrumb items={breadcrumbItems} />
<div className="flex items-center justify-between">
<div>
<h1
className={`text-3xl font-bold flex items-center ${getThemeClasses("text-primary")}`}
>
My Files
<SparklesIcon
className={`h-8 w-8 ml-2 ${getThemeClasses("text-accent")}`}
/>
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Organize and manage your encrypted files
</p>
</div>
<div className="flex items-center space-x-3"> {/* Main Card */}
<Card>
{/* Header with icon, title, and action buttons */}
<div className={`p-6 border-b ${getThemeClasses("border-secondary")}`}>
<div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
<div className="flex-1 min-w-0">
<div className="flex items-start">
{/* Icon */}
<div className={`p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 ${getThemeClasses("bg-gradient-secondary")}`}>
<FolderIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
</div>
{/* Title and subtitle */}
<div>
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
My Files
</h1>
<p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
Organize and manage your encrypted files
</p>
</div>
</div>
</div>
{/* Action buttons */}
<div className="flex-shrink-0 flex items-center space-x-3">
{/* Tag Filter Menu */} {/* Tag Filter Menu */}
{availableTags.length > 0 && ( {availableTags.length > 0 && (
<div className="relative"> <div className="relative">
<Button <Button
onClick={() => setShowTagFilterMenu(!showTagFilterMenu)} onClick={() => setShowTagFilterMenu(!showTagFilterMenu)}
variant={selectedTagIds.length > 0 ? "primary" : "secondary"} variant={selectedTagIds.length > 0 ? "primary" : "secondary"}
className="flex items-center"
> >
{selectedTagIds.length > 0 ? ( <span className="inline-flex items-center whitespace-nowrap">
<> {selectedTagIds.length > 0 ? (
<div className="flex items-center -space-x-1 mr-2"> <>
{selectedTagIds.slice(0, 3).map(tagId => { <span className="flex items-center -space-x-1 mr-2 flex-shrink-0">
const tag = availableTags.find(t => t.id === tagId); {selectedTagIds.slice(0, 3).map(tagId => {
return ( const tag = availableTags.find(t => t.id === tagId);
<span return (
key={tagId} <span
className="h-3 w-3 rounded-full border border-white" key={tagId}
style={{ backgroundColor: tag?.color || '#6b7280' }} className="h-3 w-3 rounded-full border border-white"
></span> style={{ backgroundColor: tag?.color || '#6b7280' }}
); ></span>
})} );
</div> })}
{selectedTagIds.length === 1 </span>
? availableTags.find(t => t.id === selectedTagIds[0])?.name || 'Tag' {selectedTagIds.length === 1
: `${selectedTagIds.length} tags`} ? availableTags.find(t => t.id === selectedTagIds[0])?.name || 'Tag'
<button : `${selectedTagIds.length} tags`}
onClick={(e) => { <Button
e.stopPropagation(); onClick={(e) => {
setSelectedTagIds([]); e.stopPropagation();
}} setSelectedTagIds([]);
className="ml-2 hover:opacity-70" }}
> variant="ghost"
<span className="text-xs"></span> size="sm"
</button> className="ml-1 p-0 min-w-0 h-auto"
</> aria-label="Clear tag filter"
) : ( >
<> <span className="text-xs"></span>
<TagIcon className="h-4 w-4 mr-2" /> </Button>
All Tags </>
</> ) : (
)} <>
<ChevronRightIcon <TagIcon className="h-4 w-4 mr-2 flex-shrink-0" />
className={`h-4 w-4 ml-2 transition-transform duration-200 ${ All Tags
showTagFilterMenu ? "rotate-90" : "" </>
}`} )}
/> <ChevronRightIcon
className={`h-4 w-4 ml-2 flex-shrink-0 transition-transform duration-200 ${
showTagFilterMenu ? "rotate-90" : ""
}`}
/>
</span>
</Button> </Button>
{showTagFilterMenu && ( {showTagFilterMenu && (
@ -749,12 +821,14 @@ const FileManagerIndex = () => {
Filter by Tags Filter by Tags
</span> </span>
{selectedTagIds.length > 0 && ( {selectedTagIds.length > 0 && (
<button <Button
onClick={() => setSelectedTagIds([])} onClick={() => setSelectedTagIds([])}
className="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors" variant="ghost"
size="sm"
className="text-xs"
> >
Clear all Clear all
</button> </Button>
)} )}
</div> </div>
@ -762,8 +836,17 @@ const FileManagerIndex = () => {
{availableTags.map((tag) => { {availableTags.map((tag) => {
const isSelected = selectedTagIds.includes(tag.id); const isSelected = selectedTagIds.includes(tag.id);
return ( return (
<button <div
key={tag.id} key={tag.id}
role="checkbox"
tabIndex={0}
aria-checked={isSelected}
aria-label={`Filter by tag: ${tag.name}`}
className={`w-full px-4 py-2.5 transition-colors duration-200 cursor-pointer ${
isSelected
? getThemeClasses("bg-muted")
: `hover:${getThemeClasses("bg-hover")}`
}`}
onClick={() => { onClick={() => {
if (isSelected) { if (isSelected) {
setSelectedTagIds(selectedTagIds.filter(id => id !== tag.id)); setSelectedTagIds(selectedTagIds.filter(id => id !== tag.id));
@ -771,18 +854,27 @@ const FileManagerIndex = () => {
setSelectedTagIds([...selectedTagIds, tag.id]); setSelectedTagIds([...selectedTagIds, tag.id]);
} }
}} }}
className={`w-full text-left px-4 py-2.5 transition-colors duration-200 ${ onKeyDown={(e) => {
isSelected if (e.key === "Enter" || e.key === " ") {
? getThemeClasses("bg-muted") e.preventDefault();
: `hover:${getThemeClasses("bg-hover")}` if (isSelected) {
}`} setSelectedTagIds(selectedTagIds.filter(id => id !== tag.id));
} else {
setSelectedTagIds([...selectedTagIds, tag.id]);
}
}
}}
> >
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<input <Checkbox
type="checkbox"
checked={isSelected} checked={isSelected}
onChange={() => {}} onChange={(checked) => {
className="h-4 w-4 rounded text-blue-600 focus:ring-blue-500" if (checked) {
setSelectedTagIds([...selectedTagIds, tag.id]);
} else {
setSelectedTagIds(selectedTagIds.filter(id => id !== tag.id));
}
}}
/> />
<span <span
className="h-4 w-4 rounded-full flex-shrink-0" className="h-4 w-4 rounded-full flex-shrink-0"
@ -798,7 +890,7 @@ const FileManagerIndex = () => {
{tag.name} {tag.name}
</span> </span>
</div> </div>
</button> </div>
); );
})} })}
@ -821,15 +913,16 @@ const FileManagerIndex = () => {
<Button <Button
onClick={() => setShowFilterMenu(!showFilterMenu)} onClick={() => setShowFilterMenu(!showFilterMenu)}
variant="secondary" variant="secondary"
className="flex items-center"
> >
<FunnelIcon className="h-4 w-4 mr-2" /> <span className="inline-flex items-center whitespace-nowrap">
{currentFilter.label} <FunnelIcon className="h-4 w-4 mr-2 flex-shrink-0" />
<ChevronRightIcon {currentFilter.label}
className={`h-4 w-4 ml-2 transition-transform duration-200 ${ <ChevronRightIcon
showFilterMenu ? "rotate-90" : "" className={`h-4 w-4 ml-2 flex-shrink-0 transition-transform duration-200 ${
}`} showFilterMenu ? "rotate-90" : ""
/> }`}
/>
</span>
</Button> </Button>
{showFilterMenu && ( {showFilterMenu && (
@ -842,10 +935,13 @@ const FileManagerIndex = () => {
className={`absolute right-0 mt-2 w-64 ${getThemeClasses("bg-card")} rounded-xl shadow-xl border ${getThemeClasses("border")} py-2 z-20`} className={`absolute right-0 mt-2 w-64 ${getThemeClasses("bg-card")} rounded-xl shadow-xl border ${getThemeClasses("border")} py-2 z-20`}
> >
{filterTypes.map((filter) => ( {filterTypes.map((filter) => (
<button <div
key={filter.key} key={filter.key}
role="button"
tabIndex={0}
onClick={() => handleFilterChange(filter.key)} onClick={() => handleFilterChange(filter.key)}
className={`w-full text-left px-4 py-3 transition-colors duration-200 ${ onKeyDown={(e) => e.key === 'Enter' && handleFilterChange(filter.key)}
className={`w-full text-left px-4 py-3 transition-colors duration-200 cursor-pointer ${
filterType === filter.key filterType === filter.key
? getThemeClasses("bg-muted") ? getThemeClasses("bg-muted")
: `hover:${getThemeClasses("bg-hover")}` : `hover:${getThemeClasses("bg-hover")}`
@ -885,7 +981,7 @@ const FileManagerIndex = () => {
{filter.count} {filter.count}
</span> </span>
</div> </div>
</button> </div>
))} ))}
</div> </div>
</> </>
@ -896,101 +992,108 @@ const FileManagerIndex = () => {
onClick={() => loadCollections(true)} onClick={() => loadCollections(true)}
disabled={isLoading} disabled={isLoading}
variant="secondary" variant="secondary"
icon={ArrowPathIcon}
className={isLoading ? "[&>svg]:animate-spin" : ""}
> >
<ArrowPathIcon
className={`h-4 w-4 mr-2 ${isLoading ? "animate-spin" : ""}`}
/>
Refresh Refresh
</Button> </Button>
<Button <Button
onClick={() => navigate("/file-manager/upload")} onClick={() => navigate("/file-manager/upload")}
variant="primary" variant="primary"
icon={CloudArrowUpIcon}
> >
<CloudArrowUpIcon className="h-4 w-4 mr-2" />
Upload Upload
</Button> </Button>
</div>
</div>
</div>
{/* Search Bar */}
<div className="mb-8">
<div className="relative max-w-2xl">
<MagnifyingGlassIcon className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
<Input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search folders..."
className="pl-12 h-12 text-base w-full"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="absolute right-4 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
</button>
)}
</div>
</div>
{/* Error Display */}
{(generalError || Object.keys(fieldErrors).length > 0) && (
<Alert type="error" className="mb-8">
{generalError && <p>{generalError}</p>}
{Object.keys(fieldErrors).length > 0 && (
<div className="mt-2">
<p className="font-semibold mb-1">
Please fix the following errors:
</p>
<ul className="list-disc list-inside space-y-1">
{Object.entries(fieldErrors).map(([field, message]) => (
<li key={field}>
<span className="font-medium capitalize">{field}:</span>{" "}
{message}
</li>
))}
</ul>
</div> </div>
)}
</Alert>
)}
{/* Collections Grid */}
{isLoading ? (
<div className="flex items-center justify-center py-24">
<div className="text-center">
<div
className={`h-12 w-12 spinner mx-auto mb-4 ${getThemeClasses("border-primary")}`}
></div>
<p className="text-gray-600 dark:text-gray-400">
Loading folders...
</p>
</div> </div>
</div> </div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> {/* Content */}
<div className="p-6">
{/* Search Bar */}
<div className="mb-6">
<div className="relative max-w-2xl">
<MagnifyingGlassIcon className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
<Input
type="text"
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search folders..."
aria-label="Search folders"
className="pl-12 h-12 text-base w-full"
/>
{searchQuery && (
<Button
onClick={() => setSearchQuery("")}
variant="ghost"
size="sm"
className="absolute right-4 top-1/2 transform -translate-y-1/2"
aria-label="Clear search"
>
</Button>
)}
</div>
</div>
{/* Error Display */}
{(generalError || Object.keys(fieldErrors).length > 0) && (
<Alert type="error" className="mb-6">
{generalError && <p>{generalError}</p>}
{Object.keys(fieldErrors).length > 0 && (
<div className="mt-2">
<p className="font-semibold mb-1">
Please fix the following errors:
</p>
<ul className="list-disc list-inside space-y-1">
{Object.entries(fieldErrors).map(([field, message]) => (
<li key={field}>
<span className="font-medium capitalize">{field}:</span>{" "}
{message}
</li>
))}
</ul>
</div>
)}
</Alert>
)}
{/* Collections Grid */}
{isLoading ? (
<div className="flex items-center justify-center py-24">
<div className="text-center">
<Spinner size="lg" className="mx-auto mb-4" />
<p className="text-gray-600 dark:text-gray-400">
Loading folders...
</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredCollections.map((collection, index) => ( {filteredCollections.map((collection, index) => (
<div <div
key={collection.id} key={collection.id}
className={`${getThemeClasses("bg-card")} rounded-xl border ${getThemeClasses("border")} shadow-sm hover:shadow-md cursor-pointer transition-all duration-200 group relative`} role="article"
onClick={() => tabIndex={0}
navigate(`/file-manager/collections/${collection.id}`) aria-label={`Folder: ${collection.name || "Locked Folder"}, ${collection.fileCount} ${collection.fileCount === 1 ? "file" : "files"}${collection.isShared ? ", shared" : ""}`}
} className={`${getThemeClasses("bg-card")} rounded-xl border ${getThemeClasses("border")} shadow-sm hover:shadow-md cursor-pointer transition-all duration-200 group relative focus:outline-none focus:ring-2 ${getThemeClasses("focus:ring-primary")}`}
onMouseEnter={() => setHoveredCollection(collection.id)} onClick={() => handleCollectionClick(collection.id)}
onMouseLeave={() => setHoveredCollection(null)} onKeyDown={(e) => handleCollectionKeyDown(e, collection.id)}
onMouseEnter={() => handleCollectionMouseEnter(collection.id)}
onMouseLeave={handleCollectionMouseLeave}
> >
{/* Delete Button - Only show if user is owner */} {/* Delete Button - Only show if user is owner */}
{collection.isOwned && ( {collection.isOwned && (
<button <Button
onClick={(e) => handleDeleteClick(e, collection)} onClick={(e) => handleDeleteClick(e, collection)}
className={`absolute top-4 right-4 p-2 bg-red-50 hover:bg-red-100 text-red-600 hover:text-red-700 rounded-lg transition-all duration-200 opacity-0 group-hover:opacity-100 z-10`} variant="danger"
title="Delete folder" size="sm"
className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 z-10"
aria-label={`Delete folder ${collection.name || 'Locked Folder'}`}
> >
<TrashIcon className="h-4 w-4" /> <TrashIcon className="h-4 w-4" />
</button> </Button>
)} )}
<div className="p-6"> <div className="p-6">
@ -1065,8 +1168,8 @@ const FileManagerIndex = () => {
)} )}
</div> </div>
<div className="flex items-center justify-between pt-3 border-t border-gray-100 dark:border-gray-700"> <div className={`flex items-center justify-between pt-3 border-t ${getThemeClasses("border-muted")}`}>
<span className="text-xs text-gray-500 dark:text-gray-400"> <span className={`text-xs ${getThemeClasses("text-secondary")}`}>
{getTimeAgo(collection.modified)} {getTimeAgo(collection.modified)}
</span> </span>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@ -1074,8 +1177,8 @@ const FileManagerIndex = () => {
<div <div
className={`flex items-center space-x-1 px-2 py-1 rounded-full text-xs font-medium ${ className={`flex items-center space-x-1 px-2 py-1 rounded-full text-xs font-medium ${
collection.isOwned collection.isOwned
? "bg-green-100 text-green-700" ? `${getThemeClasses("bg-success-light")} ${getThemeClasses("text-success")}`
: "bg-blue-100 text-blue-700" : `${getThemeClasses("bg-info-light")} ${getThemeClasses("text-info")}`
}`} }`}
> >
<ShareIcon className="h-3 w-3" /> <ShareIcon className="h-3 w-3" />
@ -1099,10 +1202,20 @@ const FileManagerIndex = () => {
</div> </div>
))} ))}
{(filterType === "owned" || filterType === "all") && ( {/* Only show New Folder card when there are existing folders */}
{(filterType === "owned" || filterType === "all") && filteredCollections.length > 0 && (
<div <div
role="button"
tabIndex={0}
aria-label="Create new folder"
onClick={() => navigate("/file-manager/collections/create")} onClick={() => navigate("/file-manager/collections/create")}
className={`group ${getThemeClasses("bg-card")} rounded-xl border-2 border-dashed ${getThemeClasses("border")} ${getThemeClasses("hover:border-primary")} cursor-pointer transition-all duration-300`} onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
navigate("/file-manager/collections/create");
}
}}
className={`group ${getThemeClasses("bg-card")} rounded-xl border-2 border-dashed ${getThemeClasses("border")} ${getThemeClasses("hover:border-primary")} cursor-pointer transition-all duration-300 focus:outline-none focus:ring-2 ${getThemeClasses("focus:ring-primary")}`}
> >
<div className="p-6 text-center"> <div className="p-6 text-center">
<div <div
@ -1121,101 +1234,102 @@ const FileManagerIndex = () => {
</div> </div>
</div> </div>
)} )}
</div> </div>
)} )}
{/* Tag Filter Empty State */} {/* Tag Filter Empty State */}
{!isLoading && filteredCollections.length === 0 && selectedTagIds.length > 0 && !searchQuery && ( {!isLoading && filteredCollections.length === 0 && selectedTagIds.length > 0 && !searchQuery && (
<div className="text-center py-24"> <div className="text-center py-24">
<div <div
className={`h-20 w-20 ${getThemeClasses("bg-muted")} rounded-2xl flex items-center justify-center mx-auto mb-6`} className={`h-20 w-20 ${getThemeClasses("bg-muted")} rounded-2xl flex items-center justify-center mx-auto mb-6`}
> >
<TagIcon className="h-10 w-10 text-gray-400" /> <TagIcon className="h-10 w-10 text-gray-400" />
</div> </div>
<h3 <h3
className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`} className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`}
> >
No folders with {selectedTagIds.length === 1 ? 'this tag' : 'these tags'} No folders with {selectedTagIds.length === 1 ? 'this tag' : 'these tags'}
</h3> </h3>
<p className="text-gray-600 dark:text-gray-400 mb-8 max-w-md mx-auto"> <p className="text-gray-600 dark:text-gray-400 mb-8 max-w-md mx-auto">
No folders are tagged with {selectedTagIds.length === 1 No folders are tagged with {selectedTagIds.length === 1
? `"${availableTags.find(t => t.id === selectedTagIds[0])?.name || 'this tag'}"` ? `"${availableTags.find(t => t.id === selectedTagIds[0])?.name || 'this tag'}"`
: `all ${selectedTagIds.length} selected tags`} : `all ${selectedTagIds.length} selected tags`}
</p> </p>
<Button onClick={() => setSelectedTagIds([])} variant="secondary"> <Button onClick={() => setSelectedTagIds([])} variant="secondary">
<TagIcon className="h-4 w-4 mr-2" /> <TagIcon className="h-4 w-4 mr-2" />
Clear Tag Filter Clear Tag Filter
</Button> </Button>
</div> </div>
)} )}
{/* Empty State */} {/* Empty State */}
{!isLoading && filteredCollections.length === 0 && !searchQuery && selectedTagIds.length === 0 && ( {!isLoading && filteredCollections.length === 0 && !searchQuery && selectedTagIds.length === 0 && (
<div className="text-center py-24"> <div className="text-center">
<div <div
className={`h-20 w-20 ${getThemeClasses("bg-muted")} rounded-2xl flex items-center justify-center mx-auto mb-6`} className={`h-20 w-20 ${getThemeClasses("bg-muted")} rounded-2xl flex items-center justify-center mx-auto mb-0.5`}
> >
<currentFilter.icon className="h-10 w-10 text-gray-400" /> <currentFilter.icon className="h-10 w-10 text-gray-400" />
</div> </div>
<h3 <h3
className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`} className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`}
> >
{filterType === "shared" {filterType === "shared"
? "No shared folders" ? "No shared folders"
: filterType === "all" : filterType === "all"
? "No folders found" ? "No folders found"
: "No folders yet"} : "No folders yet"}
</h3> </h3>
<p className="text-gray-600 dark:text-gray-400 mb-8 max-w-md mx-auto"> <p className="text-gray-600 dark:text-gray-400 mb-8 max-w-md mx-auto">
{filterType === "shared" {filterType === "shared"
? "When someone shares a folder with you, it will appear here" ? "When someone shares a folder with you, it will appear here"
: filterType === "all" : "Create your first folder now"}
? "Create your first folder or wait for someone to share with you" </p>
: "Create your first encrypted folder to start organizing your files"} {filterType !== "shared" && (
</p> <Button
{filterType !== "shared" && ( onClick={() => navigate("/file-manager/collections/create")}
<Button variant="primary"
onClick={() => navigate("/file-manager/collections/create")} icon={PlusIcon}
variant="primary" >
> Create Your First Folder
<PlusIcon className="h-4 w-4 mr-2" /> </Button>
Create Your First Folder )}
</Button> </div>
)}
{/* Search Empty State */}
{!isLoading && filteredCollections.length === 0 && searchQuery && (
<div className="text-center py-24">
<div
className={`h-20 w-20 ${getThemeClasses("bg-muted")} rounded-2xl flex items-center justify-center mx-auto mb-6`}
>
<MagnifyingGlassIcon className="h-10 w-10 text-gray-400" />
</div>
<h3
className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`}
>
No folders found
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-8">
No folders match your search for "{searchQuery}"
{selectedTagIds.length > 0 && ` with ${selectedTagIds.length === 1
? `tag "${availableTags.find(t => t.id === selectedTagIds[0])?.name || 'selected tag'}"`
: `${selectedTagIds.length} selected tags`}`}
</p>
<div className="flex items-center justify-center space-x-3">
<Button onClick={() => setSearchQuery("")} variant="secondary">
Clear search
</Button>
{selectedTagIds.length > 0 && (
<Button onClick={() => setSelectedTagIds([])} variant="secondary">
Clear tag filter
</Button>
)}
</div>
</div>
)} )}
</div> </div>
)} </Card>
{/* Search Empty State */}
{!isLoading && filteredCollections.length === 0 && searchQuery && (
<div className="text-center py-24">
<div
className={`h-20 w-20 ${getThemeClasses("bg-muted")} rounded-2xl flex items-center justify-center mx-auto mb-6`}
>
<MagnifyingGlassIcon className="h-10 w-10 text-gray-400" />
</div>
<h3
className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`}
>
No folders found
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-8">
No folders match your search for "{searchQuery}"
{selectedTagIds.length > 0 && ` with ${selectedTagIds.length === 1
? `tag "${availableTags.find(t => t.id === selectedTagIds[0])?.name || 'selected tag'}"`
: `${selectedTagIds.length} selected tags`}`}
</p>
<div className="flex items-center justify-center space-x-3">
<Button onClick={() => setSearchQuery("")} variant="secondary">
Clear search
</Button>
{selectedTagIds.length > 0 && (
<Button onClick={() => setSelectedTagIds([])} variant="secondary">
Clear tag filter
</Button>
)}
</div>
</div>
)}
</div> </div>
{/* Delete Confirmation Modal */} {/* Delete Confirmation Modal */}
@ -1236,46 +1350,39 @@ const FileManagerIndex = () => {
onClick={handleConfirmDelete} onClick={handleConfirmDelete}
disabled={isDeleting} disabled={isDeleting}
variant="danger" variant="danger"
loading={isDeleting}
loadingText="Deleting..."
> >
{isDeleting ? ( <TrashIcon className="h-4 w-4 mr-2" />
<> Delete Folder
<div className="h-4 w-4 spinner border-white mr-2"></div>
Deleting...
</>
) : (
<>
<TrashIcon className="h-4 w-4 mr-2" />
Delete Folder
</>
)}
</Button> </Button>
</div> </div>
} }
> >
{collectionToDelete && ( {collectionToDelete && (
<> <>
<p className="text-gray-600 dark:text-gray-400 mb-4"> <p className={`${getThemeClasses("text-secondary")} mb-4`}>
Are you sure you want to delete " Are you sure you want to delete "
<span className="font-semibold"> <span className="font-semibold">
{collectionToDelete.name || "Locked Folder"} {collectionToDelete.name || "Locked Folder"}
</span> </span>
"? This will permanently delete: "? This will permanently delete:
</p> </p>
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-2 mb-6 bg-red-50 dark:bg-red-900/20 p-4 rounded-lg"> <ul className={`text-sm ${getThemeClasses("text-secondary")} space-y-2 mb-6 ${getThemeClasses("bg-error-light")} p-4 rounded-lg`}>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-red-600 mr-2"></span> <span className={`${getThemeClasses("text-error")} mr-2`}></span>
<span>All files in this folder</span> <span>All files in this folder</span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-red-600 mr-2"></span> <span className={`${getThemeClasses("text-error")} mr-2`}></span>
<span>All sub-folders and their contents</span> <span>All sub-folders and their contents</span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-red-600 mr-2"></span> <span className={`${getThemeClasses("text-error")} mr-2`}></span>
<span>All files in sub-folders</span> <span>All files in sub-folders</span>
</li> </li>
</ul> </ul>
<p className="text-sm text-red-600 font-medium"> <p className={`text-sm ${getThemeClasses("text-error")} font-medium`}>
This action cannot be undone. This action cannot be undone.
</p> </p>
</> </>

View file

@ -735,7 +735,7 @@ const FileDetails = () => {
type="checkbox" type="checkbox"
checked={selectedTagIds.includes(tag.id)} checked={selectedTagIds.includes(tag.id)}
onChange={() => handleToggleTag(tag.id)} onChange={() => handleToggleTag(tag.id)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" className={`h-4 w-4 rounded ${getThemeClasses("checkbox-focus")}`}
/> />
<span <span
className="w-4 h-4 rounded-full ml-3 mr-2 flex-shrink-0" className="w-4 h-4 rounded-full ml-3 mr-2 flex-shrink-0"
@ -749,7 +749,7 @@ const FileDetails = () => {
</div> </div>
)} )}
<div className="flex justify-end space-x-3 mt-4 pt-4 border-t border-gray-200"> <div className={`flex justify-end space-x-3 mt-4 pt-4 border-t ${getThemeClasses("border-secondary")}`}>
<Button <Button
onClick={handleCloseTagEditor} onClick={handleCloseTagEditor}
variant="secondary" variant="secondary"

View file

@ -17,6 +17,8 @@ import {
useUIXTheme, useUIXTheme,
Breadcrumb, Breadcrumb,
Card, Card,
Select,
Checkbox,
} from "../../../../components/UIX"; } from "../../../../components/UIX";
import { import {
CloudArrowUpIcon, CloudArrowUpIcon,
@ -63,7 +65,8 @@ const FileUpload = () => {
preSelectedCollectionId || "", preSelectedCollectionId || "",
); );
const [availableCollections, setAvailableCollections] = useState([]); const [availableCollections, setAvailableCollections] = useState([]);
const [isLoadingCollections, setIsLoadingCollections] = useState(false); const [isLoadingCollections, setIsLoadingCollections] = useState(true); // Start with loading true
const [isCollectionsInitialized, setIsCollectionsInitialized] = useState(false);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
@ -104,19 +107,24 @@ const FileUpload = () => {
}, [authManager]); }, [authManager]);
const loadCollections = useCallback(async () => { const loadCollections = useCallback(async () => {
if (!listCollectionManager) return;
setIsLoadingCollections(true); setIsLoadingCollections(true);
try { try {
const result = await listCollectionManager.listCollections(false); const result = await listCollectionManager.listCollections(false);
if ( if (isMountedRef.current) {
result.collections && if (result.collections && result.collections.length > 0) {
result.collections.length > 0 && setAvailableCollections(result.collections);
isMountedRef.current }
) { setIsCollectionsInitialized(true);
setAvailableCollections(result.collections);
} }
} catch (err) { } catch (err) {
if (isMountedRef.current) { if (isMountedRef.current) {
if (import.meta.env.DEV) {
console.error("[FileUpload] Could not load folders:", err);
}
setError("Could not load folders"); setError("Could not load folders");
setIsCollectionsInitialized(true);
} }
} finally { } finally {
if (isMountedRef.current) { if (isMountedRef.current) {
@ -125,11 +133,12 @@ const FileUpload = () => {
} }
}, [listCollectionManager]); }, [listCollectionManager]);
// Load collections when manager becomes available
useEffect(() => { useEffect(() => {
if (createCollectionManager && listCollectionManager) { if (createCollectionManager && listCollectionManager && !isCollectionsInitialized) {
loadCollections(); loadCollections();
} }
}, [createCollectionManager, listCollectionManager, loadCollections]); }, [createCollectionManager, listCollectionManager, isCollectionsInitialized, loadCollections]);
const handleDragOver = useCallback((e) => { const handleDragOver = useCallback((e) => {
e.preventDefault(); e.preventDefault();
@ -665,6 +674,14 @@ const FileUpload = () => {
errorFiles, errorFiles,
} = fileStats; } = fileStats;
// Memoize collection options for Select component
const collectionOptions = useMemo(() => {
return availableCollections.map((collection) => ({
value: collection.id,
label: collection.name || "Unnamed Folder",
}));
}, [availableCollections]);
// Build breadcrumb // Build breadcrumb
const breadcrumbItems = [ const breadcrumbItems = [
{ {
@ -756,39 +773,73 @@ const FileUpload = () => {
{/* Content */} {/* Content */}
<div className="px-6 pb-6 pt-5"> <div className="px-6 pb-6 pt-5">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> <div className={`grid grid-cols-1 gap-8 ${preSelectedCollectionId ? "lg:grid-cols-3" : ""}`}>
{/* Upload Area */} {/* Upload Area */}
<div className="lg:col-span-2 space-y-6"> <div className={`space-y-6 ${preSelectedCollectionId ? "lg:col-span-2" : ""}`}>
{/* Collection Selector (only show if no pre-selected collection) */} {/* Collection Selector with Upload Button (only show if no pre-selected collection) */}
{!preSelectedCollectionId && ( {!preSelectedCollectionId && (
<div> <div className="flex items-end gap-3">
<label <Select
className={`block text-sm font-medium mb-2 ${getThemeClasses("text-primary")}`} label="Select destination folder"
>
Select destination folder
</label>
<select
value={selectedCollection} value={selectedCollection}
onChange={(e) => setSelectedCollection(e.target.value)} onChange={setSelectedCollection}
options={collectionOptions}
disabled={isLoadingCollections || isUploading} disabled={isLoadingCollections || isUploading}
className={`w-full px-4 py-3 border ${getThemeClasses("border-secondary")} rounded-lg ${getThemeClasses("bg-card")} ${getThemeClasses("text-primary")} focus:ring-2 ${getThemeClasses("focus:ring-primary")} ${getThemeClasses("focus:border-primary")} transition-all duration-200`} placeholder="Choose a folder..."
> size="md"
<option value="">Choose a folder...</option> className="flex-1"
{availableCollections.map((collection) => ( />
<option key={collection.id} value={collection.id}> <Button
{collection.name || "Unnamed Folder"} onClick={startUpload}
</option> disabled={
))} !selectedCollection ||
</select> !fileManager ||
files.length === 0 ||
isUploading ||
pendingFiles.length === 0 ||
!gdprConsent
}
variant="primary"
className="flex-shrink-0 h-[50px]"
>
<span className="inline-flex items-center justify-center gap-2">
{isUploading ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-2 border-white border-t-transparent"></div>
<span>
Uploading {uploadingFiles.length} of {files.length}...
</span>
</>
) : (
<>
<ArrowUpTrayIcon className="h-5 w-5" />
<span>
Upload {pendingFiles.length} File
{pendingFiles.length !== 1 ? "s" : ""}
</span>
</>
)}
</span>
</Button>
</div> </div>
)} )}
{/* Drop Zone */} {/* Drop Zone */}
<div <div
role="button"
tabIndex={0}
aria-label={isDragging ? "Drop files here to upload" : "Click or drag files here to upload"}
aria-disabled={isUploading}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
onClick={() => !isUploading && fileInputRef.current?.click()} onClick={() => !isUploading && fileInputRef.current?.click()}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && !isUploading) {
e.preventDefault();
fileInputRef.current?.click();
}
}}
className={`${getThemeClasses("bg-muted")} rounded-lg border-2 border-dashed p-12 text-center cursor-pointer transition-all duration-300 ${ className={`${getThemeClasses("bg-muted")} rounded-lg border-2 border-dashed p-12 text-center cursor-pointer transition-all duration-300 ${
isDragging isDragging
? `${getThemeClasses("border-primary")} ${getThemeClasses("bg-accent-light")} scale-105 shadow-lg` ? `${getThemeClasses("border-primary")} ${getThemeClasses("bg-accent-light")} scale-105 shadow-lg`
@ -833,6 +884,8 @@ const FileUpload = () => {
onChange={handleFileSelect} onChange={handleFileSelect}
disabled={isUploading} disabled={isUploading}
className="sr-only" className="sr-only"
aria-label="Select files to upload"
id="file-upload-input"
/> />
</div> </div>
@ -860,11 +913,15 @@ const FileUpload = () => {
</div> </div>
<div <div
role="list"
aria-label="Selected files for upload"
className={`divide-y ${getThemeClasses("border-secondary")} max-h-96 overflow-y-auto`} className={`divide-y ${getThemeClasses("border-secondary")} max-h-96 overflow-y-auto`}
> >
{files.map((file) => ( {files.map((file) => (
<div <div
key={file.id} key={file.id}
role="listitem"
aria-label={`${file.name}, ${formatFileSize(file.size)}, status: ${file.status}`}
className={`p-4 ${getThemeClasses("hover:bg-muted")} transition-colors duration-200`} className={`p-4 ${getThemeClasses("hover:bg-muted")} transition-colors duration-200`}
> >
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
@ -919,16 +976,19 @@ const FileUpload = () => {
</div> </div>
<div className="flex-shrink-0"> <div className="flex-shrink-0">
{file.status === "pending" && ( {file.status === "pending" && (
<button <Button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
removeFile(file.id); removeFile(file.id);
}} }}
disabled={isUploading} disabled={isUploading}
className={`p-2 ${getThemeClasses("text-muted")} hover:opacity-80 ${getThemeClasses("alert-error-bg")} rounded-lg transition-all duration-200`} variant="ghost"
size="sm"
aria-label={`Remove ${file.name}`}
className={`${getThemeClasses("hover:text-error")}`}
> >
<XMarkIcon className="h-5 w-5" /> <XMarkIcon className="h-5 w-5" />
</button> </Button>
)} )}
{file.status === "uploading" && ( {file.status === "uploading" && (
<div className="p-2"> <div className="p-2">
@ -960,7 +1020,8 @@ const FileUpload = () => {
)} )}
</div> </div>
{/* Sidebar */} {/* Sidebar - only show as separate column when collection is pre-selected */}
{preSelectedCollectionId && (
<div className="space-y-6"> <div className="space-y-6">
{files.length > 0 && ( {files.length > 0 && (
<div <div
@ -1077,12 +1138,11 @@ const FileUpload = () => {
<div <div
className={`p-4 ${getThemeClasses("bg-accent-light")} rounded-lg border ${getThemeClasses("border-accent")}`} className={`p-4 ${getThemeClasses("bg-accent-light")} rounded-lg border ${getThemeClasses("border-accent")}`}
> >
<label className="flex items-start space-x-3 cursor-pointer"> <div className="flex items-start space-x-3">
<input <Checkbox
type="checkbox"
checked={gdprConsent} checked={gdprConsent}
onChange={(e) => setGdprConsent(e.target.checked)} onChange={setGdprConsent}
className={`mt-1 h-4 w-4 rounded ${getThemeClasses("checkbox-focus")}`} className="mt-0.5"
/> />
<div className="flex-1"> <div className="flex-1">
<p <p
@ -1097,10 +1157,11 @@ const FileUpload = () => {
you share with can decrypt them. you share with can decrypt them.
</p> </p>
</div> </div>
</label> </div>
</div> </div>
)} )}
{/* Upload button in sidebar */}
<Button <Button
onClick={startUpload} onClick={startUpload}
disabled={ disabled={
@ -1134,7 +1195,150 @@ const FileUpload = () => {
</span> </span>
</Button> </Button>
</div> </div>
)}
</div> </div>
{/* Options section - show below content when no pre-selected collection */}
{!preSelectedCollectionId && files.length > 0 && (
<div className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Upload Status */}
<div
className={`${getThemeClasses("bg-card")} rounded-lg border ${getThemeClasses("border-secondary")} shadow-sm p-6`}
>
<h3
className={`font-semibold mb-4 ${getThemeClasses("text-primary")}`}
>
Upload Status
</h3>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span
className={`text-sm ${getThemeClasses("text-secondary")}`}
>
Pending
</span>
<span
className={`text-sm font-medium ${getThemeClasses("text-primary")}`}
>
{pendingFiles.length}
</span>
</div>
<div className="flex items-center justify-between">
<span
className={`text-sm ${getThemeClasses("text-secondary")}`}
>
Uploading
</span>
<span
className={`text-sm font-medium ${getThemeClasses("text-info")}`}
>
{uploadingFiles.length}
</span>
</div>
<div className="flex items-center justify-between">
<span
className={`text-sm ${getThemeClasses("text-secondary")}`}
>
Completed
</span>
<span
className={`text-sm font-medium ${getThemeClasses("text-success")}`}
>
{completedFiles.length}
</span>
</div>
{errorFiles.length > 0 && (
<div className="flex items-center justify-between">
<span
className={`text-sm ${getThemeClasses("text-secondary")}`}
>
Errors
</span>
<span
className={`text-sm font-medium ${getThemeClasses("text-error")}`}
>
{errorFiles.length}
</span>
</div>
)}
</div>
{completedFiles.length + errorFiles.length ===
files.length &&
files.length > 0 &&
!isUploading &&
pendingFiles.length === 0 && (
<div
className={`mt-4 p-3 ${getThemeClasses("alert-success-bg")} border ${getThemeClasses("alert-success-border")} rounded-lg`}
>
<p
className={`text-sm ${getThemeClasses("text-success")} flex items-center`}
>
<CheckCircleIcon className="h-4 w-4 mr-2" />
All uploads processed!
</p>
</div>
)}
</div>
{/* Tag Selection */}
{pendingFiles.length > 0 && (
<div
className={`${getThemeClasses("bg-card")} rounded-lg border ${getThemeClasses("border-secondary")} shadow-sm p-6`}
>
<div className="flex items-center gap-2 mb-4">
<TagIcon
className={`h-5 w-5 ${getThemeClasses("text-secondary")}`}
/>
<h3
className={`font-semibold ${getThemeClasses("text-primary")}`}
>
Add Tags
</h3>
</div>
<p
className={`text-xs ${getThemeClasses("text-secondary")} mb-3`}
>
Tags will be applied to all uploaded files
</p>
<TagSelector
value={selectedTagIds}
onChange={setSelectedTagIds}
disabled={isUploading}
label=""
placeholder="Select tags..."
/>
</div>
)}
{/* Consent & Security Notice */}
{pendingFiles.length > 0 && (
<div
className={`p-4 ${getThemeClasses("bg-accent-light")} rounded-lg border ${getThemeClasses("border-accent")} h-fit`}
>
<div className="flex items-start space-x-3">
<Checkbox
checked={gdprConsent}
onChange={setGdprConsent}
className="mt-0.5"
/>
<div className="flex-1">
<p
className={`text-sm font-medium ${getThemeClasses("text-primary")}`}
>
I consent to encrypted upload
</p>
<p
className={`text-xs ${getThemeClasses("text-secondary")} mt-1`}
>
Files are encrypted end-to-end and only you and those
you share with can decrypt them.
</p>
</div>
</div>
</div>
)}
</div>
)}
</div> </div>
</Card> </Card>
</div> </div>

View file

@ -84,14 +84,28 @@ const SearchResults = memo(function SearchResults() {
// Refs for cleanup and debouncing // Refs for cleanup and debouncing
const searchDebounceRef = useRef(null); const searchDebounceRef = useRef(null);
// Ref to hold cache for cleanup (avoids stale closure)
const searchCacheRef = useRef(searchCache);
searchCacheRef.current = searchCache;
useEffect(() => { useEffect(() => {
isMountedRef.current = true; isMountedRef.current = true;
return () => { return () => {
isMountedRef.current = false; isMountedRef.current = false;
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
// Clear search cache on unmount to prevent memory leaks
searchCacheRef.current.clear();
}; };
}, []); }, []);
// Sanitize filename to prevent path traversal and special character issues
const sanitizeFilename = useCallback((filename) => {
return filename
.replace(/[^a-z0-9_-]/gi, '_') // Replace non-alphanumeric with underscore
.replace(/_+/g, '_') // Collapse multiple underscores
.substring(0, 50); // Limit length
}, []);
// Export search results (GDPR Article 20 - Data Portability) // Export search results (GDPR Article 20 - Data Portability)
const handleExportResults = useCallback(() => { const handleExportResults = useCallback(() => {
const exportData = { const exportData = {
@ -113,12 +127,14 @@ const SearchResults = memo(function SearchResults() {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `search_results_${query}_${Date.now()}.json`; // Sanitize query for safe filename
const safeQuery = sanitizeFilename(query || 'export');
a.download = `search_results_${safeQuery}_${Date.now()}.json`;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}, [query, results]); }, [query, results, sanitizeFilename]);
// Search function with caching and validation // Search function with caching and validation
const performSearch = useCallback( const performSearch = useCallback(
@ -292,9 +308,9 @@ const SearchResults = memo(function SearchResults() {
const getFileIcon = useCallback((file) => { const getFileIcon = useCallback((file) => {
const mimeType = file.mime_type || ""; const mimeType = file.mime_type || "";
if (mimeType.startsWith("image/")) if (mimeType.startsWith("image/"))
return <PhotoIcon className="h-5 w-5 text-pink-600" />; return <PhotoIcon className={`h-5 w-5 ${getThemeClasses("text-accent")}`} />;
return <DocumentIcon className="h-5 w-5 text-gray-600 dark:text-gray-400" />; return <DocumentIcon className={`h-5 w-5 ${getThemeClasses("text-secondary")}`} />;
}, []); }, [getThemeClasses]);
const totalResults = results.collections.length + results.files.length; const totalResults = results.collections.length + results.files.length;

View file

@ -85,10 +85,22 @@ const TrashView = () => {
let auditLogs = []; let auditLogs = [];
const stored = localStorage.getItem("security_audit_logs"); const stored = localStorage.getItem("security_audit_logs");
if (stored) { if (stored) {
auditLogs = JSON.parse(stored); const parsed = JSON.parse(stored);
// Validate it's an array // Validate it's an array and each entry has expected structure
if (!Array.isArray(auditLogs)) { if (Array.isArray(parsed)) {
auditLogs = []; // Filter to only valid audit log entries to prevent injection
auditLogs = parsed.filter(log =>
log &&
typeof log === 'object' &&
typeof log.action === 'string' &&
typeof log.timestamp === 'string'
);
}
// If structure was invalid, clear corrupted data
if (!Array.isArray(parsed) || auditLogs.length !== parsed.length) {
if (import.meta.env.DEV) {
console.warn("[TrashView] Cleared invalid audit log entries");
}
} }
} }
auditLogs.push(auditLog); auditLogs.push(auditLog);
@ -98,7 +110,8 @@ const TrashView = () => {
} }
localStorage.setItem("security_audit_logs", JSON.stringify(auditLogs)); localStorage.setItem("security_audit_logs", JSON.stringify(auditLogs));
} catch (storageErr) { } catch (storageErr) {
// If storage fails, just log to console in dev mode // If storage fails, clear potentially corrupted data and log to console in dev mode
localStorage.removeItem("security_audit_logs");
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
console.warn("[TrashView] Could not store audit log:", storageErr.message); console.warn("[TrashView] Could not store audit log:", storageErr.message);
} }

View file

@ -4,7 +4,7 @@ import React from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import withPasswordProtection from "../../../hocs/withPasswordProtection"; import withPasswordProtection from "../../../hocs/withPasswordProtection";
import Layout from "../../../components/Layout/Layout"; import Layout from "../../../components/Layout/Layout";
import { Button, Card, useUIXTheme } from "../../../components/UIX"; import { Button, Card, Breadcrumb, GDPRFooter, useUIXTheme } from "../../../components/UIX";
import { import {
QuestionMarkCircleIcon, QuestionMarkCircleIcon,
ShieldCheckIcon, ShieldCheckIcon,
@ -13,11 +13,9 @@ import {
ArrowDownTrayIcon, ArrowDownTrayIcon,
ShareIcon, ShareIcon,
TrashIcon, TrashIcon,
MagnifyingGlassIcon,
KeyIcon,
DocumentTextIcon, DocumentTextIcon,
LockClosedIcon, LockClosedIcon,
ArrowLeftIcon, HomeIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
const Help = () => { const Help = () => {
@ -28,8 +26,8 @@ const Help = () => {
{ {
title: "Getting Started", title: "Getting Started",
icon: QuestionMarkCircleIcon, icon: QuestionMarkCircleIcon,
color: "text-blue-600 dark:text-blue-400", color: getThemeClasses("help-section-blue-text"),
bgColor: "bg-blue-50 dark:bg-blue-900/20", bgColor: getThemeClasses("help-section-blue-bg"),
items: [ items: [
{ {
title: "What is MapleFile?", title: "What is MapleFile?",
@ -51,8 +49,8 @@ const Help = () => {
{ {
title: "Security & Encryption", title: "Security & Encryption",
icon: ShieldCheckIcon, icon: ShieldCheckIcon,
color: "text-green-600 dark:text-green-400", color: getThemeClasses("help-section-green-text"),
bgColor: "bg-green-50 dark:bg-green-900/20", bgColor: getThemeClasses("help-section-green-bg"),
items: [ items: [
{ {
title: "End-to-End Encryption (E2EE)", title: "End-to-End Encryption (E2EE)",
@ -74,8 +72,8 @@ const Help = () => {
{ {
title: "File Management", title: "File Management",
icon: FolderIcon, icon: FolderIcon,
color: "text-purple-600 dark:text-purple-400", color: getThemeClasses("help-section-purple-text"),
bgColor: "bg-purple-50 dark:bg-purple-900/20", bgColor: getThemeClasses("help-section-purple-bg"),
items: [ items: [
{ {
title: "Organizing with Folders", title: "Organizing with Folders",
@ -97,8 +95,8 @@ const Help = () => {
{ {
title: "Sharing & Collaboration", title: "Sharing & Collaboration",
icon: ShareIcon, icon: ShareIcon,
color: "text-pink-600 dark:text-pink-400", color: getThemeClasses("help-section-pink-text"),
bgColor: "bg-pink-50 dark:bg-pink-900/20", bgColor: getThemeClasses("help-section-pink-bg"),
items: [ items: [
{ {
title: "Sharing Folders", title: "Sharing Folders",
@ -120,8 +118,8 @@ const Help = () => {
{ {
title: "Data Management", title: "Data Management",
icon: TrashIcon, icon: TrashIcon,
color: "text-red-600 dark:text-red-400", color: getThemeClasses("help-section-red-text"),
bgColor: "bg-red-50 dark:bg-red-900/20", bgColor: getThemeClasses("help-section-red-bg"),
items: [ items: [
{ {
title: "Trash & Recovery", title: "Trash & Recovery",
@ -173,94 +171,136 @@ const Help = () => {
}, },
]; ];
const breadcrumbItems = [
{
label: "Dashboard",
to: "/dashboard",
icon: HomeIcon,
},
{
label: "Help",
isActive: true,
},
];
return ( return (
<Layout> <Layout>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */} {/* Breadcrumb Navigation */}
<div className="mb-8"> <Breadcrumb items={breadcrumbItems} />
<Button
onClick={() => navigate("/dashboard")}
variant="secondary"
className="mb-4"
>
<ArrowLeftIcon className="h-4 w-4 mr-2" />
Back to Dashboard
</Button>
<h1 className={`text-3xl font-bold flex items-center ${getThemeClasses("text-primary")}`}> {/* Main Card */}
<QuestionMarkCircleIcon className={`h-8 w-8 mr-3 ${getThemeClasses("text-accent")}`} /> <Card>
Help & Documentation {/* Header with icon, title, and action button */}
</h1> <div className={`p-6 border-b ${getThemeClasses("border-secondary")}`}>
<p className={`${getThemeClasses("text-secondary")} mt-2`}> <div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
Learn how to use MapleFile's secure file storage features <div className="flex-1 min-w-0">
</p> <div className="flex items-start">
</div> {/* Icon */}
<div className={`p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 ${getThemeClasses("bg-gradient-secondary")}`}>
{/* Quick Actions */} <QuestionMarkCircleIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
<div className="mb-8"> </div>
<h2 className={`text-xl font-semibold mb-4 ${getThemeClasses("text-primary")}`}> {/* Title and subtitle */}
Quick Actions <div>
</h2> <h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> Help & Documentation
{quickActions.map((action, index) => ( </h1>
<Card <p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
key={index} Learn how to use MapleFile's secure file storage features
className={`${getThemeClasses("hover:shadow")} ${getThemeClasses("hover:border-primary")} cursor-pointer transition-all duration-200`} </p>
onClick={() => navigate(action.path)}
>
<div className="p-4">
<div className={`h-10 w-10 rounded-lg ${getThemeClasses("bg-accent-light")} flex items-center justify-center mb-3`}>
<action.icon className={`h-6 w-6 ${action.color}`} />
</div> </div>
<h3 className={`font-medium mb-1 ${getThemeClasses("text-primary")}`}>
{action.title}
</h3>
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>
{action.description}
</p>
</div> </div>
</Card>
))}
</div>
</div>
{/* Help Sections */}
<div className="space-y-8">
{helpSections.map((section, sectionIndex) => (
<div key={sectionIndex}>
<div className="flex items-center mb-4">
<div className={`h-10 w-10 rounded-lg ${section.bgColor} flex items-center justify-center mr-3`}>
<section.icon className={`h-6 w-6 ${section.color}`} />
</div>
<h2 className={`text-2xl font-bold ${getThemeClasses("text-primary")}`}>
{section.title}
</h2>
</div> </div>
{/* Action button */}
<div className="flex-shrink-0">
<Button
onClick={() => navigate("/dashboard")}
variant="secondary"
size="sm"
>
<span className="inline-flex items-center gap-2">
<HomeIcon className="h-4 w-4" />
<span>Back to Dashboard</span>
</span>
</Button>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> {/* Content */}
{section.items.map((item, itemIndex) => ( <div className="p-6">
<Card key={itemIndex} className="h-full"> {/* Quick Actions */}
<div className="p-6"> <div className="mb-8">
<h3 className={`text-lg font-semibold mb-3 ${getThemeClasses("text-primary")}`}> <h2 className={`text-xl font-semibold mb-4 ${getThemeClasses("text-primary")}`}>
{item.title} Quick Actions
</h3> </h2>
<p className={`text-sm ${getThemeClasses("text-secondary")} leading-relaxed`}> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4" role="list" aria-label="Quick actions">
{item.description} {quickActions.map((action, index) => (
</p> <div
key={index}
role="listitem"
tabIndex={0}
aria-label={`${action.title}: ${action.description}`}
className={`p-4 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-card")} ${getThemeClasses("hover:shadow")} ${getThemeClasses("hover:border-primary")} cursor-pointer transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500`}
onClick={() => navigate(action.path)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
navigate(action.path);
}
}}
>
<div className={`h-10 w-10 rounded-lg ${getThemeClasses("bg-accent-light")} flex items-center justify-center mb-3`} aria-hidden="true">
<action.icon className={`h-6 w-6 ${action.color}`} />
</div> </div>
</Card> <h3 className={`font-medium mb-1 ${getThemeClasses("text-primary")}`}>
{action.title}
</h3>
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>
{action.description}
</p>
</div>
))} ))}
</div> </div>
</div> </div>
))}
</div>
{/* Additional Resources */} {/* Help Sections */}
<div className="mt-12"> <div className="space-y-8">
<Card className={getThemeClasses("bg-accent-light")}> {helpSections.map((section, sectionIndex) => (
<div className="p-6"> <section key={sectionIndex} aria-labelledby={`section-${sectionIndex}`}>
<div className="flex items-center mb-4">
<div className={`h-10 w-10 rounded-lg ${section.bgColor} flex items-center justify-center mr-3`} aria-hidden="true">
<section.icon className={`h-6 w-6 ${section.color}`} />
</div>
<h2 id={`section-${sectionIndex}`} className={`text-2xl font-bold ${getThemeClasses("text-primary")}`}>
{section.title}
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" role="list" aria-label={`${section.title} topics`}>
{section.items.map((item, itemIndex) => (
<article
key={itemIndex}
role="listitem"
className={`p-6 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-card")} h-full`}
>
<h3 className={`text-lg font-semibold mb-3 ${getThemeClasses("text-primary")}`}>
{item.title}
</h3>
<p className={`text-sm ${getThemeClasses("text-secondary")} leading-relaxed`}>
{item.description}
</p>
</article>
))}
</div>
</section>
))}
</div>
{/* Privacy & Security Notice */}
<div className={`mt-12 p-6 rounded-lg ${getThemeClasses("bg-accent-light")}`}>
<div className="flex items-start"> <div className="flex items-start">
<LockClosedIcon className={`h-6 w-6 ${getThemeClasses("text-accent")} mr-3 flex-shrink-0 mt-0.5`} /> <LockClosedIcon className={`h-6 w-6 ${getThemeClasses("text-accent")} mr-3 flex-shrink-0 mt-0.5`} aria-hidden="true" />
<div className="flex-1"> <div className="flex-1">
<h3 className={`text-lg font-semibold mb-2 ${getThemeClasses("text-primary")}`}> <h3 className={`text-lg font-semibold mb-2 ${getThemeClasses("text-primary")}`}>
Privacy & Security Notice Privacy & Security Notice
@ -268,57 +308,56 @@ const Help = () => {
<p className={`text-sm ${getThemeClasses("text-secondary")} mb-3`}> <p className={`text-sm ${getThemeClasses("text-secondary")} mb-3`}>
MapleFile uses end-to-end encryption to protect your data. This means: MapleFile uses end-to-end encryption to protect your data. This means:
</p> </p>
<ul className={`space-y-2 text-sm ${getThemeClasses("text-secondary")}`}> <ul className={`space-y-2 text-sm ${getThemeClasses("text-secondary")}`} role="list">
<li className="flex items-start"> <li className="flex items-start">
<span className={`mr-2 ${getThemeClasses("text-accent")}`}></span> <span className={`mr-2 ${getThemeClasses("text-accent")}`} aria-hidden="true"></span>
<span>Your files are encrypted before leaving your device</span> <span>Your files are encrypted before leaving your device</span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className={`mr-2 ${getThemeClasses("text-accent")}`}></span> <span className={`mr-2 ${getThemeClasses("text-accent")}`} aria-hidden="true"></span>
<span>MapleFile cannot access, read, or decrypt your files</span> <span>MapleFile cannot access, read, or decrypt your files</span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className={`mr-2 ${getThemeClasses("text-accent")}`}></span> <span className={`mr-2 ${getThemeClasses("text-accent")}`} aria-hidden="true"></span>
<span>If you forget your password, your data cannot be recovered</span> <span>If you forget your password, your data cannot be recovered</span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className={`mr-2 ${getThemeClasses("text-accent")}`}></span> <span className={`mr-2 ${getThemeClasses("text-accent")}`} aria-hidden="true"></span>
<span>Always keep your recovery keys safe and secure</span> <span>Always keep your recovery keys safe and secure</span>
</li> </li>
</ul> </ul>
</div> </div>
</div> </div>
</div> </div>
</Card>
</div>
{/* Support Section */} {/* Support Section */}
<div className="mt-8"> <div className={`mt-8 p-6 rounded-lg border ${getThemeClasses("border-secondary")}`}>
<Card>
<div className="p-6">
<h3 className={`text-lg font-semibold mb-3 ${getThemeClasses("text-primary")}`}> <h3 className={`text-lg font-semibold mb-3 ${getThemeClasses("text-primary")}`}>
Need More Help? Need More Help?
</h3> </h3>
<p className={`text-sm ${getThemeClasses("text-secondary")} mb-4`}> <p className={`text-sm ${getThemeClasses("text-secondary")} mb-4`}>
If you have questions not covered in this help documentation: If you have questions not covered in this help documentation:
</p> </p>
<ul className={`space-y-2 text-sm ${getThemeClasses("text-secondary")}`}> <ul className={`space-y-2 text-sm ${getThemeClasses("text-secondary")}`} role="list">
<li className="flex items-start"> <li className="flex items-start">
<span className={`mr-2 ${getThemeClasses("text-accent")}`}></span> <span className={`mr-2 ${getThemeClasses("text-accent")}`} aria-hidden="true"></span>
<span>Check the README.md file in the MapleFile repository</span> <span>Check the README.md file in the MapleFile repository</span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className={`mr-2 ${getThemeClasses("text-accent")}`}></span> <span className={`mr-2 ${getThemeClasses("text-accent")}`} aria-hidden="true"></span>
<span>Open an issue on Codeberg for technical support</span> <span>Open an issue on Codeberg for technical support</span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className={`mr-2 ${getThemeClasses("text-accent")}`}></span> <span className={`mr-2 ${getThemeClasses("text-accent")}`} aria-hidden="true"></span>
<span>Review the application's documentation and user guide</span> <span>Review the application's documentation and user guide</span>
</li> </li>
</ul> </ul>
</div> </div>
</Card> </div>
</div> </Card>
{/* GDPR Footer */}
<GDPRFooter className="mt-8" />
</div> </div>
</Layout> </Layout>
); );

View file

@ -1,23 +1,33 @@
// File: src/pages/User/Me/BlockedUsers.jsx // File: src/pages/User/Me/BlockedUsers.jsx
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback, useRef } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { useAuth } from "../../../services/Services"; import { useAuth } from "../../../services/Services";
import withPasswordProtection from "../../../hocs/withPasswordProtection"; import withPasswordProtection from "../../../hocs/withPasswordProtection";
import Navigation from "../../../components/Navigation"; import Layout from "../../../components/Layout/Layout";
import BlockedEmailManager from "../../../services/Manager/BlockedEmailManager"; import BlockedEmailManager from "../../../services/Manager/BlockedEmailManager";
import {
Button,
Input,
Alert,
Card,
Breadcrumb,
Spinner,
useUIXTheme,
} from "../../../components/UIX";
import { import {
NoSymbolIcon, NoSymbolIcon,
PlusIcon, PlusIcon,
TrashIcon, TrashIcon,
ArrowLeftIcon, ArrowLeftIcon,
ExclamationTriangleIcon,
CheckCircleIcon,
EnvelopeIcon, EnvelopeIcon,
Cog6ToothIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
const BlockedUsers = () => { const BlockedUsers = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { authManager } = useAuth(); const { authManager } = useAuth();
const { getThemeClasses } = useUIXTheme();
const isMountedRef = useRef(true);
// Page state // Page state
const [blockedEmails, setBlockedEmails] = useState([]); const [blockedEmails, setBlockedEmails] = useState([]);
@ -36,6 +46,14 @@ const BlockedUsers = () => {
// Manager instance // Manager instance
const [blockedEmailManager, setBlockedEmailManager] = useState(null); const [blockedEmailManager, setBlockedEmailManager] = useState(null);
// Cleanup on unmount
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
// Initialize manager // Initialize manager
useEffect(() => { useEffect(() => {
if (authManager) { if (authManager) {
@ -53,15 +71,25 @@ const BlockedUsers = () => {
setError(""); setError("");
try { try {
console.log("[BlockedUsers] Loading blocked emails..."); if (import.meta.env.DEV) {
console.log("[BlockedUsers] Loading blocked emails...");
}
const emails = await blockedEmailManager.getBlockedEmails(true); const emails = await blockedEmailManager.getBlockedEmails(true);
if (!isMountedRef.current) return;
setBlockedEmails(emails); setBlockedEmails(emails);
console.log("[BlockedUsers] Loaded", emails.length, "blocked emails"); if (import.meta.env.DEV) {
console.log("[BlockedUsers] Loaded", emails.length, "blocked emails");
}
} catch (err) { } catch (err) {
console.error("[BlockedUsers] Failed to load blocked emails:", err); if (!isMountedRef.current) return;
if (import.meta.env.DEV) {
console.error("[BlockedUsers] Failed to load blocked emails:", err);
}
setError(err.message || "Failed to load blocked users."); setError(err.message || "Failed to load blocked users.");
} finally { } finally {
setIsLoading(false); if (isMountedRef.current) {
setIsLoading(false);
}
} }
}, [blockedEmailManager]); }, [blockedEmailManager]);
@ -72,244 +100,251 @@ const BlockedUsers = () => {
}, [blockedEmailManager, authManager, loadBlockedEmails]); }, [blockedEmailManager, authManager, loadBlockedEmails]);
// Handle add email // Handle add email
const handleAddEmail = async (e) => { const handleAddEmail = useCallback(async (e) => {
e.preventDefault(); e.preventDefault();
if (!newEmail.trim()) return; if (!newEmail.trim() || !isMountedRef.current) return;
setAddLoading(true); setAddLoading(true);
setAddError(""); setAddError("");
setSuccess(""); setSuccess("");
try { try {
console.log("[BlockedUsers] Adding email to blocked list:", newEmail); if (import.meta.env.DEV) {
console.log("[BlockedUsers] Adding email to blocked list:", newEmail);
}
await blockedEmailManager.addBlockedEmail(newEmail.trim()); await blockedEmailManager.addBlockedEmail(newEmail.trim());
if (!isMountedRef.current) return;
setSuccess(`${newEmail} has been blocked successfully.`); setSuccess(`${newEmail} has been blocked successfully.`);
setNewEmail(""); setNewEmail("");
await loadBlockedEmails(); await loadBlockedEmails();
} catch (err) { } catch (err) {
console.error("[BlockedUsers] Failed to add blocked email:", err); if (!isMountedRef.current) return;
if (import.meta.env.DEV) {
console.error("[BlockedUsers] Failed to add blocked email:", err);
}
setAddError(err.message || "Failed to block email."); setAddError(err.message || "Failed to block email.");
} finally { } finally {
setAddLoading(false); if (isMountedRef.current) {
setAddLoading(false);
}
} }
}; }, [newEmail, blockedEmailManager, loadBlockedEmails]);
// Handle remove email // Handle remove email
const handleRemoveEmail = async (email) => { const handleRemoveEmail = useCallback(async (email) => {
if (!isMountedRef.current) return;
setDeleteLoading(email); setDeleteLoading(email);
setError(""); setError("");
setSuccess(""); setSuccess("");
try { try {
console.log("[BlockedUsers] Removing email from blocked list:", email); if (import.meta.env.DEV) {
console.log("[BlockedUsers] Removing email from blocked list:", email);
}
await blockedEmailManager.removeBlockedEmail(email); await blockedEmailManager.removeBlockedEmail(email);
if (!isMountedRef.current) return;
setSuccess(`${email} has been unblocked.`); setSuccess(`${email} has been unblocked.`);
await loadBlockedEmails(); await loadBlockedEmails();
} catch (err) { } catch (err) {
console.error("[BlockedUsers] Failed to remove blocked email:", err); if (!isMountedRef.current) return;
if (import.meta.env.DEV) {
console.error("[BlockedUsers] Failed to remove blocked email:", err);
}
setError(err.message || "Failed to unblock email."); setError(err.message || "Failed to unblock email.");
} finally { } finally {
setDeleteLoading(""); if (isMountedRef.current) {
setDeleteLoading("");
}
} }
}; }, [blockedEmailManager, loadBlockedEmails]);
// Styles // Breadcrumb items
const input_style = const breadcrumbItems = [
"w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 transition-colors"; { label: "Settings", to: "/me", icon: Cog6ToothIcon },
const btn_primary = { label: "Blocked Users", isActive: true, icon: NoSymbolIcon },
"flex items-center justify-center px-4 py-2.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"; ];
const btn_secondary =
"flex items-center justify-center px-4 py-2.5 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors font-medium";
const btn_danger =
"flex items-center justify-center px-3 py-2 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 transition-colors font-medium disabled:opacity-50";
return ( return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100"> <Layout>
<Navigation /> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Breadcrumb Navigation */}
<Breadcrumb items={breadcrumbItems} />
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> {/* Main Card */}
{/* Back button */} <Card>
<button {/* Header */}
onClick={() => navigate("/me")} <div className={`p-6 border-b ${getThemeClasses("border-secondary")}`}>
className={`${btn_secondary} mb-6`} <div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
> <div className="flex-1 min-w-0">
<ArrowLeftIcon className="h-4 w-4 mr-2" /> <div className="flex items-start">
Back to Profile <div className="p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 bg-red-500">
</button> <NoSymbolIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
</div>
{/* Header */} <div>
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-6 mb-6"> <h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
<div className="flex items-center"> Blocked Users
<div className="flex items-center justify-center h-12 w-12 bg-red-100 rounded-xl mr-4"> </h1>
<NoSymbolIcon className="h-6 w-6 text-red-600" /> <p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
</div> Manage users who cannot share folders with you
<div> </p>
<h1 className="text-2xl font-bold text-gray-900">Blocked Users</h1>
<p className="text-gray-600">
Manage users who cannot share folders with you
</p>
</div>
</div>
</div>
{/* Success message */}
{success && (
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg flex items-center">
<CheckCircleIcon className="h-5 w-5 text-green-600 mr-3" />
<p className="text-green-700">{success}</p>
</div>
)}
{/* Error message */}
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-center">
<ExclamationTriangleIcon className="h-5 w-5 text-red-600 mr-3" />
<p className="text-red-700">{error}</p>
</div>
)}
{/* Add Email Form */}
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Block a User
</h2>
<p className="text-sm text-gray-600 mb-4">
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} className="flex gap-3">
<div className="flex-1">
<input
type="email"
value={newEmail}
onChange={(e) => {
setNewEmail(e.target.value);
if (addError) setAddError("");
}}
placeholder="Enter email address to block"
className={input_style}
disabled={addLoading}
required
/>
{addError && (
<p className="mt-2 text-sm text-red-600">{addError}</p>
)}
</div>
<button
type="submit"
className={btn_primary}
disabled={addLoading || !newEmail.trim()}
>
{addLoading ? (
"Adding..."
) : (
<>
<PlusIcon className="h-4 w-4 mr-2" />
Block
</>
)}
</button>
</form>
</div>
{/* Blocked Emails List */}
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Blocked Users ({blockedEmails.length})
</h2>
{isLoading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 mx-auto mb-4"></div>
<p className="text-gray-500">Loading blocked users...</p>
</div>
) : blockedEmails.length === 0 ? (
<div className="text-center py-8">
<NoSymbolIcon className="h-12 w-12 text-gray-300 mx-auto mb-4" />
<p className="text-gray-500">No blocked users</p>
<p className="text-sm text-gray-400 mt-1">
Users you block won't be able to share folders with you.
</p>
</div>
) : (
<div className="space-y-3">
{blockedEmails.map((blocked) => (
<div
key={blocked.blocked_email}
className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border border-gray-200"
>
<div className="flex items-center">
<EnvelopeIcon className="h-5 w-5 text-gray-400 mr-3" />
<div>
<p className="font-medium text-gray-900">
{blocked.blocked_email}
</p>
<p className="text-xs text-gray-500">
Blocked on{" "}
{new Date(blocked.created_at).toLocaleDateString()}
</p>
</div>
</div> </div>
<button
onClick={() => handleRemoveEmail(blocked.blocked_email)}
className={btn_danger}
disabled={deleteLoading === blocked.blocked_email}
>
{deleteLoading === blocked.blocked_email ? (
"Removing..."
) : (
<>
<TrashIcon className="h-4 w-4 mr-1" />
Unblock
</>
)}
</button>
</div> </div>
))} </div>
<div className="flex-shrink-0">
<Button
onClick={() => navigate("/me")}
variant="secondary"
size="sm"
>
<span className="inline-flex items-center gap-2">
<ArrowLeftIcon className="h-4 w-4" />
<span>Back to Settings</span>
</span>
</Button>
</div>
</div> </div>
)} </div>
</div>
{/* Info section */} {/* Messages */}
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg"> <div className="px-6 pt-6">
<h3 className="text-sm font-medium text-blue-900 mb-2"> {success && (
How blocking works <Alert type="success" className="mb-4" onClose={() => setSuccess("")}>
</h3> {success}
<ul className="text-sm text-blue-700 space-y-1"> </Alert>
<li> )}
- Blocked users cannot share folders or files with you {error && (
</li> <Alert type="error" className="mb-4" onClose={() => setError("")}>
<li> {error}
- You can still share folders with blocked users </Alert>
</li> )}
<li> </div>
- Blocking is private - users are not notified when blocked
</li> {/* Content */}
<li> <div className="px-6 pb-6 pt-2">
- Existing shares are not affected when you block someone {/* Add Email Form */}
</li> <Card className={`border ${getThemeClasses("border-muted")} p-6 mb-6`}>
</ul> <h2 className={`text-lg font-semibold ${getThemeClasses("text-primary")} mb-4`}>
</div> Block a User
</h2>
<p className={`text-sm ${getThemeClasses("text-secondary")} mb-4`}>
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} className="flex gap-3">
<div className="flex-1">
<Input
type="email"
name="block_email"
value={newEmail}
onChange={(value) => {
setNewEmail(value);
if (addError) setAddError("");
}}
placeholder="Enter email address to block"
disabled={addLoading}
required
error={addError}
icon={EnvelopeIcon}
/>
</div>
<Button
type="submit"
variant="primary"
disabled={addLoading || !newEmail.trim()}
loading={addLoading}
>
{!addLoading && (
<span className="inline-flex items-center gap-2">
<PlusIcon className="h-4 w-4" />
<span>Block</span>
</span>
)}
{addLoading && "Adding..."}
</Button>
</form>
</Card>
{/* Blocked Emails List */}
<Card className={`border ${getThemeClasses("border-muted")} p-6`}>
<h2 className={`text-lg font-semibold ${getThemeClasses("text-primary")} mb-4`}>
Blocked Users ({blockedEmails.length})
</h2>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="text-center">
<Spinner size="lg" className="mx-auto mb-4" />
<p className={getThemeClasses("text-secondary")}>Loading blocked users...</p>
</div>
</div>
) : blockedEmails.length === 0 ? (
<div className="text-center py-8">
<NoSymbolIcon className={`h-12 w-12 ${getThemeClasses("text-secondary")} mx-auto mb-4`} />
<p className={getThemeClasses("text-secondary")}>No blocked users</p>
<p className={`text-sm ${getThemeClasses("text-secondary")} mt-1`}>
Users you block won't be able to share folders with you.
</p>
</div>
) : (
<div className="space-y-3">
{blockedEmails.map((blocked) => (
<div
key={blocked.blocked_email}
className={`flex items-center justify-between p-4 ${getThemeClasses("bg-muted")} rounded-lg border ${getThemeClasses("border-muted")}`}
>
<div className="flex items-center">
<EnvelopeIcon className={`h-5 w-5 ${getThemeClasses("text-secondary")} mr-3`} />
<div>
<p className={`font-medium ${getThemeClasses("text-primary")}`}>
{blocked.blocked_email}
</p>
<p className={`text-xs ${getThemeClasses("text-secondary")}`}>
Blocked on{" "}
{new Date(blocked.created_at).toLocaleDateString()}
</p>
</div>
</div>
<Button
onClick={() => handleRemoveEmail(blocked.blocked_email)}
variant="danger"
size="sm"
disabled={deleteLoading === blocked.blocked_email}
loading={deleteLoading === blocked.blocked_email}
>
{deleteLoading !== blocked.blocked_email && (
<span className="inline-flex items-center gap-1">
<TrashIcon className="h-4 w-4" />
<span>Unblock</span>
</span>
)}
{deleteLoading === blocked.blocked_email && "Removing..."}
</Button>
</div>
))}
</div>
)}
</Card>
{/* Info section */}
<Alert type="info" className="mt-6">
<div>
<h3 className={`text-sm font-medium ${getThemeClasses("text-primary")} mb-2`}>
How blocking works
</h3>
<ul className={`text-sm ${getThemeClasses("text-secondary")} space-y-1`}>
<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>
</Alert>
</div>
</Card>
</div> </div>
</Layout>
{/* CSS Animations */}
<style>{`
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fade-in-up 0.5s ease-out both;
}
`}</style>
</div>
); );
}; };

View file

@ -1,9 +1,18 @@
// File: src/pages/User/Me/DeleteAccount.jsx // File: src/pages/User/Me/DeleteAccount.jsx
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useRef, useCallback } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { useAuth } from "../../../services/Services"; import { useAuth } from "../../../services/Services";
import withPasswordProtection from "../../../hocs/withPasswordProtection"; import withPasswordProtection from "../../../hocs/withPasswordProtection";
import Navigation from "../../../components/Navigation"; import Layout from "../../../components/Layout/Layout";
import {
Button,
Input,
Alert,
Card,
Breadcrumb,
Spinner,
useUIXTheme,
} from "../../../components/UIX";
import { import {
ExclamationTriangleIcon, ExclamationTriangleIcon,
ShieldExclamationIcon, ShieldExclamationIcon,
@ -13,11 +22,14 @@ import {
XCircleIcon, XCircleIcon,
InformationCircleIcon, InformationCircleIcon,
LockClosedIcon, LockClosedIcon,
Cog6ToothIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
const DeleteAccount = () => { const DeleteAccount = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { authManager, meManager } = useAuth(); const { authManager, meManager } = useAuth();
const { getThemeClasses } = useUIXTheme();
const isMountedRef = useRef(true);
// State management // State management
const [currentStep, setCurrentStep] = useState(1); const [currentStep, setCurrentStep] = useState(1);
@ -32,14 +44,26 @@ const DeleteAccount = () => {
gdprRights: false, gdprRights: false,
}); });
// Cleanup on unmount
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
// Load user info // Load user info
useEffect(() => { useEffect(() => {
const loadUserInfo = async () => { const loadUserInfo = async () => {
try { try {
const user = await meManager.getCurrentUser(); const user = await meManager.getCurrentUser();
if (!isMountedRef.current) return;
setUserEmail(user.email); setUserEmail(user.email);
} catch (err) { } catch (err) {
console.error("Failed to load user info:", err); if (!isMountedRef.current) return;
if (import.meta.env.DEV) {
console.error("Failed to load user info:", err);
}
setError("Failed to load your account information."); setError("Failed to load your account information.");
} }
}; };
@ -64,23 +88,25 @@ const DeleteAccount = () => {
}; };
// Handle back button // Handle back button
const handleBack = () => { const handleBack = useCallback(() => {
if (currentStep === 1) { if (currentStep === 1) {
navigate("/me"); navigate("/me");
} else { } else {
setCurrentStep(currentStep - 1); setCurrentStep(currentStep - 1);
setError(""); setError("");
} }
}; }, [currentStep, navigate]);
// Handle next step // Handle next step
const handleNext = () => { const handleNext = useCallback(() => {
setError(""); setError("");
setCurrentStep(currentStep + 1); setCurrentStep(currentStep + 1);
}; }, [currentStep]);
// Handle account deletion // Handle account deletion
const handleDeleteAccount = async () => { const handleDeleteAccount = useCallback(async () => {
if (!isMountedRef.current) return;
if (!password) { if (!password) {
setError("Please enter your password to confirm deletion."); setError("Please enter your password to confirm deletion.");
return; return;
@ -103,16 +129,23 @@ const DeleteAccount = () => {
// Call the delete API // Call the delete API
await meManager.deleteCurrentUser(password); await meManager.deleteCurrentUser(password);
if (!isMountedRef.current) return;
// Show success message and logout // Show success message and logout
setCurrentStep(4); // Success step setCurrentStep(4); // Success step
// Wait 3 seconds then logout and redirect // Wait 3 seconds then logout and redirect
setTimeout(() => { setTimeout(() => {
if (!isMountedRef.current) return;
authManager.logout(); authManager.logout();
navigate("/"); navigate("/");
}, 3000); }, 3000);
} catch (err) { } catch (err) {
console.error("Account deletion failed:", err); if (!isMountedRef.current) return;
if (import.meta.env.DEV) {
console.error("Account deletion failed:", err);
}
// Handle specific error cases // Handle specific error cases
if (err.response?.status === 401) { if (err.response?.status === 401) {
@ -125,118 +158,116 @@ const DeleteAccount = () => {
setError("Failed to delete account. Please try again or contact support."); setError("Failed to delete account. Please try again or contact support.");
} }
} finally { } finally {
setLoading(false); if (isMountedRef.current) {
setLoading(false);
}
} }
}; }, [password, allAcknowledged, confirmationMatches, meManager, authManager, navigate]);
// Render Step 1: Warning and Information // Render Step 1: Warning and Information
const renderStep1 = () => ( const renderStep1 = () => (
<div className="space-y-6"> <div className="space-y-6">
{/* Warning Banner */} {/* Warning Banner */}
<div className="bg-red-50 border-l-4 border-red-500 p-4 rounded"> <Alert type="error">
<div className="flex items-start"> <div className="flex items-start">
<ExclamationTriangleIcon className="h-6 w-6 text-red-500 mr-3 flex-shrink-0 mt-0.5" /> <ExclamationTriangleIcon className={`h-6 w-6 ${getThemeClasses("text-error")} mr-3 flex-shrink-0 mt-0.5`} />
<div> <div>
<h3 className="text-lg font-semibold text-red-800 mb-2"> <h3 className={`text-lg font-semibold ${getThemeClasses("text-primary")} mb-2`}>
This action is permanent and cannot be undone This action is permanent and cannot be undone
</h3> </h3>
<p className="text-red-700"> <p className={getThemeClasses("text-secondary")}>
Once you delete your account, all your data will be permanently Once you delete your account, all your data will be permanently
removed from our servers. This includes all files, collections, removed from our servers. This includes all files, collections,
and personal information. and personal information.
</p> </p>
</div> </div>
</div> </div>
</div> </Alert>
{/* What will be deleted */} {/* What will be deleted */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6"> <Card className={`border ${getThemeClasses("border-muted")} p-6`}>
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center"> <h3 className={`text-lg font-semibold ${getThemeClasses("text-primary")} mb-4 flex items-center`}>
<TrashIcon className="h-5 w-5 mr-2 text-gray-500" /> <TrashIcon className={`h-5 w-5 mr-2 ${getThemeClasses("text-secondary")}`} />
What will be deleted What will be deleted
</h3> </h3>
<ul className="space-y-3"> <ul className="space-y-3">
<li className="flex items-start"> <li className="flex items-start">
<XCircleIcon className="h-5 w-5 text-red-500 mr-3 flex-shrink-0 mt-0.5" /> <XCircleIcon className={`h-5 w-5 ${getThemeClasses("text-error")} mr-3 flex-shrink-0 mt-0.5`} />
<div> <div>
<strong className="text-gray-900">All your files</strong> <strong className={getThemeClasses("text-primary")}>All your files</strong>
<p className="text-sm text-gray-600"> <p className={`text-sm ${getThemeClasses("text-secondary")}`}>
Every file you've uploaded will be permanently deleted from our Every file you've uploaded will be permanently deleted from our
servers servers
</p> </p>
</div> </div>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<XCircleIcon className="h-5 w-5 text-red-500 mr-3 flex-shrink-0 mt-0.5" /> <XCircleIcon className={`h-5 w-5 ${getThemeClasses("text-error")} mr-3 flex-shrink-0 mt-0.5`} />
<div> <div>
<strong className="text-gray-900">All your collections</strong> <strong className={getThemeClasses("text-primary")}>All your collections</strong>
<p className="text-sm text-gray-600"> <p className={`text-sm ${getThemeClasses("text-secondary")}`}>
All folders and collections you own will be permanently removed All folders and collections you own will be permanently removed
</p> </p>
</div> </div>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<XCircleIcon className="h-5 w-5 text-red-500 mr-3 flex-shrink-0 mt-0.5" /> <XCircleIcon className={`h-5 w-5 ${getThemeClasses("text-error")} mr-3 flex-shrink-0 mt-0.5`} />
<div> <div>
<strong className="text-gray-900">Personal information</strong> <strong className={getThemeClasses("text-primary")}>Personal information</strong>
<p className="text-sm text-gray-600"> <p className={`text-sm ${getThemeClasses("text-secondary")}`}>
Your profile, email, and all associated data will be deleted Your profile, email, and all associated data will be deleted
</p> </p>
</div> </div>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<XCircleIcon className="h-5 w-5 text-red-500 mr-3 flex-shrink-0 mt-0.5" /> <XCircleIcon className={`h-5 w-5 ${getThemeClasses("text-error")} mr-3 flex-shrink-0 mt-0.5`} />
<div> <div>
<strong className="text-gray-900">Shared access</strong> <strong className={getThemeClasses("text-primary")}>Shared access</strong>
<p className="text-sm text-gray-600"> <p className={`text-sm ${getThemeClasses("text-secondary")}`}>
You'll be removed from any collections shared with you You'll be removed from any collections shared with you
</p> </p>
</div> </div>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<XCircleIcon className="h-5 w-5 text-red-500 mr-3 flex-shrink-0 mt-0.5" /> <XCircleIcon className={`h-5 w-5 ${getThemeClasses("text-error")} mr-3 flex-shrink-0 mt-0.5`} />
<div> <div>
<strong className="text-gray-900">Storage usage history</strong> <strong className={getThemeClasses("text-primary")}>Storage usage history</strong>
<p className="text-sm text-gray-600"> <p className={`text-sm ${getThemeClasses("text-secondary")}`}>
All your storage metrics and usage history will be deleted All your storage metrics and usage history will be deleted
</p> </p>
</div> </div>
</li> </li>
</ul> </ul>
</div> </Card>
{/* GDPR Information */} {/* GDPR Information */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4"> <Alert type="info">
<div className="flex items-start"> <div className="flex items-start">
<InformationCircleIcon className="h-5 w-5 text-blue-500 mr-3 flex-shrink-0 mt-0.5" /> <InformationCircleIcon className={`h-5 w-5 ${getThemeClasses("text-info")} mr-3 flex-shrink-0 mt-0.5`} />
<div> <div>
<h4 className="text-sm font-semibold text-blue-900 mb-1"> <h4 className={`text-sm font-semibold ${getThemeClasses("text-primary")} mb-1`}>
Your Right to Erasure (GDPR Article 17) Your Right to Erasure (GDPR Article 17)
</h4> </h4>
<p className="text-sm text-blue-800"> <p className={`text-sm ${getThemeClasses("text-secondary")}`}>
This deletion process complies with GDPR regulations. All your This deletion process complies with GDPR regulations. All your
personal data will be permanently erased from our systems within personal data will be permanently erased from our systems within
moments of confirmation. This action cannot be reversed. moments of confirmation. This action cannot be reversed.
</p> </p>
</div> </div>
</div> </div>
</div> </Alert>
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex justify-between pt-4"> <div className="flex justify-between pt-4">
<button <Button onClick={handleBack} variant="secondary">
onClick={handleBack} <span className="inline-flex items-center gap-2">
className="px-6 py-2 text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors flex items-center" <ArrowLeftIcon className="h-4 w-4" />
> <span>Cancel</span>
<ArrowLeftIcon className="h-4 w-4 mr-2" /> </span>
Cancel </Button>
</button> <Button onClick={handleNext} variant="danger">
<button
onClick={handleNext}
className="px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Continue to Confirmation Continue to Confirmation
</button> </Button>
</div> </div>
</div> </div>
); );
@ -244,36 +275,36 @@ const DeleteAccount = () => {
// Render Step 2: Acknowledgments // Render Step 2: Acknowledgments
const renderStep2 = () => ( const renderStep2 = () => (
<div className="space-y-6"> <div className="space-y-6">
<div className="bg-yellow-50 border-l-4 border-yellow-500 p-4 rounded"> <Alert type="warning">
<div className="flex items-start"> <div className="flex items-start">
<ShieldExclamationIcon className="h-6 w-6 text-yellow-600 mr-3 flex-shrink-0 mt-0.5" /> <ShieldExclamationIcon className={`h-6 w-6 ${getThemeClasses("text-warning")} mr-3 flex-shrink-0 mt-0.5`} />
<div> <div>
<h3 className="text-lg font-semibold text-yellow-800 mb-2"> <h3 className={`text-lg font-semibold ${getThemeClasses("text-primary")} mb-2`}>
Please confirm you understand Please confirm you understand
</h3> </h3>
<p className="text-yellow-700"> <p className={getThemeClasses("text-secondary")}>
Before proceeding, you must acknowledge the following statements Before proceeding, you must acknowledge the following statements
about your account deletion. about your account deletion.
</p> </p>
</div> </div>
</div> </div>
</div> </Alert>
{/* Acknowledgment Checkboxes */} {/* Acknowledgment Checkboxes */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 space-y-4"> <Card className={`border ${getThemeClasses("border-muted")} p-6 space-y-4`}>
<div className="flex items-start"> <div className="flex items-start">
<input <input
type="checkbox" type="checkbox"
id="ack-permanent" id="ack-permanent"
checked={acknowledgments.permanentDeletion} checked={acknowledgments.permanentDeletion}
onChange={() => handleAcknowledgmentChange("permanentDeletion")} onChange={() => handleAcknowledgmentChange("permanentDeletion")}
className="mt-1 h-4 w-4 text-red-600 focus:ring-red-500 border-gray-300 rounded" className={`mt-1 h-4 w-4 ${getThemeClasses("text-accent")} focus:ring-2 ${getThemeClasses("border-muted")} rounded`}
/> />
<label <label
htmlFor="ack-permanent" htmlFor="ack-permanent"
className="ml-3 text-sm text-gray-700 cursor-pointer" className={`ml-3 text-sm ${getThemeClasses("text-secondary")} cursor-pointer`}
> >
<strong className="text-gray-900"> <strong className={getThemeClasses("text-primary")}>
I understand that this deletion is permanent and irreversible. I understand that this deletion is permanent and irreversible.
</strong>{" "} </strong>{" "}
Once deleted, my account and all associated data cannot be Once deleted, my account and all associated data cannot be
@ -287,13 +318,13 @@ const DeleteAccount = () => {
id="ack-data" id="ack-data"
checked={acknowledgments.dataLoss} checked={acknowledgments.dataLoss}
onChange={() => handleAcknowledgmentChange("dataLoss")} onChange={() => handleAcknowledgmentChange("dataLoss")}
className="mt-1 h-4 w-4 text-red-600 focus:ring-red-500 border-gray-300 rounded" className={`mt-1 h-4 w-4 ${getThemeClasses("text-accent")} focus:ring-2 ${getThemeClasses("border-muted")} rounded`}
/> />
<label <label
htmlFor="ack-data" htmlFor="ack-data"
className="ml-3 text-sm text-gray-700 cursor-pointer" className={`ml-3 text-sm ${getThemeClasses("text-secondary")} cursor-pointer`}
> >
<strong className="text-gray-900"> <strong className={getThemeClasses("text-primary")}>
I understand that all my files and collections will be permanently I understand that all my files and collections will be permanently
deleted. deleted.
</strong>{" "} </strong>{" "}
@ -307,49 +338,44 @@ const DeleteAccount = () => {
id="ack-gdpr" id="ack-gdpr"
checked={acknowledgments.gdprRights} checked={acknowledgments.gdprRights}
onChange={() => handleAcknowledgmentChange("gdprRights")} onChange={() => handleAcknowledgmentChange("gdprRights")}
className="mt-1 h-4 w-4 text-red-600 focus:ring-red-500 border-gray-300 rounded" className={`mt-1 h-4 w-4 ${getThemeClasses("text-accent")} focus:ring-2 ${getThemeClasses("border-muted")} rounded`}
/> />
<label <label
htmlFor="ack-gdpr" htmlFor="ack-gdpr"
className="ml-3 text-sm text-gray-700 cursor-pointer" className={`ml-3 text-sm ${getThemeClasses("text-secondary")} cursor-pointer`}
> >
<strong className="text-gray-900"> <strong className={getThemeClasses("text-primary")}>
I am exercising my right to erasure under GDPR Article 17. I am exercising my right to erasure under GDPR Article 17.
</strong>{" "} </strong>{" "}
I understand that this will result in the immediate and complete I understand that this will result in the immediate and complete
deletion of all my personal data from MapleFile servers. deletion of all my personal data from MapleFile servers.
</label> </label>
</div> </div>
</div> </Card>
{/* Current Account */} {/* Current Account */}
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4"> <div className={`${getThemeClasses("bg-muted")} border ${getThemeClasses("border-muted")} rounded-lg p-4`}>
<p className="text-sm text-gray-600"> <p className={`text-sm ${getThemeClasses("text-secondary")}`}>
Account to be deleted:{" "} Account to be deleted:{" "}
<strong className="text-gray-900">{userEmail}</strong> <strong className={getThemeClasses("text-primary")}>{userEmail}</strong>
</p> </p>
</div> </div>
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex justify-between pt-4"> <div className="flex justify-between pt-4">
<button <Button onClick={handleBack} variant="secondary">
onClick={handleBack} <span className="inline-flex items-center gap-2">
className="px-6 py-2 text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors flex items-center" <ArrowLeftIcon className="h-4 w-4" />
> <span>Back</span>
<ArrowLeftIcon className="h-4 w-4 mr-2" /> </span>
Back </Button>
</button> <Button
<button
onClick={handleNext} onClick={handleNext}
disabled={!allAcknowledged} disabled={!allAcknowledged}
className={`px-6 py-2 rounded-lg transition-colors ${ variant="danger"
allAcknowledged
? "bg-red-600 text-white hover:bg-red-700"
: "bg-gray-300 text-gray-500 cursor-not-allowed"
}`}
> >
Continue to Final Step Continue to Final Step
</button> </Button>
</div> </div>
</div> </div>
); );
@ -357,135 +383,89 @@ const DeleteAccount = () => {
// Render Step 3: Final Confirmation // Render Step 3: Final Confirmation
const renderStep3 = () => ( const renderStep3 = () => (
<div className="space-y-6"> <div className="space-y-6">
<div className="bg-red-100 border-2 border-red-500 p-4 rounded-lg"> <Alert type="error">
<div className="flex items-start"> <div className="flex items-start">
<ExclamationTriangleIcon className="h-8 w-8 text-red-600 mr-3 flex-shrink-0" /> <ExclamationTriangleIcon className={`h-8 w-8 ${getThemeClasses("text-error")} mr-3 flex-shrink-0`} />
<div> <div>
<h3 className="text-xl font-bold text-red-900 mb-2"> <h3 className={`text-xl font-bold ${getThemeClasses("text-primary")} mb-2`}>
Final Confirmation Required Final Confirmation Required
</h3> </h3>
<p className="text-red-800"> <p className={getThemeClasses("text-secondary")}>
This is your last chance to cancel. After clicking "Delete My This is your last chance to cancel. After clicking "Delete My
Account", your data will be permanently erased. Account", your data will be permanently erased.
</p> </p>
</div> </div>
</div> </div>
</div> </Alert>
{/* Password Confirmation */} {/* Password Confirmation */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 space-y-4"> <Card className={`border ${getThemeClasses("border-muted")} p-6 space-y-4`}>
<div> <Input
<label label="Enter your password to confirm"
htmlFor="password" type="password"
className="block text-sm font-medium text-gray-700 mb-2 flex items-center" name="password"
> value={password}
<LockClosedIcon className="h-4 w-4 mr-2" /> onChange={(value) => {
Enter your password to confirm setPassword(value);
</label> setError("");
<input }}
type="password" placeholder="Your account password"
id="password" disabled={loading}
value={password} icon={LockClosedIcon}
onChange={(e) => { />
setPassword(e.target.value);
setError("");
}}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500"
placeholder="Your account password"
disabled={loading}
/>
</div>
<div> <div>
<label <label
htmlFor="confirm-text" htmlFor="confirm-text"
className="block text-sm font-medium text-gray-700 mb-2" className={`block text-sm font-medium ${getThemeClasses("text-primary")} mb-2`}
> >
Type <strong>"DELETE MY ACCOUNT"</strong> to confirm (exactly as Type <strong>"DELETE MY ACCOUNT"</strong> to confirm (exactly as
shown) shown)
</label> </label>
<input <Input
type="text" type="text"
id="confirm-text" name="confirm-text"
value={confirmText} value={confirmText}
onChange={(e) => { onChange={(value) => {
setConfirmText(e.target.value); setConfirmText(value);
setError(""); setError("");
}} }}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500 font-mono"
placeholder="DELETE MY ACCOUNT" placeholder="DELETE MY ACCOUNT"
disabled={loading} disabled={loading}
error={confirmText && !confirmationMatches ? 'Text must match exactly: "DELETE MY ACCOUNT"' : ""}
/> />
{confirmText && !confirmationMatches && (
<p className="mt-1 text-sm text-red-600">
Text must match exactly: "DELETE MY ACCOUNT"
</p>
)}
</div> </div>
</div> </Card>
{/* Error Display */} {/* Error Display */}
{error && ( {error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4"> <Alert type="error" onClose={() => setError("")}>
<div className="flex items-start"> {error}
<XCircleIcon className="h-5 w-5 text-red-500 mr-3 flex-shrink-0 mt-0.5" /> </Alert>
<p className="text-sm text-red-800">{error}</p>
</div>
</div>
)} )}
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex justify-between pt-4"> <div className="flex justify-between pt-4">
<button <Button onClick={handleBack} variant="secondary" disabled={loading}>
onClick={handleBack} <span className="inline-flex items-center gap-2">
disabled={loading} <ArrowLeftIcon className="h-4 w-4" />
className="px-6 py-2 text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors flex items-center disabled:opacity-50 disabled:cursor-not-allowed" <span>Back</span>
> </span>
<ArrowLeftIcon className="h-4 w-4 mr-2" /> </Button>
Back <Button
</button>
<button
onClick={handleDeleteAccount} onClick={handleDeleteAccount}
disabled={ disabled={loading || !password || !confirmationMatches || !allAcknowledged}
loading || !password || !confirmationMatches || !allAcknowledged variant="danger"
} loading={loading}
className={`px-6 py-2 rounded-lg transition-colors flex items-center ${
loading || !password || !confirmationMatches || !allAcknowledged
? "bg-gray-300 text-gray-500 cursor-not-allowed"
: "bg-red-600 text-white hover:bg-red-700"
}`}
> >
{loading ? ( {!loading && (
<> <span className="inline-flex items-center gap-2">
<svg <TrashIcon className="h-4 w-4" />
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" <span>Delete My Account</span>
xmlns="http://www.w3.org/2000/svg" </span>
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Deleting Account...
</>
) : (
<>
<TrashIcon className="h-4 w-4 mr-2" />
Delete My Account
</>
)} )}
</button> {loading && "Deleting Account..."}
</Button>
</div> </div>
</div> </div>
); );
@ -493,33 +473,33 @@ const DeleteAccount = () => {
// Render Step 4: Success // Render Step 4: Success
const renderStep4 = () => ( const renderStep4 = () => (
<div className="space-y-6"> <div className="space-y-6">
<div className="bg-green-50 border-2 border-green-500 p-8 rounded-lg text-center"> <div className={`${getThemeClasses("bg-success-light")} border-2 ${getThemeClasses("border-success")} p-8 rounded-lg text-center`}>
<CheckCircleIcon className="h-16 w-16 text-green-600 mx-auto mb-4" /> <CheckCircleIcon className={`h-16 w-16 ${getThemeClasses("text-success")} mx-auto mb-4`} />
<h3 className="text-2xl font-bold text-green-900 mb-2"> <h3 className={`text-2xl font-bold ${getThemeClasses("text-primary")} mb-2`}>
Account Deleted Successfully Account Deleted Successfully
</h3> </h3>
<p className="text-green-800 mb-4"> <p className={`${getThemeClasses("text-secondary")} mb-4`}>
Your account and all associated data have been permanently deleted Your account and all associated data have been permanently deleted
from our servers. from our servers.
</p> </p>
<p className="text-sm text-green-700"> <p className={`text-sm ${getThemeClasses("text-secondary")}`}>
You will be logged out and redirected to the home page in a few You will be logged out and redirected to the home page in a few
seconds... seconds...
</p> </p>
</div> </div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4"> <Alert type="info">
<div className="flex items-start"> <div className="flex items-start">
<InformationCircleIcon className="h-5 w-5 text-blue-500 mr-3 flex-shrink-0 mt-0.5" /> <InformationCircleIcon className={`h-5 w-5 ${getThemeClasses("text-info")} mr-3 flex-shrink-0 mt-0.5`} />
<div className="text-sm text-blue-800"> <div>
<strong className="text-blue-900">Thank you for using MapleFile.</strong> <strong className={getThemeClasses("text-primary")}>Thank you for using MapleFile.</strong>
<p className="mt-1"> <p className={`mt-1 ${getThemeClasses("text-secondary")}`}>
If you have any feedback or concerns, please contact our support If you have any feedback or concerns, please contact our support
team. You're always welcome to create a new account in the future. team. You're always welcome to create a new account in the future.
</p> </p>
</div> </div>
</div> </div>
</div> </Alert>
</div> </div>
); );
@ -544,8 +524,8 @@ const DeleteAccount = () => {
<div <div
className={`flex items-center justify-center w-10 h-10 rounded-full border-2 transition-colors ${ className={`flex items-center justify-center w-10 h-10 rounded-full border-2 transition-colors ${
currentStep >= step.number currentStep >= step.number
? "border-red-600 bg-red-600 text-white" ? `${getThemeClasses("border-error")} ${getThemeClasses("bg-error")} text-white`
: "border-gray-300 bg-white text-gray-400" : `${getThemeClasses("border-muted")} ${getThemeClasses("bg-card")} ${getThemeClasses("text-secondary")}`
}`} }`}
> >
{currentStep > step.number ? ( {currentStep > step.number ? (
@ -558,8 +538,8 @@ const DeleteAccount = () => {
<span <span
className={`mt-2 text-xs whitespace-nowrap ${ className={`mt-2 text-xs whitespace-nowrap ${
currentStep >= step.number currentStep >= step.number
? "font-semibold text-red-600" ? `font-semibold ${getThemeClasses("text-error")}`
: "text-gray-600" : getThemeClasses("text-secondary")
}`} }`}
> >
{step.label} {step.label}
@ -570,7 +550,7 @@ const DeleteAccount = () => {
{index < steps.length - 1 && ( {index < steps.length - 1 && (
<div <div
className={`flex-1 h-1 mx-4 mb-6 transition-colors ${ className={`flex-1 h-1 mx-4 mb-6 transition-colors ${
currentStep > step.number ? "bg-red-600" : "bg-gray-300" currentStep > step.number ? getThemeClasses("bg-error") : getThemeClasses("bg-muted")
}`} }`}
/> />
)} )}
@ -581,40 +561,74 @@ const DeleteAccount = () => {
); );
}; };
// Breadcrumb items
const breadcrumbItems = [
{ label: "Settings", to: "/me", icon: Cog6ToothIcon },
{ label: "Delete Account", isActive: true },
];
return ( return (
<div className="min-h-screen bg-gray-50"> <Layout>
<Navigation /> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="max-w-4xl mx-auto px-4 py-8"> {/* Breadcrumb Navigation */}
{/* Header */} <Breadcrumb items={breadcrumbItems} />
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2 flex items-center">
<TrashIcon className="h-8 w-8 mr-3 text-red-600" />
Delete Account
</h1>
<p className="text-gray-600">
Permanently delete your MapleFile account and all associated data
</p>
</div>
{/* Progress Indicator */} {/* Main Card */}
{renderProgressIndicator()} <Card>
{/* Header */}
<div className={`p-6 border-b ${getThemeClasses("border-secondary")}`}>
<div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
<div className="flex-1 min-w-0">
<div className="flex items-start">
<div className={`p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 ${getThemeClasses("bg-error")}`}>
<TrashIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
</div>
<div>
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
Delete Account
</h1>
<p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
Permanently delete your MapleFile account and all associated data
</p>
</div>
</div>
</div>
<div className="flex-shrink-0">
<Button
onClick={() => navigate("/me")}
variant="secondary"
size="sm"
>
<span className="inline-flex items-center gap-2">
<ArrowLeftIcon className="h-4 w-4" />
<span>Back to Settings</span>
</span>
</Button>
</div>
</div>
</div>
{/* Main Content */} {/* Content */}
<div className="bg-white rounded-lg shadow-lg p-8"> <div className="p-6">
{currentStep === 1 && renderStep1()} {/* Progress Indicator */}
{currentStep === 2 && renderStep2()} {renderProgressIndicator()}
{currentStep === 3 && renderStep3()}
{currentStep === 4 && renderStep4()} {/* Step Content */}
</div> {currentStep === 1 && renderStep1()}
{currentStep === 2 && renderStep2()}
{currentStep === 3 && renderStep3()}
{currentStep === 4 && renderStep4()}
</div>
</Card>
{/* Footer Notice */} {/* Footer Notice */}
{currentStep !== 4 && ( {currentStep !== 4 && (
<div className="mt-6 text-center text-sm text-gray-500"> <div className={`mt-6 text-center text-sm ${getThemeClasses("text-secondary")}`}>
<p> <p>
Need help? Contact our support team at{" "} Need help? Contact our support team at{" "}
<a <a
href="mailto:support@maplefile.com" href="mailto:support@maplefile.com"
className="text-blue-600 hover:text-blue-700" className={getThemeClasses("link-primary")}
> >
support@maplefile.com support@maplefile.com
</a> </a>
@ -622,7 +636,7 @@ const DeleteAccount = () => {
</div> </div>
)} )}
</div> </div>
</div> </Layout>
); );
}; };

File diff suppressed because it is too large Load diff

View file

@ -21,6 +21,34 @@ const EMPTY_ENTITY_DATA = {};
const ExportData = () => { const ExportData = () => {
const { getThemeClasses } = useUIXTheme(); const { getThemeClasses } = useUIXTheme();
// Theme-aware section styles
const sectionStyles = useMemo(() => ({
success: {
container: getThemeClasses("export-section-success-bg"),
border: getThemeClasses("export-section-success-border"),
icon: getThemeClasses("export-section-success-icon"),
title: getThemeClasses("export-section-success-title"),
text: getThemeClasses("export-section-success-text"),
muted: getThemeClasses("export-section-success-muted"),
},
info: {
container: getThemeClasses("export-section-info-bg"),
border: getThemeClasses("export-section-info-border"),
icon: getThemeClasses("export-section-info-icon"),
title: getThemeClasses("export-section-info-title"),
text: getThemeClasses("export-section-info-text"),
},
warning: {
container: getThemeClasses("export-section-warning-bg"),
border: getThemeClasses("export-section-warning-border"),
icon: getThemeClasses("export-section-warning-icon"),
title: getThemeClasses("export-section-warning-title"),
text: getThemeClasses("export-section-warning-text"),
muted: getThemeClasses("export-section-warning-muted"),
code: getThemeClasses("export-section-warning-code"),
},
}), [getThemeClasses]);
// Breadcrumb configuration // Breadcrumb configuration
const breadcrumbItems = useMemo( const breadcrumbItems = useMemo(
() => [ () => [
@ -55,105 +83,116 @@ const ExportData = () => {
// Content sections // Content sections
const contentSections = useMemo(() => { const contentSections = useMemo(() => {
return ( return (
<div className="space-y-6"> <div className="space-y-6" role="main" aria-label="Export data options">
{/* Quick Export Option - GDPR Compliance */} {/* Quick Export Option - GDPR Compliance */}
<div className="bg-green-50 dark:bg-green-900/20 border-2 border-green-200 dark:border-green-800 rounded-xl p-6"> <section
aria-labelledby="quick-export-heading"
className={`${sectionStyles.success.container} border-2 ${sectionStyles.success.border} rounded-xl p-6`}
>
<div className="flex items-start"> <div className="flex items-start">
<ArrowDownTrayIcon className="h-6 w-6 text-green-600 dark:text-green-400 mr-3 flex-shrink-0 mt-0.5" /> <ArrowDownTrayIcon className={`h-6 w-6 ${sectionStyles.success.icon} mr-3 flex-shrink-0 mt-0.5`} aria-hidden="true" />
<div className="flex-1"> <div className="flex-1">
<h3 className="text-lg font-semibold text-green-900 dark:text-green-100 mb-2"> <h3 id="quick-export-heading" className={`text-lg font-semibold ${sectionStyles.success.title} mb-2`}>
Quick Export (Web Browser) Quick Export (Web Browser)
</h3> </h3>
<p className="text-green-800 dark:text-green-200 mb-3"> <p className={`${sectionStyles.success.text} mb-3`}>
In compliance with GDPR Article 20, you can export your data directly from your web browser. This option is suitable for accounts with moderate amounts of data. In compliance with GDPR Article 20, you can export your data directly from your web browser. This option is suitable for accounts with moderate amounts of data.
</p> </p>
<p className="text-green-800 dark:text-green-200 mb-4"> <p className={`${sectionStyles.success.text} mb-4`}>
<strong>What gets exported:</strong> Profile information, collection metadata, and file lists (metadata only - actual file downloads require desktop app due to E2EE). <strong>What gets exported:</strong> Profile information, collection metadata, and file lists (metadata only - actual file downloads require desktop app due to E2EE).
</p> </p>
<div className="flex space-x-3"> <div className="flex space-x-3">
<Button <Button
variant="primary" variant="primary"
className="flex items-center" icon={ArrowDownTrayIcon}
onClick={() => alert('This feature will be implemented by the backend team. It should generate a JSON export of user profile, collections, and file metadata.')} onClick={() => alert('This feature will be implemented by the backend team. It should generate a JSON export of user profile, collections, and file metadata.')}
> >
<ArrowDownTrayIcon className="h-5 w-5 mr-2" />
Export Metadata (JSON) Export Metadata (JSON)
</Button> </Button>
</div> </div>
<p className="text-xs text-green-700 dark:text-green-300 mt-3"> <p className={`text-xs ${sectionStyles.success.muted} mt-3`}>
<strong>Timeline:</strong> Your export will be available for download within 24 hours. Large exports may take up to 72 hours. You'll receive an email when ready. <strong>Timeline:</strong> Your export will be available for download within 24 hours. Large exports may take up to 72 hours. You'll receive an email when ready.
</p> </p>
</div> </div>
</div> </div>
</div> </section>
{/* Information Notice */} {/* Information Notice */}
<div className="bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-200 dark:border-blue-800 rounded-xl p-6"> <section
aria-labelledby="desktop-info-heading"
className={`${sectionStyles.info.container} border-2 ${sectionStyles.info.border} rounded-xl p-6`}
>
<div className="flex items-start"> <div className="flex items-start">
<InformationCircleIcon className="h-6 w-6 text-blue-600 dark:text-blue-400 mr-3 flex-shrink-0 mt-0.5" /> <InformationCircleIcon className={`h-6 w-6 ${sectionStyles.info.icon} mr-3 flex-shrink-0 mt-0.5`} aria-hidden="true" />
<div className="flex-1"> <div className="flex-1">
<h3 className="text-lg font-semibold text-blue-900 dark:text-blue-100 mb-2"> <h3 id="desktop-info-heading" className={`text-lg font-semibold ${sectionStyles.info.title} mb-2`}>
Desktop Application for Complete Export Desktop Application for Complete Export
</h3> </h3>
<p className="text-blue-800 dark:text-blue-200 mb-3"> <p className={`${sectionStyles.info.text} mb-3`}>
For exporting <strong>complete data including decrypted files</strong>, we recommend using the <strong>MapleFile Desktop Application</strong>. Due to end-to-end encryption, only the desktop app can decrypt and export your actual file contents. For exporting <strong>complete data including decrypted files</strong>, we recommend using the <strong>MapleFile Desktop Application</strong>. Due to end-to-end encryption, only the desktop app can decrypt and export your actual file contents.
</p> </p>
<p className="text-blue-800 dark:text-blue-200"> <p className={sectionStyles.info.text}>
The desktop application efficiently manages the decryption and download of your complete data archive with all files in their original format. The desktop application efficiently manages the decryption and download of your complete data archive with all files in their original format.
</p> </p>
</div> </div>
</div> </div>
</div> </section>
{/* E2EE Explanation */} {/* E2EE Explanation */}
<div className={`${getThemeClasses("bg-card")} rounded-xl shadow-lg border ${getThemeClasses("border")} p-6`}> <section
aria-labelledby="e2ee-heading"
className={`${getThemeClasses("bg-card")} rounded-xl shadow-lg border ${getThemeClasses("border-secondary")} p-6`}
>
<div className="flex items-start mb-4"> <div className="flex items-start mb-4">
<ShieldCheckIcon className="h-6 w-6 text-green-600 dark:text-green-400 mr-3 flex-shrink-0 mt-0.5" /> <ShieldCheckIcon className={`h-6 w-6 ${getThemeClasses("icon-success")} mr-3 flex-shrink-0 mt-0.5`} aria-hidden="true" />
<div> <div>
<h3 className={`text-lg font-semibold mb-2 ${getThemeClasses("text-primary")}`}> <h3 id="e2ee-heading" className={`text-lg font-semibold mb-2 ${getThemeClasses("text-primary")}`}>
Why End-to-End Encryption Matters Why End-to-End Encryption Matters
</h3> </h3>
<p className="text-gray-700 dark:text-gray-300 mb-3"> <p className={`${getThemeClasses("text-secondary")} mb-3`}>
Your files are encrypted on your device before they're uploaded to our servers. This means: Your files are encrypted on your device before they're uploaded to our servers. This means:
</p> </p>
<ul className="space-y-2 text-gray-700 dark:text-gray-300"> <ul className={`space-y-2 ${getThemeClasses("text-secondary")}`} role="list" aria-label="Encryption benefits">
<li className="flex items-start"> <li className="flex items-start">
<span className="text-green-600 dark:text-green-400 mr-2"></span> <span className={`${getThemeClasses("icon-success")} mr-2`} aria-hidden="true"></span>
<span>Only you can decrypt and read your files</span> <span>Only you can decrypt and read your files</span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-green-600 dark:text-green-400 mr-2"></span> <span className={`${getThemeClasses("icon-success")} mr-2`} aria-hidden="true"></span>
<span>MapleFile servers cannot access your file contents</span> <span>MapleFile servers cannot access your file contents</span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-green-600 dark:text-green-400 mr-2"></span> <span className={`${getThemeClasses("icon-success")} mr-2`} aria-hidden="true"></span>
<span>Your privacy is protected even if servers are compromised</span> <span>Your privacy is protected even if servers are compromised</span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-green-600 dark:text-green-400 mr-2"></span> <span className={`${getThemeClasses("icon-success")} mr-2`} aria-hidden="true"></span>
<span>Decryption requires your local encryption keys</span> <span>Decryption requires your local encryption keys</span>
</li> </li>
</ul> </ul>
</div> </div>
</div> </div>
</div> </section>
{/* Desktop Application Installation */} {/* Desktop Application Installation */}
<div className={`${getThemeClasses("bg-card")} rounded-xl shadow-lg border ${getThemeClasses("border")} p-6`}> <section
aria-labelledby="install-heading"
className={`${getThemeClasses("bg-card")} rounded-xl shadow-lg border ${getThemeClasses("border-secondary")} p-6`}
>
<div className="flex items-start mb-4"> <div className="flex items-start mb-4">
<ComputerDesktopIcon className={`h-6 w-6 mr-3 flex-shrink-0 mt-0.5 ${getThemeClasses("text-accent")}`} /> <ComputerDesktopIcon className={`h-6 w-6 mr-3 flex-shrink-0 mt-0.5 ${getThemeClasses("link-primary")}`} aria-hidden="true" />
<div className="flex-1"> <div className="flex-1">
<h3 className={`text-lg font-semibold mb-2 ${getThemeClasses("text-primary")}`}> <h3 id="install-heading" className={`text-lg font-semibold mb-2 ${getThemeClasses("text-primary")}`}>
Install MapleFile Desktop Application Install MapleFile Desktop Application
</h3> </h3>
<p className="text-gray-700 dark:text-gray-300 mb-4"> <p className={`${getThemeClasses("text-secondary")} mb-4`}>
The MapleFile Desktop Application is a native application that can export your data with full decryption support and an intuitive user interface. The MapleFile Desktop Application is a native application that can export your data with full decryption support and an intuitive user interface.
</p> </p>
{/* Repository Link */} {/* Repository Link */}
<div className={`${getThemeClasses("bg-muted")} border ${getThemeClasses("border")} rounded-lg p-4 mb-4`}> <div className={`${getThemeClasses("bg-muted")} border ${getThemeClasses("border-secondary")} rounded-lg p-4 mb-4`}>
<div className="flex items-start"> <div className="flex items-start">
<CodeBracketIcon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-3 mt-0.5" /> <CodeBracketIcon className={`h-5 w-5 ${getThemeClasses("text-muted")} mr-3 mt-0.5`} aria-hidden="true" />
<div className="flex-1"> <div className="flex-1">
<p className={`text-sm font-medium mb-2 ${getThemeClasses("text-primary")}`}> <p className={`text-sm font-medium mb-2 ${getThemeClasses("text-primary")}`}>
Source Code & Installation Instructions Source Code & Installation Instructions
@ -162,7 +201,7 @@ const ExportData = () => {
href="https://codeberg.org/mapleopentech/monorepo/src/branch/main/native/desktop/maplefile" href="https://codeberg.org/mapleopentech/monorepo/src/branch/main/native/desktop/maplefile"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={`${getThemeClasses("text-accent")} hover:underline text-sm font-mono break-all`} className={`${getThemeClasses("link-primary")} hover:underline text-sm font-mono break-all`}
> >
https://codeberg.org/mapleopentech/monorepo/src/branch/main/native/desktop/maplefile https://codeberg.org/mapleopentech/monorepo/src/branch/main/native/desktop/maplefile
</a> </a>
@ -173,145 +212,157 @@ const ExportData = () => {
{/* Installation Steps */} {/* Installation Steps */}
<div className="space-y-3"> <div className="space-y-3">
<h4 className={`font-medium ${getThemeClasses("text-primary")}`}>Installation Steps:</h4> <h4 className={`font-medium ${getThemeClasses("text-primary")}`}>Installation Steps:</h4>
<ol className="space-y-3 text-gray-700 dark:text-gray-300"> <ol className={`space-y-3 ${getThemeClasses("text-secondary")}`} role="list" aria-label="Installation steps">
<li className="flex items-start"> <li className="flex items-start">
<span className={`font-semibold mr-3 min-w-[24px] ${getThemeClasses("text-accent")}`}>1.</span> <span className={`font-semibold mr-3 min-w-[24px] ${getThemeClasses("link-primary")}`} aria-hidden="true">1.</span>
<span>Visit the repository link above</span> <span>Visit the repository link above</span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className={`font-semibold mr-3 min-w-[24px] ${getThemeClasses("text-accent")}`}>2.</span> <span className={`font-semibold mr-3 min-w-[24px] ${getThemeClasses("link-primary")}`} aria-hidden="true">2.</span>
<span>Follow the installation instructions in the README.md file</span> <span>Follow the installation instructions in the README.md file</span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className={`font-semibold mr-3 min-w-[24px] ${getThemeClasses("text-accent")}`}>3.</span> <span className={`font-semibold mr-3 min-w-[24px] ${getThemeClasses("link-primary")}`} aria-hidden="true">3.</span>
<span>Install the desktop application for your operating system (Windows, macOS, or Linux)</span> <span>Install the desktop application for your operating system (Windows, macOS, or Linux)</span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className={`font-semibold mr-3 min-w-[24px] ${getThemeClasses("text-accent")}`}>4.</span> <span className={`font-semibold mr-3 min-w-[24px] ${getThemeClasses("link-primary")}`} aria-hidden="true">4.</span>
<span>Log in to your MapleFile account using the desktop application</span> <span>Log in to your MapleFile account using the desktop application</span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className={`font-semibold mr-3 min-w-[24px] ${getThemeClasses("text-accent")}`}>5.</span> <span className={`font-semibold mr-3 min-w-[24px] ${getThemeClasses("link-primary")}`} aria-hidden="true">5.</span>
<span>Use the export feature to download and decrypt your data</span> <span>Use the export feature to download and decrypt your data</span>
</li> </li>
</ol> </ol>
</div> </div>
</div> </div>
</div> </div>
</div> </section>
{/* What Gets Exported */} {/* What Gets Exported */}
<div className={`${getThemeClasses("bg-card")} rounded-xl shadow-lg border ${getThemeClasses("border")} p-6`}> <section
aria-labelledby="exported-data-heading"
className={`${getThemeClasses("bg-card")} rounded-xl shadow-lg border ${getThemeClasses("border-secondary")} p-6`}
>
<div className="flex items-start"> <div className="flex items-start">
<DocumentTextIcon className="h-6 w-6 text-gray-600 dark:text-gray-400 mr-3 flex-shrink-0 mt-0.5" /> <DocumentTextIcon className={`h-6 w-6 ${getThemeClasses("text-muted")} mr-3 flex-shrink-0 mt-0.5`} aria-hidden="true" />
<div className="flex-1"> <div className="flex-1">
<h3 className={`text-lg font-semibold mb-3 ${getThemeClasses("text-primary")}`}> <h3 id="exported-data-heading" className={`text-lg font-semibold mb-3 ${getThemeClasses("text-primary")}`}>
What Data Will Be Exported What Data Will Be Exported
</h3> </h3>
<p className="text-gray-700 dark:text-gray-300 mb-3"> <p className={`${getThemeClasses("text-secondary")} mb-3`}>
The desktop application will export a complete archive of your MapleFile account, including: The desktop application will export a complete archive of your MapleFile account, including:
</p> </p>
<ul className="space-y-2 text-gray-700 dark:text-gray-300"> <ul className={`space-y-2 ${getThemeClasses("text-secondary")}`} role="list" aria-label="Exported data types">
<li className="flex items-start"> <li className="flex items-start">
<span className={`mr-2 ${getThemeClasses("text-accent")}`}></span> <span className={`mr-2 ${getThemeClasses("link-primary")}`} aria-hidden="true"></span>
<span><strong>Profile Information:</strong> Your account details, settings, and preferences</span> <span><strong>Profile Information:</strong> Your account details, settings, and preferences</span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className={`mr-2 ${getThemeClasses("text-accent")}`}></span> <span className={`mr-2 ${getThemeClasses("link-primary")}`} aria-hidden="true"></span>
<span><strong>Collections (Folders):</strong> All your collection metadata and structure</span> <span><strong>Collections (Folders):</strong> All your collection metadata and structure</span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className={`mr-2 ${getThemeClasses("text-accent")}`}></span> <span className={`mr-2 ${getThemeClasses("link-primary")}`} aria-hidden="true"></span>
<span><strong>Files:</strong> All uploaded files, fully decrypted and in their original format</span> <span><strong>Files:</strong> All uploaded files, fully decrypted and in their original format</span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className={`mr-2 ${getThemeClasses("text-accent")}`}></span> <span className={`mr-2 ${getThemeClasses("link-primary")}`} aria-hidden="true"></span>
<span><strong>File Metadata:</strong> Creation dates, modification dates, file sizes, and descriptions</span> <span><strong>File Metadata:</strong> Creation dates, modification dates, file sizes, and descriptions</span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className={`mr-2 ${getThemeClasses("text-accent")}`}></span> <span className={`mr-2 ${getThemeClasses("link-primary")}`} aria-hidden="true"></span>
<span><strong>Sharing Information:</strong> Details about collections shared with you or by you</span> <span><strong>Sharing Information:</strong> Details about collections shared with you or by you</span>
</li> </li>
</ul> </ul>
<p className="text-gray-600 dark:text-gray-400 text-sm mt-4"> <p className={`${getThemeClasses("text-muted")} text-sm mt-4`}>
The export will be provided as a ZIP archive with a structured folder hierarchy matching your collections. The export will be provided as a ZIP archive with a structured folder hierarchy matching your collections.
</p> </p>
</div> </div>
</div> </div>
</div> </section>
{/* GDPR Information */} {/* GDPR Information */}
<div className={`${getThemeClasses("bg-muted")} border ${getThemeClasses("border")} rounded-xl p-6`}> <section
<h3 className={`text-sm font-semibold mb-2 ${getThemeClasses("text-primary")}`}> aria-labelledby="gdpr-heading"
className={`${getThemeClasses("bg-muted")} border ${getThemeClasses("border-secondary")} rounded-xl p-6`}
>
<h3 id="gdpr-heading" className={`text-sm font-semibold mb-2 ${getThemeClasses("text-primary")}`}>
GDPR Article 20 - Right to Data Portability GDPR Article 20 - Right to Data Portability
</h3> </h3>
<p className="text-sm text-gray-700 dark:text-gray-300 mb-2"> <p className={`text-sm ${getThemeClasses("text-secondary")} mb-2`}>
You have the right to receive the personal data concerning you, which you have provided to us, in a structured, commonly used and machine-readable format. You also have the right to transmit those data to another controller. You have the right to receive the personal data concerning you, which you have provided to us, in a structured, commonly used and machine-readable format. You also have the right to transmit those data to another controller.
</p> </p>
<p className="text-sm text-gray-600 dark:text-gray-400"> <p className={`text-sm ${getThemeClasses("text-muted")}`}>
The MapleFile Desktop Application ensures compliance with this right by providing your complete data archive in standard formats (JSON metadata, original file formats). The MapleFile Desktop Application ensures compliance with this right by providing your complete data archive in standard formats (JSON metadata, original file formats).
</p> </p>
</div> </section>
{/* Security Notice */} {/* Security Notice */}
<div className="bg-yellow-50 dark:bg-yellow-900/20 border-2 border-yellow-200 dark:border-yellow-800 rounded-xl p-6"> <section
aria-labelledby="security-heading"
className={`${sectionStyles.warning.container} border-2 ${sectionStyles.warning.border} rounded-xl p-6`}
>
<div className="flex items-start"> <div className="flex items-start">
<ShieldCheckIcon className="h-6 w-6 text-yellow-600 dark:text-yellow-400 mr-3 flex-shrink-0 mt-0.5" /> <ShieldCheckIcon className={`h-6 w-6 ${sectionStyles.warning.icon} mr-3 flex-shrink-0 mt-0.5`} aria-hidden="true" />
<div className="flex-1"> <div className="flex-1">
<h3 className="text-lg font-semibold text-yellow-900 dark:text-yellow-100 mb-2"> <h3 id="security-heading" className={`text-lg font-semibold ${sectionStyles.warning.title} mb-2`}>
Security: Verify Your Download Security: Verify Your Download
</h3> </h3>
<p className="text-yellow-800 dark:text-yellow-200 mb-3"> <p className={`${sectionStyles.warning.text} mb-3`}>
For your security, always verify the authenticity of downloaded software: For your security, always verify the authenticity of downloaded software:
</p> </p>
<ul className="space-y-2 text-yellow-800 dark:text-yellow-200"> <ul className={`space-y-2 ${sectionStyles.warning.text}`} role="list" aria-label="Security verification steps">
<li className="flex items-start"> <li className="flex items-start">
<span className="text-yellow-600 dark:text-yellow-400 mr-2"></span> <span className={`${sectionStyles.warning.icon} mr-2`} aria-hidden="true"></span>
<span>Only download from the official Codeberg repository linked above</span> <span>Only download from the official Codeberg repository linked above</span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-yellow-600 dark:text-yellow-400 mr-2"></span> <span className={`${sectionStyles.warning.icon} mr-2`} aria-hidden="true"></span>
<span>Verify the repository URL matches: <code className="bg-yellow-100 dark:bg-yellow-900 px-1 rounded">codeberg.org/mapleopentech</code></span> <span>Verify the repository URL matches: <code className={`${sectionStyles.warning.code} px-1 rounded`}>codeberg.org/mapleopentech</code></span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-yellow-600 dark:text-yellow-400 mr-2"></span> <span className={`${sectionStyles.warning.icon} mr-2`} aria-hidden="true"></span>
<span>Check that your browser shows a secure HTTPS connection (padlock icon)</span> <span>Check that your browser shows a secure HTTPS connection (padlock icon)</span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-yellow-600 dark:text-yellow-400 mr-2"></span> <span className={`${sectionStyles.warning.icon} mr-2`} aria-hidden="true"></span>
<span>Review the release notes and commit history before installation</span> <span>Review the release notes and commit history before installation</span>
</li> </li>
</ul> </ul>
<p className="text-sm text-yellow-700 dark:text-yellow-300 mt-3"> <p className={`text-sm ${sectionStyles.warning.muted} mt-3`}>
<strong>Warning:</strong> Never download MapleFile software from unofficial sources or third-party websites. <strong>Warning:</strong> Never download MapleFile software from unofficial sources or third-party websites.
</p> </p>
</div> </div>
</div> </div>
</div> </section>
{/* Support Section */} {/* Support Section */}
<div className={`${getThemeClasses("bg-card")} rounded-xl shadow-lg border ${getThemeClasses("border")} p-6`}> <section
<h3 className={`text-lg font-semibold mb-3 ${getThemeClasses("text-primary")}`}> aria-labelledby="support-heading"
className={`${getThemeClasses("bg-card")} rounded-xl shadow-lg border ${getThemeClasses("border-secondary")} p-6`}
>
<h3 id="support-heading" className={`text-lg font-semibold mb-3 ${getThemeClasses("text-primary")}`}>
Need Help? Need Help?
</h3> </h3>
<p className="text-gray-700 dark:text-gray-300 mb-3"> <p className={`${getThemeClasses("text-secondary")} mb-3`}>
If you encounter any issues installing or using the MapleFile Desktop Application, please: If you encounter any issues installing or using the MapleFile Desktop Application, please:
</p> </p>
<ul className="space-y-2 text-gray-700 dark:text-gray-300"> <ul className={`space-y-2 ${getThemeClasses("text-secondary")}`} role="list" aria-label="Support options">
<li className="flex items-start"> <li className="flex items-start">
<span className={`mr-2 ${getThemeClasses("text-accent")}`}></span> <span className={`mr-2 ${getThemeClasses("link-primary")}`} aria-hidden="true"></span>
<span>Check the README.md file in the repository for detailed documentation</span> <span>Check the README.md file in the repository for detailed documentation</span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className={`mr-2 ${getThemeClasses("text-accent")}`}></span> <span className={`mr-2 ${getThemeClasses("link-primary")}`} aria-hidden="true"></span>
<span>Open an issue on the Codeberg repository for technical support</span> <span>Open an issue on the Codeberg repository for technical support</span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className={`mr-2 ${getThemeClasses("text-accent")}`}></span> <span className={`mr-2 ${getThemeClasses("link-primary")}`} aria-hidden="true"></span>
<span>Review the application's help documentation and user guide</span> <span>Review the application's help documentation and user guide</span>
</li> </li>
</ul> </ul>
</div> </section>
{/* Action Button */} {/* Action Button */}
<div className="flex justify-center py-6"> <div className="flex justify-center py-6">
@ -319,19 +370,19 @@ const ExportData = () => {
href="https://codeberg.org/mapleopentech/monorepo/src/branch/main/native/desktop/maplefile" href="https://codeberg.org/mapleopentech/monorepo/src/branch/main/native/desktop/maplefile"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
aria-label="Go to MapleFile Desktop Application Repository (opens in new tab)"
> >
<Button <Button
variant="primary" variant="primary"
className="flex items-center" icon={CodeBracketIcon}
> >
<CodeBracketIcon className="h-5 w-5 mr-2" />
Go to MapleFile Desktop Application Repository Go to MapleFile Desktop Application Repository
</Button> </Button>
</a> </a>
</div> </div>
</div> </div>
); );
}, [getThemeClasses]); }, [getThemeClasses, sectionStyles]);
// Field sections for DetailLiteView // Field sections for DetailLiteView
const fieldSections = useMemo(() => { const fieldSections = useMemo(() => {

View file

@ -1,16 +1,36 @@
// File: src/pages/User/Me/Tags/TagsManagement.jsx // File: src/pages/User/Me/Tags/TagsManagement.jsx
// Tags Management Page - Complete CRUD for tags with E2EE // Tags Management Page - Complete CRUD for tags with E2EE
// Layout pattern matching FileUpload.jsx
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useNavigate } from "react-router";
import { useTags } from "../../../../services/Services.jsx"; import { useTags } from "../../../../services/Services.jsx";
import Button from "../../../../components/UIX/Button/Button.jsx"; import withPasswordProtection from "../../../../hocs/withPasswordProtection";
import Input from "../../../../components/UIX/Input/Input.jsx"; import Layout from "../../../../components/Layout/Layout";
import Alert from "../../../../components/UIX/Alert/Alert.jsx"; import {
import Card from "../../../../components/UIX/Card/Card.jsx"; Button,
import { TrashIcon, PencilIcon, PlusIcon, TagIcon } from "@heroicons/react/24/outline"; Input,
Alert,
Card,
Breadcrumb,
Spinner,
useUIXTheme,
} from "../../../../components/UIX";
import {
TrashIcon,
PencilIcon,
PlusIcon,
TagIcon,
ArrowLeftIcon,
HomeIcon,
Cog6ToothIcon,
} from "@heroicons/react/24/outline";
const TagsManagement = () => { const TagsManagement = () => {
const navigate = useNavigate();
const { getThemeClasses } = useUIXTheme();
const { tagManager } = useTags(); const { tagManager } = useTags();
const isMountedRef = useRef(true);
// State // State
const [tags, setTags] = useState([]); const [tags, setTags] = useState([]);
@ -24,10 +44,41 @@ const TagsManagement = () => {
const [formData, setFormData] = useState({ name: "", color: "#3B82F6" }); const [formData, setFormData] = useState({ name: "", color: "#3B82F6" });
const [formErrors, setFormErrors] = useState({}); const [formErrors, setFormErrors] = useState({});
// Cleanup on unmount
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
// Load tags
const loadTags = useCallback(async () => {
if (!tagManager) return;
try {
setIsLoading(true);
setError("");
const fetchedTags = await tagManager.listTags();
if (isMountedRef.current) {
setTags(fetchedTags || []);
}
} catch (err) {
if (isMountedRef.current) {
console.error("Failed to load tags:", err);
setError(err.message || "Failed to load tags");
}
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
}
}, [tagManager]);
// Load tags on mount // Load tags on mount
useEffect(() => { useEffect(() => {
loadTags(); loadTags();
}, []); }, [loadTags]);
// Listen for tag events // Listen for tag events
useEffect(() => { useEffect(() => {
@ -44,25 +95,10 @@ const TagsManagement = () => {
window.removeEventListener("tagUpdated", handleTagUpdated); window.removeEventListener("tagUpdated", handleTagUpdated);
window.removeEventListener("tagDeleted", handleTagDeleted); window.removeEventListener("tagDeleted", handleTagDeleted);
}; };
}, []); }, [loadTags]);
// Load tags
const loadTags = async () => {
try {
setIsLoading(true);
setError("");
const fetchedTags = await tagManager.listTags();
setTags(fetchedTags || []);
} catch (err) {
console.error("Failed to load tags:", err);
setError(err.message || "Failed to load tags");
} finally {
setIsLoading(false);
}
};
// Handle create // Handle create
const handleCreate = async () => { const handleCreate = useCallback(async () => {
try { try {
setFormErrors({}); setFormErrors({});
setError(""); setError("");
@ -84,20 +120,26 @@ const TagsManagement = () => {
setIsLoading(true); setIsLoading(true);
await tagManager.createTag(formData); await tagManager.createTag(formData);
setSuccess("Tag created successfully!"); if (isMountedRef.current) {
setFormData({ name: "", color: "#3B82F6" }); setSuccess("Tag created successfully!");
setIsCreating(false); setFormData({ name: "", color: "#3B82F6" });
await loadTags(); setIsCreating(false);
await loadTags();
}
} catch (err) { } catch (err) {
console.error("Failed to create tag:", err); if (isMountedRef.current) {
setError(err.message || "Failed to create tag"); console.error("Failed to create tag:", err);
setError(err.message || "Failed to create tag");
}
} finally { } finally {
setIsLoading(false); if (isMountedRef.current) {
setIsLoading(false);
}
} }
}; }, [formData, tagManager, loadTags]);
// Handle edit // Handle edit
const handleEdit = async () => { const handleEdit = useCallback(async () => {
try { try {
setFormErrors({}); setFormErrors({});
setError(""); setError("");
@ -119,20 +161,26 @@ const TagsManagement = () => {
setIsLoading(true); setIsLoading(true);
await tagManager.updateTag(editingTagId, formData); await tagManager.updateTag(editingTagId, formData);
setSuccess("Tag updated successfully!"); if (isMountedRef.current) {
setFormData({ name: "", color: "#3B82F6" }); setSuccess("Tag updated successfully!");
setEditingTagId(null); setFormData({ name: "", color: "#3B82F6" });
await loadTags(); setEditingTagId(null);
await loadTags();
}
} catch (err) { } catch (err) {
console.error("Failed to update tag:", err); if (isMountedRef.current) {
setError(err.message || "Failed to update tag"); console.error("Failed to update tag:", err);
setError(err.message || "Failed to update tag");
}
} finally { } finally {
setIsLoading(false); if (isMountedRef.current) {
setIsLoading(false);
}
} }
}; }, [formData, editingTagId, tagManager, loadTags]);
// Handle delete // Handle delete
const handleDelete = async (tagId) => { const handleDelete = useCallback(async (tagId) => {
if (!window.confirm("Are you sure you want to delete this tag?")) { if (!window.confirm("Are you sure you want to delete this tag?")) {
return; return;
} }
@ -142,200 +190,261 @@ const TagsManagement = () => {
setSuccess(""); setSuccess("");
setIsLoading(true); setIsLoading(true);
await tagManager.deleteTag(tagId); await tagManager.deleteTag(tagId);
setSuccess("Tag deleted successfully!"); if (isMountedRef.current) {
await loadTags(); setSuccess("Tag deleted successfully!");
await loadTags();
}
} catch (err) { } catch (err) {
console.error("Failed to delete tag:", err); if (isMountedRef.current) {
setError(err.message || "Failed to delete tag"); console.error("Failed to delete tag:", err);
setError(err.message || "Failed to delete tag");
}
} finally { } finally {
setIsLoading(false); if (isMountedRef.current) {
setIsLoading(false);
}
} }
}; }, [tagManager, loadTags]);
// Start editing // Start editing
const startEdit = (tag) => { const startEdit = useCallback((tag) => {
setFormData({ name: tag.name, color: tag.color }); setFormData({ name: tag.name, color: tag.color });
setEditingTagId(tag.id); setEditingTagId(tag.id);
setIsCreating(false); setIsCreating(false);
}; }, []);
// Cancel form // Cancel form
const cancelForm = () => { const cancelForm = useCallback(() => {
setFormData({ name: "", color: "#3B82F6" }); setFormData({ name: "", color: "#3B82F6" });
setIsCreating(false); setIsCreating(false);
setEditingTagId(null); setEditingTagId(null);
setFormErrors({}); setFormErrors({});
}; }, []);
// Memoize breadcrumb items
const breadcrumbItems = useMemo(() => [
{
label: "Settings",
to: "/me",
icon: Cog6ToothIcon,
},
{
label: "Tags",
isActive: true,
},
], []);
return ( return (
<div className="space-y-6"> <Layout>
{/* Header */} <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex justify-between items-center"> {/* Breadcrumb Navigation */}
<div> <Breadcrumb items={breadcrumbItems} />
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
Manage Tags
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Create and organize tags for your files and collections
</p>
</div>
{!isCreating && !editingTagId && (
<Button
onClick={() => setIsCreating(true)}
icon={PlusIcon}
variant="primary"
>
New Tag
</Button>
)}
</div>
{/* Alerts */} {/* Main Card */}
{error && (
<Alert type="error" onClose={() => setError("")}>
{error}
</Alert>
)}
{success && (
<Alert type="success" onClose={() => setSuccess("")}>
{success}
</Alert>
)}
{/* Create/Edit Form */}
{(isCreating || editingTagId) && (
<Card> <Card>
<div className="p-6 space-y-4"> {/* Header with icon, title, and action button */}
<h3 className="text-lg font-semibold text-gray-900 dark:text-white"> <div className={`p-6 border-b ${getThemeClasses("border-secondary")}`}>
{editingTagId ? "Edit Tag" : "Create New Tag"} <div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
</h3> <div className="flex-1 min-w-0">
<div className="flex items-start">
<div className="space-y-4"> {/* Icon */}
<Input <div className={`p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 ${getThemeClasses("bg-gradient-secondary")}`}>
label="Tag Name" <TagIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
value={formData.name} </div>
onChange={(value) => { {/* Title and subtitle */}
setFormData({ ...formData, name: value }); <div>
setFormErrors({ ...formErrors, name: "" }); <h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
}} Manage Tags
error={formErrors.name} </h1>
placeholder="e.g., Important, Work, Personal" <p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
maxLength={50} Create and organize tags for your files and collections
/> </p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Tag Color
</label>
<div className="flex items-center space-x-4">
<input
type="color"
value={formData.color}
onChange={(e) => {
setFormData({ ...formData, color: e.target.value });
setFormErrors({ ...formErrors, color: "" });
}}
className="h-10 w-20 rounded cursor-pointer border border-gray-300 dark:border-gray-600"
/>
<Input
value={formData.color}
onChange={(value) => {
setFormData({ ...formData, color: value });
setFormErrors({ ...formErrors, color: "" });
}}
error={formErrors.color}
placeholder="#3B82F6"
maxLength={7}
/>
</div> </div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1"> </div>
Choose a color to identify this tag {/* Action buttons */}
</p> <div className="flex-shrink-0 flex items-center space-x-3">
{!isCreating && !editingTagId && (
<Button
onClick={() => setIsCreating(true)}
icon={PlusIcon}
variant="primary"
>
New Tag
</Button>
)}
<Button
onClick={() => navigate("/me")}
variant="secondary"
size="sm"
>
<span className="inline-flex items-center gap-2">
<ArrowLeftIcon className="h-4 w-4" />
<span>Back to Settings</span>
</span>
</Button>
</div> </div>
</div> </div>
</div>
<div className="flex space-x-3 pt-4"> {/* Messages */}
<Button <div className="px-6 pt-6">
onClick={editingTagId ? handleEdit : handleCreate} {error && (
variant="primary" <Alert type="error" className="mb-4" onClose={() => setError("")}>
disabled={isLoading} {error}
> </Alert>
{editingTagId ? "Update Tag" : "Create Tag"} )}
</Button> {success && (
<Button onClick={cancelForm} variant="secondary" disabled={isLoading}> <Alert type="success" className="mb-4" onClose={() => setSuccess("")}>
Cancel {success}
</Button> </Alert>
</div> )}
</div> </div>
</Card>
)}
{/* Tags List */} {/* Content */}
{isLoading && tags.length === 0 ? ( <div className="px-6 pb-6 pt-2">
<Card> {/* Create/Edit Form */}
<div className="p-8 text-center text-gray-500 dark:text-gray-400"> {(isCreating || editingTagId) && (
Loading tags... <div className={`mb-6 p-6 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-muted")}`}>
</div> <h3 className={`text-lg font-semibold mb-4 ${getThemeClasses("text-primary")}`}>
</Card> {editingTagId ? "Edit Tag" : "Create New Tag"}
) : tags.length === 0 && !isCreating ? ( </h3>
<Card>
<div className="p-8 text-center"> <div className="space-y-4">
<TagIcon className="h-12 w-12 mx-auto text-gray-400 dark:text-gray-600 mb-3" /> <Input
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2"> label="Tag Name"
No tags yet value={formData.name}
</h3> onChange={(value) => {
<p className="text-gray-600 dark:text-gray-400 mb-4"> setFormData({ ...formData, name: value });
Create your first tag to organize your files and collections setFormErrors({ ...formErrors, name: "" });
</p> }}
<Button onClick={() => setIsCreating(true)} icon={PlusIcon} variant="primary"> error={formErrors.name}
Create Your First Tag placeholder="e.g., Important, Work, Personal"
</Button> maxLength={50}
</div> />
</Card>
) : ( <div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <label className={`block text-sm font-medium mb-2 ${getThemeClasses("text-primary")}`}>
{tags.map((tag) => ( Tag Color
<Card key={tag.id}> </label>
<div className="p-4"> <div className="flex items-center space-x-4">
<div className="flex items-start justify-between"> <input
<div className="flex items-center space-x-3 flex-1 min-w-0"> type="color"
<div value={formData.color}
className="w-4 h-4 rounded-full flex-shrink-0" onChange={(e) => {
style={{ backgroundColor: tag.color }} setFormData({ ...formData, color: e.target.value });
/> setFormErrors({ ...formErrors, color: "" });
<div className="flex-1 min-w-0"> }}
<h4 className="text-sm font-medium text-gray-900 dark:text-white truncate"> className={`h-10 w-20 rounded cursor-pointer border ${getThemeClasses("border-secondary")}`}
{tag.name} />
</h4> <Input
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1"> value={formData.color}
{tag.color} onChange={(value) => {
</p> setFormData({ ...formData, color: value });
setFormErrors({ ...formErrors, color: "" });
}}
error={formErrors.color}
placeholder="#3B82F6"
maxLength={7}
/>
</div>
<p className={`text-xs mt-1 ${getThemeClasses("text-secondary")}`}>
Choose a color to identify this tag
</p>
</div>
</div>
<div className="flex space-x-3 pt-4">
<Button
onClick={editingTagId ? handleEdit : handleCreate}
variant="primary"
disabled={isLoading}
loading={isLoading}
loadingText={editingTagId ? "Updating..." : "Creating..."}
>
{editingTagId ? "Update Tag" : "Create Tag"}
</Button>
<Button onClick={cancelForm} variant="secondary" disabled={isLoading}>
Cancel
</Button>
</div>
</div>
)}
{/* Tags List */}
{isLoading && tags.length === 0 ? (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<Spinner size="lg" className="mx-auto mb-4" />
<p className={getThemeClasses("text-secondary")}>Loading tags...</p>
</div>
</div>
) : tags.length === 0 && !isCreating ? (
<div className="text-center py-12">
<div className={`h-20 w-20 ${getThemeClasses("bg-muted")} rounded-2xl flex items-center justify-center mx-auto mb-3`}>
<TagIcon className="h-10 w-10 text-gray-400" />
</div>
<h3 className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`}>
No tags yet
</h3>
<p className={`mb-6 ${getThemeClasses("text-secondary")}`}>
Create your first tag to organize your files
</p>
<Button onClick={() => setIsCreating(true)} icon={PlusIcon} variant="primary">
Create Your First Tag
</Button>
</div>
) : tags.length > 0 && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{tags.map((tag) => (
<div
key={tag.id}
className={`p-4 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-card")} ${getThemeClasses("hover:shadow")} transition-shadow duration-200`}
>
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3 flex-1 min-w-0">
<div
className="w-5 h-5 rounded-full flex-shrink-0 shadow-sm"
style={{ backgroundColor: tag.color }}
/>
<div className="flex-1 min-w-0">
<h4 className={`text-sm font-medium truncate ${getThemeClasses("text-primary")}`}>
{tag.name}
</h4>
<p className={`text-xs mt-0.5 ${getThemeClasses("text-secondary")}`}>
{tag.color}
</p>
</div>
</div>
<div className="flex space-x-1 ml-2">
<Button
onClick={() => startEdit(tag)}
variant="ghost"
size="sm"
aria-label={`Edit tag ${tag.name}`}
>
<PencilIcon className="h-4 w-4" />
</Button>
<Button
onClick={() => handleDelete(tag.id)}
variant="ghost"
size="sm"
aria-label={`Delete tag ${tag.name}`}
className="hover:text-red-600 dark:hover:text-red-400"
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
</div> </div>
</div> </div>
<div className="flex space-x-2 ml-2"> ))}
<button
onClick={() => startEdit(tag)}
className="p-1 text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
title="Edit tag"
>
<PencilIcon className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(tag.id)}
className="p-1 text-gray-500 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400"
title="Delete tag"
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
</div>
</div> </div>
</Card> )}
))} </div>
</div> </Card>
)} </div>
</div> </Layout>
); );
}; };
export default TagsManagement; export default withPasswordProtection(TagsManagement);

View file

@ -1,16 +1,31 @@
// File: src/pages/User/Tags/TagCreate.jsx // File: src/pages/User/Tags/TagCreate.jsx
// Tag Create Page - Create a new tag // Tag Create Page - Create a new tag
import React, { useState } from "react"; import React, { useState, useRef, useEffect, useCallback } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { useTags } from "../../../services/Services.jsx"; import { useTags } from "../../../services/Services.jsx";
import Navigation from "../../../components/Navigation"; import withPasswordProtection from "../../../hocs/withPasswordProtection";
import Alert from "../../../components/UIX/Alert/Alert.jsx"; import Layout from "../../../components/Layout/Layout";
import { ArrowLeftIcon } from "@heroicons/react/24/outline"; import {
Button,
Input,
Alert,
Card,
Breadcrumb,
useUIXTheme,
} from "../../../components/UIX";
import {
ArrowLeftIcon,
TagIcon,
PlusIcon,
Cog6ToothIcon,
} from "@heroicons/react/24/outline";
const TagCreate = () => { const TagCreate = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { tagManager } = useTags(); const { tagManager } = useTags();
const { getThemeClasses } = useUIXTheme();
const isMountedRef = useRef(true);
// State // State
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -18,10 +33,20 @@ const TagCreate = () => {
const [formData, setFormData] = useState({ name: "", color: "#3B82F6" }); const [formData, setFormData] = useState({ name: "", color: "#3B82F6" });
const [formErrors, setFormErrors] = useState({}); const [formErrors, setFormErrors] = useState({});
// Cleanup on unmount
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
// Handle create // Handle create
const handleCreate = async (e) => { const handleCreate = useCallback(async (e) => {
e.preventDefault(); e.preventDefault();
if (!isMountedRef.current) return;
try { try {
setFormErrors({}); setFormErrors({});
setError(""); setError("");
@ -42,165 +67,168 @@ const TagCreate = () => {
setIsLoading(true); setIsLoading(true);
await tagManager.createTag(formData); await tagManager.createTag(formData);
if (!isMountedRef.current) return;
// Navigate back to list // Navigate back to list
navigate("/me/tags"); navigate("/me/tags");
} catch (err) { } catch (err) {
console.error("Failed to create tag:", err); if (!isMountedRef.current) return;
if (import.meta.env.DEV) {
console.error("Failed to create tag:", err);
}
setError(err.message || "Failed to create tag"); setError(err.message || "Failed to create tag");
} finally { } finally {
setIsLoading(false); if (isMountedRef.current) {
setIsLoading(false);
}
} }
}; }, [formData, tagManager, navigate]);
const btn_primary = // Breadcrumb items
"inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-white bg-red-700 hover:bg-red-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"; const breadcrumbItems = [
const btn_secondary = { label: "Settings", to: "/me", icon: Cog6ToothIcon },
"inline-flex items-center justify-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"; { label: "Tags", to: "/me/tags", icon: TagIcon },
const input_style = { label: "Create Tag", isActive: true },
"block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-red-500 focus:border-red-500 sm:text-sm disabled:opacity-50"; ];
const label_style = "block text-sm font-medium text-gray-700 mb-1";
return ( return (
<div className="min-h-screen bg-gray-50"> <Layout>
<Navigation /> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> {/* Breadcrumb Navigation */}
{/* Header */} <Breadcrumb items={breadcrumbItems} />
<div className="mb-8 animate-fade-in-down">
<button
onClick={() => navigate("/me/tags")}
className="inline-flex items-center text-sm text-gray-600 hover:text-red-700 mb-4 transition-colors duration-200"
>
<ArrowLeftIcon className="h-4 w-4 mr-2" />
Back to Tags
</button>
<h1 className="text-3xl font-bold text-gray-900">Create Tag</h1>
<p className="text-gray-600 mt-1">
Create a new tag to organize your files and collections
</p>
</div>
{/* Alert */} {/* Main Card */}
{error && ( <Card>
<div className="mb-6 animate-fade-in-up"> {/* Header */}
<Alert type="error" onClose={() => setError("")}> <div className={`p-6 border-b ${getThemeClasses("border-secondary")}`}>
{error} <div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
</Alert> <div className="flex-1 min-w-0">
<div className="flex items-start">
<div className={`p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 ${getThemeClasses("bg-gradient-secondary")}`}>
<PlusIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
</div>
<div>
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
Create Tag
</h1>
<p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
Create a new tag to organize your files and collections
</p>
</div>
</div>
</div>
<div className="flex-shrink-0">
<Button
onClick={() => navigate("/me/tags")}
variant="secondary"
size="sm"
>
<span className="inline-flex items-center gap-2">
<ArrowLeftIcon className="h-4 w-4" />
<span>Back to Tags</span>
</span>
</Button>
</div>
</div>
</div> </div>
)}
{/* Form */} {/* Messages */}
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-6 animate-fade-in-up"> <div className="px-6 pt-6">
<form onSubmit={handleCreate} className="space-y-6"> {error && (
<div> <Alert type="error" className="mb-4" onClose={() => setError("")}>
<label htmlFor="tag_name" className={label_style}> {error}
Tag Name <span className="text-red-500">*</span> </Alert>
</label> )}
<input </div>
id="tag_name"
{/* Content */}
<div className="px-6 pb-6 pt-2">
<form onSubmit={handleCreate} className="space-y-6 max-w-xl">
<Input
label="Tag Name"
type="text" type="text"
name="tag_name"
placeholder="e.g., Important, Work, Personal"
value={formData.name} value={formData.name}
onChange={(e) => { onChange={(value) => {
setFormData({ ...formData, name: e.target.value }); setFormData({ ...formData, name: value });
setFormErrors({ ...formErrors, name: "" }); setFormErrors({ ...formErrors, name: "" });
}} }}
placeholder="e.g., Important, Work, Personal"
maxLength={50} maxLength={50}
required required
className={input_style}
disabled={isLoading} disabled={isLoading}
error={formErrors.name}
icon={TagIcon}
/> />
{formErrors.name && (
<p className="mt-1 text-sm text-red-600">{formErrors.name}</p>
)}
</div>
<div> <div>
<label className={label_style}> <label className={`block text-sm font-medium ${getThemeClasses("text-primary")} mb-2`}>
Tag Color <span className="text-red-500">*</span> Tag Color <span className={getThemeClasses("text-error")}>*</span>
</label> </label>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<input
type="color"
value={formData.color}
onChange={(e) => {
setFormData({ ...formData, color: e.target.value });
setFormErrors({ ...formErrors, color: "" });
}}
className="h-12 w-24 rounded-lg cursor-pointer border-2 border-gray-300 shadow-sm hover:border-red-500 transition-colors duration-200"
disabled={isLoading}
/>
<div className="flex-1">
<input <input
type="text" type="color"
value={formData.color} value={formData.color}
onChange={(e) => { onChange={(e) => {
setFormData({ ...formData, color: e.target.value }); setFormData({ ...formData, color: e.target.value });
setFormErrors({ ...formErrors, color: "" }); setFormErrors({ ...formErrors, color: "" });
}} }}
placeholder="#3B82F6" className={`h-12 w-24 rounded-lg cursor-pointer border-2 ${getThemeClasses("border-muted")} shadow-sm ${getThemeClasses("hover:border-accent")} transition-colors duration-200`}
maxLength={7}
className={input_style}
disabled={isLoading} disabled={isLoading}
/> />
<div className="flex-1">
<Input
type="text"
name="tag_color"
placeholder="#3B82F6"
value={formData.color}
onChange={(value) => {
setFormData({ ...formData, color: value });
setFormErrors({ ...formErrors, color: "" });
}}
maxLength={7}
disabled={isLoading}
error={formErrors.color}
/>
</div>
</div> </div>
<p className={`text-xs ${getThemeClasses("text-secondary")} mt-2`}>
Choose a color to identify this tag visually
</p>
</div> </div>
{formErrors.color && (
<p className="mt-1 text-sm text-red-600">{formErrors.color}</p>
)}
<p className="text-xs text-gray-500 mt-2">
Choose a color to identify this tag visually
</p>
</div>
<div className="flex space-x-3 pt-4 border-t border-gray-200"> <div className={`flex space-x-3 pt-4 border-t ${getThemeClasses("border-muted")}`}>
<button type="submit" className={btn_primary} disabled={isLoading}> <Button
{isLoading ? "Creating..." : "Create Tag"} type="submit"
</button> variant="primary"
<button disabled={isLoading}
type="button" loading={isLoading}
onClick={() => navigate("/me/tags")} >
className={btn_secondary} {!isLoading && (
disabled={isLoading} <span className="inline-flex items-center gap-2">
> <PlusIcon className="h-4 w-4" />
Cancel <span>Create Tag</span>
</button> </span>
</div> )}
</form> {isLoading && "Creating..."}
</div> </Button>
<Button
type="button"
variant="secondary"
onClick={() => navigate("/me/tags")}
disabled={isLoading}
>
Cancel
</Button>
</div>
</form>
</div>
</Card>
</div> </div>
</Layout>
{/* CSS Animations */}
<style>{`
@keyframes fade-in-down {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-down {
animation: fade-in-down 0.5s ease-out both;
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fade-in-up 0.5s ease-out both;
}
`}</style>
</div>
); );
}; };
export default TagCreate; export default withPasswordProtection(TagCreate);

View file

@ -1,14 +1,25 @@
// File: src/pages/User/Tags/TagDelete.jsx // File: src/pages/User/Tags/TagDelete.jsx
// Tag Delete Page - Delete confirmation for a tag // Tag Delete Page - Delete confirmation for a tag
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useRef, useCallback } from "react";
import { useNavigate, useParams, useLocation } from "react-router"; import { useNavigate, useParams, useLocation } from "react-router";
import { useTags } from "../../../services/Services.jsx"; import { useTags } from "../../../services/Services.jsx";
import Navigation from "../../../components/Navigation"; import withPasswordProtection from "../../../hocs/withPasswordProtection";
import Alert from "../../../components/UIX/Alert/Alert.jsx"; import Layout from "../../../components/Layout/Layout";
import {
Button,
Alert,
Card,
Breadcrumb,
Spinner,
useUIXTheme,
} from "../../../components/UIX";
import { import {
ArrowLeftIcon, ArrowLeftIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
TagIcon,
TrashIcon,
Cog6ToothIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
const TagDelete = () => { const TagDelete = () => {
@ -16,27 +27,34 @@ const TagDelete = () => {
const { tagId } = useParams(); const { tagId } = useParams();
const location = useLocation(); const location = useLocation();
const { tagManager } = useTags(); const { tagManager } = useTags();
const { getThemeClasses } = useUIXTheme();
const isMountedRef = useRef(true);
// State // State
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [tag, setTag] = useState(null); const [tag, setTag] = useState(null);
// Get tag name from location state or load tag // Cleanup on unmount
useEffect(() => { useEffect(() => {
if (location.state?.tagName) { isMountedRef.current = true;
setTag({ id: tagId, name: location.state.tagName }); return () => {
} else { isMountedRef.current = false;
loadTag(); };
} }, []);
}, [tagId]);
// Load tag // Load tag
const loadTag = async () => { const loadTag = useCallback(async () => {
if (!tagManager) return;
try { try {
setIsLoading(true); setIsLoading(true);
setError(""); setError("");
const tags = await tagManager.listTags(); const tags = await tagManager.listTags();
if (!isMountedRef.current) return;
const foundTag = tags.find((t) => t.id === tagId); const foundTag = tags.find((t) => t.id === tagId);
if (!foundTag) { if (!foundTag) {
setError("Tag not found"); setError("Tag not found");
@ -44,152 +62,188 @@ const TagDelete = () => {
return; return;
} }
setTag(foundTag); setTag(foundTag);
setIsInitialized(true);
} catch (err) { } catch (err) {
console.error("Failed to load tag:", err); if (!isMountedRef.current) return;
if (import.meta.env.DEV) {
console.error("Failed to load tag:", err);
}
setError(err.message || "Failed to load tag"); setError(err.message || "Failed to load tag");
setIsInitialized(true);
} finally { } finally {
setIsLoading(false); if (isMountedRef.current) {
setIsLoading(false);
}
} }
}; }, [tagManager, tagId, navigate]);
// Get tag name from location state or load tag
useEffect(() => {
if (location.state?.tagName) {
setTag({ id: tagId, name: location.state.tagName });
setIsInitialized(true);
} else if (tagManager && !isInitialized) {
loadTag();
}
}, [tagId, location.state, tagManager, isInitialized, loadTag]);
// Handle delete // Handle delete
const handleDelete = async () => { const handleDelete = useCallback(async () => {
if (!isMountedRef.current) return;
try { try {
setError(""); setError("");
setIsLoading(true); setIsLoading(true);
console.log("[TagDelete] Attempting to delete tag:", tagId);
if (import.meta.env.DEV) {
console.log("[TagDelete] Attempting to delete tag:", tagId);
}
await tagManager.deleteTag(tagId); await tagManager.deleteTag(tagId);
console.log("[TagDelete] Tag deleted successfully");
if (!isMountedRef.current) return;
if (import.meta.env.DEV) {
console.log("[TagDelete] Tag deleted successfully");
}
// Navigate back to list // Navigate back to list
navigate("/me/tags"); navigate("/me/tags");
} catch (err) { } catch (err) {
console.error("[TagDelete] Failed to delete tag:", err); if (!isMountedRef.current) return;
console.error("[TagDelete] Error details:", {
message: err.message, if (import.meta.env.DEV) {
status: err.status, console.error("[TagDelete] Failed to delete tag:", err);
response: err.response, }
});
setError(err.message || "Failed to delete tag"); setError(err.message || "Failed to delete tag");
setIsLoading(false); setIsLoading(false);
} }
}; }, [tagManager, tagId, navigate]);
const btn_danger = // Breadcrumb items
"inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"; const breadcrumbItems = [
const btn_secondary = { label: "Settings", to: "/me", icon: Cog6ToothIcon },
"inline-flex items-center justify-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"; { label: "Tags", to: "/me/tags", icon: TagIcon },
{ label: "Delete Tag", isActive: true },
];
return ( return (
<div className="min-h-screen bg-gray-50"> <Layout>
<Navigation /> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> {/* Breadcrumb Navigation */}
{/* Header */} <Breadcrumb items={breadcrumbItems} />
<div className="mb-8 animate-fade-in-down">
<button
onClick={() => navigate("/me/tags")}
className="inline-flex items-center text-sm text-gray-600 hover:text-red-700 mb-4 transition-colors duration-200"
>
<ArrowLeftIcon className="h-4 w-4 mr-2" />
Back to Tags
</button>
<h1 className="text-3xl font-bold text-gray-900">Delete Tag</h1>
<p className="text-gray-600 mt-1">Confirm tag deletion</p>
</div>
{/* Alert */} {/* Main Card */}
{error && ( <Card>
<div className="mb-6 animate-fade-in-up"> {/* Header */}
<Alert type="error" onClose={() => setError("")}> <div className={`p-6 border-b ${getThemeClasses("border-secondary")}`}>
{error} <div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
</Alert> <div className="flex-1 min-w-0">
</div> <div className="flex items-start">
)} <div className={`p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 ${getThemeClasses("bg-error")}`}>
<TrashIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
{/* Confirmation */} </div>
{isLoading && !tag ? ( <div>
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-8 text-center animate-fade-in-up"> <h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-red-800 mx-auto mb-4"></div> Delete Tag
<p className="text-gray-600">Loading tag...</p> </h1>
</div> <p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
) : tag ? ( Confirm tag deletion
<div className="bg-white rounded-xl shadow-lg border-2 border-red-100 p-8 animate-fade-in-up">
<div className="flex items-start space-x-4">
<div className="flex-shrink-0">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-red-100">
<ExclamationTriangleIcon className="h-8 w-8 text-red-600" />
</div>
</div>
<div className="flex-1">
<h3 className="text-xl font-semibold text-gray-900 mb-2">
Delete Tag "{tag.name}"?
</h3>
<div className="space-y-2">
<p className="text-sm text-gray-600">
Are you sure you want to delete this tag? This action cannot
be undone.
</p>
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-800">
<strong>Note:</strong> This will only delete the tag itself. Files and
collections with this tag will not be affected.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</div> <div className="flex-shrink-0">
<Button
<div className="flex space-x-3 mt-6 pt-6 border-t border-gray-200"> onClick={() => navigate("/me/tags")}
<button variant="secondary"
onClick={handleDelete} size="sm"
className={btn_danger} >
disabled={isLoading} <span className="inline-flex items-center gap-2">
> <ArrowLeftIcon className="h-4 w-4" />
{isLoading ? "Deleting..." : "Delete Tag"} <span>Back to Tags</span>
</button> </span>
<button </Button>
onClick={() => navigate("/me/tags")} </div>
className={btn_secondary}
disabled={isLoading}
>
Cancel
</button>
</div> </div>
</div> </div>
) : null}
{/* Messages */}
<div className="px-6 pt-6">
{error && (
<Alert type="error" className="mb-4" onClose={() => setError("")}>
{error}
</Alert>
)}
</div>
{/* Content */}
<div className="px-6 pb-6 pt-2">
{isLoading && !tag ? (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<Spinner size="lg" className="mx-auto mb-4" />
<p className={getThemeClasses("text-secondary")}>Loading tag...</p>
</div>
</div>
) : tag ? (
<div className="max-w-xl">
<div className={`${getThemeClasses("bg-error-light")} border ${getThemeClasses("border-error")} rounded-xl p-6`}>
<div className="flex items-start space-x-4">
<div className="flex-shrink-0">
<div className={`inline-flex items-center justify-center w-14 h-14 rounded-2xl ${getThemeClasses("bg-error-light")}`}>
<ExclamationTriangleIcon className={`h-8 w-8 ${getThemeClasses("text-error")}`} />
</div>
</div>
<div className="flex-1">
<h3 className={`text-xl font-semibold ${getThemeClasses("text-primary")} mb-2`}>
Delete Tag "{tag.name}"?
</h3>
<div className="space-y-2">
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>
Are you sure you want to delete this tag? This action cannot
be undone.
</p>
<Alert type="info" className="mt-4">
<strong>Note:</strong> This will only delete the tag itself. Files and
collections with this tag will not be affected.
</Alert>
</div>
</div>
</div>
</div>
<div className={`flex space-x-3 mt-6 pt-6 border-t ${getThemeClasses("border-muted")}`}>
<Button
onClick={handleDelete}
variant="danger"
disabled={isLoading}
loading={isLoading}
>
{!isLoading && (
<span className="inline-flex items-center gap-2">
<TrashIcon className="h-4 w-4" />
<span>Delete Tag</span>
</span>
)}
{isLoading && "Deleting..."}
</Button>
<Button
onClick={() => navigate("/me/tags")}
variant="secondary"
disabled={isLoading}
>
Cancel
</Button>
</div>
</div>
) : null}
</div>
</Card>
</div> </div>
</Layout>
{/* CSS Animations */}
<style>{`
@keyframes fade-in-down {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-down {
animation: fade-in-down 0.5s ease-out both;
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fade-in-up 0.5s ease-out both;
}
`}</style>
</div>
); );
}; };
export default TagDelete; export default withPasswordProtection(TagDelete);

View file

@ -1,35 +1,59 @@
// File: src/pages/User/Tags/TagEdit.jsx // File: src/pages/User/Tags/TagEdit.jsx
// Tag Edit Page - Edit an existing tag // Tag Edit Page - Edit an existing tag
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useRef, useCallback } from "react";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { useTags } from "../../../services/Services.jsx"; import { useTags } from "../../../services/Services.jsx";
import Navigation from "../../../components/Navigation"; import withPasswordProtection from "../../../hocs/withPasswordProtection";
import Alert from "../../../components/UIX/Alert/Alert.jsx"; import Layout from "../../../components/Layout/Layout";
import { ArrowLeftIcon } from "@heroicons/react/24/outline"; import {
Button,
Input,
Alert,
Card,
Breadcrumb,
Spinner,
useUIXTheme,
} from "../../../components/UIX";
import {
ArrowLeftIcon,
TagIcon,
Cog6ToothIcon,
} from "@heroicons/react/24/outline";
const TagEdit = () => { const TagEdit = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { tagId } = useParams(); const { tagId } = useParams();
const { tagManager } = useTags(); const { tagManager } = useTags();
const { getThemeClasses } = useUIXTheme();
const isMountedRef = useRef(true);
// State // State
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [formData, setFormData] = useState({ name: "", color: "#3B82F6" }); const [formData, setFormData] = useState({ name: "", color: "#3B82F6" });
const [formErrors, setFormErrors] = useState({}); const [formErrors, setFormErrors] = useState({});
// Load tag on mount // Cleanup on unmount
useEffect(() => { useEffect(() => {
loadTag(); isMountedRef.current = true;
}, [tagId]); return () => {
isMountedRef.current = false;
};
}, []);
// Load tag on mount
const loadTag = useCallback(async () => {
if (!tagManager) return;
// Load tag
const loadTag = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
setError(""); setError("");
const tags = await tagManager.listTags(); const tags = await tagManager.listTags();
if (!isMountedRef.current) return;
const tag = tags.find((t) => t.id === tagId); const tag = tags.find((t) => t.id === tagId);
if (!tag) { if (!tag) {
setError("Tag not found"); setError("Tag not found");
@ -37,18 +61,34 @@ const TagEdit = () => {
return; return;
} }
setFormData({ name: tag.name, color: tag.color }); setFormData({ name: tag.name, color: tag.color });
setIsInitialized(true);
} catch (err) { } catch (err) {
console.error("Failed to load tag:", err); if (!isMountedRef.current) return;
if (import.meta.env.DEV) {
console.error("Failed to load tag:", err);
}
setError(err.message || "Failed to load tag"); setError(err.message || "Failed to load tag");
setIsInitialized(true);
} finally { } finally {
setIsLoading(false); if (isMountedRef.current) {
setIsLoading(false);
}
} }
}; }, [tagManager, tagId, navigate]);
useEffect(() => {
if (tagManager && !isInitialized) {
loadTag();
}
}, [tagManager, isInitialized, loadTag]);
// Handle update // Handle update
const handleUpdate = async (e) => { const handleUpdate = useCallback(async (e) => {
e.preventDefault(); e.preventDefault();
if (!isMountedRef.current) return;
try { try {
setFormErrors({}); setFormErrors({});
setError(""); setError("");
@ -69,170 +109,172 @@ const TagEdit = () => {
setIsLoading(true); setIsLoading(true);
await tagManager.updateTag(tagId, formData); await tagManager.updateTag(tagId, formData);
if (!isMountedRef.current) return;
// Navigate back to list // Navigate back to list
navigate("/me/tags"); navigate("/me/tags");
} catch (err) { } catch (err) {
console.error("Failed to update tag:", err); if (!isMountedRef.current) return;
if (import.meta.env.DEV) {
console.error("Failed to update tag:", err);
}
setError(err.message || "Failed to update tag"); setError(err.message || "Failed to update tag");
} finally { } finally {
setIsLoading(false); if (isMountedRef.current) {
setIsLoading(false);
}
} }
}; }, [formData, tagManager, tagId, navigate]);
const btn_primary = // Breadcrumb items
"inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-white bg-red-700 hover:bg-red-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"; const breadcrumbItems = [
const btn_secondary = { label: "Settings", to: "/me", icon: Cog6ToothIcon },
"inline-flex items-center justify-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"; { label: "Tags", to: "/me/tags", icon: TagIcon },
const input_style = { label: "Edit Tag", isActive: true },
"block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-red-500 focus:border-red-500 sm:text-sm disabled:opacity-50"; ];
const label_style = "block text-sm font-medium text-gray-700 mb-1";
return ( return (
<div className="min-h-screen bg-gray-50"> <Layout>
<Navigation /> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> {/* Breadcrumb Navigation */}
{/* Header */} <Breadcrumb items={breadcrumbItems} />
<div className="mb-8 animate-fade-in-down">
<button
onClick={() => navigate("/me/tags")}
className="inline-flex items-center text-sm text-gray-600 hover:text-red-700 mb-4 transition-colors duration-200"
>
<ArrowLeftIcon className="h-4 w-4 mr-2" />
Back to Tags
</button>
<h1 className="text-3xl font-bold text-gray-900">Edit Tag</h1>
<p className="text-gray-600 mt-1">Update tag information</p>
</div>
{/* Alert */} {/* Main Card */}
{error && ( <Card>
<div className="mb-6 animate-fade-in-up"> {/* Header */}
<Alert type="error" onClose={() => setError("")}> <div className={`p-6 border-b ${getThemeClasses("border-secondary")}`}>
{error} <div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
</Alert> <div className="flex-1 min-w-0">
<div className="flex items-start">
<div className={`p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 ${getThemeClasses("bg-gradient-secondary")}`}>
<TagIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
</div>
<div>
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
Edit Tag
</h1>
<p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
Update tag information
</p>
</div>
</div>
</div>
<div className="flex-shrink-0">
<Button
onClick={() => navigate("/me/tags")}
variant="secondary"
size="sm"
>
<span className="inline-flex items-center gap-2">
<ArrowLeftIcon className="h-4 w-4" />
<span>Back to Tags</span>
</span>
</Button>
</div>
</div>
</div> </div>
)}
{/* Form */} {/* Messages */}
{isLoading && !formData.name ? ( <div className="px-6 pt-6">
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-8 text-center animate-fade-in-up"> {error && (
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-red-800 mx-auto mb-4"></div> <Alert type="error" className="mb-4" onClose={() => setError("")}>
<p className="text-gray-600">Loading tag...</p> {error}
</Alert>
)}
</div> </div>
) : (
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-6 animate-fade-in-up"> {/* Content */}
<form onSubmit={handleUpdate} className="space-y-6"> <div className="px-6 pb-6 pt-2">
<div> {isLoading && !formData.name ? (
<label htmlFor="tag_name" className={label_style}> <div className="flex items-center justify-center py-12">
Tag Name <span className="text-red-500">*</span> <div className="text-center">
</label> <Spinner size="lg" className="mx-auto mb-4" />
<input <p className={getThemeClasses("text-secondary")}>Loading tag...</p>
id="tag_name" </div>
</div>
) : (
<form onSubmit={handleUpdate} className="space-y-6 max-w-xl">
<Input
label="Tag Name"
type="text" type="text"
name="tag_name"
placeholder="e.g., Important, Work, Personal"
value={formData.name} value={formData.name}
onChange={(e) => { onChange={(value) => {
setFormData({ ...formData, name: e.target.value }); setFormData({ ...formData, name: value });
setFormErrors({ ...formErrors, name: "" }); setFormErrors({ ...formErrors, name: "" });
}} }}
placeholder="e.g., Important, Work, Personal"
maxLength={50} maxLength={50}
required required
className={input_style}
disabled={isLoading} disabled={isLoading}
error={formErrors.name}
icon={TagIcon}
/> />
{formErrors.name && (
<p className="mt-1 text-sm text-red-600">{formErrors.name}</p>
)}
</div>
<div> <div>
<label className={label_style}> <label className={`block text-sm font-medium ${getThemeClasses("text-primary")} mb-2`}>
Tag Color <span className="text-red-500">*</span> Tag Color <span className={getThemeClasses("text-error")}>*</span>
</label> </label>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<input
type="color"
value={formData.color}
onChange={(e) => {
setFormData({ ...formData, color: e.target.value });
setFormErrors({ ...formErrors, color: "" });
}}
className="h-12 w-24 rounded-lg cursor-pointer border-2 border-gray-300 shadow-sm hover:border-red-500 transition-colors duration-200"
disabled={isLoading}
/>
<div className="flex-1">
<input <input
type="text" type="color"
value={formData.color} value={formData.color}
onChange={(e) => { onChange={(e) => {
setFormData({ ...formData, color: e.target.value }); setFormData({ ...formData, color: e.target.value });
setFormErrors({ ...formErrors, color: "" }); setFormErrors({ ...formErrors, color: "" });
}} }}
placeholder="#3B82F6" className={`h-12 w-24 rounded-lg cursor-pointer border-2 ${getThemeClasses("border-muted")} shadow-sm ${getThemeClasses("hover:border-accent")} transition-colors duration-200`}
maxLength={7}
className={input_style}
disabled={isLoading} disabled={isLoading}
/> />
<div className="flex-1">
<Input
type="text"
name="tag_color"
placeholder="#3B82F6"
value={formData.color}
onChange={(value) => {
setFormData({ ...formData, color: value });
setFormErrors({ ...formErrors, color: "" });
}}
maxLength={7}
disabled={isLoading}
error={formErrors.color}
/>
</div>
</div> </div>
<p className={`text-xs ${getThemeClasses("text-secondary")} mt-2`}>
Choose a color to identify this tag visually
</p>
</div> </div>
{formErrors.color && (
<p className="mt-1 text-sm text-red-600">{formErrors.color}</p>
)}
<p className="text-xs text-gray-500 mt-2">
Choose a color to identify this tag visually
</p>
</div>
<div className="flex space-x-3 pt-4 border-t border-gray-200"> <div className={`flex space-x-3 pt-4 border-t ${getThemeClasses("border-muted")}`}>
<button type="submit" className={btn_primary} disabled={isLoading}> <Button
{isLoading ? "Updating..." : "Update Tag"} type="submit"
</button> variant="primary"
<button disabled={isLoading}
type="button" loading={isLoading}
onClick={() => navigate("/me/tags")} >
className={btn_secondary} {!isLoading && "Update Tag"}
disabled={isLoading} {isLoading && "Updating..."}
> </Button>
Cancel <Button
</button> type="button"
</div> variant="secondary"
</form> onClick={() => navigate("/me/tags")}
disabled={isLoading}
>
Cancel
</Button>
</div>
</form>
)}
</div> </div>
)} </Card>
</div> </div>
</Layout>
{/* CSS Animations */}
<style>{`
@keyframes fade-in-down {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-down {
animation: fade-in-down 0.5s ease-out both;
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fade-in-up 0.5s ease-out both;
}
`}</style>
</div>
); );
}; };
export default TagEdit; export default withPasswordProtection(TagEdit);

View file

@ -1,63 +1,83 @@
// File: src/pages/User/Tags/TagList.jsx // File: src/pages/User/Tags/TagList.jsx
// Tags List Page - Display all user tags // Tags List Page - Display all user tags with Layout (sidebar + topbar)
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { useTags, useAuth } from "../../../services/Services.jsx"; import { useTags } from "../../../services/Services.jsx";
import Navigation from "../../../components/Navigation"; import withPasswordProtection from "../../../hocs/withPasswordProtection";
import Alert from "../../../components/UIX/Alert/Alert.jsx"; import Layout from "../../../components/Layout/Layout";
import {
Button,
Alert,
Card,
Breadcrumb,
Spinner,
useUIXTheme,
} from "../../../components/UIX";
import { import {
PlusIcon, PlusIcon,
TagIcon, TagIcon,
PencilIcon, PencilIcon,
TrashIcon, TrashIcon,
UserCircleIcon, ArrowLeftIcon,
ShieldCheckIcon, Cog6ToothIcon,
CheckIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
const TagList = () => { const TagList = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { getThemeClasses } = useUIXTheme();
const { tagManager } = useTags(); const { tagManager } = useTags();
const { meManager } = useAuth(); const isMountedRef = useRef(true);
// State // State
const [tags, setTags] = useState([]); const [tags, setTags] = useState([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(true); // Start with loading true
const [isInitialized, setIsInitialized] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [success, setSuccess] = useState(""); const [success, setSuccess] = useState("");
const [userProfile, setUserProfile] = useState(null);
const [activeTab, setActiveTab] = useState("tags");
const tabs = [ // Cleanup on unmount
{ id: "profile", label: "Profile", icon: UserCircleIcon },
{ id: "security", label: "Security", icon: ShieldCheckIcon },
{ id: "tags", label: "Tags", icon: TagIcon },
];
const badge_success =
"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800";
// Load user profile
useEffect(() => { useEffect(() => {
const loadUserProfile = async () => { isMountedRef.current = true;
if (meManager) { return () => {
try { isMountedRef.current = false;
const profile = await meManager.getCurrentUser();
setUserProfile(profile);
} catch (err) {
console.error("Failed to load user profile:", err);
}
}
}; };
loadUserProfile();
}, [meManager]);
// Load tags on mount
useEffect(() => {
loadTags();
}, []); }, []);
// Load tags
const loadTags = useCallback(async () => {
if (!tagManager) return;
try {
setIsLoading(true);
setError("");
const fetchedTags = await tagManager.listTags();
if (isMountedRef.current) {
setTags(fetchedTags || []);
setIsInitialized(true);
}
} catch (err) {
if (isMountedRef.current) {
if (import.meta.env.DEV) {
console.error("Failed to load tags:", err);
}
setError(err.message || "Failed to load tags");
setIsInitialized(true);
}
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
}
}, [tagManager]);
// Load tags when tagManager becomes available
useEffect(() => {
if (tagManager && !isInitialized) {
loadTags();
}
}, [tagManager, isInitialized, loadTags]);
// Listen for tag events // Listen for tag events
useEffect(() => { useEffect(() => {
const handleTagCreated = () => loadTags(); const handleTagCreated = () => loadTags();
@ -73,232 +93,170 @@ const TagList = () => {
window.removeEventListener("tagUpdated", handleTagUpdated); window.removeEventListener("tagUpdated", handleTagUpdated);
window.removeEventListener("tagDeleted", handleTagDeleted); window.removeEventListener("tagDeleted", handleTagDeleted);
}; };
}, []); }, [loadTags]);
// Load tags
const loadTags = async () => {
try {
setIsLoading(true);
setError("");
const fetchedTags = await tagManager.listTags();
setTags(fetchedTags || []);
} catch (err) {
console.error("Failed to load tags:", err);
setError(err.message || "Failed to load tags");
} finally {
setIsLoading(false);
}
};
// Handle delete // Handle delete
const handleDelete = async (tagId, tagName) => { const handleDelete = useCallback((tagId, tagName) => {
navigate(`/me/tags/${tagId}/delete`, { state: { tagName } }); navigate(`/me/tags/${tagId}/delete`, { state: { tagName } });
}; }, [navigate]);
// Memoize breadcrumb items
const breadcrumbItems = useMemo(() => [
{
label: "Settings",
to: "/me",
icon: Cog6ToothIcon,
},
{
label: "Tags",
isActive: true,
},
], []);
return ( return (
<div className="min-h-screen bg-gray-50"> <Layout>
<Navigation /> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> {/* Breadcrumb Navigation */}
{/* Header */} <Breadcrumb items={breadcrumbItems} />
<div className="mb-8 animate-fade-in-down">
<h1 className="text-3xl font-bold text-gray-900 flex items-center">
My Account
<TagIcon className="h-8 w-8 text-red-700 ml-2" />
</h1>
<p className="text-gray-600 mt-1">
Manage your profile and security settings
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8"> {/* Main Card */}
{/* Sidebar */} <Card>
<div className="lg:col-span-1"> {/* Header with icon, title, and action button */}
{userProfile && ( <div className={`p-6 border-b ${getThemeClasses("border-secondary")}`}>
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-6 mb-6 text-center animate-fade-in-up"> <div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
<div className="relative inline-block mb-4"> <div className="flex-1 min-w-0">
<div className="h-24 w-24 bg-gradient-to-br from-red-600 to-red-800 rounded-2xl flex items-center justify-center text-white text-3xl font-bold mx-auto"> <div className="flex items-start">
{userProfile.first_name?.charAt(0)} {/* Icon */}
{userProfile.last_name?.charAt(0)} <div className={`p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 ${getThemeClasses("bg-gradient-secondary")}`}>
<TagIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
</div>
{/* Title and subtitle */}
<div>
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
Manage Tags
</h1>
<p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
Create and organize tags for your files and collections
</p>
</div> </div>
</div> </div>
<h2 className="text-lg font-semibold text-gray-900">
{userProfile.first_name} {userProfile.last_name}
</h2>
<p className="text-sm text-gray-600">{userProfile.email}</p>
<div className={`mt-4 ${badge_success}`}>
<CheckIcon className="h-3 w-3 mr-1" />
Verified User
</div>
</div> </div>
)} {/* Action buttons */}
<div className="flex-shrink-0 flex items-center space-x-3">
<nav <Button
className="space-y-1 animate-fade-in-up"
style={{ animationDelay: "100ms" }}
>
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => {
if (tab.id === "profile") {
navigate("/me");
} else if (tab.id === "security") {
navigate("/me");
} else {
setActiveTab(tab.id);
}
}}
className={`w-full flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-all duration-200 ${
activeTab === tab.id
? "bg-gradient-to-r from-red-700 to-red-800 text-white shadow-md"
: "text-gray-700 hover:bg-gray-100"
}`}
>
<tab.icon className="h-5 w-5 mr-3" />
{tab.label}
</button>
))}
</nav>
</div>
{/* Main Content */}
<div className="lg:col-span-3">
<div className="space-y-6 animate-fade-in-up">
{/* Header with New Tag Button */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900">My Tags</h2>
<p className="text-sm text-gray-600 mt-1">
Create and organize tags for your files and collections
</p>
</div>
<button
onClick={() => navigate("/me/tags/create")} onClick={() => navigate("/me/tags/create")}
className="inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-white bg-red-700 hover:bg-red-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200" icon={PlusIcon}
variant="primary"
> >
<PlusIcon className="h-4 w-4 mr-2" />
New Tag New Tag
</button> </Button>
<Button
onClick={() => navigate("/me")}
variant="secondary"
size="sm"
>
<span className="inline-flex items-center gap-2">
<ArrowLeftIcon className="h-4 w-4" />
<span>Back to Settings</span>
</span>
</Button>
</div> </div>
{/* Alerts */}
{error && (
<Alert type="error" onClose={() => setError("")}>
{error}
</Alert>
)}
{success && (
<Alert type="success" onClose={() => setSuccess("")}>
{success}
</Alert>
)}
{/* Tags List */}
{isLoading && tags.length === 0 ? (
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-8 text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-red-800 mx-auto mb-4"></div>
<p className="text-gray-600">Loading tags...</p>
</div>
) : tags.length === 0 ? (
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-12 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-red-600 to-red-800 mb-4">
<TagIcon className="h-8 w-8 text-white" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
No tags yet
</h3>
<p className="text-gray-600 mb-6 max-w-md mx-auto">
Create your first tag to organize your files and collections
</p>
<button
onClick={() => navigate("/me/tags/create")}
className="inline-flex items-center justify-center px-6 py-3 border border-transparent text-sm font-medium rounded-lg text-white bg-red-700 hover:bg-red-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors duration-200"
>
<PlusIcon className="h-5 w-5 mr-2" />
Create Your First Tag
</button>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-2">
{tags.map((tag, index) => (
<div
key={tag.id}
className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-5 hover:shadow-xl transition-all duration-200 animate-fade-in-up"
style={{ animationDelay: `${index * 50}ms` }}
>
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3 flex-1 min-w-0">
<div
className="w-5 h-5 rounded-full flex-shrink-0 shadow-sm"
style={{ backgroundColor: tag.color }}
/>
<div className="flex-1 min-w-0">
<h4 className="text-base font-semibold text-gray-900 truncate">
{tag.name}
</h4>
<p className="text-xs text-gray-500 mt-0.5 font-mono">
{tag.color}
</p>
</div>
</div>
<div className="flex space-x-1 ml-2">
<button
onClick={() => navigate(`/me/tags/${tag.id}/edit`)}
className="p-2 text-gray-500 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors duration-200"
title="Edit tag"
>
<PencilIcon className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(tag.id, tag.name)}
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors duration-200"
title="Delete tag"
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div> </div>
</div> </div>
</div>
{/* Messages */}
<div className="px-6 pt-6">
{error && (
<Alert type="error" className="mb-4" onClose={() => setError("")}>
{error}
</Alert>
)}
{success && (
<Alert type="success" className="mb-4" onClose={() => setSuccess("")}>
{success}
</Alert>
)}
</div>
{/* Content */}
<div className="px-6 pb-6 pt-2">
{/* Tags List */}
{isLoading && tags.length === 0 ? (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<Spinner size="lg" className="mx-auto mb-4" />
<p className={getThemeClasses("text-secondary")}>Loading tags...</p>
</div>
</div>
) : tags.length === 0 ? (
<div className="text-center py-12">
<div className={`h-20 w-20 ${getThemeClasses("bg-muted")} rounded-2xl flex items-center justify-center mx-auto mb-3`}>
<TagIcon className="h-10 w-10 text-gray-400" />
</div>
<h3 className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`}>
No tags yet
</h3>
<p className={`mb-6 ${getThemeClasses("text-secondary")}`}>
Create your first tag to organize your files
</p>
<Button onClick={() => navigate("/me/tags/create")} icon={PlusIcon} variant="primary">
Create Your First Tag
</Button>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{tags.map((tag) => (
<div
key={tag.id}
role="article"
aria-label={`Tag: ${tag.name}`}
className={`p-4 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-card")} ${getThemeClasses("hover:shadow")} transition-shadow duration-200`}
>
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3 flex-1 min-w-0">
<div
className="w-5 h-5 rounded-full flex-shrink-0 shadow-sm"
style={{ backgroundColor: tag.color }}
aria-hidden="true"
/>
<div className="flex-1 min-w-0">
<h4 className={`text-sm font-medium truncate ${getThemeClasses("text-primary")}`}>
{tag.name}
</h4>
<p className={`text-xs mt-0.5 ${getThemeClasses("text-secondary")}`}>
{tag.color}
</p>
</div>
</div>
<div className="flex space-x-1 ml-2" role="group" aria-label={`Actions for tag ${tag.name}`}>
<Button
onClick={() => navigate(`/me/tags/${tag.id}/edit`)}
variant="ghost"
size="sm"
aria-label={`Edit tag ${tag.name}`}
>
<PencilIcon className="h-4 w-4" />
</Button>
<Button
onClick={() => handleDelete(tag.id, tag.name)}
variant="ghost"
size="sm"
aria-label={`Delete tag ${tag.name}`}
className="hover:text-red-600 dark:hover:text-red-400"
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</Card>
</div> </div>
</Layout>
{/* CSS Animations */}
<style>{`
@keyframes fade-in-down {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-down {
animation: fade-in-down 0.5s ease-out both;
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fade-in-up 0.5s ease-out both;
}
`}</style>
</div>
); );
}; };
export default TagList; export default withPasswordProtection(TagList);

View file

@ -1,59 +1,97 @@
// File: src/pages/User/Tags/TagSearch.jsx // File: src/pages/User/Tags/TagSearch.jsx
// Tag Search Page - Select tags and search for collections and files // Tag Search Page - Select tags and search for collections and files
// Layout pattern matching FileManagerIndex.jsx
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { useTags } from "../../../services/Services.jsx"; import { useTags } from "../../../services/Services.jsx";
import withPasswordProtection from "../../../hocs/withPasswordProtection";
import Layout from "../../../components/Layout/Layout"; import Layout from "../../../components/Layout/Layout";
import Alert from "../../../components/UIX/Alert/Alert.jsx"; import {
Button,
Alert,
Card,
Breadcrumb,
Spinner,
Checkbox,
useUIXTheme,
} from "../../../components/UIX";
import { import {
MagnifyingGlassIcon, MagnifyingGlassIcon,
TagIcon, TagIcon,
XMarkIcon, PlusIcon,
ArrowLeftIcon, ArrowLeftIcon,
Cog6ToothIcon,
CheckIcon,
InformationCircleIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
const TagSearch = () => { const TagSearch = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { getThemeClasses } = useUIXTheme();
const { tagManager } = useTags(); const { tagManager } = useTags();
const isMountedRef = useRef(true);
// State // State
const [tags, setTags] = useState([]); const [tags, setTags] = useState([]);
const [selectedTagIds, setSelectedTagIds] = useState([]); const [selectedTagIds, setSelectedTagIds] = useState([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(true); // Start with loading true
const [isInitialized, setIsInitialized] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
// Load tags on mount // Cleanup on unmount
useEffect(() => { useEffect(() => {
loadTags(); isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []); }, []);
// Load tags // Load tags
const loadTags = async () => { const loadTags = useCallback(async () => {
if (!tagManager) return;
try { try {
setIsLoading(true); setIsLoading(true);
setError(""); setError("");
const fetchedTags = await tagManager.listTags(); const fetchedTags = await tagManager.listTags();
setTags(fetchedTags || []); if (isMountedRef.current) {
setTags(fetchedTags || []);
setIsInitialized(true);
}
} catch (err) { } catch (err) {
console.error("Failed to load tags:", err); if (isMountedRef.current) {
setError(err.message || "Failed to load tags"); if (import.meta.env.DEV) {
console.error("Failed to load tags:", err);
}
setError(err.message || "Failed to load tags");
setIsInitialized(true);
}
} finally { } finally {
setIsLoading(false); if (isMountedRef.current) {
setIsLoading(false);
}
} }
}; }, [tagManager]);
// Load tags when tagManager becomes available
useEffect(() => {
if (tagManager && !isInitialized) {
loadTags();
}
}, [tagManager, isInitialized, loadTags]);
// Toggle tag selection // Toggle tag selection
const handleTagToggle = (tagId) => { const handleTagToggle = useCallback((tagId) => {
if (selectedTagIds.includes(tagId)) { setSelectedTagIds((prev) =>
setSelectedTagIds(selectedTagIds.filter((id) => id !== tagId)); prev.includes(tagId)
} else { ? prev.filter((id) => id !== tagId)
setSelectedTagIds([...selectedTagIds, tagId]); : [...prev, tagId]
} );
}; }, []);
// Handle search // Handle search
const handleSearch = () => { const handleSearch = useCallback(() => {
if (selectedTagIds.length === 0) { if (selectedTagIds.length === 0) {
setError("Please select at least one tag to search"); setError("Please select at least one tag to search");
return; return;
@ -63,233 +101,218 @@ const TagSearch = () => {
navigate("/me/tags/search/results", { navigate("/me/tags/search/results", {
state: { selectedTagIds }, state: { selectedTagIds },
}); });
}; }, [selectedTagIds, navigate]);
// Clear selection // Clear selection
const handleClearSelection = () => { const handleClearSelection = useCallback(() => {
setSelectedTagIds([]); setSelectedTagIds([]);
setError(""); setError("");
}; }, []);
// Memoize breadcrumb items
const breadcrumbItems = useMemo(() => [
{
label: "Settings",
to: "/me",
icon: Cog6ToothIcon,
},
{
label: "Tags",
to: "/me/tags",
},
{
label: "Search",
isActive: true,
},
], []);
return ( return (
<Layout> <Layout>
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */} {/* Breadcrumb Navigation */}
<div className="mb-8 animate-fade-in-down"> <Breadcrumb items={breadcrumbItems} />
<div className="flex items-center justify-between">
<div className="flex items-center"> {/* Main Card */}
<button <Card>
onClick={() => navigate("/me/tags")} {/* Header with icon, title, and action buttons */}
className="mr-4 p-2 text-gray-600 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors duration-200" <div className={`p-6 border-b ${getThemeClasses("border-secondary")}`}>
> <div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
<ArrowLeftIcon className="h-6 w-6" /> <div className="flex-1 min-w-0">
</button> <div className="flex items-start">
<div> {/* Icon */}
<h1 className="text-3xl font-bold text-gray-900 flex items-center"> <div className={`p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 ${getThemeClasses("bg-gradient-secondary")}`}>
Search by Tags <MagnifyingGlassIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
<MagnifyingGlassIcon className="h-8 w-8 text-red-700 ml-2" /> </div>
</h1> {/* Title and subtitle */}
<p className="text-gray-600 mt-1"> <div>
Select tags to find matching files and collections <h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
</p> Search by Tags
</h1>
<p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
Select tags to find matching files and collections
</p>
</div>
</div>
</div>
{/* Action buttons */}
<div className="flex-shrink-0 flex items-center space-x-3">
<Button
onClick={handleSearch}
disabled={selectedTagIds.length === 0}
variant="primary"
icon={MagnifyingGlassIcon}
>
Search {selectedTagIds.length > 0 && `(${selectedTagIds.length})`}
</Button>
<Button
onClick={() => navigate("/me/tags")}
variant="secondary"
size="sm"
>
<span className="inline-flex items-center gap-2">
<ArrowLeftIcon className="h-4 w-4" />
<span>Back to Tags</span>
</span>
</Button>
</div> </div>
</div> </div>
</div> </div>
</div>
{/* Alerts */} {/* Messages */}
{error && ( <div className="px-6 pt-6">
<Alert type="error" onClose={() => setError("")}> {error && (
{error} <Alert type="error" className="mb-4" onClose={() => setError("")}>
</Alert> {error}
)} </Alert>
)}
</div>
{/* Main Content */} {/* Content */}
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-8 animate-fade-in-up"> <div className="px-6 pb-6 pt-2">
{/* Selection Counter */} {/* Selection Counter */}
<div className="mb-6 pb-6 border-b border-gray-200"> <div className={`mb-6 pb-4 border-b ${getThemeClasses("border-secondary")}`}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center"> <div className="flex items-center">
<TagIcon className="h-6 w-6 text-red-700 mr-3" /> <TagIcon className={`h-5 w-5 mr-2 ${getThemeClasses("text-secondary")}`} />
<h2 className="text-xl font-semibold text-gray-900"> <span className={`text-sm font-medium ${getThemeClasses("text-primary")}`}>
Select Tags {selectedTagIds.length} tag{selectedTagIds.length !== 1 ? "s" : ""} selected
</h2> </span>
</div> </div>
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-600">
{selectedTagIds.length} selected
</span>
{selectedTagIds.length > 0 && ( {selectedTagIds.length > 0 && (
<button <Button
onClick={handleClearSelection} onClick={handleClearSelection}
className="text-sm text-red-700 hover:text-red-800 font-medium" variant="ghost"
size="sm"
> >
Clear All Clear All
</button> </Button>
)} )}
</div> </div>
</div> </div>
</div>
{/* Tags Grid */} {/* Tags Grid */}
{isLoading && tags.length === 0 ? ( {isLoading && tags.length === 0 ? (
<div className="text-center py-12"> <div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-red-800 mx-auto mb-4"></div> <div className="text-center">
<p className="text-gray-600">Loading tags...</p> <Spinner size="lg" className="mx-auto mb-4" />
</div> <p className={getThemeClasses("text-secondary")}>Loading tags...</p>
) : tags.length === 0 ? (
<div className="text-center py-12">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-red-600 to-red-800 mb-4">
<TagIcon className="h-8 w-8 text-white" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
No tags available
</h3>
<p className="text-gray-600 mb-6 max-w-md mx-auto">
Create tags first before searching
</p>
<button
onClick={() => navigate("/me/tags/create")}
className="inline-flex items-center justify-center px-6 py-3 border border-transparent text-sm font-medium rounded-lg text-white bg-red-700 hover:bg-red-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors duration-200"
>
Create Your First Tag
</button>
</div>
) : (
<>
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3 mb-8">
{tags.map((tag, index) => {
const isSelected = selectedTagIds.includes(tag.id);
return (
<button
key={tag.id}
onClick={() => handleTagToggle(tag.id)}
className={`p-4 rounded-lg border-2 transition-all duration-200 text-left animate-fade-in-up ${
isSelected
? "border-red-700 bg-red-50 shadow-md"
: "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm"
}`}
style={{ animationDelay: `${index * 30}ms` }}
>
<div className="flex items-center space-x-3">
<div
className="w-5 h-5 rounded-full flex-shrink-0 shadow-sm"
style={{ backgroundColor: tag.color }}
/>
<div className="flex-1 min-w-0">
<h4
className={`text-base font-semibold truncate ${
isSelected ? "text-red-900" : "text-gray-900"
}`}
>
{tag.name}
</h4>
</div>
{isSelected && (
<div className="flex-shrink-0 w-6 h-6 bg-red-700 rounded-full flex items-center justify-center">
<svg
className="w-4 h-4 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</div>
)}
</div>
</button>
);
})}
</div>
{/* Search Button */}
<div className="flex items-center justify-center pt-6 border-t border-gray-200">
<button
onClick={handleSearch}
disabled={selectedTagIds.length === 0}
className={`inline-flex items-center justify-center px-8 py-4 border border-transparent text-base font-medium rounded-lg text-white transition-all duration-200 ${
selectedTagIds.length === 0
? "bg-gray-400 cursor-not-allowed"
: "bg-gradient-to-r from-red-700 to-red-800 hover:from-red-800 hover:to-red-900 shadow-lg hover:shadow-xl"
}`}
>
<MagnifyingGlassIcon className="h-5 w-5 mr-2" />
Search {selectedTagIds.length > 0 && `(${selectedTagIds.length} tags)`}
</button>
</div>
</>
)}
</div>
{/* Info Box */}
{tags.length > 0 && (
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-4 animate-fade-in-up" style={{ animationDelay: "200ms" }}>
<div className="flex">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-blue-600"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800">
How tag search works
</h3>
<div className="mt-2 text-sm text-blue-700">
<p>
Search returns items that have <strong>ALL</strong> selected tags (AND logic).
The more tags you select, the more specific your search becomes.
</p>
</div> </div>
</div> </div>
</div> ) : tags.length === 0 ? (
<div className="text-center py-12">
<div className={`h-20 w-20 ${getThemeClasses("bg-muted")} rounded-2xl flex items-center justify-center mx-auto mb-3`}>
<TagIcon className="h-10 w-10 text-gray-400" />
</div>
<h3 className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`}>
No tags available
</h3>
<p className={`mb-6 ${getThemeClasses("text-secondary")}`}>
Create tags first before searching
</p>
<Button
onClick={() => navigate("/me/tags/create")}
icon={PlusIcon}
variant="primary"
>
Create Your First Tag
</Button>
</div>
) : (
<>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{tags.map((tag) => {
const isSelected = selectedTagIds.includes(tag.id);
return (
<div
key={tag.id}
role="button"
tabIndex={0}
aria-label={`${isSelected ? "Deselect" : "Select"} tag ${tag.name}`}
aria-pressed={isSelected}
onClick={() => handleTagToggle(tag.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleTagToggle(tag.id);
}
}}
className={`p-4 rounded-lg border-2 cursor-pointer transition-all duration-200 ${
isSelected
? `${getThemeClasses("border-primary")} ${getThemeClasses("bg-accent-light")} shadow-md`
: `${getThemeClasses("border-secondary")} ${getThemeClasses("bg-card")} ${getThemeClasses("hover:border-primary")} hover:shadow-sm`
}`}
>
<div className="flex items-center space-x-3">
<div onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onChange={() => handleTagToggle(tag.id)}
/>
</div>
<div
className="w-5 h-5 rounded-full flex-shrink-0 shadow-sm"
style={{ backgroundColor: tag.color }}
/>
<div className="flex-1 min-w-0">
<h4 className={`text-base font-semibold truncate ${getThemeClasses("text-primary")}`}>
{tag.name}
</h4>
</div>
{isSelected && (
<div className={`flex-shrink-0 w-6 h-6 ${getThemeClasses("bg-gradient-secondary")} rounded-full flex items-center justify-center`}>
<CheckIcon className="w-4 h-4 text-white" />
</div>
)}
</div>
</div>
);
})}
</div>
{/* Info Box */}
<div className={`mt-6 p-4 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-muted")}`}>
<div className="flex">
<div className="flex-shrink-0">
<InformationCircleIcon className={`h-5 w-5 ${getThemeClasses("text-info")}`} />
</div>
<div className="ml-3">
<h3 className={`text-sm font-medium ${getThemeClasses("text-primary")}`}>
How tag search works
</h3>
<p className={`mt-1 text-sm ${getThemeClasses("text-secondary")}`}>
Search returns items that have <strong>ALL</strong> selected tags (AND logic).
The more tags you select, the more specific your search becomes.
</p>
</div>
</div>
</div>
</>
)}
</div> </div>
)} </Card>
{/* CSS Animations */}
<style>{`
@keyframes fade-in-down {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-down {
animation: fade-in-down 0.5s ease-out both;
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fade-in-up 0.5s ease-out both;
}
`}</style>
</div> </div>
</Layout> </Layout>
); );
}; };
export default TagSearch; export default withPasswordProtection(TagSearch);

View file

@ -1,36 +1,75 @@
// File: src/pages/User/Tags/TagSearchResults.jsx // File: src/pages/User/Tags/TagSearchResults.jsx
// Tag Search Results Page - Display collections and files matching selected tags // Tag Search Results Page - Display collections and files matching selected tags
// Layout pattern matching TagSearch.jsx
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useNavigate, useLocation } from "react-router"; import { useNavigate, useLocation } from "react-router";
import { useTags, useCrypto } from "../../../services/Services.jsx"; import { useTags, useCrypto } from "../../../services/Services.jsx";
import withPasswordProtection from "../../../hocs/withPasswordProtection";
import Layout from "../../../components/Layout/Layout"; import Layout from "../../../components/Layout/Layout";
import Alert from "../../../components/UIX/Alert/Alert.jsx"; import {
Button,
Alert,
Card,
Breadcrumb,
Spinner,
useUIXTheme,
} from "../../../components/UIX";
import { import {
MagnifyingGlassIcon, MagnifyingGlassIcon,
FolderIcon, FolderIcon,
DocumentIcon, DocumentIcon,
ArrowLeftIcon, ArrowLeftIcon,
TagIcon, TagIcon,
Cog6ToothIcon,
InformationCircleIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
// Module-level cache for FileCryptoService to avoid repeated dynamic imports
let FileCryptoServiceCache = null;
const getFileCryptoService = async () => {
if (!FileCryptoServiceCache) {
const module = await import("../../../services/Crypto/FileCryptoService.js");
FileCryptoServiceCache = module.default;
}
return FileCryptoServiceCache;
};
// UUID validation regex for tag IDs
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const TagSearchResults = () => { const TagSearchResults = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { getThemeClasses } = useUIXTheme();
const { tagManager } = useTags(); const { tagManager } = useTags();
const { CollectionCryptoService } = useCrypto(); const { CollectionCryptoService } = useCrypto();
const isMountedRef = useRef(true);
// Get selected tag IDs from navigation state // Get selected tag IDs from navigation state and validate
const selectedTagIds = location.state?.selectedTagIds || []; const selectedTagIds = useMemo(() => {
const rawIds = location.state?.selectedTagIds;
if (!Array.isArray(rawIds)) return [];
// Filter to only valid UUID strings
return rawIds.filter(id => typeof id === 'string' && UUID_REGEX.test(id));
}, [location.state?.selectedTagIds]);
// State // State
const [results, setResults] = useState(null); const [results, setResults] = useState(null);
const [decryptedCollections, setDecryptedCollections] = useState([]); const [decryptedCollections, setDecryptedCollections] = useState([]);
const [decryptedFiles, setDecryptedFiles] = useState([]); const [decryptedFiles, setDecryptedFiles] = useState([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(true);
const [isDecrypting, setIsDecrypting] = useState(false); const [isDecrypting, setIsDecrypting] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
// Cleanup on unmount
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
// Redirect if no tags selected // Redirect if no tags selected
useEffect(() => { useEffect(() => {
if (!selectedTagIds || selectedTagIds.length === 0) { if (!selectedTagIds || selectedTagIds.length === 0) {
@ -38,37 +77,10 @@ const TagSearchResults = () => {
} }
}, [selectedTagIds, navigate]); }, [selectedTagIds, navigate]);
// Perform search on mount
useEffect(() => {
if (selectedTagIds && selectedTagIds.length > 0) {
performSearch();
}
}, []);
// Perform search
const performSearch = async () => {
try {
setIsLoading(true);
setError("");
console.log("[TagSearchResults] Searching with tags:", selectedTagIds);
const searchResults = await tagManager.searchByTags(selectedTagIds, 50);
setResults(searchResults);
console.log("[TagSearchResults] Search completed:", searchResults);
// Decrypt results
await decryptResults(searchResults);
} catch (err) {
console.error("[TagSearchResults] Search failed:", err);
setError(err.message || "Failed to search by tags");
} finally {
setIsLoading(false);
}
};
// Decrypt search results // Decrypt search results
const decryptResults = async (searchResults) => { const decryptResults = useCallback(async (searchResults) => {
if (!isMountedRef.current) return;
try { try {
setIsDecrypting(true); setIsDecrypting(true);
@ -79,273 +91,378 @@ const TagSearchResults = () => {
const decrypted = await CollectionCryptoService.decryptCollectionFromAPI(collection); const decrypted = await CollectionCryptoService.decryptCollectionFromAPI(collection);
return decrypted; return decrypted;
} catch (error) { } catch (error) {
console.error("[TagSearchResults] Failed to decrypt collection:", collection.id, error); if (import.meta.env.DEV) {
console.error("[TagSearchResults] Failed to decrypt collection:", collection.id, error);
}
return null; return null;
} }
}); });
const decryptedColls = (await Promise.all(collectionPromises)).filter(Boolean); const decryptedColls = (await Promise.all(collectionPromises)).filter(Boolean);
setDecryptedCollections(decryptedColls); if (isMountedRef.current) {
setDecryptedCollections(decryptedColls);
}
} }
// Decrypt files // Decrypt files
if (searchResults.files && searchResults.files.length > 0) { if (searchResults.files && searchResults.files.length > 0) {
// Dynamically import FileCryptoService // Use cached FileCryptoService
const { default: FileCryptoService } = await import( const FileCryptoService = await getFileCryptoService();
"../../../services/Crypto/FileCryptoService.js"
);
const filePromises = searchResults.files.map(async (file) => { const filePromises = searchResults.files.map(async (file) => {
try { try {
const decrypted = await FileCryptoService.decryptFileFromAPI(file); const decrypted = await FileCryptoService.decryptFileFromAPI(file);
return decrypted; return decrypted;
} catch (error) { } catch (error) {
console.error("[TagSearchResults] Failed to decrypt file:", file.id, error); if (import.meta.env.DEV) {
console.error("[TagSearchResults] Failed to decrypt file:", file.id, error);
}
return null; return null;
} }
}); });
const decryptedFileList = (await Promise.all(filePromises)).filter(Boolean); const decryptedFileList = (await Promise.all(filePromises)).filter(Boolean);
setDecryptedFiles(decryptedFileList); if (isMountedRef.current) {
setDecryptedFiles(decryptedFileList);
}
} }
} catch (error) { } catch (error) {
console.error("[TagSearchResults] Decryption failed:", error); if (import.meta.env.DEV) {
console.error("[TagSearchResults] Decryption failed:", error);
}
} finally { } finally {
setIsDecrypting(false); if (isMountedRef.current) {
setIsDecrypting(false);
}
} }
}; }, [CollectionCryptoService]);
// Perform search
const performSearch = useCallback(async () => {
if (!tagManager || !isMountedRef.current) return;
try {
setIsLoading(true);
setError("");
if (import.meta.env.DEV) {
console.log("[TagSearchResults] Searching with tags:", selectedTagIds);
}
const searchResults = await tagManager.searchByTags(selectedTagIds, 50);
if (isMountedRef.current) {
setResults(searchResults);
if (import.meta.env.DEV) {
console.log("[TagSearchResults] Search completed:", searchResults);
}
// Decrypt results
await decryptResults(searchResults);
}
} catch (err) {
if (isMountedRef.current) {
if (import.meta.env.DEV) {
console.error("[TagSearchResults] Search failed:", err);
}
setError(err.message || "Failed to search by tags");
}
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
}
}, [tagManager, selectedTagIds, decryptResults]);
// Perform search on mount
useEffect(() => {
if (selectedTagIds && selectedTagIds.length > 0 && tagManager) {
performSearch();
}
}, [tagManager]); // Only run when tagManager becomes available
// Format file size // Format file size
const formatFileSize = (bytes) => { const formatFileSize = useCallback((bytes) => {
if (bytes === 0) return "0 Bytes"; if (bytes === 0) return "0 Bytes";
const k = 1024; const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"]; const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i]; return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i];
}; }, []);
// Format date // Format date
const formatDate = (dateString) => { const formatDate = useCallback((dateString) => {
const date = new Date(dateString); const date = new Date(dateString);
return date.toLocaleDateString("en-US", { return date.toLocaleDateString("en-US", {
year: "numeric", year: "numeric",
month: "short", month: "short",
day: "numeric", day: "numeric",
}); });
}; }, []);
// Memoize breadcrumb items
const breadcrumbItems = useMemo(() => [
{
label: "Settings",
to: "/me",
icon: Cog6ToothIcon,
},
{
label: "Tags",
to: "/me/tags",
icon: TagIcon,
},
{
label: "Search",
to: "/me/tags/search",
icon: MagnifyingGlassIcon,
},
{
label: "Results",
isActive: true,
},
], []);
// Calculate totals
const totalCollections = results?.collectionCount || 0;
const totalFiles = results?.fileCount || 0;
const hasResults = totalCollections > 0 || totalFiles > 0;
return ( return (
<Layout> <Layout>
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */} {/* Breadcrumb Navigation */}
<div className="mb-8 animate-fade-in-down"> <Breadcrumb items={breadcrumbItems} />
<div className="flex items-center justify-between">
<div className="flex items-center">
<button
onClick={() => navigate("/me/tags/search")}
className="mr-4 p-2 text-gray-600 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors duration-200"
>
<ArrowLeftIcon className="h-6 w-6" />
</button>
<div>
<h1 className="text-3xl font-bold text-gray-900 flex items-center">
Search Results
<MagnifyingGlassIcon className="h-8 w-8 text-red-700 ml-2" />
</h1>
<p className="text-gray-600 mt-1">
Showing results for {selectedTagIds.length} selected tag{selectedTagIds.length !== 1 ? "s" : ""}
</p>
</div>
</div>
</div>
</div>
{/* Alerts */} {/* Main Card */}
{error && ( <Card>
<Alert type="error" onClose={() => setError("")}> {/* Header with icon, title, and action buttons */}
{error} <div className={`p-6 border-b ${getThemeClasses("border-secondary")}`}>
</Alert> <div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
)} <div className="flex-1 min-w-0">
<div className="flex items-start">
{/* Loading State */} {/* Icon */}
{isLoading ? ( <div className={`p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 ${getThemeClasses("bg-gradient-secondary")}`}>
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-12 text-center animate-fade-in-up"> <MagnifyingGlassIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-red-800 mx-auto mb-4"></div>
<p className="text-gray-600">Searching...</p>
</div>
) : results ? (
<>
{/* Summary */}
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-6 mb-6 animate-fade-in-up">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-6">
<div className="flex items-center">
<FolderIcon className="h-6 w-6 text-red-700 mr-2" />
<div>
<p className="text-2xl font-bold text-gray-900">{results.collectionCount}</p>
<p className="text-sm text-gray-600">Collections</p>
</div>
</div> </div>
<div className="flex items-center"> {/* Title and subtitle */}
<DocumentIcon className="h-6 w-6 text-red-700 mr-2" /> <div>
<div> <h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
<p className="text-2xl font-bold text-gray-900">{results.fileCount}</p> Search Results
<p className="text-sm text-gray-600">Files</p> </h1>
</div> <p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
Showing results for {selectedTagIds.length} selected tag{selectedTagIds.length !== 1 ? "s" : ""}
</p>
</div> </div>
</div> </div>
<button </div>
{/* Action buttons */}
<div className="flex-shrink-0 flex items-center space-x-3">
<Button
onClick={() => navigate("/me/tags/search")} onClick={() => navigate("/me/tags/search")}
className="inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-white bg-red-700 hover:bg-red-800 transition-colors duration-200" variant="primary"
icon={MagnifyingGlassIcon}
> >
<MagnifyingGlassIcon className="h-4 w-4 mr-2" />
New Search New Search
</button> </Button>
<Button
onClick={() => navigate("/me/tags/search")}
variant="secondary"
size="sm"
>
<span className="inline-flex items-center gap-2">
<ArrowLeftIcon className="h-4 w-4" />
<span>Back</span>
</span>
</Button>
</div> </div>
</div> </div>
</div>
{/* No Results */} {/* Messages */}
{results.collectionCount === 0 && results.fileCount === 0 ? ( <div className="px-6 pt-6">
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-12 text-center animate-fade-in-up"> {error && (
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-gray-400 to-gray-600 mb-4"> <Alert type="error" className="mb-4" onClose={() => setError("")}>
<MagnifyingGlassIcon className="h-8 w-8 text-white" /> {error}
</div> </Alert>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
No results found
</h3>
<p className="text-gray-600 mb-6 max-w-md mx-auto">
No collections or files match all the selected tags. Try selecting fewer tags.
</p>
<button
onClick={() => navigate("/me/tags/search")}
className="inline-flex items-center justify-center px-6 py-3 border border-transparent text-sm font-medium rounded-lg text-white bg-red-700 hover:bg-red-800 transition-colors duration-200"
>
Try Different Tags
</button>
</div>
) : (
<div className="space-y-8">
{/* Collections Section */}
{decryptedCollections.length > 0 && (
<div className="animate-fade-in-up">
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center">
<FolderIcon className="h-6 w-6 text-red-700 mr-2" />
Collections ({decryptedCollections.length})
</h2>
<div className="grid gap-4 md:grid-cols-2">
{decryptedCollections.map((collection, index) => (
<div
key={collection.id}
onClick={() => navigate(`/file-manager/collections/${collection.id}`)}
className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-6 hover:shadow-xl transition-all duration-200 cursor-pointer animate-fade-in-up"
style={{ animationDelay: `${index * 50}ms` }}
>
<div className="flex items-start">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-gradient-to-br from-red-600 to-red-800 rounded-lg flex items-center justify-center">
<FolderIcon className="h-6 w-6 text-white" />
</div>
</div>
<div className="ml-4 flex-1 min-w-0">
<h3 className="text-lg font-semibold text-gray-900 truncate">
{collection.name}
</h3>
{collection.description && (
<p className="mt-1 text-sm text-gray-600 line-clamp-2">
{collection.description}
</p>
)}
<div className="mt-2 flex items-center text-xs text-gray-500">
<span>Created {formatDate(collection.created_at)}</span>
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Files Section */}
{decryptedFiles.length > 0 && (
<div className="animate-fade-in-up" style={{ animationDelay: "100ms" }}>
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center">
<DocumentIcon className="h-6 w-6 text-red-700 mr-2" />
Files ({decryptedFiles.length})
</h2>
<div className="grid gap-4 md:grid-cols-2">
{decryptedFiles.map((file, index) => (
<div
key={file.id}
onClick={() => navigate(`/file-manager/files/${file.id}`)}
className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-6 hover:shadow-xl transition-all duration-200 cursor-pointer animate-fade-in-up"
style={{ animationDelay: `${(decryptedCollections.length + index) * 50}ms` }}
>
<div className="flex items-start">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-gradient-to-br from-blue-600 to-blue-800 rounded-lg flex items-center justify-center">
<DocumentIcon className="h-6 w-6 text-white" />
</div>
</div>
<div className="ml-4 flex-1 min-w-0">
<h3 className="text-lg font-semibold text-gray-900 truncate">
{file.filename}
</h3>
<div className="mt-2 flex items-center space-x-4 text-xs text-gray-500">
<span>{formatFileSize(file.size_in_bytes)}</span>
<span></span>
<span>Uploaded {formatDate(file.created_at)}</span>
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Decrypting State */}
{isDecrypting && (
<div className="text-center py-4">
<p className="text-sm text-gray-600">Decrypting results...</p>
</div>
)}
</div>
)} )}
</> </div>
) : null}
{/* CSS Animations */} {/* Content */}
<style>{` <div className="px-6 pb-3 pt-2">
@keyframes fade-in-down { {/* Loading State */}
from { {isLoading ? (
opacity: 0; <div className="flex items-center justify-center py-12">
transform: translateY(-20px); <div className="text-center">
} <Spinner size="lg" className="mx-auto mb-4" />
to { <p className={getThemeClasses("text-secondary")}>Searching...</p>
opacity: 1; </div>
transform: translateY(0); </div>
} ) : results ? (
} <>
.animate-fade-in-down { {/* Summary Stats */}
animation: fade-in-down 0.5s ease-out both; <div className={`mb-6 pb-4 border-b ${getThemeClasses("border-secondary")}`}>
} <div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center space-x-6">
<div className="flex items-center">
<FolderIcon className={`h-6 w-6 mr-2 ${getThemeClasses("text-secondary")}`} />
<div>
<p className={`text-2xl font-bold ${getThemeClasses("text-primary")}`}>{totalCollections}</p>
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>Collections</p>
</div>
</div>
<div className="flex items-center">
<DocumentIcon className={`h-6 w-6 mr-2 ${getThemeClasses("text-secondary")}`} />
<div>
<p className={`text-2xl font-bold ${getThemeClasses("text-primary")}`}>{totalFiles}</p>
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>Files</p>
</div>
</div>
</div>
{isDecrypting && (
<div className="flex items-center">
<Spinner size="sm" className="mr-2" />
<span className={`text-sm ${getThemeClasses("text-secondary")}`}>Decrypting...</span>
</div>
)}
</div>
</div>
@keyframes fade-in-up { {/* No Results */}
from { {!hasResults ? (
opacity: 0; <div className="text-center py-3">
transform: translateY(20px); <div className={`h-20 w-20 ${getThemeClasses("bg-muted")} rounded-2xl flex items-center justify-center mx-auto mb-3`}>
} <MagnifyingGlassIcon className="h-10 w-10 text-gray-400" />
to { </div>
opacity: 1; <h3 className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`}>
transform: translateY(0); No results found
} </h3>
} <p className={`mb-6 ${getThemeClasses("text-secondary")}`}>
.animate-fade-in-up { No collections or files match all the selected tags. Try selecting fewer tags.
animation: fade-in-up 0.5s ease-out both; </p>
} <Button
`}</style> onClick={() => navigate("/me/tags/search")}
icon={MagnifyingGlassIcon}
variant="primary"
>
Try Different Tags
</Button>
</div>
) : (
<div className="space-y-8">
{/* Collections Section */}
{decryptedCollections.length > 0 && (
<div>
<h2 className={`text-lg font-semibold mb-4 flex items-center ${getThemeClasses("text-primary")}`}>
<FolderIcon className={`h-5 w-5 mr-2 ${getThemeClasses("text-secondary")}`} />
Collections ({decryptedCollections.length})
</h2>
<div className="grid gap-4 md:grid-cols-2">
{decryptedCollections.map((collection) => (
<div
key={collection.id}
role="button"
tabIndex={0}
aria-label={`Open collection ${collection.name}`}
onClick={() => navigate(`/file-manager/collections/${collection.id}`)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
navigate(`/file-manager/collections/${collection.id}`);
}
}}
className={`p-4 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-card")} hover:shadow-md transition-all duration-200 cursor-pointer`}
>
<div className="flex items-start">
<div className={`flex-shrink-0 w-12 h-12 ${getThemeClasses("bg-gradient-secondary")} rounded-lg flex items-center justify-center`}>
<FolderIcon className="h-6 w-6 text-white" />
</div>
<div className="ml-4 flex-1 min-w-0">
<h3 className={`text-base font-semibold truncate ${getThemeClasses("text-primary")}`}>
{collection.name}
</h3>
{collection.description && (
<p className={`mt-1 text-sm line-clamp-2 ${getThemeClasses("text-secondary")}`}>
{collection.description}
</p>
)}
<p className={`mt-2 text-xs ${getThemeClasses("text-muted")}`}>
Created {formatDate(collection.created_at)}
</p>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Files Section */}
{decryptedFiles.length > 0 && (
<div>
<h2 className={`text-lg font-semibold mb-4 flex items-center ${getThemeClasses("text-primary")}`}>
<DocumentIcon className={`h-5 w-5 mr-2 ${getThemeClasses("text-secondary")}`} />
Files ({decryptedFiles.length})
</h2>
<div className="grid gap-4 md:grid-cols-2">
{decryptedFiles.map((file) => (
<div
key={file.id}
role="button"
tabIndex={0}
aria-label={`Open file ${file.filename}`}
onClick={() => navigate(`/file-manager/files/${file.id}`)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
navigate(`/file-manager/files/${file.id}`);
}
}}
className={`p-4 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-card")} hover:shadow-md transition-all duration-200 cursor-pointer`}
>
<div className="flex items-start">
<div className="flex-shrink-0 w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center">
<DocumentIcon className="h-6 w-6 text-white" />
</div>
<div className="ml-4 flex-1 min-w-0">
<h3 className={`text-base font-semibold truncate ${getThemeClasses("text-primary")}`}>
{file.filename}
</h3>
<p className={`mt-2 text-xs ${getThemeClasses("text-muted")}`}>
{formatFileSize(file.size_in_bytes)} Uploaded {formatDate(file.created_at)}
</p>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Info Box */}
<div className={`mt-6 p-4 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-muted")}`}>
<div className="flex">
<div className="flex-shrink-0">
<InformationCircleIcon className={`h-5 w-5 ${getThemeClasses("text-info")}`} />
</div>
<div className="ml-3">
<h3 className={`text-sm font-medium ${getThemeClasses("text-primary")}`}>
About these results
</h3>
<p className={`mt-1 text-sm ${getThemeClasses("text-secondary")}`}>
These results show items that have <strong>ALL</strong> of your selected tags.
Click on any item to view its details.
</p>
</div>
</div>
</div>
</>
) : null}
</div>
</Card>
</div> </div>
</Layout> </Layout>
); );
}; };
export default TagSearchResults; export default withPasswordProtection(TagSearchResults);

View file

@ -361,10 +361,27 @@ class LocalStorageService {
localStorage.setItem(`login_session_${key}`, JSON.stringify(data)); localStorage.setItem(`login_session_${key}`, JSON.stringify(data));
} }
// Get login session data // Get login session data with validation
getLoginSessionData(key) { getLoginSessionData(key) {
const data = localStorage.getItem(`login_session_${key}`); const data = localStorage.getItem(`login_session_${key}`);
return data ? JSON.parse(data) : null; if (!data) return null;
try {
const parsed = JSON.parse(data);
// Validate it's an object (not null, array, or primitive)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed;
}
// Invalid structure - clear corrupted data
localStorage.removeItem(`login_session_${key}`);
console.warn(`[LocalStorageService] Invalid login session data for key: ${key}, cleared`);
return null;
} catch (err) {
// Parse error - clear corrupted data
localStorage.removeItem(`login_session_${key}`);
console.warn(`[LocalStorageService] Failed to parse login session data for key: ${key}, cleared`);
return null;
}
} }
// Clear login session data // Clear login session data