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} containerClassName - Additional classes for the outer container
* @param {boolean} showSecurityFeatures - Whether to show the security features section (default: true)
*/
const GDPRFooter = memo(function GDPRFooter({
className = "",
containerClassName = ""
containerClassName = "",
showSecurityFeatures = true,
}) {
const { getThemeClasses } = useUIXTheme();
@ -36,6 +38,7 @@ 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={`max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 ${className}`}>
{/* Security Features */}
{showSecurityFeatures && (
<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">
<div className="flex items-center space-x-2">
<ShieldCheckIcon className={`h-4 w-4 ${getThemeClasses("icon-success")}`} />
@ -56,9 +59,10 @@ const GDPRFooter = memo(function GDPRFooter({
<span className={getThemeClasses("text-secondary")}>Made in Canada</span>
</div>
</div>
)}
{/* 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>
<strong>Data Controller:</strong> Maple Open Tech Inc. |{" "}
<strong>Location:</strong> Canada (Adequate protection under GDPR Art. 45)

View file

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

View file

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

View file

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

View file

@ -1,8 +1,8 @@
// File: monorepo/web/maplefile-frontend/src/pages/Anonymous/Index/IndexPage.jsx
// 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 { Button, Card, Alert } from "../../../components/UIX";
import { Button, Card, Alert, useUIXTheme } from "../../../components/UIX";
import {
ArrowRightIcon,
PlayIcon,
@ -22,8 +22,22 @@ import {
} from "@heroicons/react/24/outline";
function IndexPage() {
const { getThemeClasses } = useUIXTheme();
const [authMessage, setAuthMessage] = useState("");
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(() => {
// Check for auth redirect message
@ -33,11 +47,11 @@ function IndexPage() {
sessionStorage.removeItem("auth_redirect_message");
// Clear message after 10 seconds
const timer = setTimeout(() => {
timerRef.current = setTimeout(() => {
if (isMountedRef.current) {
setAuthMessage("");
}
}, 10000);
return () => clearTimeout(timer);
}
}, []);
@ -175,28 +189,28 @@ function IndexPage() {
)}
{/* 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="flex justify-between items-center py-4">
<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" />
</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
</span>
</div>
<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
</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
</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
</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
</a>
</div>
@ -220,32 +234,32 @@ function IndexPage() {
</nav>
{/* 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="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-16 pb-24">
<div className="text-center">
{/* Trust Badge */}
<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-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>
<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">
Your Files. Your Privacy.
</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.
</span>
</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{" "}
<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.
{" "}<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>
<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>
</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
</p>
</div>
@ -272,7 +286,7 @@ function IndexPage() {
</div>
{/* 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="text-center mb-12">
<h2 className="text-3xl font-black text-white mb-4">
@ -287,13 +301,13 @@ function IndexPage() {
{problemsWeSolve.map((item, index) => (
<div key={index} className="text-center">
<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
</span>
<p className="text-gray-400 text-lg">{item.problem}</p>
</div>
<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
</span>
<p className="text-white font-semibold text-lg">{item.solution}</p>
@ -305,39 +319,39 @@ function IndexPage() {
</div>
{/* 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="grid grid-cols-2 md:grid-cols-4 gap-8 text-center">
<div className="text-white">
<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
</div>
</div>
<div className="text-white">
<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 className="text-white">
<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 className="text-white">
<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>
{/* 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="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
</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.
</p>
</div>
@ -346,20 +360,20 @@ function IndexPage() {
{howItWorks.map((item, index) => (
<div key={index} className="text-center relative">
{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="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" />
</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}
</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}
</h3>
<p className="text-gray-600 leading-relaxed">
<p className={`${getThemeClasses("text-secondary")} leading-relaxed`}>
{item.description}
</p>
</div>
@ -380,13 +394,13 @@ function IndexPage() {
</div>
{/* 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="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
</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.
</p>
</div>
@ -395,20 +409,20 @@ function IndexPage() {
{securityFeatures.map((feature, index) => (
<Card
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="flex items-start">
<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" />
</div>
</div>
<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}
</h3>
<p className="text-gray-600 leading-relaxed">
<p className={`${getThemeClasses("text-secondary")} leading-relaxed`}>
{feature.description}
</p>
</div>
@ -421,44 +435,44 @@ function IndexPage() {
</div>
{/* 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="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
</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
</p>
</div>
<div className="max-w-3xl mx-auto">
<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="text-gray-600">Feature</div>
<div className="text-red-800">MapleFile</div>
<div className="text-gray-500">Others</div>
<div className={getThemeClasses("text-secondary")}>Feature</div>
<div className={getThemeClasses("text-accent")}>MapleFile</div>
<div className={getThemeClasses("text-secondary")}>Others</div>
</div>
</div>
<div className="divide-y divide-gray-100">
<div className={`divide-y ${getThemeClasses("divide-muted")}`}>
{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 className="text-gray-700 font-medium text-left">{item.feature}</div>
<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={`${getThemeClasses("text-primary")} font-medium text-left`}>{item.feature}</div>
<div>
{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>
{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 ? (
<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>
@ -470,13 +484,13 @@ function IndexPage() {
</div>
{/* 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="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
</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.
</p>
</div>
@ -485,16 +499,16 @@ function IndexPage() {
{useCases.map((useCase, index) => (
<Card
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="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">
<useCase.icon className="h-7 w-7 text-red-800" />
<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 ${getThemeClasses("text-accent")}`} />
</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}
</h3>
<p className="text-gray-600 text-sm leading-relaxed">
<p className={`${getThemeClasses("text-secondary")} text-sm leading-relaxed`}>
{useCase.description}
</p>
</div>
@ -505,34 +519,34 @@ function IndexPage() {
</div>
{/* 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="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
</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.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-6xl mx-auto">
{/* 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="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
</h3>
<p className="text-gray-600 mb-8">
<p className={`${getThemeClasses("text-secondary")} mb-8`}>
Everything you need to get started
</p>
<div className="mb-8">
<span className="text-5xl font-black text-gray-900">
<span className={`text-5xl font-black ${getThemeClasses("text-primary")}`}>
Free
</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 className="space-y-4 mb-8 text-left">
@ -545,8 +559,8 @@ function IndexPage() {
"Community support",
].map((feature, idx) => (
<div key={idx} className="flex items-start">
<CheckIcon className="h-5 w-5 text-green-500 mr-3 mt-0.5 flex-shrink-0" />
<span className="text-gray-700 text-sm font-medium">
<CheckIcon className={`h-5 w-5 ${getThemeClasses("text-success")} mr-3 mt-0.5 flex-shrink-0`} />
<span className={`${getThemeClasses("text-primary")} text-sm font-medium`}>
{feature}
</span>
</div>
@ -558,7 +572,7 @@ function IndexPage() {
Get Started Free
</Button>
</Link>
<p className="mt-3 text-sm text-gray-500">
<p className={`mt-3 text-sm ${getThemeClasses("text-secondary")}`}>
No credit card required
</p>
</div>
@ -567,26 +581,26 @@ function IndexPage() {
{/* Pro Plan */}
<div className="relative">
<div className="absolute -inset-4 bg-gradient-to-r from-red-800 to-red-900 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">
<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 -inset-4 ${getThemeClasses("bg-gradient-secondary")} rounded-3xl blur opacity-20`}></div>
<Card className={`relative border-2 ${getThemeClasses("border-accent")} shadow-2xl`}>
<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
</div>
<div className="p-8 pt-12">
<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
</h3>
<p className="text-gray-600 mb-8">
<p className={`${getThemeClasses("text-secondary")} mb-8`}>
For power users and professionals
</p>
<div className="mb-8">
<span className="text-5xl font-black text-gray-900">
<span className={`text-5xl font-black ${getThemeClasses("text-primary")}`}>
$9.99
</span>
<span className="text-lg text-gray-600 ml-2">
<span className={`text-lg ${getThemeClasses("text-secondary")} ml-2`}>
CAD/month
</span>
</div>
@ -601,8 +615,8 @@ function IndexPage() {
"Early access to features",
].map((feature, idx) => (
<div key={idx} className="flex items-start">
<CheckIcon className="h-5 w-5 text-green-500 mr-3 mt-0.5 flex-shrink-0" />
<span className="text-gray-700 text-sm font-medium">
<CheckIcon className={`h-5 w-5 ${getThemeClasses("text-success")} mr-3 mt-0.5 flex-shrink-0`} />
<span className={`${getThemeClasses("text-primary")} text-sm font-medium`}>
{feature}
</span>
</div>
@ -617,7 +631,7 @@ function IndexPage() {
</span>
</Button>
</Link>
<p className="mt-3 text-sm text-gray-500">
<p className={`mt-3 text-sm ${getThemeClasses("text-secondary")}`}>
Then $9.99 CAD/month
</p>
</div>
@ -626,24 +640,24 @@ function IndexPage() {
</div>
{/* 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="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
</h3>
<p className="text-gray-600 mb-8">
<p className={`${getThemeClasses("text-secondary")} mb-8`}>
For teams and organizations
</p>
<div className="mb-8">
<span className="text-5xl font-black text-gray-900">
<span className={`text-5xl font-black ${getThemeClasses("text-primary")}`}>
$49.99
</span>
<span className="text-lg text-gray-600 ml-2">
<span className={`text-lg ${getThemeClasses("text-secondary")} ml-2`}>
CAD/month
</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 className="space-y-4 mb-8 text-left">
@ -656,8 +670,8 @@ function IndexPage() {
"Dedicated support",
].map((feature, idx) => (
<div key={idx} className="flex items-start">
<CheckIcon className="h-5 w-5 text-green-500 mr-3 mt-0.5 flex-shrink-0" />
<span className="text-gray-700 text-sm font-medium">
<CheckIcon className={`h-5 w-5 ${getThemeClasses("text-success")} mr-3 mt-0.5 flex-shrink-0`} />
<span className={`${getThemeClasses("text-primary")} text-sm font-medium`}>
{feature}
</span>
</div>
@ -677,13 +691,13 @@ function IndexPage() {
</div>
{/* 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="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
</h2>
<p className="text-xl text-gray-600">
<p className={`text-xl ${getThemeClasses("text-secondary")}`}>
Have questions? We've got answers.
</p>
</div>
@ -692,7 +706,7 @@ function IndexPage() {
{faqs.map((faq, index) => (
<Card
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"
}`}
>
@ -700,16 +714,16 @@ function IndexPage() {
onClick={() => setOpenFaq(openFaq === index ? null : index)}
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
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" : ""
}`}
/>
</button>
{openFaq === index && (
<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>
)}
</Card>
@ -719,12 +733,12 @@ function IndexPage() {
</div>
{/* 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">
<h2 className="text-4xl font-black text-white mb-6">
Ready to Take Control of Your Privacy?
</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.
Get started free with 10 GB of encrypted storage.
</p>
@ -733,7 +747,7 @@ function IndexPage() {
<Button
variant="secondary"
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">
Get Started Free
@ -755,43 +769,43 @@ function IndexPage() {
</div>
{/* 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="grid md:grid-cols-2 lg:grid-cols-4 gap-8 mb-12">
<div className="lg:col-span-2">
<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" />
</div>
<span className="text-2xl font-bold text-white">MapleFile</span>
<span className={`text-2xl font-bold ${getThemeClasses("text-primary")}`}>MapleFile</span>
</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
with military-grade encryption and zero-knowledge architecture.
</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>Proudly Canadian</span>
</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
</h3>
<ul className="space-y-3">
<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
</a>
</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
</a>
</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
</a>
</li>
@ -799,22 +813,22 @@ function IndexPage() {
</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
</h3>
<ul className="space-y-3">
<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
</Link>
</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
</Link>
</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
</Link>
</li>
@ -822,19 +836,19 @@ function IndexPage() {
</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">
<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.
</p>
<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
</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
</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
</a>
</div>

View file

@ -1,6 +1,6 @@
// File: monorepo/web/maplefile-frontend/src/pages/Anonymous/Login/CompleteLogin.jsx
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 {
ArrowRightIcon,
@ -15,12 +15,18 @@ import {
} from "@heroicons/react/24/outline";
// UIX Components
import Input from "../../../components/UIX/Input/Input";
import Button from "../../../components/UIX/Button/Button";
import Checkbox from "../../../components/UIX/Checkbox/Checkbox";
import Alert from "../../../components/UIX/Alert/Alert";
import Card from "../../../components/UIX/Card/Card";
import GDPRFooter from "../../../components/UIX/GDPRFooter/GDPRFooter";
import {
Input,
Button,
Checkbox,
Alert,
Card,
GDPRFooter,
PageContainer,
Navigation,
ProgressIndicator,
PageHeader,
} from "../../../components/UIX";
import { useUIXTheme } from "../../../components/UIX/themes/useUIXTheme";
const CompleteLogin = () => {
@ -114,18 +120,34 @@ const CompleteLogin = () => {
}
}
// Try to get verification data
// Try to get verification data with schema validation
try {
const sessionData = sessionStorage.getItem("otpVerificationResult");
if (sessionData) {
storedVerificationData = JSON.parse(sessionData);
const parsed = JSON.parse(sessionData);
// Validate expected structure to prevent injection attacks
if (
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) {
// Parse error - clear corrupted data
sessionStorage.removeItem("otpVerificationResult");
if (import.meta.env.DEV) {
console.warn(
"[CompleteLogin] Could not parse verification data from sessionStorage:",
@ -480,11 +502,27 @@ const CompleteLogin = () => {
// Memoize display 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
if (!verificationData) {
return (
<div className={`min-h-screen ${getThemeClasses("bg-gradient-primary")} flex items-center justify-center`}>
<Card className="text-center p-8">
<PageContainer>
<Card className="text-center p-8 max-w-md mx-auto mt-20">
<h2 className={`text-2xl font-bold ${getThemeClasses("text-primary")} mb-4`}>
Loading...
</h2>
@ -492,86 +530,28 @@ const CompleteLogin = () => {
Preparing secure login...
</p>
</Card>
</div>
</PageContainer>
);
}
return (
<div className={`min-h-screen ${getThemeClasses("bg-gradient-primary")} flex flex-col`}>
{/* Decorative Background Blobs */}
<div className={`${getThemeClasses("decorative-blob-1")}`}></div>
<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>
<PageContainer showBlobs>
{/* Navigation */}
<Navigation icon={LockClosedIcon} logoText="MapleFile" links={navLinks} />
{/* 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="w-full max-w-md space-y-8">
{/* Progress Indicator */}
<div className="flex items-center justify-center">
<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>
<ProgressIndicator steps={progressSteps} currentStep={3} />
{/* Header */}
<div className="text-center">
<div className="flex justify-center mb-6">
<div className="relative">
<div className={`absolute inset-0 ${getThemeClasses("bg-gradient-secondary")} rounded-2xl blur-2xl opacity-20 animate-pulse`}></div>
<div className={`relative h-16 w-16 ${getThemeClasses("bg-gradient-secondary")} rounded-2xl flex items-center justify-center shadow-xl`}>
<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>
<PageHeader
icon={KeyIcon}
title="Unlock Your Account"
subtitle="Enter your master password to decrypt your data."
className="text-center"
/>
{/* GDPR Notice - Master Password Privacy */}
<Alert type="info">
@ -748,7 +728,7 @@ const CompleteLogin = () => {
{/* Footer - GDPR Rights */}
<GDPRFooter />
</div>
</PageContainer>
);
};

View file

@ -1,26 +1,46 @@
// 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 {
Button,
Card,
Alert,
useUIXTheme,
} from "../../../components/UIX";
import {
ClockIcon,
LockClosedIcon,
ShieldCheckIcon,
ArrowRightIcon,
ExclamationTriangleIcon,
InformationCircleIcon,
CheckCircleIcon,
HomeIcon,
} from "@heroicons/react/24/outline";
const SessionExpired = () => {
const navigate = useNavigate();
const location = useLocation();
const { getThemeClasses } = useUIXTheme();
const [countdown, setCountdown] = useState(30);
const timerRef = useRef(null);
const isMountedRef = useRef(true);
// 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
const getSessionInfo = () => {
const getSessionInfo = useCallback(() => {
switch (reason) {
case "inactivity_timeout":
return {
@ -29,8 +49,7 @@ const SessionExpired = () => {
description:
"For your security, we automatically log you out after 60 minutes of inactivity. This helps protect your encrypted files from unauthorized access.",
icon: ClockIcon,
iconColor: "text-amber-600",
iconBg: "bg-amber-100",
type: "warning",
};
case "manual_clear":
return {
@ -39,8 +58,7 @@ const SessionExpired = () => {
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.",
icon: ShieldCheckIcon,
iconColor: "text-blue-600",
iconBg: "bg-blue-100",
type: "info",
};
default:
return {
@ -49,17 +67,18 @@ const SessionExpired = () => {
description:
"For your security, your session has expired. Please sign in again to continue accessing your encrypted files.",
icon: LockClosedIcon,
iconColor: "text-red-600",
iconBg: "bg-red-100",
type: "error",
};
}
};
}, [reason]);
const sessionInfo = getSessionInfo();
// Auto-redirect countdown
useEffect(() => {
const timer = setInterval(() => {
timerRef.current = setInterval(() => {
if (!isMountedRef.current) return;
setCountdown((prev) => {
if (prev <= 1) {
navigate("/login", {
@ -75,46 +94,50 @@ const SessionExpired = () => {
});
}, 1000);
return () => clearInterval(timer);
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
}, [navigate, from]);
const handleSignInNow = () => {
const handleSignInNow = useCallback(() => {
navigate("/login", {
state: {
from: from,
reason: "session_expired",
},
});
};
}, [navigate, from]);
const handleGoHome = () => {
const handleGoHome = useCallback(() => {
navigate("/");
};
}, [navigate]);
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 */}
<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="flex justify-between items-center py-4">
<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" />
</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
</span>
</Link>
<div className="flex items-center space-x-6">
<Link
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?
</Link>
<Link
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?
</Link>
@ -130,107 +153,106 @@ const SessionExpired = () => {
<div className="text-center animate-fade-in-up">
<div className="flex justify-center mb-6">
<div className="relative">
<div
className={`flex items-center justify-center h-16 w-16 ${sessionInfo.iconBg} rounded-2xl shadow-lg`}
>
<sessionInfo.icon
className={`h-8 w-8 ${sessionInfo.iconColor}`}
/>
<div className={`flex items-center justify-center h-16 w-16 ${
sessionInfo.type === "warning" ? getThemeClasses("bg-warning-light") :
sessionInfo.type === "info" ? getThemeClasses("bg-info-light") :
getThemeClasses("bg-error-light")
} 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 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>
<h2 className="text-3xl font-black text-gray-900 mb-2">
<h2 className={`text-3xl font-black ${getThemeClasses("text-primary")} mb-2`}>
{sessionInfo.title}
</h2>
<p className="text-gray-600 mb-4">{sessionInfo.subtitle}</p>
<p className={`${getThemeClasses("text-secondary")} mb-4`}>{sessionInfo.subtitle}</p>
</div>
{/* 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 */}
<div className="mb-6 p-4 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-lg border border-blue-100">
<div className="flex items-start">
<InformationCircleIcon className="h-5 w-5 text-blue-500 mr-3 flex-shrink-0 mt-0.5" />
<Alert type="info" className="mb-6">
<div>
<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>
<strong className="font-semibold">What happened?</strong>
<p className="mt-1">{sessionInfo.description}</p>
</div>
</Alert>
{/* Security Info */}
<div className="mb-6 p-4 bg-gradient-to-r from-green-50 to-emerald-50 rounded-lg border border-green-100">
<div className="flex items-start">
<CheckCircleIcon className="h-5 w-5 text-green-500 mr-3 flex-shrink-0 mt-0.5" />
<Alert type="success" className="mb-6">
<div>
<h3 className="text-sm font-semibold text-green-900 mb-2">
Your data is secure
</h3>
<ul className="text-sm text-green-800 space-y-1">
<strong className="font-semibold">Your data is secure</strong>
<ul className="mt-1 space-y-1">
<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>
<li> Session expiry is a security feature, not a problem</li>
</ul>
</div>
</div>
</div>
</Alert>
{/* 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">
<ClockIcon className="h-5 w-5 text-amber-500 mr-3" />
<p className="text-sm text-amber-800">
<ClockIcon className="h-5 w-5 mr-3 flex-shrink-0" />
<p>
Automatically redirecting to sign in page in{" "}
<span className="font-bold text-amber-900">{countdown}</span>{" "}
<span className="font-bold">{countdown}</span>{" "}
seconds
</p>
</div>
</div>
</Alert>
{/* Action Buttons */}
<div className="space-y-4">
<button
<Button
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
<ArrowRightIcon className="ml-2 h-4 w-4 group-hover:translate-x-1 transition-transform duration-200" />
</button>
<span className="inline-flex items-center gap-2">
<span>Sign In Now</span>
<ArrowRightIcon className="h-4 w-4" />
</span>
</Button>
<button
<Button
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
</button>
<span className="inline-flex items-center gap-2">
<HomeIcon className="h-4 w-4" />
<span>Go to Homepage</span>
</span>
</Button>
</div>
{/* 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">
<Link
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
</Link>
<Link
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?
</Link>
</div>
</div>
</Card>
{/* 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 className="mt-1">This helps keep your encrypted files secure</p>
</div>
@ -238,26 +260,26 @@ const SessionExpired = () => {
</div>
{/* 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="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>
<div className="mt-2 space-x-4">
<Link
to="#"
className="hover:text-gray-700 transition-colors duration-200"
to="/privacy"
className={`${getThemeClasses("hover:text-accent")} transition-colors duration-200`}
>
Privacy Policy
</Link>
<Link
to="#"
className="hover:text-gray-700 transition-colors duration-200"
to="/terms"
className={`${getThemeClasses("hover:text-accent")} transition-colors duration-200`}
>
Terms of Service
</Link>
<Link
to="#"
className="hover:text-gray-700 transition-colors duration-200"
to="/support"
className={`${getThemeClasses("hover:text-accent")} transition-colors duration-200`}
>
Support
</Link>

View file

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

View file

@ -1,7 +1,15 @@
// 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 { useServices } from "../../../services/Services";
import {
Button,
Input,
Alert,
Card,
Spinner,
useUIXTheme,
} from "../../../components/UIX";
import {
ArrowRightIcon,
ArrowLeftIcon,
@ -13,16 +21,17 @@ import {
KeyIcon,
EyeIcon,
EyeSlashIcon,
DocumentTextIcon,
CheckCircleIcon,
ArrowPathIcon,
LockOpenIcon,
ServerIcon,
} from "@heroicons/react/24/outline";
const CompleteRecovery = () => {
const navigate = useNavigate();
const { recoveryManager } = useServices();
const { getThemeClasses } = useUIXTheme();
const isMountedRef = useRef(true);
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [recoveryPhrase, setRecoveryPhrase] = useState("");
@ -31,7 +40,14 @@ const CompleteRecovery = () => {
const [email, setEmail] = useState("");
const [showNewPassword, setShowNewPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [showRecoveryPhrase, setShowRecoveryPhrase] = useState(false);
// Cleanup on unmount
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
useEffect(() => {
// Check if we have completed verification
@ -39,9 +55,9 @@ const CompleteRecovery = () => {
const isVerified = recoveryManager.isVerificationComplete();
if (!recoveryEmail || !isVerified) {
console.log(
"[CompleteRecovery] No verified recovery session, redirecting",
);
if (import.meta.env.DEV) {
console.log("[CompleteRecovery] No verified recovery session, redirecting");
}
navigate("/recovery/initiate");
return;
}
@ -49,8 +65,11 @@ const CompleteRecovery = () => {
setEmail(recoveryEmail);
}, [navigate, recoveryManager]);
const handleSubmit = async (e) => {
const handleSubmit = useCallback(async (e) => {
e.preventDefault();
if (!isMountedRef.current) return;
setLoading(true);
setError("");
@ -77,15 +96,21 @@ const CompleteRecovery = () => {
// Join words with single space
const normalizedPhrase = words.join(" ");
if (import.meta.env.DEV) {
console.log("[CompleteRecovery] Completing recovery with new password");
}
// Complete recovery with both recovery phrase and new password
const response = await recoveryManager.completeRecoveryWithPhrase(
await recoveryManager.completeRecoveryWithPhrase(
normalizedPhrase,
newPassword,
);
if (!isMountedRef.current) return;
if (import.meta.env.DEV) {
console.log("[CompleteRecovery] Recovery completed successfully");
}
// Show success modal or alert
alert(
@ -94,50 +119,88 @@ const CompleteRecovery = () => {
// Navigate to login
navigate("/login");
} catch (error) {
console.error("[CompleteRecovery] Recovery completion failed:", error);
setError(error.message);
} catch (err) {
if (!isMountedRef.current) return;
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 = () => {
const handleBackToVerify = useCallback(() => {
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) {
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">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Loading...</h2>
<p className="text-gray-600">Checking recovery session...</p>
<Spinner size="lg" className="mx-auto mb-4" />
<h2 className={`text-2xl font-bold ${getThemeClasses("text-primary")} mb-4`}>Loading...</h2>
<p className={getThemeClasses("text-secondary")}>Checking recovery session...</p>
</div>
</div>
);
}
// Count words in recovery phrase
const wordCount = recoveryPhrase.trim()
? recoveryPhrase.trim().split(/\s+/).length
: 0;
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 */}
<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="flex justify-between items-center py-4">
<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" />
</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
</span>
</Link>
<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
</span>
</div>
@ -152,28 +215,28 @@ const CompleteRecovery = () => {
<div className="flex items-center justify-center mb-8">
<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-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" />
</div>
<span className="ml-2 text-sm font-semibold text-green-600">
<span className={`ml-2 text-sm font-semibold ${getThemeClasses("text-success")}`}>
Email
</span>
</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 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" />
</div>
<span className="ml-2 text-sm font-semibold text-green-600">
<span className={`ml-2 text-sm font-semibold ${getThemeClasses("text-success")}`}>
Verify
</span>
</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 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
</div>
<span className="ml-2 text-sm font-semibold text-gray-900">
<span className={`ml-2 text-sm font-semibold ${getThemeClasses("text-primary")}`}>
Reset
</span>
</div>
@ -184,20 +247,20 @@ const CompleteRecovery = () => {
<div className="text-center animate-fade-in-up">
<div className="flex justify-center mb-6">
<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" />
</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>
<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
</h2>
<p className="text-gray-600 mb-2">
<p className={`${getThemeClasses("text-secondary")} mb-2`}>
Final step: Create a new password for {email}
</p>
<div className="flex items-center justify-center space-x-2 text-sm text-gray-500">
<ArrowPathIcon className="h-4 w-4 text-green-600" />
<div className={`flex items-center justify-center space-x-2 text-sm ${getThemeClasses("text-secondary")}`}>
<ArrowPathIcon className={`h-4 w-4 ${getThemeClasses("text-success")}`} />
<span>
Your encryption keys will be re-encrypted with the new password
</span>
@ -205,38 +268,33 @@ const CompleteRecovery = () => {
</div>
{/* 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 && (
<div className="mb-6 p-4 rounded-lg bg-red-50 border border-red-200 animate-fade-in">
<div className="flex items-center">
<ExclamationTriangleIcon className="h-5 w-5 text-red-500 mr-3 flex-shrink-0" />
<Alert
type="error"
dismissible
onDismiss={() => setError("")}
className="mb-6 animate-fade-in"
>
<div>
<h3 className="text-sm font-semibold text-red-800">
Recovery Error
</h3>
<p className="text-sm text-red-700 mt-1">{error}</p>
</div>
</div>
<strong className="font-semibold">Recovery Error</strong>
<p className="mt-1">{error}</p>
</div>
</Alert>
)}
{/* Security Notice */}
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start">
<InformationCircleIcon className="h-5 w-5 text-blue-600 mr-3 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="text-sm font-semibold text-blue-800 mb-1">
Why enter your recovery phrase again?
</h3>
<p className="text-sm text-blue-700">
<Alert type="info" className="mb-6">
<div>
<strong className="font-semibold">Why enter your recovery phrase again?</strong>
<p className="mt-1">
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>
</Alert>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
@ -245,17 +303,17 @@ const CompleteRecovery = () => {
<div className="flex items-center justify-between mb-2">
<label
htmlFor="recoveryPhrase"
className="block text-sm font-semibold text-gray-700"
className={`block text-sm font-semibold ${getThemeClasses("text-primary")}`}
>
Recovery Phrase (Required Again)
</label>
<span
className={`text-xs font-medium ${
wordCount === 12
? "text-green-600"
? getThemeClasses("text-success")
: wordCount > 0
? "text-amber-600"
: "text-gray-500"
? getThemeClasses("text-warning")
: getThemeClasses("text-secondary")
}`}
>
{wordCount}/12 words
@ -270,112 +328,56 @@ const CompleteRecovery = () => {
rows={3}
required
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
? "border-green-300 bg-green-50"
: "border-gray-300"
? `${getThemeClasses("border-success")} ${getThemeClasses("bg-success-light")}`
: 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>
{/* 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">
<h3 className="text-sm font-semibold text-green-900 flex items-center">
<div className={`space-y-4 p-4 ${getThemeClasses("bg-success-light")} rounded-lg border ${getThemeClasses("border-success")}`}>
<h3 className={`text-sm font-semibold ${getThemeClasses("text-primary")} flex items-center`}>
<KeyIcon className="h-4 w-4 mr-2" />
Create Your New Password
</h3>
{/* New Password */}
<div>
<label
htmlFor="newPassword"
className="block text-sm font-semibold text-gray-700 mb-2"
>
New Password
</label>
<div className="relative">
<input
<Input
label="New Password"
type={showNewPassword ? "text" : "password"}
id="newPassword"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
name="newPassword"
placeholder="Enter your new password"
required
value={newPassword}
onChange={(value) => setNewPassword(value)}
disabled={loading}
required
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"
}`}
icon={LockClosedIcon}
suffix={newPasswordSuffix}
helperText="Password must be at least 8 characters long"
error={error && error.includes("password") ? error : null}
/>
<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 */}
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-semibold text-gray-700 mb-2"
>
Confirm New Password
</label>
<div className="relative">
<input
<Input
label="Confirm New Password"
type={showConfirmPassword ? "text" : "password"}
id="confirmPassword"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
name="confirmPassword"
placeholder="Confirm your new password"
required
value={confirmPassword}
onChange={(value) => setConfirmPassword(value)}
disabled={loading}
required
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"
}`}
icon={LockClosedIcon}
suffix={confirmPasswordSuffix}
/>
<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 && (
<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" />
Passwords match
</p>
@ -384,99 +386,82 @@ const CompleteRecovery = () => {
</div>
<div className="space-y-3">
<button
<Button
type="submit"
variant="primary"
fullWidth
disabled={
loading ||
wordCount !== 12 ||
!newPassword ||
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 ? (
<>
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
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>
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" />
</>
{!loading && (
<span className="inline-flex items-center gap-2">
<CheckCircleIcon className="h-4 w-4" />
<span>Complete Recovery</span>
<ArrowRightIcon className="h-4 w-4" />
</span>
)}
</button>
{loading && "Setting New Password..."}
</Button>
<button
<Button
type="button"
variant="secondary"
fullWidth
onClick={handleBackToVerify}
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" />
Back to Verification
</button>
<span className="inline-flex items-center gap-2">
<ArrowLeftIcon className="h-4 w-4" />
<span>Back to Verification</span>
</span>
</Button>
</div>
</form>
</div>
</Card>
{/* 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">
<h3 className="text-sm font-semibold text-blue-900 mb-3 flex items-center">
<InformationCircleIcon className="h-4 w-4 mr-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 ${getThemeClasses("text-primary")} mb-3 flex items-center`}>
<InformationCircleIcon className={`h-4 w-4 mr-2 ${getThemeClasses("text-info")}`} />
What Happens Next?
</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">
<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
</li>
<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
</li>
<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
</li>
<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
</li>
<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
</li>
</ul>
</div>
{/* Security Notes */}
<div className="bg-gray-50 rounded-lg border border-gray-200 p-4 animate-fade-in-up-delay-3">
<h4 className="text-xs font-semibold text-gray-700 mb-2 flex items-center">
<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 ${getThemeClasses("text-primary")} mb-2 flex items-center`}>
<ShieldCheckIcon className="h-4 w-4 mr-1" />
Security Notes
</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> Your new password will be used to encrypt your keys</p>
<p> Keep your recovery phrase safe - it hasn't changed</p>
@ -487,26 +472,26 @@ const CompleteRecovery = () => {
</div>
{/* 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="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>
<div className="mt-2 space-x-4">
<Link
to="/privacy"
className="hover:text-gray-700 transition-colors duration-200"
className={`${getThemeClasses("hover:text-accent")} transition-colors duration-200`}
>
Privacy Policy
</Link>
<Link
to="/terms"
className="hover:text-gray-700 transition-colors duration-200"
className={`${getThemeClasses("hover:text-accent")} transition-colors duration-200`}
>
Terms of Service
</Link>
<Link
to="/support"
className="hover:text-gray-700 transition-colors duration-200"
className={`${getThemeClasses("hover:text-accent")} transition-colors duration-200`}
>
Support
</Link>

View file

@ -161,7 +161,7 @@ const VerifyRecovery = () => {
if (!email) {
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">
<h2 className={`text-2xl font-bold ${getThemeClasses("text-primary")} mb-4`}>Loading...</h2>
<p className={getThemeClasses("text-secondary")}>Checking recovery session...</p>
@ -176,16 +176,16 @@ const VerifyRecovery = () => {
: 0;
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 */}
<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="flex justify-between items-center py-4">
<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" />
</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
</span>
</Link>
@ -205,16 +205,16 @@ const VerifyRecovery = () => {
<div className="flex items-center justify-center mb-8">
<div className="flex items-center space-x-4">
<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" />
</div>
<span className={`ml-2 text-sm font-semibold ${getThemeClasses("text-success")}`}>
Email
</span>
</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 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
</div>
<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="flex justify-center mb-6">
<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" />
</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>
<h2 className={`text-3xl font-black ${getThemeClasses("text-primary")} mb-2`}>
@ -325,10 +325,10 @@ const VerifyRecovery = () => {
rows={4}
required
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
? "border-green-300 bg-green-50"
: getThemeClasses("input-border")
? `${getThemeClasses("border-success")} ${getThemeClasses("bg-success-light")}`
: getThemeClasses("border-muted")
}`}
/>
<div className="mt-2 flex items-center justify-between">

View file

@ -177,30 +177,19 @@ const RecoveryCode = () => {
}, [recoveryMnemonic]);
const handlePrint = useCallback(() => {
// HTML escape function to prevent XSS
const escapeHtml = (text) => {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
};
const printWindow = window.open("", "_blank");
if (!printWindow) {
// Popup blocked - fall back to alert
alert("Please allow popups to print your recovery phrase.");
return;
}
// Sanitize user-controlled data to prevent XSS
const safeEmail = escapeHtml(email);
const safeDate = escapeHtml(new Date().toLocaleString());
const safeWords = recoveryMnemonic
.split(" ")
.map((word, index) =>
`<span class="word">${index + 1}. ${escapeHtml(word)}</span>`
)
.join("");
const doc = printWindow.document;
printWindow.document.write(`
<html>
<head>
<title>MapleFile Recovery Phrase</title>
<style>
// 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;
@ -241,48 +230,147 @@ const RecoveryCode = () => {
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>
.privacy-notice {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #ddd;
}
.privacy-text {
font-size: 10px;
color: #666;
}
`;
doc.head.appendChild(style);
<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>
// Set title safely
doc.title = "MapleFile Recovery Phrase";
<div class="mnemonic">
${safeWords}
</div>
// Build body content using textContent for user data (XSS-safe)
const body = doc.body;
<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>
// 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();
};
<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();
// Fallback: close after delay if onafterprint isn't supported
setTimeout(() => {
if (!printWindow.closed) {
printWindow.close();
}
}, 500);
};
// Trigger print once content is ready
if (printWindow.document.readyState === 'complete') {
triggerPrint();
} else {
printWindow.onload = triggerPrint;
// Fallback timeout in case onload doesn't fire
setTimeout(triggerPrint, 250);
}
}, [email, recoveryMnemonic]);
const handleCheckboxChange = useCallback((checked) => {

View file

@ -125,9 +125,14 @@ const VerifyEmail = () => {
const sanitized = value.replace(/\D/g, ""); // Only allow digits
if (sanitized.length <= 8) {
setVerificationCode(sanitized);
// Clear errors when user types
setGeneralError("");
setFieldErrors((prev) => ({ ...prev, code: "" }));
// Clear errors when user types - use functional updates to avoid unnecessary re-renders
setGeneralError((prev) => prev ? "" : prev);
setFieldErrors((prev) => {
if (prev.code) {
return { ...prev, code: "" };
}
return prev;
});
}
}, []);
@ -341,42 +346,16 @@ const VerifyEmail = () => {
</span>
</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 */}
<Card className="shadow-2xl animate-fade-in-up-delay">
{/* Error Message Box with Field Errors */}
{(generalError || Object.keys(fieldErrors).length > 0) && (
{/* Error Message Box - only show after form submission attempt */}
{generalError && (
<Alert type="error" className="mb-6">
<div>
<h3 className="text-base font-bold mb-2">
Verification Failed
</h3>
{generalError && (
<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>
)}
<p className="text-sm">{generalError}</p>
</div>
</Alert>
)}
@ -436,8 +415,7 @@ const VerifyEmail = () => {
) : (
<>
<CheckIcon className="mr-2 h-4 w-4 inline-block" />
<span className="inline-block">Verify Email & Complete</span>
<ArrowRightIcon className="ml-2 h-4 w-4 inline-block" />
<span className="inline-block">Submit</span>
</>
)}
</Button>
@ -488,8 +466,7 @@ const VerifyEmail = () => {
{/* Help Section */}
<Alert type="info" className="animate-fade-in-up-delay-2">
<div>
<h3 className="text-sm font-semibold mb-3 flex items-center">
<InformationCircleIcon className="h-4 w-4 mr-2" />
<h3 className="text-sm font-semibold mb-3">
Having trouble?
</h3>
<ul className="text-sm space-y-2">

View file

@ -94,7 +94,6 @@ const VerifySuccess = () => {
timerRef.current = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
navigate("/login");
return 0;
}
return prev - 1;
@ -106,8 +105,18 @@ const VerifySuccess = () => {
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) => {
switch (role) {
@ -362,10 +371,11 @@ const VerifySuccess = () => {
onClick={handleGoToLogin}
variant="primary"
fullWidth
className="whitespace-nowrap"
>
<LockClosedIcon className="mr-2 h-5 w-5" />
Sign In Now
<ArrowRightIcon className="ml-2 h-4 w-4" />
<LockClosedIcon className="mr-2 h-5 w-5 inline-block" />
<span className="inline-block">Sign In Now</span>
<ArrowRightIcon className="ml-2 h-4 w-4 inline-block" />
</Button>
</div>
</div>

View file

@ -11,16 +11,7 @@ import {
} from "../../../services/Services";
import withPasswordProtection from "../../../hocs/withPasswordProtection";
import Layout from "../../../components/Layout/Layout";
import { Card, Button, Alert, useUIXTheme } from "../../../components/UIX";
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { Card, Button, Alert, Spinner, useUIXTheme } from "../../../components/UIX";
import {
CloudArrowUpIcon,
FolderIcon,
@ -29,10 +20,19 @@ import {
ArrowPathIcon,
ArrowTrendingUpIcon,
ClockIcon,
ChartBarIcon,
SparklesIcon,
} 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 navigate = useNavigate();
const location = useLocation();
@ -47,6 +47,7 @@ const Dashboard = () => {
const isMountedRef = useRef(true);
const [isLoading, setIsLoading] = useState(true);
const [isInitialized, setIsInitialized] = useState(false);
const [generalError, setGeneralError] = useState("");
const [fieldErrors, setFieldErrors] = useState({});
const [dashboardData, setDashboardData] = useState(null);
@ -77,38 +78,38 @@ const Dashboard = () => {
async (files) => {
if (!files || files.length === 0) return [];
const { default: FileCryptoService } = await import(
"../../../services/Crypto/FileCryptoService.js"
);
const decryptedFiles = [];
// Use cached import for better performance
const FileCryptoService = await getFileCryptoService();
for (const file of files) {
// Process files in parallel for better performance
const decryptedFiles = await Promise.all(
files.map(async (file) => {
try {
const collectionKey = CollectionCryptoService.getCachedCollectionKey(
file.collection_id,
);
if (!collectionKey) {
decryptedFiles.push({
return {
...file,
name: "Locked File",
_isDecrypted: false,
});
continue;
};
}
const decryptedFile = await FileCryptoService.decryptFileFromAPI(
file,
collectionKey,
);
decryptedFiles.push(decryptedFile);
return decryptedFile;
} catch {
decryptedFiles.push({
return {
...file,
name: "Locked File",
_isDecrypted: false,
});
}
};
}
})
);
return decryptedFiles;
},
@ -149,6 +150,7 @@ const Dashboard = () => {
if (isMountedRef.current) {
setDashboardData(data);
setIsInitialized(true);
if (import.meta.env.DEV) {
console.log("[Dashboard] Dashboard loaded successfully");
}
@ -170,6 +172,7 @@ const Dashboard = () => {
} else {
setGeneralError("Could not load your dashboard. Please try again.");
}
setIsInitialized(true);
} finally {
if (isMountedRef.current) {
setIsLoading(false);
@ -212,19 +215,21 @@ const Dashboard = () => {
useEffect(() => {
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
// This handles cases where sharing/unsharing happened in other tabs
// or when the user was removed from a shared collection
clearDashboardCache();
loadDashboardData(true);
}
return () => {
isMountedRef.current = false;
};
}, [dashboardManager, authManager, loadDashboardData, clearDashboardCache]);
}, [dashboardManager, authManager, isInitialized, loadDashboardData, clearDashboardCache]);
useEffect(() => {
if (createFileManager) {
@ -379,18 +384,6 @@ const Dashboard = () => {
bgColor: getThemeClasses("stat-folders-bg"),
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",
value:
@ -408,23 +401,6 @@ const Dashboard = () => {
[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(() =>
dashboardData?.recent_files?.slice(0, 5) || [],
[dashboardData]
@ -475,7 +451,7 @@ const Dashboard = () => {
{isLoading && !dashboardData && (
<div className="flex items-center justify-center py-12">
<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>
</div>
</div>
@ -513,10 +489,12 @@ const Dashboard = () => {
{dashboardData && (
<>
{/* 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) => (
<Card
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"
style={{ animationDelay: `${index * 100}ms` }}
>
@ -544,140 +522,6 @@ const Dashboard = () => {
))}
</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 */}
<Card
className="animate-fade-in-up"
@ -698,15 +542,24 @@ const Dashboard = () => {
</div>
{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) => (
<div
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 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)}
</div>
<div>
@ -717,27 +570,30 @@ const Dashboard = () => {
<span className={`text-xs ${getThemeClasses("text-secondary")}`}>
{formatFileSize(file.size)}
</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`}>
<ClockIcon className="h-3 w-3 mr-1" />
<ClockIcon className="h-3 w-3 mr-1" aria-hidden="true" />
{getTimeAgo(file.created_at)}
</span>
</div>
</div>
</div>
<button
<Button
onClick={() => handleDownloadFile(file.id)}
disabled={
!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) ? (
<ArrowPathIcon className="h-4 w-4 animate-spin" />
) : (
<ArrowDownTrayIcon className="h-4 w-4" />
)}
</button>
</Button>
</div>
</div>
))}
@ -758,40 +614,8 @@ const Dashboard = () => {
</Card>
</>
)}
</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>
);
};

View file

@ -420,7 +420,7 @@ const CollectionCreate = () => {
className={`relative flex cursor-pointer rounded-lg border p-4 transition-all ${
isSelected
? `${getThemeClasses("input-border")} ${getThemeClasses("alert-info-bg")}`
: `${getThemeClasses("border-secondary")} hover:bg-gray-50`
: `${getThemeClasses("border-secondary")} ${getThemeClasses("hover:bg-muted")}`
}`}
>
<input
@ -474,7 +474,7 @@ const CollectionCreate = () => {
</span>
</span>
</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
customIcon={customIcon}
collectionType={collectionType}
@ -495,7 +495,7 @@ const CollectionCreate = () => {
type="button"
onClick={handleOpenIconPicker}
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"}
</button>
@ -504,7 +504,7 @@ const CollectionCreate = () => {
type="button"
onClick={() => setCustomIcon("")}
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
</button>

View file

@ -1,34 +1,53 @@
// File: src/pages/FileManager/Collections/CollectionEdit.jsx
import React, { useState, useEffect } from "react";
import { Link, useNavigate, useParams } from "react-router";
import Navigation from "../../../../components/Navigation";
// File: src/pages/User/FileManager/Collections/CollectionEdit.jsx
// NOTE: This is a mock/placeholder page - the logic is not implemented yet
import React, { useState, useEffect, useRef, useCallback } from "react";
import { useNavigate, useParams } from "react-router";
import withPasswordProtection from "../../../../hocs/withPasswordProtection";
import Layout from "../../../../components/Layout/Layout";
import {
Button,
Input,
Alert,
Card,
Breadcrumb,
Modal,
Select,
useUIXTheme,
} from "../../../../components/UIX";
import {
FolderIcon,
PhotoIcon,
ArrowLeftIcon,
InformationCircleIcon,
ShieldCheckIcon,
LockClosedIcon,
UsersIcon,
CheckIcon,
XMarkIcon,
PlusIcon,
SparklesIcon,
DocumentDuplicateIcon,
GlobeAltIcon,
ChevronRightIcon,
HomeIcon,
ExclamationTriangleIcon,
TrashIcon,
ArrowPathIcon,
LockClosedIcon,
Cog6ToothIcon,
} from "@heroicons/react/24/outline";
const CollectionEdit = () => {
const navigate = useNavigate();
const { collectionId } = useParams();
const { getThemeClasses } = useUIXTheme();
const isMountedRef = useRef(true);
// Cleanup on unmount
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
// Mock existing collection data
const [collectionData, setCollectionData] = useState({
const [collectionData] = useState({
id: collectionId || "1",
name: "Work Documents",
type: "folder",
@ -91,319 +110,275 @@ const CollectionEdit = () => {
setHasChanges(changed);
}, [formData, selectedUsers, collectionData]);
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleAddUser = (user) => {
if (!selectedUsers.find((u) => u.id === user.id)) {
setSelectedUsers([...selectedUsers, { ...user, role: "viewer" }]);
const handleAddUser = useCallback((user) => {
setSelectedUsers((prev) => {
if (!prev.find((u) => u.id === user.id)) {
return [...prev, { ...user, role: "viewer" }];
}
return prev;
});
setShowUserSearch(false);
setUserSearchQuery("");
};
}, []);
const handleRemoveUser = (userId) => {
setSelectedUsers(selectedUsers.filter((u) => u.id !== userId));
};
const handleRemoveUser = useCallback((userId) => {
setSelectedUsers((prev) => prev.filter((u) => u.id !== userId));
}, []);
const handleUserRoleChange = (userId, newRole) => {
setSelectedUsers(
selectedUsers.map((u) => (u.id === userId ? { ...u, role: newRole } : u)),
const handleUserRoleChange = useCallback((userId, newRole) => {
setSelectedUsers((prev) =>
prev.map((u) => (u.id === userId ? { ...u, role: newRole } : u))
);
};
}, []);
const handleSave = () => {
const handleSave = useCallback(() => {
if (!isMountedRef.current) return;
setIsLoading(true);
// Simulate save
setTimeout(() => {
if (isMountedRef.current) {
navigate(`/file-manager/collections/${collectionId}`);
}
}, 1000);
};
}, [navigate, collectionId]);
const handleDelete = () => {
const handleDelete = useCallback(() => {
// Simulate delete
navigate("/file-manager/collections");
};
navigate("/file-manager");
}, [navigate]);
const handleReset = useCallback(() => {
setFormData({
name: collectionData.name,
description: collectionData.description,
parentCollection: collectionData.parentCollection,
privacyMode: collectionData.privacyMode,
});
setSelectedUsers(collectionData.sharedWith);
}, [collectionData]);
// Breadcrumb items
const breadcrumbItems = [
{ label: "My Files", to: "/file-manager", icon: HomeIcon },
{ label: collectionData.name, to: `/file-manager/collections/${collectionId}`, icon: FolderIcon },
{ label: "Edit", isActive: true },
];
// Parent collection options
const parentOptions = [
{ value: "", label: "Root level (no parent)" },
{ value: "2", label: "Personal Files" },
{ value: "3", label: "Archive" },
{ value: "4", label: "Shared Projects" },
];
// Role options
const roleOptions = [
{ value: "viewer", label: "Can view" },
{ value: "editor", label: "Can edit" },
];
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-red-50">
<Navigation />
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{/* Breadcrumb */}
<div className="flex items-center space-x-2 text-sm text-gray-600 mb-6">
<HomeIcon className="h-4 w-4" />
<ChevronRightIcon className="h-3 w-3" />
<Link to="/file-manager/collections" className="hover:text-gray-900">
My Files
</Link>
<ChevronRightIcon className="h-3 w-3" />
<Link
to={`/file-manager/collections/${collectionId}`}
className="hover:text-gray-900"
>
{collectionData.name}
</Link>
<ChevronRightIcon className="h-3 w-3" />
<span className="font-medium text-gray-900">Edit</span>
</div>
<Layout>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Breadcrumb Navigation */}
<Breadcrumb items={breadcrumbItems} />
{/* Main Card */}
<Card>
{/* Header */}
<div className="mb-8">
<button
onClick={() =>
navigate(`/file-manager/collections/${collectionId}`)
}
className="inline-flex items-center text-sm text-gray-600 hover:text-gray-900 mb-4 transition-colors duration-200"
>
<ArrowLeftIcon className="h-4 w-4 mr-1" />
Back to Collection
</button>
<div className="flex items-center justify-between">
<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-gradient-secondary")}`}>
<Cog6ToothIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
</div>
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
Edit Collection
</h1>
<p className="text-gray-600">
<p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
Update collection settings and permissions
</p>
</div>
</div>
</div>
<div className="flex items-center gap-3">
{hasChanges && (
<div className="flex items-center text-amber-600 bg-amber-50 px-4 py-2 rounded-lg">
<div className={`flex items-center ${getThemeClasses("bg-warning-light")} ${getThemeClasses("text-warning")} px-4 py-2 rounded-lg`}>
<ExclamationTriangleIcon className="h-5 w-5 mr-2" />
<span className="text-sm font-medium">
You have unsaved changes
</span>
<span className="text-sm font-medium">Unsaved changes</span>
</div>
)}
<Button
onClick={() => navigate(`/file-manager/collections/${collectionId}`)}
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>
{/* Main Form */}
<div className="space-y-6">
{/* Collection Info Card */}
<div className="bg-gray-50 rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between">
{/* Content */}
<div className="p-6 space-y-6">
{/* Collection Info Summary */}
<Card className={`border ${getThemeClasses("border-muted")} p-6`}>
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center space-x-4">
<div
className={`flex items-center justify-center h-12 w-12 rounded-lg ${
<div className={`flex items-center justify-center h-12 w-12 rounded-lg ${
collectionData.type === "album"
? "bg-pink-100 text-pink-600"
: "bg-blue-100 text-blue-600"
}`}
>
? getThemeClasses("bg-accent-light")
: getThemeClasses("bg-info-light")
}`}>
{collectionData.type === "album" ? (
<PhotoIcon className="h-6 w-6" />
<PhotoIcon className={`h-6 w-6 ${getThemeClasses("text-accent")}`} />
) : (
<FolderIcon className="h-6 w-6" />
<FolderIcon className={`h-6 w-6 ${getThemeClasses("text-info")}`} />
)}
</div>
<div>
<p className="text-sm text-gray-500">Collection Type</p>
<p className="font-semibold text-gray-900 capitalize">
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>Collection Type</p>
<p className={`font-semibold ${getThemeClasses("text-primary")} capitalize`}>
{collectionData.type}
</p>
</div>
</div>
<div className="grid grid-cols-3 gap-6 text-center">
<div>
<p className="text-2xl font-bold text-gray-900">
<div className="flex gap-8">
<div className="text-center">
<p className={`text-2xl font-bold ${getThemeClasses("text-primary")}`}>
{collectionData.itemCount}
</p>
<p className="text-sm text-gray-500">Items</p>
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>Items</p>
</div>
<div>
<p className="text-2xl font-bold text-gray-900">
<div className="text-center">
<p className={`text-2xl font-bold ${getThemeClasses("text-primary")}`}>
{collectionData.totalSize}
</p>
<p className="text-sm text-gray-500">Total Size</p>
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>Total Size</p>
</div>
<div>
<p className="text-2xl font-bold text-gray-900">
<div className="text-center">
<p className={`text-2xl font-bold ${getThemeClasses("text-primary")}`}>
{selectedUsers.length}
</p>
<p className="text-sm text-gray-500">Shared</p>
</div>
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>Shared</p>
</div>
</div>
</div>
</Card>
{/* Basic Information */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<DocumentDuplicateIcon className="h-5 w-5 mr-2 text-gray-500" />
<Card className={`border ${getThemeClasses("border-muted")} p-6`}>
<h2 className={`text-lg font-semibold ${getThemeClasses("text-primary")} mb-4 flex items-center`}>
<DocumentDuplicateIcon className={`h-5 w-5 mr-2 ${getThemeClasses("text-secondary")}`} />
Basic Information
</h2>
<div className="space-y-4">
<div>
<label
htmlFor="name"
className="block text-sm font-medium text-gray-700 mb-2"
>
Collection Name <span className="text-red-500">*</span>
</label>
<input
<Input
label="Collection Name"
type="text"
id="name"
name="name"
name="collection_name"
value={formData.name}
onChange={handleInputChange}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-all duration-200"
onChange={(value) => setFormData((prev) => ({ ...prev, name: value }))}
placeholder="Enter collection name"
required
disabled={isLoading}
icon={FolderIcon}
/>
</div>
<div>
<label
htmlFor="description"
className="block text-sm font-medium text-gray-700 mb-2"
>
<label className={`block text-sm font-medium ${getThemeClasses("text-primary")} mb-2`}>
Description
</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleInputChange}
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
rows={3}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-all duration-200"
disabled={isLoading}
className={`w-full px-4 py-3 border rounded-xl ${getThemeClasses("bg-input")} ${getThemeClasses("border-input")} ${getThemeClasses("text-primary")} ${getThemeClasses("placeholder-muted")} focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-all duration-200 disabled:opacity-50`}
placeholder="Optional description for this collection"
/>
</div>
<div>
<label
htmlFor="parentCollection"
className="block text-sm font-medium text-gray-700 mb-2"
>
Parent Collection
</label>
<select
id="parentCollection"
name="parentCollection"
<Select
label="Parent Collection"
name="parent_collection"
value={formData.parentCollection || ""}
onChange={handleInputChange}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-all duration-200"
>
<option value="">Root level (no parent)</option>
<option value="2">Personal Files</option>
<option value="3">Archive</option>
<option value="4">Shared Projects</option>
</select>
</div>
</div>
onChange={(value) => setFormData((prev) => ({ ...prev, parentCollection: value || null }))}
options={parentOptions}
disabled={isLoading}
/>
</div>
</Card>
{/* Privacy & Sharing */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<UsersIcon className="h-5 w-5 mr-2 text-gray-500" />
<Card className={`border ${getThemeClasses("border-muted")} p-6`}>
<h2 className={`text-lg font-semibold ${getThemeClasses("text-primary")} mb-4 flex items-center`}>
<UsersIcon className={`h-5 w-5 mr-2 ${getThemeClasses("text-secondary")}`} />
Privacy & Sharing
</h2>
{/* Privacy Mode */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-3">
<label className={`block text-sm font-medium ${getThemeClasses("text-primary")} mb-3`}>
Privacy Mode
</label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{[
{ value: "private", icon: LockClosedIcon, label: "Private", desc: "Only you" },
{ value: "shared", icon: UsersIcon, label: "Shared", desc: "Specific users" },
{ value: "public", icon: GlobeAltIcon, label: "Public", desc: "Anyone with link" },
].map((option) => (
<label
className={`relative flex cursor-pointer rounded-lg border p-4 hover:border-gray-300 transition-all duration-200 ${
formData.privacyMode === "private"
? "border-red-500 bg-red-50"
: "border-gray-200"
key={option.value}
className={`relative flex cursor-pointer rounded-lg border p-4 transition-all duration-200 ${
formData.privacyMode === option.value
? `${getThemeClasses("border-accent")} ${getThemeClasses("bg-accent-light")}`
: `${getThemeClasses("border-secondary")} ${getThemeClasses("hover:bg-muted")}`
}`}
>
<input
type="radio"
name="privacyMode"
value="private"
checked={formData.privacyMode === "private"}
onChange={handleInputChange}
value={option.value}
checked={formData.privacyMode === option.value}
onChange={(e) => setFormData((prev) => ({ ...prev, privacyMode: e.target.value }))}
className="sr-only"
disabled={isLoading}
/>
<div className="flex items-center">
<LockClosedIcon
className={`h-5 w-5 mr-2 ${formData.privacyMode === "private" ? "text-red-600" : "text-gray-400"}`}
/>
<div>
<p className="font-medium text-gray-900">Private</p>
<p className="text-xs text-gray-500">Only you</p>
</div>
</div>
{formData.privacyMode === "private" && (
<CheckIcon className="absolute top-3 right-3 h-4 w-4 text-red-600" />
)}
</label>
<label
className={`relative flex cursor-pointer rounded-lg border p-4 hover:border-gray-300 transition-all duration-200 ${
formData.privacyMode === "shared"
? "border-red-500 bg-red-50"
: "border-gray-200"
<option.icon
className={`h-5 w-5 mr-2 ${
formData.privacyMode === option.value
? getThemeClasses("text-accent")
: getThemeClasses("text-secondary")
}`}
>
<input
type="radio"
name="privacyMode"
value="shared"
checked={formData.privacyMode === "shared"}
onChange={handleInputChange}
className="sr-only"
/>
<div className="flex items-center">
<UsersIcon
className={`h-5 w-5 mr-2 ${formData.privacyMode === "shared" ? "text-red-600" : "text-gray-400"}`}
/>
<div>
<p className="font-medium text-gray-900">Shared</p>
<p className="text-xs text-gray-500">Specific users</p>
<p className={`font-medium ${getThemeClasses("text-primary")}`}>{option.label}</p>
<p className={`text-xs ${getThemeClasses("text-secondary")}`}>{option.desc}</p>
</div>
</div>
{formData.privacyMode === "shared" && (
<CheckIcon className="absolute top-3 right-3 h-4 w-4 text-red-600" />
)}
</label>
<label
className={`relative flex cursor-pointer rounded-lg border p-4 hover:border-gray-300 transition-all duration-200 ${
formData.privacyMode === "public"
? "border-red-500 bg-red-50"
: "border-gray-200"
}`}
>
<input
type="radio"
name="privacyMode"
value="public"
checked={formData.privacyMode === "public"}
onChange={handleInputChange}
className="sr-only"
/>
<div className="flex items-center">
<GlobeAltIcon
className={`h-5 w-5 mr-2 ${formData.privacyMode === "public" ? "text-red-600" : "text-gray-400"}`}
/>
<div>
<p className="font-medium text-gray-900">Public</p>
<p className="text-xs text-gray-500">Anyone with link</p>
</div>
</div>
{formData.privacyMode === "public" && (
<CheckIcon className="absolute top-3 right-3 h-4 w-4 text-red-600" />
{formData.privacyMode === option.value && (
<CheckIcon className={`absolute top-3 right-3 h-4 w-4 ${getThemeClasses("text-accent")}`} />
)}
</label>
))}
</div>
</div>
{/* Share with Users */}
{formData.privacyMode === "shared" && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
<label className={`block text-sm font-medium ${getThemeClasses("text-primary")} mb-3`}>
Shared with
</label>
@ -413,35 +388,34 @@ const CollectionEdit = () => {
{selectedUsers.map((user) => (
<div
key={user.id}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
className={`flex items-center justify-between p-3 ${getThemeClasses("bg-muted")} rounded-lg`}
>
<div className="flex items-center">
<div className="flex items-center justify-center h-8 w-8 bg-red-100 text-red-600 text-sm font-medium rounded-full mr-3">
<div className={`flex items-center justify-center h-8 w-8 ${getThemeClasses("bg-accent-light")} ${getThemeClasses("text-accent")} text-sm font-medium rounded-full mr-3`}>
{user.avatar}
</div>
<div>
<p className="text-sm font-medium text-gray-900">
<p className={`text-sm font-medium ${getThemeClasses("text-primary")}`}>
{user.name}
</p>
<p className="text-xs text-gray-500">
<p className={`text-xs ${getThemeClasses("text-secondary")}`}>
{user.email}
</p>
</div>
</div>
<div className="flex items-center space-x-2">
<select
<Select
name={`role_${user.id}`}
value={user.role}
onChange={(e) =>
handleUserRoleChange(user.id, e.target.value)
}
className="text-sm px-3 py-1 border border-gray-300 rounded-lg"
>
<option value="viewer">Can view</option>
<option value="editor">Can edit</option>
</select>
onChange={(value) => handleUserRoleChange(user.id, value)}
options={roleOptions}
size="sm"
disabled={isLoading}
/>
<button
onClick={() => handleRemoveUser(user.id)}
className="text-gray-400 hover:text-red-600 transition-colors duration-200"
className={`${getThemeClasses("text-secondary")} ${getThemeClasses("hover:text-error")} transition-colors duration-200`}
disabled={isLoading}
>
<XMarkIcon className="h-4 w-4" />
</button>
@ -452,43 +426,46 @@ const CollectionEdit = () => {
)}
{/* Add User Button */}
<button
<Button
onClick={() => setShowUserSearch(!showUserSearch)}
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 transition-all duration-200"
variant="secondary"
size="sm"
disabled={isLoading}
>
<PlusIcon className="h-4 w-4 mr-2" />
Add Users
</button>
<span className="inline-flex items-center gap-2">
<PlusIcon className="h-4 w-4" />
<span>Add Users</span>
</span>
</Button>
{/* User Search Dropdown */}
{showUserSearch && (
<div className="mt-3 p-4 bg-gray-50 rounded-lg border border-gray-200">
<input
<div className={`mt-3 p-4 ${getThemeClasses("bg-muted")} rounded-lg border ${getThemeClasses("border-muted")}`}>
<Input
type="text"
name="user_search"
value={userSearchQuery}
onChange={(e) => setUserSearchQuery(e.target.value)}
onChange={(value) => setUserSearchQuery(value)}
placeholder="Search by name or email..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-red-500 focus:border-red-500"
size="sm"
/>
<div className="mt-3 space-y-2 max-h-48 overflow-y-auto">
{mockUsers
.filter(
(u) => !selectedUsers.find((su) => su.id === u.id),
)
.filter((u) => !selectedUsers.find((su) => su.id === u.id))
.map((user) => (
<button
key={user.id}
onClick={() => handleAddUser(user)}
className="w-full flex items-center p-2 hover:bg-white rounded-lg transition-colors duration-200"
className={`w-full flex items-center p-2 ${getThemeClasses("hover:bg-card")} rounded-lg transition-colors duration-200`}
>
<div className="flex items-center justify-center h-8 w-8 bg-red-100 text-red-600 text-sm font-medium rounded-full mr-3">
<div className={`flex items-center justify-center h-8 w-8 ${getThemeClasses("bg-accent-light")} ${getThemeClasses("text-accent")} text-sm font-medium rounded-full mr-3`}>
{user.avatar}
</div>
<div className="text-left">
<p className="text-sm font-medium text-gray-900">
<p className={`text-sm font-medium ${getThemeClasses("text-primary")}`}>
{user.name}
</p>
<p className="text-xs text-gray-500">
<p className={`text-xs ${getThemeClasses("text-secondary")}`}>
{user.email}
</p>
</div>
@ -499,102 +476,95 @@ const CollectionEdit = () => {
)}
</div>
)}
</div>
</Card>
{/* Danger Zone */}
<div className="bg-red-50 border border-red-200 rounded-xl p-6">
<h2 className="text-lg font-semibold text-red-900 mb-4 flex items-center">
<Card className={`border ${getThemeClasses("border-error")} ${getThemeClasses("bg-error-light")} p-6`}>
<h2 className={`text-lg font-semibold ${getThemeClasses("text-error")} mb-4 flex items-center`}>
<ExclamationTriangleIcon className="h-5 w-5 mr-2" />
Danger Zone
</h2>
<div className="flex items-center justify-between">
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<h3 className="font-medium text-gray-900">
<h3 className={`font-medium ${getThemeClasses("text-primary")}`}>
Delete this collection
</h3>
<p className="text-sm text-gray-600 mt-1">
Once deleted, this collection and all its contents cannot be
recovered.
<p className={`text-sm ${getThemeClasses("text-secondary")} mt-1`}>
Once deleted, this collection and all its contents cannot be recovered.
</p>
</div>
<button
<Button
onClick={() => setShowDeleteConfirm(true)}
className="px-4 py-2 border border-red-300 rounded-lg text-red-700 hover:bg-red-100 transition-all duration-200"
variant="danger"
size="sm"
disabled={isLoading}
>
Delete Collection
</button>
</div>
</Button>
</div>
</Card>
{/* Action Buttons */}
<div className="flex items-center justify-between pt-6 border-t">
<button
onClick={() =>
navigate(`/file-manager/collections/${collectionId}`)
}
className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-all duration-200"
<div className={`flex items-center justify-between pt-6 border-t ${getThemeClasses("border-muted")}`}>
<Button
onClick={() => navigate(`/file-manager/collections/${collectionId}`)}
variant="secondary"
disabled={isLoading}
>
Cancel
</button>
</Button>
<div className="flex items-center space-x-3">
<div className="flex items-center gap-3">
{hasChanges && (
<button
onClick={() => window.location.reload()}
className="inline-flex items-center px-4 py-2 text-sm text-gray-600 hover:text-gray-900"
<Button
onClick={handleReset}
variant="ghost"
size="sm"
disabled={isLoading}
>
<ArrowPathIcon className="h-4 w-4 mr-1" />
Reset Changes
</button>
<span className="inline-flex items-center gap-1">
<ArrowPathIcon className="h-4 w-4" />
<span>Reset</span>
</span>
</Button>
)}
<button
<Button
onClick={handleSave}
variant="primary"
disabled={!hasChanges || !formData.name.trim() || isLoading}
className="inline-flex items-center px-6 py-2 border border-transparent rounded-lg shadow-sm 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:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
loading={isLoading}
>
{isLoading ? (
<>
<ArrowPathIcon className="h-4 w-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<CheckIcon className="h-4 w-4 mr-2" />
Save Changes
</>
{!isLoading && (
<span className="inline-flex items-center gap-2">
<CheckIcon className="h-4 w-4" />
<span>Save Changes</span>
</span>
)}
</button>
{isLoading && "Saving..."}
</Button>
</div>
</div>
</div>
</Card>
</div>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">
Delete Collection
</h3>
<button
onClick={() => setShowDeleteConfirm(false)}
className="text-gray-400 hover:text-gray-600"
<Modal
isOpen={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
title="Delete Collection"
size="md"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
<div className="mb-6">
<div className="flex items-center justify-center h-12 w-12 bg-red-100 rounded-lg mb-4">
<TrashIcon className="h-6 w-6 text-red-600" />
<div className={`flex items-center justify-center h-12 w-12 ${getThemeClasses("bg-error-light")} rounded-lg mb-4`}>
<TrashIcon className={`h-6 w-6 ${getThemeClasses("text-error")}`} />
</div>
<p className="text-gray-700 mb-2">
<p className={`${getThemeClasses("text-primary")} mb-2`}>
Are you sure you want to delete{" "}
<strong>{collectionData.name}</strong>?
</p>
<p className="text-sm text-gray-600">
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>
This will permanently delete the collection and all{" "}
{collectionData.itemCount} items inside it. This action cannot
be undone.
@ -602,24 +572,22 @@ const CollectionEdit = () => {
</div>
<div className="flex justify-end space-x-3">
<button
<Button
onClick={() => setShowDeleteConfirm(false)}
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50"
variant="secondary"
>
Cancel
</button>
<button
</Button>
<Button
onClick={handleDelete}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
variant="danger"
>
Delete Collection
</button>
</div>
</div>
</div>
)}
</Button>
</div>
</Modal>
</Layout>
);
};
export default CollectionEdit;
export default withPasswordProtection(CollectionEdit);

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)
const handleExportMembers = useCallback(() => {
const exportData = {
@ -124,12 +132,14 @@ const CollectionShare = () => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
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);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, [collection, ownerEmail, collectionMembers]);
}, [collection, ownerEmail, collectionMembers, sanitizeFilename]);
useEffect(() => {
if (collectionId && getCollectionManager && shareCollectionManager) {

View file

@ -1,6 +1,6 @@
// File: monorepo/web/maplefile-frontend/src/pages/User/FileManager/FileManagerIndex.jsx
// 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 { useFiles, useCrypto, useAuth, useTags } from "../../../services/Services";
import withPasswordProtection from "../../../hocs/withPasswordProtection";
@ -10,6 +10,10 @@ import {
Input,
Alert,
Modal,
Checkbox,
Spinner,
Card,
Breadcrumb,
useUIXTheme,
CollectionIcon,
} from "../../../components/UIX";
@ -31,8 +35,19 @@ import {
ShareIcon,
TrashIcon,
TagIcon,
HomeIcon,
} 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 navigate = useNavigate();
const location = useLocation();
@ -50,6 +65,7 @@ const FileManagerIndex = () => {
const isMountedRef = useRef(true);
const [isLoading, setIsLoading] = useState(true);
const [isInitialized, setIsInitialized] = useState(false);
const [generalError, setGeneralError] = useState("");
const [collectionTagsMap, setCollectionTagsMap] = useState({});
const [fieldErrors, setFieldErrors] = useState({});
@ -105,9 +121,8 @@ const FileManagerIndex = () => {
if (!Array.isArray(rawCollections) || rawCollections.length === 0)
return [];
const processedCollections = [];
for (const collection of rawCollections) {
// Process single collection - extracted for parallel execution
const processSingleCollection = async (collection) => {
try {
let processedCollection = { ...collection };
@ -140,9 +155,7 @@ const FileManagerIndex = () => {
if (collectionKey) {
try {
const { default: CollectionCryptoServiceClass } = await import(
"../../../services/Crypto/CollectionCryptoService.js"
);
const CollectionCryptoServiceClass = await getCollectionCryptoServiceClass();
const decryptedCollection =
await CollectionCryptoServiceClass.decryptCollectionFromAPI(
@ -180,9 +193,9 @@ const FileManagerIndex = () => {
processedCollection.isOwned =
collection._isOwned !== undefined ? collection._isOwned : true;
processedCollections.push(processedCollection);
return processedCollection;
} catch (error) {
processedCollections.push({
return {
...collection,
name: "Locked Folder",
type: "folder",
@ -190,9 +203,14 @@ const FileManagerIndex = () => {
_isDecrypted: false,
isShared: false,
isOwned: true,
});
}
};
}
};
// Process all collections in parallel for better performance
const processedCollections = await Promise.all(
rawCollections.map(processSingleCollection)
);
return processedCollections;
},
@ -283,6 +301,7 @@ const FileManagerIndex = () => {
if (isMountedRef.current) {
setCollections(processedCollections);
setIsInitialized(true);
// Load tags for the collections
loadCollectionTags(processedCollections);
}
@ -303,6 +322,7 @@ const FileManagerIndex = () => {
} else {
setGeneralError("Could not load your folders. Please try again.");
}
setIsInitialized(true);
}
} finally {
if (isMountedRef.current) {
@ -444,6 +464,28 @@ const FileManagerIndex = () => {
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(() => {
setCollectionToDelete(null);
setShowDeleteConfirm(false);
@ -536,11 +578,12 @@ const FileManagerIndex = () => {
loadCollections,
]);
// Initial load when managers become available
useEffect(() => {
if (listCollectionManager && authManager?.isAuthenticated()) {
if (listCollectionManager && authManager?.isAuthenticated() && !isInitialized) {
loadCollections();
}
}, [listCollectionManager, authManager, loadCollections]);
}, [listCollectionManager, authManager, isInitialized, loadCollections]);
useEffect(() => {
const handleCollectionEvent = () => {
@ -617,7 +660,9 @@ const FileManagerIndex = () => {
location.pathname,
]);
const filteredCollections = collections.filter((collection) => {
// Memoize filtered collections to prevent recalculation on every render
const filteredCollections = useMemo(() => {
return collections.filter((collection) => {
// Filter by search query
if (searchQuery) {
const query = searchQuery.toLowerCase();
@ -638,8 +683,10 @@ const FileManagerIndex = () => {
return true;
});
}, [collections, searchQuery, selectedTagIds, collectionTagsMap]);
const filterTypes = [
// Memoize filter types to prevent recreation on every render
const filterTypes = useMemo(() => [
{
key: "owned",
label: "My Folders",
@ -661,42 +708,63 @@ const FileManagerIndex = () => {
description: "All folders you can access",
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 (
<Layout>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
{/* Breadcrumb Navigation */}
<Breadcrumb items={breadcrumbItems} />
{/* 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-3xl font-bold flex items-center ${getThemeClasses("text-primary")}`}
>
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
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">
<p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
Organize and manage your encrypted files
</p>
</div>
<div className="flex items-center space-x-3">
</div>
</div>
{/* Action buttons */}
<div className="flex-shrink-0 flex items-center space-x-3">
{/* Tag Filter Menu */}
{availableTags.length > 0 && (
<div className="relative">
<Button
onClick={() => setShowTagFilterMenu(!showTagFilterMenu)}
variant={selectedTagIds.length > 0 ? "primary" : "secondary"}
className="flex items-center"
>
<span className="inline-flex items-center whitespace-nowrap">
{selectedTagIds.length > 0 ? (
<>
<div className="flex items-center -space-x-1 mr-2">
<span className="flex items-center -space-x-1 mr-2 flex-shrink-0">
{selectedTagIds.slice(0, 3).map(tagId => {
const tag = availableTags.find(t => t.id === tagId);
return (
@ -707,31 +775,35 @@ const FileManagerIndex = () => {
></span>
);
})}
</div>
</span>
{selectedTagIds.length === 1
? availableTags.find(t => t.id === selectedTagIds[0])?.name || 'Tag'
: `${selectedTagIds.length} tags`}
<button
<Button
onClick={(e) => {
e.stopPropagation();
setSelectedTagIds([]);
}}
className="ml-2 hover:opacity-70"
variant="ghost"
size="sm"
className="ml-1 p-0 min-w-0 h-auto"
aria-label="Clear tag filter"
>
<span className="text-xs"></span>
</button>
</Button>
</>
) : (
<>
<TagIcon className="h-4 w-4 mr-2" />
<TagIcon className="h-4 w-4 mr-2 flex-shrink-0" />
All Tags
</>
)}
<ChevronRightIcon
className={`h-4 w-4 ml-2 transition-transform duration-200 ${
className={`h-4 w-4 ml-2 flex-shrink-0 transition-transform duration-200 ${
showTagFilterMenu ? "rotate-90" : ""
}`}
/>
</span>
</Button>
{showTagFilterMenu && (
@ -749,12 +821,14 @@ const FileManagerIndex = () => {
Filter by Tags
</span>
{selectedTagIds.length > 0 && (
<button
<Button
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
</button>
</Button>
)}
</div>
@ -762,8 +836,17 @@ const FileManagerIndex = () => {
{availableTags.map((tag) => {
const isSelected = selectedTagIds.includes(tag.id);
return (
<button
<div
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={() => {
if (isSelected) {
setSelectedTagIds(selectedTagIds.filter(id => id !== tag.id));
@ -771,18 +854,27 @@ const FileManagerIndex = () => {
setSelectedTagIds([...selectedTagIds, tag.id]);
}
}}
className={`w-full text-left px-4 py-2.5 transition-colors duration-200 ${
isSelected
? getThemeClasses("bg-muted")
: `hover:${getThemeClasses("bg-hover")}`
}`}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (isSelected) {
setSelectedTagIds(selectedTagIds.filter(id => id !== tag.id));
} else {
setSelectedTagIds([...selectedTagIds, tag.id]);
}
}
}}
>
<div className="flex items-center space-x-3">
<input
type="checkbox"
<Checkbox
checked={isSelected}
onChange={() => {}}
className="h-4 w-4 rounded text-blue-600 focus:ring-blue-500"
onChange={(checked) => {
if (checked) {
setSelectedTagIds([...selectedTagIds, tag.id]);
} else {
setSelectedTagIds(selectedTagIds.filter(id => id !== tag.id));
}
}}
/>
<span
className="h-4 w-4 rounded-full flex-shrink-0"
@ -798,7 +890,7 @@ const FileManagerIndex = () => {
{tag.name}
</span>
</div>
</button>
</div>
);
})}
@ -821,15 +913,16 @@ const FileManagerIndex = () => {
<Button
onClick={() => setShowFilterMenu(!showFilterMenu)}
variant="secondary"
className="flex items-center"
>
<FunnelIcon className="h-4 w-4 mr-2" />
<span className="inline-flex items-center whitespace-nowrap">
<FunnelIcon className="h-4 w-4 mr-2 flex-shrink-0" />
{currentFilter.label}
<ChevronRightIcon
className={`h-4 w-4 ml-2 transition-transform duration-200 ${
className={`h-4 w-4 ml-2 flex-shrink-0 transition-transform duration-200 ${
showFilterMenu ? "rotate-90" : ""
}`}
/>
</span>
</Button>
{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`}
>
{filterTypes.map((filter) => (
<button
<div
key={filter.key}
role="button"
tabIndex={0}
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
? getThemeClasses("bg-muted")
: `hover:${getThemeClasses("bg-hover")}`
@ -885,7 +981,7 @@ const FileManagerIndex = () => {
{filter.count}
</span>
</div>
</button>
</div>
))}
</div>
</>
@ -896,49 +992,54 @@ const FileManagerIndex = () => {
onClick={() => loadCollections(true)}
disabled={isLoading}
variant="secondary"
icon={ArrowPathIcon}
className={isLoading ? "[&>svg]:animate-spin" : ""}
>
<ArrowPathIcon
className={`h-4 w-4 mr-2 ${isLoading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
<Button
onClick={() => navigate("/file-manager/upload")}
variant="primary"
icon={CloudArrowUpIcon}
>
<CloudArrowUpIcon className="h-4 w-4 mr-2" />
Upload
</Button>
</div>
</div>
</div>
{/* Content */}
<div className="p-6">
{/* Search Bar */}
<div className="mb-8">
<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={(e) => setSearchQuery(e.target.value)}
onChange={setSearchQuery}
placeholder="Search folders..."
aria-label="Search folders"
className="pl-12 h-12 text-base w-full"
/>
{searchQuery && (
<button
<Button
onClick={() => setSearchQuery("")}
className="absolute right-4 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
variant="ghost"
size="sm"
className="absolute right-4 top-1/2 transform -translate-y-1/2"
aria-label="Clear search"
>
</button>
</Button>
)}
</div>
</div>
{/* Error Display */}
{(generalError || Object.keys(fieldErrors).length > 0) && (
<Alert type="error" className="mb-8">
<Alert type="error" className="mb-6">
{generalError && <p>{generalError}</p>}
{Object.keys(fieldErrors).length > 0 && (
<div className="mt-2">
@ -962,9 +1063,7 @@ const FileManagerIndex = () => {
{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>
<Spinner size="lg" className="mx-auto mb-4" />
<p className="text-gray-600 dark:text-gray-400">
Loading folders...
</p>
@ -975,22 +1074,26 @@ const FileManagerIndex = () => {
{filteredCollections.map((collection, index) => (
<div
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`}
onClick={() =>
navigate(`/file-manager/collections/${collection.id}`)
}
onMouseEnter={() => setHoveredCollection(collection.id)}
onMouseLeave={() => setHoveredCollection(null)}
role="article"
tabIndex={0}
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")}`}
onClick={() => handleCollectionClick(collection.id)}
onKeyDown={(e) => handleCollectionKeyDown(e, collection.id)}
onMouseEnter={() => handleCollectionMouseEnter(collection.id)}
onMouseLeave={handleCollectionMouseLeave}
>
{/* Delete Button - Only show if user is owner */}
{collection.isOwned && (
<button
<Button
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`}
title="Delete folder"
variant="danger"
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" />
</button>
</Button>
)}
<div className="p-6">
@ -1065,8 +1168,8 @@ const FileManagerIndex = () => {
)}
</div>
<div className="flex items-center justify-between pt-3 border-t border-gray-100 dark:border-gray-700">
<span className="text-xs text-gray-500 dark:text-gray-400">
<div className={`flex items-center justify-between pt-3 border-t ${getThemeClasses("border-muted")}`}>
<span className={`text-xs ${getThemeClasses("text-secondary")}`}>
{getTimeAgo(collection.modified)}
</span>
<div className="flex items-center space-x-2">
@ -1074,8 +1177,8 @@ const FileManagerIndex = () => {
<div
className={`flex items-center space-x-1 px-2 py-1 rounded-full text-xs font-medium ${
collection.isOwned
? "bg-green-100 text-green-700"
: "bg-blue-100 text-blue-700"
? `${getThemeClasses("bg-success-light")} ${getThemeClasses("text-success")}`
: `${getThemeClasses("bg-info-light")} ${getThemeClasses("text-info")}`
}`}
>
<ShareIcon className="h-3 w-3" />
@ -1099,10 +1202,20 @@ const FileManagerIndex = () => {
</div>
))}
{(filterType === "owned" || filterType === "all") && (
{/* Only show New Folder card when there are existing folders */}
{(filterType === "owned" || filterType === "all") && filteredCollections.length > 0 && (
<div
role="button"
tabIndex={0}
aria-label="Create new folder"
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
@ -1151,9 +1264,9 @@ const FileManagerIndex = () => {
{/* Empty State */}
{!isLoading && filteredCollections.length === 0 && !searchQuery && selectedTagIds.length === 0 && (
<div className="text-center py-24">
<div className="text-center">
<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" />
</div>
@ -1169,16 +1282,14 @@ const FileManagerIndex = () => {
<p className="text-gray-600 dark:text-gray-400 mb-8 max-w-md mx-auto">
{filterType === "shared"
? "When someone shares a folder with you, it will appear here"
: filterType === "all"
? "Create your first folder or wait for someone to share with you"
: "Create your first encrypted folder to start organizing your files"}
: "Create your first folder now"}
</p>
{filterType !== "shared" && (
<Button
onClick={() => navigate("/file-manager/collections/create")}
variant="primary"
icon={PlusIcon}
>
<PlusIcon className="h-4 w-4 mr-2" />
Create Your First Folder
</Button>
)}
@ -1217,6 +1328,9 @@ const FileManagerIndex = () => {
</div>
)}
</div>
</Card>
</div>
{/* Delete Confirmation Modal */}
<Modal
@ -1236,46 +1350,39 @@ const FileManagerIndex = () => {
onClick={handleConfirmDelete}
disabled={isDeleting}
variant="danger"
loading={isDeleting}
loadingText="Deleting..."
>
{isDeleting ? (
<>
<div className="h-4 w-4 spinner border-white mr-2"></div>
Deleting...
</>
) : (
<>
<TrashIcon className="h-4 w-4 mr-2" />
Delete Folder
</>
)}
</Button>
</div>
}
>
{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 "
<span className="font-semibold">
{collectionToDelete.name || "Locked Folder"}
</span>
"? This will permanently delete:
</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">
<span className="text-red-600 mr-2"></span>
<span className={`${getThemeClasses("text-error")} mr-2`}></span>
<span>All files in this folder</span>
</li>
<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>
</li>
<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>
</li>
</ul>
<p className="text-sm text-red-600 font-medium">
<p className={`text-sm ${getThemeClasses("text-error")} font-medium`}>
This action cannot be undone.
</p>
</>

View file

@ -735,7 +735,7 @@ const FileDetails = () => {
type="checkbox"
checked={selectedTagIds.includes(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
className="w-4 h-4 rounded-full ml-3 mr-2 flex-shrink-0"
@ -749,7 +749,7 @@ const FileDetails = () => {
</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
onClick={handleCloseTagEditor}
variant="secondary"

View file

@ -17,6 +17,8 @@ import {
useUIXTheme,
Breadcrumb,
Card,
Select,
Checkbox,
} from "../../../../components/UIX";
import {
CloudArrowUpIcon,
@ -63,7 +65,8 @@ const FileUpload = () => {
preSelectedCollectionId || "",
);
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 [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState("");
@ -104,19 +107,24 @@ const FileUpload = () => {
}, [authManager]);
const loadCollections = useCallback(async () => {
if (!listCollectionManager) return;
setIsLoadingCollections(true);
try {
const result = await listCollectionManager.listCollections(false);
if (
result.collections &&
result.collections.length > 0 &&
isMountedRef.current
) {
if (isMountedRef.current) {
if (result.collections && result.collections.length > 0) {
setAvailableCollections(result.collections);
}
setIsCollectionsInitialized(true);
}
} catch (err) {
if (isMountedRef.current) {
if (import.meta.env.DEV) {
console.error("[FileUpload] Could not load folders:", err);
}
setError("Could not load folders");
setIsCollectionsInitialized(true);
}
} finally {
if (isMountedRef.current) {
@ -125,11 +133,12 @@ const FileUpload = () => {
}
}, [listCollectionManager]);
// Load collections when manager becomes available
useEffect(() => {
if (createCollectionManager && listCollectionManager) {
if (createCollectionManager && listCollectionManager && !isCollectionsInitialized) {
loadCollections();
}
}, [createCollectionManager, listCollectionManager, loadCollections]);
}, [createCollectionManager, listCollectionManager, isCollectionsInitialized, loadCollections]);
const handleDragOver = useCallback((e) => {
e.preventDefault();
@ -665,6 +674,14 @@ const FileUpload = () => {
errorFiles,
} = 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
const breadcrumbItems = [
{
@ -756,39 +773,73 @@ const FileUpload = () => {
{/* Content */}
<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 */}
<div className="lg:col-span-2 space-y-6">
{/* Collection Selector (only show if no pre-selected collection) */}
<div className={`space-y-6 ${preSelectedCollectionId ? "lg:col-span-2" : ""}`}>
{/* Collection Selector with Upload Button (only show if no pre-selected collection) */}
{!preSelectedCollectionId && (
<div>
<label
className={`block text-sm font-medium mb-2 ${getThemeClasses("text-primary")}`}
>
Select destination folder
</label>
<select
<div className="flex items-end gap-3">
<Select
label="Select destination folder"
value={selectedCollection}
onChange={(e) => setSelectedCollection(e.target.value)}
onChange={setSelectedCollection}
options={collectionOptions}
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"
className="flex-1"
/>
<Button
onClick={startUpload}
disabled={
!selectedCollection ||
!fileManager ||
files.length === 0 ||
isUploading ||
pendingFiles.length === 0 ||
!gdprConsent
}
variant="primary"
className="flex-shrink-0 h-[50px]"
>
<option value="">Choose a folder...</option>
{availableCollections.map((collection) => (
<option key={collection.id} value={collection.id}>
{collection.name || "Unnamed Folder"}
</option>
))}
</select>
<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>
)}
{/* Drop Zone */}
<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}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
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 ${
isDragging
? `${getThemeClasses("border-primary")} ${getThemeClasses("bg-accent-light")} scale-105 shadow-lg`
@ -833,6 +884,8 @@ const FileUpload = () => {
onChange={handleFileSelect}
disabled={isUploading}
className="sr-only"
aria-label="Select files to upload"
id="file-upload-input"
/>
</div>
@ -860,11 +913,15 @@ const FileUpload = () => {
</div>
<div
role="list"
aria-label="Selected files for upload"
className={`divide-y ${getThemeClasses("border-secondary")} max-h-96 overflow-y-auto`}
>
{files.map((file) => (
<div
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`}
>
<div className="flex items-center space-x-4">
@ -919,16 +976,19 @@ const FileUpload = () => {
</div>
<div className="flex-shrink-0">
{file.status === "pending" && (
<button
<Button
onClick={(e) => {
e.stopPropagation();
removeFile(file.id);
}}
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" />
</button>
</Button>
)}
{file.status === "uploading" && (
<div className="p-2">
@ -960,7 +1020,8 @@ const FileUpload = () => {
)}
</div>
{/* Sidebar */}
{/* Sidebar - only show as separate column when collection is pre-selected */}
{preSelectedCollectionId && (
<div className="space-y-6">
{files.length > 0 && (
<div
@ -1077,12 +1138,11 @@ const FileUpload = () => {
<div
className={`p-4 ${getThemeClasses("bg-accent-light")} rounded-lg border ${getThemeClasses("border-accent")}`}
>
<label className="flex items-start space-x-3 cursor-pointer">
<input
type="checkbox"
<div className="flex items-start space-x-3">
<Checkbox
checked={gdprConsent}
onChange={(e) => setGdprConsent(e.target.checked)}
className={`mt-1 h-4 w-4 rounded ${getThemeClasses("checkbox-focus")}`}
onChange={setGdprConsent}
className="mt-0.5"
/>
<div className="flex-1">
<p
@ -1097,10 +1157,11 @@ const FileUpload = () => {
you share with can decrypt them.
</p>
</div>
</label>
</div>
</div>
)}
{/* Upload button in sidebar */}
<Button
onClick={startUpload}
disabled={
@ -1134,7 +1195,150 @@ const FileUpload = () => {
</span>
</Button>
</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>
</Card>
</div>

View file

@ -84,14 +84,28 @@ const SearchResults = memo(function SearchResults() {
// Refs for cleanup and debouncing
const searchDebounceRef = useRef(null);
// Ref to hold cache for cleanup (avoids stale closure)
const searchCacheRef = useRef(searchCache);
searchCacheRef.current = searchCache;
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
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)
const handleExportResults = useCallback(() => {
const exportData = {
@ -113,12 +127,14 @@ const SearchResults = memo(function SearchResults() {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
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);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, [query, results]);
}, [query, results, sanitizeFilename]);
// Search function with caching and validation
const performSearch = useCallback(
@ -292,9 +308,9 @@ const SearchResults = memo(function SearchResults() {
const getFileIcon = useCallback((file) => {
const mimeType = file.mime_type || "";
if (mimeType.startsWith("image/"))
return <PhotoIcon className="h-5 w-5 text-pink-600" />;
return <DocumentIcon className="h-5 w-5 text-gray-600 dark:text-gray-400" />;
}, []);
return <PhotoIcon className={`h-5 w-5 ${getThemeClasses("text-accent")}`} />;
return <DocumentIcon className={`h-5 w-5 ${getThemeClasses("text-secondary")}`} />;
}, [getThemeClasses]);
const totalResults = results.collections.length + results.files.length;

View file

@ -85,10 +85,22 @@ const TrashView = () => {
let auditLogs = [];
const stored = localStorage.getItem("security_audit_logs");
if (stored) {
auditLogs = JSON.parse(stored);
// Validate it's an array
if (!Array.isArray(auditLogs)) {
auditLogs = [];
const parsed = JSON.parse(stored);
// Validate it's an array and each entry has expected structure
if (Array.isArray(parsed)) {
// 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);
@ -98,7 +110,8 @@ const TrashView = () => {
}
localStorage.setItem("security_audit_logs", JSON.stringify(auditLogs));
} 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) {
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 withPasswordProtection from "../../../hocs/withPasswordProtection";
import Layout from "../../../components/Layout/Layout";
import { Button, Card, useUIXTheme } from "../../../components/UIX";
import { Button, Card, Breadcrumb, GDPRFooter, useUIXTheme } from "../../../components/UIX";
import {
QuestionMarkCircleIcon,
ShieldCheckIcon,
@ -13,11 +13,9 @@ import {
ArrowDownTrayIcon,
ShareIcon,
TrashIcon,
MagnifyingGlassIcon,
KeyIcon,
DocumentTextIcon,
LockClosedIcon,
ArrowLeftIcon,
HomeIcon,
} from "@heroicons/react/24/outline";
const Help = () => {
@ -28,8 +26,8 @@ const Help = () => {
{
title: "Getting Started",
icon: QuestionMarkCircleIcon,
color: "text-blue-600 dark:text-blue-400",
bgColor: "bg-blue-50 dark:bg-blue-900/20",
color: getThemeClasses("help-section-blue-text"),
bgColor: getThemeClasses("help-section-blue-bg"),
items: [
{
title: "What is MapleFile?",
@ -51,8 +49,8 @@ const Help = () => {
{
title: "Security & Encryption",
icon: ShieldCheckIcon,
color: "text-green-600 dark:text-green-400",
bgColor: "bg-green-50 dark:bg-green-900/20",
color: getThemeClasses("help-section-green-text"),
bgColor: getThemeClasses("help-section-green-bg"),
items: [
{
title: "End-to-End Encryption (E2EE)",
@ -74,8 +72,8 @@ const Help = () => {
{
title: "File Management",
icon: FolderIcon,
color: "text-purple-600 dark:text-purple-400",
bgColor: "bg-purple-50 dark:bg-purple-900/20",
color: getThemeClasses("help-section-purple-text"),
bgColor: getThemeClasses("help-section-purple-bg"),
items: [
{
title: "Organizing with Folders",
@ -97,8 +95,8 @@ const Help = () => {
{
title: "Sharing & Collaboration",
icon: ShareIcon,
color: "text-pink-600 dark:text-pink-400",
bgColor: "bg-pink-50 dark:bg-pink-900/20",
color: getThemeClasses("help-section-pink-text"),
bgColor: getThemeClasses("help-section-pink-bg"),
items: [
{
title: "Sharing Folders",
@ -120,8 +118,8 @@ const Help = () => {
{
title: "Data Management",
icon: TrashIcon,
color: "text-red-600 dark:text-red-400",
bgColor: "bg-red-50 dark:bg-red-900/20",
color: getThemeClasses("help-section-red-text"),
bgColor: getThemeClasses("help-section-red-bg"),
items: [
{
title: "Trash & Recovery",
@ -173,43 +171,86 @@ const Help = () => {
},
];
const breadcrumbItems = [
{
label: "Dashboard",
to: "/dashboard",
icon: HomeIcon,
},
{
label: "Help",
isActive: true,
},
];
return (
<Layout>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="mb-8">
<Button
onClick={() => navigate("/dashboard")}
variant="secondary"
className="mb-4"
>
<ArrowLeftIcon className="h-4 w-4 mr-2" />
Back to Dashboard
</Button>
{/* Breadcrumb Navigation */}
<Breadcrumb items={breadcrumbItems} />
<h1 className={`text-3xl font-bold flex items-center ${getThemeClasses("text-primary")}`}>
<QuestionMarkCircleIcon className={`h-8 w-8 mr-3 ${getThemeClasses("text-accent")}`} />
{/* Main Card */}
<Card>
{/* Header with icon, title, and action button */}
<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")}`}>
<QuestionMarkCircleIcon 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`}>
Help & Documentation
</h1>
<p className={`${getThemeClasses("text-secondary")} mt-2`}>
<p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
Learn how to use MapleFile's secure file storage features
</p>
</div>
</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>
{/* Content */}
<div className="p-6">
{/* Quick Actions */}
<div className="mb-8">
<h2 className={`text-xl font-semibold mb-4 ${getThemeClasses("text-primary")}`}>
Quick Actions
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4" role="list" aria-label="Quick actions">
{quickActions.map((action, index) => (
<Card
<div
key={index}
className={`${getThemeClasses("hover:shadow")} ${getThemeClasses("hover:border-primary")} cursor-pointer transition-all duration-200`}
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="p-4">
<div className={`h-10 w-10 rounded-lg ${getThemeClasses("bg-accent-light")} flex items-center justify-center mb-3`}>
<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>
<h3 className={`font-medium mb-1 ${getThemeClasses("text-primary")}`}>
@ -219,7 +260,6 @@ const Help = () => {
{action.description}
</p>
</div>
</Card>
))}
</div>
</div>
@ -227,40 +267,40 @@ const Help = () => {
{/* Help Sections */}
<div className="space-y-8">
{helpSections.map((section, sectionIndex) => (
<div key={sectionIndex}>
<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`}>
<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 className={`text-2xl font-bold ${getThemeClasses("text-primary")}`}>
<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">
<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) => (
<Card key={itemIndex} className="h-full">
<div className="p-6">
<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>
</div>
</Card>
</article>
))}
</div>
</div>
</section>
))}
</div>
{/* Additional Resources */}
<div className="mt-12">
<Card className={getThemeClasses("bg-accent-light")}>
<div className="p-6">
{/* Privacy & Security Notice */}
<div className={`mt-12 p-6 rounded-lg ${getThemeClasses("bg-accent-light")}`}>
<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">
<h3 className={`text-lg font-semibold mb-2 ${getThemeClasses("text-primary")}`}>
Privacy & Security Notice
@ -268,57 +308,56 @@ const Help = () => {
<p className={`text-sm ${getThemeClasses("text-secondary")} mb-3`}>
MapleFile uses end-to-end encryption to protect your data. This means:
</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">
<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>
</li>
<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>
</li>
<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>
</li>
<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>
</li>
</ul>
</div>
</div>
</div>
</Card>
</div>
{/* Support Section */}
<div className="mt-8">
<Card>
<div className="p-6">
<div className={`mt-8 p-6 rounded-lg border ${getThemeClasses("border-secondary")}`}>
<h3 className={`text-lg font-semibold mb-3 ${getThemeClasses("text-primary")}`}>
Need More Help?
</h3>
<p className={`text-sm ${getThemeClasses("text-secondary")} mb-4`}>
If you have questions not covered in this help documentation:
</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">
<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>
</li>
<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>
</li>
<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>
</li>
</ul>
</div>
</Card>
</div>
</Card>
{/* GDPR Footer */}
<GDPRFooter className="mt-8" />
</div>
</Layout>
);

View file

@ -1,23 +1,33 @@
// 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 { useAuth } from "../../../services/Services";
import withPasswordProtection from "../../../hocs/withPasswordProtection";
import Navigation from "../../../components/Navigation";
import Layout from "../../../components/Layout/Layout";
import BlockedEmailManager from "../../../services/Manager/BlockedEmailManager";
import {
Button,
Input,
Alert,
Card,
Breadcrumb,
Spinner,
useUIXTheme,
} from "../../../components/UIX";
import {
NoSymbolIcon,
PlusIcon,
TrashIcon,
ArrowLeftIcon,
ExclamationTriangleIcon,
CheckCircleIcon,
EnvelopeIcon,
Cog6ToothIcon,
} from "@heroicons/react/24/outline";
const BlockedUsers = () => {
const navigate = useNavigate();
const { authManager } = useAuth();
const { getThemeClasses } = useUIXTheme();
const isMountedRef = useRef(true);
// Page state
const [blockedEmails, setBlockedEmails] = useState([]);
@ -36,6 +46,14 @@ const BlockedUsers = () => {
// Manager instance
const [blockedEmailManager, setBlockedEmailManager] = useState(null);
// Cleanup on unmount
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
// Initialize manager
useEffect(() => {
if (authManager) {
@ -53,16 +71,26 @@ const BlockedUsers = () => {
setError("");
try {
if (import.meta.env.DEV) {
console.log("[BlockedUsers] Loading blocked emails...");
}
const emails = await blockedEmailManager.getBlockedEmails(true);
if (!isMountedRef.current) return;
setBlockedEmails(emails);
if (import.meta.env.DEV) {
console.log("[BlockedUsers] Loaded", emails.length, "blocked emails");
}
} catch (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.");
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
}
}, [blockedEmailManager]);
useEffect(() => {
@ -72,163 +100,190 @@ const BlockedUsers = () => {
}, [blockedEmailManager, authManager, loadBlockedEmails]);
// Handle add email
const handleAddEmail = async (e) => {
const handleAddEmail = useCallback(async (e) => {
e.preventDefault();
if (!newEmail.trim()) return;
if (!newEmail.trim() || !isMountedRef.current) return;
setAddLoading(true);
setAddError("");
setSuccess("");
try {
if (import.meta.env.DEV) {
console.log("[BlockedUsers] Adding email to blocked list:", newEmail);
}
await blockedEmailManager.addBlockedEmail(newEmail.trim());
if (!isMountedRef.current) return;
setSuccess(`${newEmail} has been blocked successfully.`);
setNewEmail("");
await loadBlockedEmails();
} catch (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.");
} finally {
if (isMountedRef.current) {
setAddLoading(false);
}
};
}
}, [newEmail, blockedEmailManager, loadBlockedEmails]);
// Handle remove email
const handleRemoveEmail = async (email) => {
const handleRemoveEmail = useCallback(async (email) => {
if (!isMountedRef.current) return;
setDeleteLoading(email);
setError("");
setSuccess("");
try {
if (import.meta.env.DEV) {
console.log("[BlockedUsers] Removing email from blocked list:", email);
}
await blockedEmailManager.removeBlockedEmail(email);
if (!isMountedRef.current) return;
setSuccess(`${email} has been unblocked.`);
await loadBlockedEmails();
} catch (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.");
} finally {
if (isMountedRef.current) {
setDeleteLoading("");
}
};
}
}, [blockedEmailManager, loadBlockedEmails]);
// Styles
const input_style =
"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";
const btn_primary =
"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";
// Breadcrumb items
const breadcrumbItems = [
{ label: "Settings", to: "/me", icon: Cog6ToothIcon },
{ label: "Blocked Users", isActive: true, icon: NoSymbolIcon },
];
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
<Navigation />
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Back button */}
<button
onClick={() => navigate("/me")}
className={`${btn_secondary} mb-6`}
>
<ArrowLeftIcon className="h-4 w-4 mr-2" />
Back to Profile
</button>
<Layout>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Breadcrumb Navigation */}
<Breadcrumb items={breadcrumbItems} />
{/* Main Card */}
<Card>
{/* Header */}
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-6 mb-6">
<div className="flex items-center">
<div className="flex items-center justify-center h-12 w-12 bg-red-100 rounded-xl mr-4">
<NoSymbolIcon className="h-6 w-6 text-red-600" />
<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 bg-red-500">
<NoSymbolIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">Blocked Users</h1>
<p className="text-gray-600">
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
Blocked Users
</h1>
<p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
Manage users who cannot share folders with you
</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>
{/* Success message */}
{/* Messages */}
<div className="px-6 pt-6">
{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>
<Alert type="success" className="mb-4" onClose={() => setSuccess("")}>
{success}
</Alert>
)}
{/* 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>
<Alert type="error" className="mb-4" onClose={() => setError("")}>
{error}
</Alert>
)}
</div>
{/* Content */}
<div className="px-6 pb-6 pt-2">
{/* 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">
<Card className={`border ${getThemeClasses("border-muted")} p-6 mb-6`}>
<h2 className={`text-lg font-semibold ${getThemeClasses("text-primary")} mb-4`}>
Block a User
</h2>
<p className="text-sm text-gray-600 mb-4">
<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
<Input
type="email"
name="block_email"
value={newEmail}
onChange={(e) => {
setNewEmail(e.target.value);
onChange={(value) => {
setNewEmail(value);
if (addError) setAddError("");
}}
placeholder="Enter email address to block"
className={input_style}
disabled={addLoading}
required
error={addError}
icon={EnvelopeIcon}
/>
{addError && (
<p className="mt-2 text-sm text-red-600">{addError}</p>
)}
</div>
<button
<Button
type="submit"
className={btn_primary}
variant="primary"
disabled={addLoading || !newEmail.trim()}
loading={addLoading}
>
{addLoading ? (
"Adding..."
) : (
<>
<PlusIcon className="h-4 w-4 mr-2" />
Block
</>
{!addLoading && (
<span className="inline-flex items-center gap-2">
<PlusIcon className="h-4 w-4" />
<span>Block</span>
</span>
)}
</button>
{addLoading && "Adding..."}
</Button>
</form>
</div>
</Card>
{/* 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">
<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="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 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 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">
<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>
@ -237,79 +292,59 @@ const BlockedUsers = () => {
{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"
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 text-gray-400 mr-3" />
<EnvelopeIcon className={`h-5 w-5 ${getThemeClasses("text-secondary")} mr-3`} />
<div>
<p className="font-medium text-gray-900">
<p className={`font-medium ${getThemeClasses("text-primary")}`}>
{blocked.blocked_email}
</p>
<p className="text-xs text-gray-500">
<p className={`text-xs ${getThemeClasses("text-secondary")}`}>
Blocked on{" "}
{new Date(blocked.created_at).toLocaleDateString()}
</p>
</div>
</div>
<button
<Button
onClick={() => handleRemoveEmail(blocked.blocked_email)}
className={btn_danger}
variant="danger"
size="sm"
disabled={deleteLoading === blocked.blocked_email}
loading={deleteLoading === blocked.blocked_email}
>
{deleteLoading === blocked.blocked_email ? (
"Removing..."
) : (
<>
<TrashIcon className="h-4 w-4 mr-1" />
Unblock
</>
{deleteLoading !== blocked.blocked_email && (
<span className="inline-flex items-center gap-1">
<TrashIcon className="h-4 w-4" />
<span>Unblock</span>
</span>
)}
</button>
{deleteLoading === blocked.blocked_email && "Removing..."}
</Button>
</div>
))}
</div>
)}
</div>
</Card>
{/* Info section */}
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h3 className="text-sm font-medium text-blue-900 mb-2">
<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 text-blue-700 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 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>
{/* 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>
</Card>
</div>
</Layout>
);
};

View file

@ -1,9 +1,18 @@
// 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 { useAuth } from "../../../services/Services";
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 {
ExclamationTriangleIcon,
ShieldExclamationIcon,
@ -13,11 +22,14 @@ import {
XCircleIcon,
InformationCircleIcon,
LockClosedIcon,
Cog6ToothIcon,
} from "@heroicons/react/24/outline";
const DeleteAccount = () => {
const navigate = useNavigate();
const { authManager, meManager } = useAuth();
const { getThemeClasses } = useUIXTheme();
const isMountedRef = useRef(true);
// State management
const [currentStep, setCurrentStep] = useState(1);
@ -32,14 +44,26 @@ const DeleteAccount = () => {
gdprRights: false,
});
// Cleanup on unmount
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
// Load user info
useEffect(() => {
const loadUserInfo = async () => {
try {
const user = await meManager.getCurrentUser();
if (!isMountedRef.current) return;
setUserEmail(user.email);
} catch (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.");
}
};
@ -64,23 +88,25 @@ const DeleteAccount = () => {
};
// Handle back button
const handleBack = () => {
const handleBack = useCallback(() => {
if (currentStep === 1) {
navigate("/me");
} else {
setCurrentStep(currentStep - 1);
setError("");
}
};
}, [currentStep, navigate]);
// Handle next step
const handleNext = () => {
const handleNext = useCallback(() => {
setError("");
setCurrentStep(currentStep + 1);
};
}, [currentStep]);
// Handle account deletion
const handleDeleteAccount = async () => {
const handleDeleteAccount = useCallback(async () => {
if (!isMountedRef.current) return;
if (!password) {
setError("Please enter your password to confirm deletion.");
return;
@ -103,16 +129,23 @@ const DeleteAccount = () => {
// Call the delete API
await meManager.deleteCurrentUser(password);
if (!isMountedRef.current) return;
// Show success message and logout
setCurrentStep(4); // Success step
// Wait 3 seconds then logout and redirect
setTimeout(() => {
if (!isMountedRef.current) return;
authManager.logout();
navigate("/");
}, 3000);
} catch (err) {
if (!isMountedRef.current) return;
if (import.meta.env.DEV) {
console.error("Account deletion failed:", err);
}
// Handle specific error cases
if (err.response?.status === 401) {
@ -125,118 +158,116 @@ const DeleteAccount = () => {
setError("Failed to delete account. Please try again or contact support.");
}
} finally {
if (isMountedRef.current) {
setLoading(false);
}
};
}
}, [password, allAcknowledged, confirmationMatches, meManager, authManager, navigate]);
// Render Step 1: Warning and Information
const renderStep1 = () => (
<div className="space-y-6">
{/* Warning Banner */}
<div className="bg-red-50 border-l-4 border-red-500 p-4 rounded">
<Alert type="error">
<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>
<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
</h3>
<p className="text-red-700">
<p className={getThemeClasses("text-secondary")}>
Once you delete your account, all your data will be permanently
removed from our servers. This includes all files, collections,
and personal information.
</p>
</div>
</div>
</div>
</Alert>
{/* What will be deleted */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<TrashIcon className="h-5 w-5 mr-2 text-gray-500" />
<Card className={`border ${getThemeClasses("border-muted")} p-6`}>
<h3 className={`text-lg font-semibold ${getThemeClasses("text-primary")} mb-4 flex items-center`}>
<TrashIcon className={`h-5 w-5 mr-2 ${getThemeClasses("text-secondary")}`} />
What will be deleted
</h3>
<ul className="space-y-3">
<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>
<strong className="text-gray-900">All your files</strong>
<p className="text-sm text-gray-600">
<strong className={getThemeClasses("text-primary")}>All your files</strong>
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>
Every file you've uploaded will be permanently deleted from our
servers
</p>
</div>
</li>
<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>
<strong className="text-gray-900">All your collections</strong>
<p className="text-sm text-gray-600">
<strong className={getThemeClasses("text-primary")}>All your collections</strong>
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>
All folders and collections you own will be permanently removed
</p>
</div>
</li>
<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>
<strong className="text-gray-900">Personal information</strong>
<p className="text-sm text-gray-600">
<strong className={getThemeClasses("text-primary")}>Personal information</strong>
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>
Your profile, email, and all associated data will be deleted
</p>
</div>
</li>
<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>
<strong className="text-gray-900">Shared access</strong>
<p className="text-sm text-gray-600">
<strong className={getThemeClasses("text-primary")}>Shared access</strong>
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>
You'll be removed from any collections shared with you
</p>
</div>
</li>
<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>
<strong className="text-gray-900">Storage usage history</strong>
<p className="text-sm text-gray-600">
<strong className={getThemeClasses("text-primary")}>Storage usage history</strong>
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>
All your storage metrics and usage history will be deleted
</p>
</div>
</li>
</ul>
</div>
</Card>
{/* GDPR Information */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<Alert type="info">
<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>
<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)
</h4>
<p className="text-sm text-blue-800">
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>
This deletion process complies with GDPR regulations. All your
personal data will be permanently erased from our systems within
moments of confirmation. This action cannot be reversed.
</p>
</div>
</div>
</div>
</Alert>
{/* Action Buttons */}
<div className="flex justify-between pt-4">
<button
onClick={handleBack}
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 mr-2" />
Cancel
</button>
<button
onClick={handleNext}
className="px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
<Button onClick={handleBack} variant="secondary">
<span className="inline-flex items-center gap-2">
<ArrowLeftIcon className="h-4 w-4" />
<span>Cancel</span>
</span>
</Button>
<Button onClick={handleNext} variant="danger">
Continue to Confirmation
</button>
</Button>
</div>
</div>
);
@ -244,36 +275,36 @@ const DeleteAccount = () => {
// Render Step 2: Acknowledgments
const renderStep2 = () => (
<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">
<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>
<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
</h3>
<p className="text-yellow-700">
<p className={getThemeClasses("text-secondary")}>
Before proceeding, you must acknowledge the following statements
about your account deletion.
</p>
</div>
</div>
</div>
</Alert>
{/* 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">
<input
type="checkbox"
id="ack-permanent"
checked={acknowledgments.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
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.
</strong>{" "}
Once deleted, my account and all associated data cannot be
@ -287,13 +318,13 @@ const DeleteAccount = () => {
id="ack-data"
checked={acknowledgments.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
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
deleted.
</strong>{" "}
@ -307,49 +338,44 @@ const DeleteAccount = () => {
id="ack-gdpr"
checked={acknowledgments.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
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.
</strong>{" "}
I understand that this will result in the immediate and complete
deletion of all my personal data from MapleFile servers.
</label>
</div>
</div>
</Card>
{/* Current Account */}
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<p className="text-sm text-gray-600">
<div className={`${getThemeClasses("bg-muted")} border ${getThemeClasses("border-muted")} rounded-lg p-4`}>
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>
Account to be deleted:{" "}
<strong className="text-gray-900">{userEmail}</strong>
<strong className={getThemeClasses("text-primary")}>{userEmail}</strong>
</p>
</div>
{/* Action Buttons */}
<div className="flex justify-between pt-4">
<button
onClick={handleBack}
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 mr-2" />
Back
</button>
<button
<Button onClick={handleBack} variant="secondary">
<span className="inline-flex items-center gap-2">
<ArrowLeftIcon className="h-4 w-4" />
<span>Back</span>
</span>
</Button>
<Button
onClick={handleNext}
disabled={!allAcknowledged}
className={`px-6 py-2 rounded-lg transition-colors ${
allAcknowledged
? "bg-red-600 text-white hover:bg-red-700"
: "bg-gray-300 text-gray-500 cursor-not-allowed"
}`}
variant="danger"
>
Continue to Final Step
</button>
</Button>
</div>
</div>
);
@ -357,135 +383,89 @@ const DeleteAccount = () => {
// Render Step 3: Final Confirmation
const renderStep3 = () => (
<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">
<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>
<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
</h3>
<p className="text-red-800">
<p className={getThemeClasses("text-secondary")}>
This is your last chance to cancel. After clicking "Delete My
Account", your data will be permanently erased.
</p>
</div>
</div>
</div>
</Alert>
{/* Password Confirmation */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 space-y-4">
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-2 flex items-center"
>
<LockClosedIcon className="h-4 w-4 mr-2" />
Enter your password to confirm
</label>
<input
<Card className={`border ${getThemeClasses("border-muted")} p-6 space-y-4`}>
<Input
label="Enter your password to confirm"
type="password"
id="password"
name="password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
onChange={(value) => {
setPassword(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}
icon={LockClosedIcon}
/>
</div>
<div>
<label
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
shown)
</label>
<input
<Input
type="text"
id="confirm-text"
name="confirm-text"
value={confirmText}
onChange={(e) => {
setConfirmText(e.target.value);
onChange={(value) => {
setConfirmText(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 font-mono"
placeholder="DELETE MY ACCOUNT"
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>
</Card>
{/* Error Display */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-start">
<XCircleIcon className="h-5 w-5 text-red-500 mr-3 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-800">{error}</p>
</div>
</div>
<Alert type="error" onClose={() => setError("")}>
{error}
</Alert>
)}
{/* Action Buttons */}
<div className="flex justify-between pt-4">
<button
onClick={handleBack}
disabled={loading}
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"
>
<ArrowLeftIcon className="h-4 w-4 mr-2" />
Back
</button>
<button
<Button onClick={handleBack} variant="secondary" disabled={loading}>
<span className="inline-flex items-center gap-2">
<ArrowLeftIcon className="h-4 w-4" />
<span>Back</span>
</span>
</Button>
<Button
onClick={handleDeleteAccount}
disabled={
loading || !password || !confirmationMatches || !allAcknowledged
}
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"
}`}
disabled={loading || !password || !confirmationMatches || !allAcknowledged}
variant="danger"
loading={loading}
>
{loading ? (
<>
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
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
</>
{!loading && (
<span className="inline-flex items-center gap-2">
<TrashIcon className="h-4 w-4" />
<span>Delete My Account</span>
</span>
)}
</button>
{loading && "Deleting Account..."}
</Button>
</div>
</div>
);
@ -493,33 +473,33 @@ const DeleteAccount = () => {
// Render Step 4: Success
const renderStep4 = () => (
<div className="space-y-6">
<div className="bg-green-50 border-2 border-green-500 p-8 rounded-lg text-center">
<CheckCircleIcon className="h-16 w-16 text-green-600 mx-auto mb-4" />
<h3 className="text-2xl font-bold text-green-900 mb-2">
<div className={`${getThemeClasses("bg-success-light")} border-2 ${getThemeClasses("border-success")} p-8 rounded-lg text-center`}>
<CheckCircleIcon className={`h-16 w-16 ${getThemeClasses("text-success")} mx-auto mb-4`} />
<h3 className={`text-2xl font-bold ${getThemeClasses("text-primary")} mb-2`}>
Account Deleted Successfully
</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
from our servers.
</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
seconds...
</p>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<Alert type="info">
<div className="flex items-start">
<InformationCircleIcon className="h-5 w-5 text-blue-500 mr-3 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800">
<strong className="text-blue-900">Thank you for using MapleFile.</strong>
<p className="mt-1">
<InformationCircleIcon className={`h-5 w-5 ${getThemeClasses("text-info")} mr-3 flex-shrink-0 mt-0.5`} />
<div>
<strong className={getThemeClasses("text-primary")}>Thank you for using MapleFile.</strong>
<p className={`mt-1 ${getThemeClasses("text-secondary")}`}>
If you have any feedback or concerns, please contact our support
team. You're always welcome to create a new account in the future.
</p>
</div>
</div>
</div>
</Alert>
</div>
);
@ -544,8 +524,8 @@ const DeleteAccount = () => {
<div
className={`flex items-center justify-center w-10 h-10 rounded-full border-2 transition-colors ${
currentStep >= step.number
? "border-red-600 bg-red-600 text-white"
: "border-gray-300 bg-white text-gray-400"
? `${getThemeClasses("border-error")} ${getThemeClasses("bg-error")} text-white`
: `${getThemeClasses("border-muted")} ${getThemeClasses("bg-card")} ${getThemeClasses("text-secondary")}`
}`}
>
{currentStep > step.number ? (
@ -558,8 +538,8 @@ const DeleteAccount = () => {
<span
className={`mt-2 text-xs whitespace-nowrap ${
currentStep >= step.number
? "font-semibold text-red-600"
: "text-gray-600"
? `font-semibold ${getThemeClasses("text-error")}`
: getThemeClasses("text-secondary")
}`}
>
{step.label}
@ -570,7 +550,7 @@ const DeleteAccount = () => {
{index < steps.length - 1 && (
<div
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 (
<div className="min-h-screen bg-gray-50">
<Navigation />
<div className="max-w-4xl mx-auto px-4 py-8">
<Layout>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Breadcrumb Navigation */}
<Breadcrumb items={breadcrumbItems} />
{/* Main Card */}
<Card>
{/* Header */}
<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" />
<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="text-gray-600">
<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>
{/* Content */}
<div className="p-6">
{/* Progress Indicator */}
{renderProgressIndicator()}
{/* Main Content */}
<div className="bg-white rounded-lg shadow-lg p-8">
{/* Step Content */}
{currentStep === 1 && renderStep1()}
{currentStep === 2 && renderStep2()}
{currentStep === 3 && renderStep3()}
{currentStep === 4 && renderStep4()}
</div>
</Card>
{/* Footer Notice */}
{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>
Need help? Contact our support team at{" "}
<a
href="mailto:support@maplefile.com"
className="text-blue-600 hover:text-blue-700"
className={getThemeClasses("link-primary")}
>
support@maplefile.com
</a>
@ -622,7 +636,7 @@ const DeleteAccount = () => {
</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 { 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
const breadcrumbItems = useMemo(
() => [
@ -55,105 +83,116 @@ const ExportData = () => {
// Content sections
const contentSections = useMemo(() => {
return (
<div className="space-y-6">
<div className="space-y-6" role="main" aria-label="Export data options">
{/* 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">
<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">
<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)
</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.
</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).
</p>
<div className="flex space-x-3">
<Button
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.')}
>
<ArrowDownTrayIcon className="h-5 w-5 mr-2" />
Export Metadata (JSON)
</Button>
</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.
</p>
</div>
</div>
</div>
</section>
{/* 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">
<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">
<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
</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.
</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.
</p>
</div>
</div>
</div>
</section>
{/* 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">
<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>
<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
</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:
</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">
<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>
</li>
<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>
</li>
<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>
</li>
<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>
</li>
</ul>
</div>
</div>
</div>
</section>
{/* 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">
<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">
<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
</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.
</p>
{/* 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">
<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">
<p className={`text-sm font-medium mb-2 ${getThemeClasses("text-primary")}`}>
Source Code & Installation Instructions
@ -162,7 +201,7 @@ const ExportData = () => {
href="https://codeberg.org/mapleopentech/monorepo/src/branch/main/native/desktop/maplefile"
target="_blank"
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
</a>
@ -173,145 +212,157 @@ const ExportData = () => {
{/* Installation Steps */}
<div className="space-y-3">
<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">
<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>
</li>
<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>
</li>
<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>
</li>
<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>
</li>
<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>
</li>
</ol>
</div>
</div>
</div>
</div>
</section>
{/* 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">
<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">
<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
</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:
</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">
<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>
</li>
<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>
</li>
<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>
</li>
<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>
</li>
<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>
</li>
</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.
</p>
</div>
</div>
</div>
</section>
{/* GDPR Information */}
<div className={`${getThemeClasses("bg-muted")} border ${getThemeClasses("border")} rounded-xl p-6`}>
<h3 className={`text-sm font-semibold mb-2 ${getThemeClasses("text-primary")}`}>
<section
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
</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.
</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).
</p>
</div>
</section>
{/* 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">
<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">
<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
</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:
</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">
<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>
</li>
<li className="flex items-start">
<span className="text-yellow-600 dark:text-yellow-400 mr-2"></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 className={`${sectionStyles.warning.icon} mr-2`} aria-hidden="true"></span>
<span>Verify the repository URL matches: <code className={`${sectionStyles.warning.code} px-1 rounded`}>codeberg.org/mapleopentech</code></span>
</li>
<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>
</li>
<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>
</li>
</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.
</p>
</div>
</div>
</div>
</section>
{/* Support Section */}
<div className={`${getThemeClasses("bg-card")} rounded-xl shadow-lg border ${getThemeClasses("border")} p-6`}>
<h3 className={`text-lg font-semibold mb-3 ${getThemeClasses("text-primary")}`}>
<section
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?
</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:
</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">
<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>
</li>
<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>
</li>
<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>
</li>
</ul>
</div>
</section>
{/* Action Button */}
<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"
target="_blank"
rel="noopener noreferrer"
aria-label="Go to MapleFile Desktop Application Repository (opens in new tab)"
>
<Button
variant="primary"
className="flex items-center"
icon={CodeBracketIcon}
>
<CodeBracketIcon className="h-5 w-5 mr-2" />
Go to MapleFile Desktop Application Repository
</Button>
</a>
</div>
</div>
);
}, [getThemeClasses]);
}, [getThemeClasses, sectionStyles]);
// Field sections for DetailLiteView
const fieldSections = useMemo(() => {

View file

@ -1,16 +1,36 @@
// File: src/pages/User/Me/Tags/TagsManagement.jsx
// 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 Button from "../../../../components/UIX/Button/Button.jsx";
import Input from "../../../../components/UIX/Input/Input.jsx";
import Alert from "../../../../components/UIX/Alert/Alert.jsx";
import Card from "../../../../components/UIX/Card/Card.jsx";
import { TrashIcon, PencilIcon, PlusIcon, TagIcon } from "@heroicons/react/24/outline";
import withPasswordProtection from "../../../../hocs/withPasswordProtection";
import Layout from "../../../../components/Layout/Layout";
import {
Button,
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 navigate = useNavigate();
const { getThemeClasses } = useUIXTheme();
const { tagManager } = useTags();
const isMountedRef = useRef(true);
// State
const [tags, setTags] = useState([]);
@ -24,10 +44,41 @@ const TagsManagement = () => {
const [formData, setFormData] = useState({ name: "", color: "#3B82F6" });
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
useEffect(() => {
loadTags();
}, []);
}, [loadTags]);
// Listen for tag events
useEffect(() => {
@ -44,25 +95,10 @@ const TagsManagement = () => {
window.removeEventListener("tagUpdated", handleTagUpdated);
window.removeEventListener("tagDeleted", handleTagDeleted);
};
}, []);
// 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);
}
};
}, [loadTags]);
// Handle create
const handleCreate = async () => {
const handleCreate = useCallback(async () => {
try {
setFormErrors({});
setError("");
@ -84,20 +120,26 @@ const TagsManagement = () => {
setIsLoading(true);
await tagManager.createTag(formData);
if (isMountedRef.current) {
setSuccess("Tag created successfully!");
setFormData({ name: "", color: "#3B82F6" });
setIsCreating(false);
await loadTags();
}
} catch (err) {
if (isMountedRef.current) {
console.error("Failed to create tag:", err);
setError(err.message || "Failed to create tag");
}
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
};
}
}, [formData, tagManager, loadTags]);
// Handle edit
const handleEdit = async () => {
const handleEdit = useCallback(async () => {
try {
setFormErrors({});
setError("");
@ -119,20 +161,26 @@ const TagsManagement = () => {
setIsLoading(true);
await tagManager.updateTag(editingTagId, formData);
if (isMountedRef.current) {
setSuccess("Tag updated successfully!");
setFormData({ name: "", color: "#3B82F6" });
setEditingTagId(null);
await loadTags();
}
} catch (err) {
if (isMountedRef.current) {
console.error("Failed to update tag:", err);
setError(err.message || "Failed to update tag");
}
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
};
}
}, [formData, editingTagId, tagManager, loadTags]);
// Handle delete
const handleDelete = async (tagId) => {
const handleDelete = useCallback(async (tagId) => {
if (!window.confirm("Are you sure you want to delete this tag?")) {
return;
}
@ -142,43 +190,80 @@ const TagsManagement = () => {
setSuccess("");
setIsLoading(true);
await tagManager.deleteTag(tagId);
if (isMountedRef.current) {
setSuccess("Tag deleted successfully!");
await loadTags();
}
} catch (err) {
if (isMountedRef.current) {
console.error("Failed to delete tag:", err);
setError(err.message || "Failed to delete tag");
}
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
};
}
}, [tagManager, loadTags]);
// Start editing
const startEdit = (tag) => {
const startEdit = useCallback((tag) => {
setFormData({ name: tag.name, color: tag.color });
setEditingTagId(tag.id);
setIsCreating(false);
};
}, []);
// Cancel form
const cancelForm = () => {
const cancelForm = useCallback(() => {
setFormData({ name: "", color: "#3B82F6" });
setIsCreating(false);
setEditingTagId(null);
setFormErrors({});
};
}, []);
// Memoize breadcrumb items
const breadcrumbItems = useMemo(() => [
{
label: "Settings",
to: "/me",
icon: Cog6ToothIcon,
},
{
label: "Tags",
isActive: true,
},
], []);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<Layout>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Breadcrumb Navigation */}
<Breadcrumb items={breadcrumbItems} />
{/* Main Card */}
<Card>
{/* Header with icon, title, and action button */}
<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")}`}>
<TagIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
</div>
{/* Title and subtitle */}
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
Manage Tags
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
</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>
{/* Action buttons */}
<div className="flex-shrink-0 flex items-center space-x-3">
{!isCreating && !editingTagId && (
<Button
onClick={() => setIsCreating(true)}
@ -188,25 +273,40 @@ const TagsManagement = () => {
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>
{/* Alerts */}
{/* Messages */}
<div className="px-6 pt-6">
{error && (
<Alert type="error" onClose={() => setError("")}>
<Alert type="error" className="mb-4" onClose={() => setError("")}>
{error}
</Alert>
)}
{success && (
<Alert type="success" onClose={() => setSuccess("")}>
<Alert type="success" className="mb-4" onClose={() => setSuccess("")}>
{success}
</Alert>
)}
</div>
{/* Content */}
<div className="px-6 pb-6 pt-2">
{/* Create/Edit Form */}
{(isCreating || editingTagId) && (
<Card>
<div className="p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
<div className={`mb-6 p-6 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-muted")}`}>
<h3 className={`text-lg font-semibold mb-4 ${getThemeClasses("text-primary")}`}>
{editingTagId ? "Edit Tag" : "Create New Tag"}
</h3>
@ -224,7 +324,7 @@ const TagsManagement = () => {
/>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<label className={`block text-sm font-medium mb-2 ${getThemeClasses("text-primary")}`}>
Tag Color
</label>
<div className="flex items-center space-x-4">
@ -235,7 +335,7 @@ const TagsManagement = () => {
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"
className={`h-10 w-20 rounded cursor-pointer border ${getThemeClasses("border-secondary")}`}
/>
<Input
value={formData.color}
@ -248,7 +348,7 @@ const TagsManagement = () => {
maxLength={7}
/>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
<p className={`text-xs mt-1 ${getThemeClasses("text-secondary")}`}>
Choose a color to identify this tag
</p>
</div>
@ -259,6 +359,8 @@ const TagsManagement = () => {
onClick={editingTagId ? handleEdit : handleCreate}
variant="primary"
disabled={isLoading}
loading={isLoading}
loadingText={editingTagId ? "Updating..." : "Creating..."}
>
{editingTagId ? "Update Tag" : "Create Tag"}
</Button>
@ -267,75 +369,82 @@ const TagsManagement = () => {
</Button>
</div>
</div>
</Card>
)}
{/* Tags List */}
{isLoading && tags.length === 0 ? (
<Card>
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
Loading tags...
<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>
</Card>
) : tags.length === 0 && !isCreating ? (
<Card>
<div className="p-8 text-center">
<TagIcon className="h-12 w-12 mx-auto text-gray-400 dark:text-gray-600 mb-3" />
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
<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="text-gray-600 dark:text-gray-400 mb-4">
Create your first tag to organize your files and collections
<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>
</Card>
) : (
) : tags.length > 0 && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{tags.map((tag) => (
<Card key={tag.id}>
<div className="p-4">
<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-4 h-4 rounded-full flex-shrink-0"
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 text-gray-900 dark:text-white truncate">
<h4 className={`text-sm font-medium truncate ${getThemeClasses("text-primary")}`}>
{tag.name}
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
<p className={`text-xs mt-0.5 ${getThemeClasses("text-secondary")}`}>
{tag.color}
</p>
</div>
</div>
<div className="flex space-x-2 ml-2">
<button
<div className="flex space-x-1 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"
variant="ghost"
size="sm"
aria-label={`Edit tag ${tag.name}`}
>
<PencilIcon className="h-4 w-4" />
</button>
<button
</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"
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>
</Button>
</div>
</div>
</div>
</Card>
))}
</div>
)}
</div>
</Card>
</div>
</Layout>
);
};
export default TagsManagement;
export default withPasswordProtection(TagsManagement);

View file

@ -1,16 +1,31 @@
// File: src/pages/User/Tags/TagCreate.jsx
// 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 { useTags } from "../../../services/Services.jsx";
import Navigation from "../../../components/Navigation";
import Alert from "../../../components/UIX/Alert/Alert.jsx";
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
import withPasswordProtection from "../../../hocs/withPasswordProtection";
import Layout from "../../../components/Layout/Layout";
import {
Button,
Input,
Alert,
Card,
Breadcrumb,
useUIXTheme,
} from "../../../components/UIX";
import {
ArrowLeftIcon,
TagIcon,
PlusIcon,
Cog6ToothIcon,
} from "@heroicons/react/24/outline";
const TagCreate = () => {
const navigate = useNavigate();
const { tagManager } = useTags();
const { getThemeClasses } = useUIXTheme();
const isMountedRef = useRef(true);
// State
const [isLoading, setIsLoading] = useState(false);
@ -18,10 +33,20 @@ const TagCreate = () => {
const [formData, setFormData] = useState({ name: "", color: "#3B82F6" });
const [formErrors, setFormErrors] = useState({});
// Cleanup on unmount
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
// Handle create
const handleCreate = async (e) => {
const handleCreate = useCallback(async (e) => {
e.preventDefault();
if (!isMountedRef.current) return;
try {
setFormErrors({});
setError("");
@ -42,81 +67,105 @@ const TagCreate = () => {
setIsLoading(true);
await tagManager.createTag(formData);
if (!isMountedRef.current) return;
// Navigate back to list
navigate("/me/tags");
} catch (err) {
if (!isMountedRef.current) return;
if (import.meta.env.DEV) {
console.error("Failed to create tag:", err);
}
setError(err.message || "Failed to create tag");
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
};
}
}, [formData, tagManager, navigate]);
const btn_primary =
"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 btn_secondary =
"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";
const input_style =
"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";
// Breadcrumb items
const breadcrumbItems = [
{ label: "Settings", to: "/me", icon: Cog6ToothIcon },
{ label: "Tags", to: "/me/tags", icon: TagIcon },
{ label: "Create Tag", isActive: true },
];
return (
<div className="min-h-screen bg-gray-50">
<Navigation />
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Layout>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Breadcrumb Navigation */}
<Breadcrumb items={breadcrumbItems} />
{/* Main Card */}
<Card>
{/* Header */}
<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">
<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-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>
{/* Alert */}
{/* Messages */}
<div className="px-6 pt-6">
{error && (
<div className="mb-6 animate-fade-in-up">
<Alert type="error" onClose={() => setError("")}>
<Alert type="error" className="mb-4" onClose={() => setError("")}>
{error}
</Alert>
</div>
)}
</div>
{/* Form */}
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-6 animate-fade-in-up">
<form onSubmit={handleCreate} className="space-y-6">
<div>
<label htmlFor="tag_name" className={label_style}>
Tag Name <span className="text-red-500">*</span>
</label>
<input
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"
name="tag_name"
placeholder="e.g., Important, Work, Personal"
value={formData.name}
onChange={(e) => {
setFormData({ ...formData, name: e.target.value });
onChange={(value) => {
setFormData({ ...formData, name: value });
setFormErrors({ ...formErrors, name: "" });
}}
placeholder="e.g., Important, Work, Personal"
maxLength={50}
required
className={input_style}
disabled={isLoading}
error={formErrors.name}
icon={TagIcon}
/>
{formErrors.name && (
<p className="mt-1 text-sm text-red-600">{formErrors.name}</p>
)}
</div>
<div>
<label className={label_style}>
Tag Color <span className="text-red-500">*</span>
<label className={`block text-sm font-medium ${getThemeClasses("text-primary")} mb-2`}>
Tag Color <span className={getThemeClasses("text-error")}>*</span>
</label>
<div className="flex items-center space-x-4">
<input
@ -126,81 +175,60 @@ const TagCreate = () => {
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"
className={`h-12 w-24 rounded-lg cursor-pointer border-2 ${getThemeClasses("border-muted")} shadow-sm ${getThemeClasses("hover:border-accent")} transition-colors duration-200`}
disabled={isLoading}
/>
<div className="flex-1">
<input
<Input
type="text"
name="tag_color"
placeholder="#3B82F6"
value={formData.color}
onChange={(e) => {
setFormData({ ...formData, color: e.target.value });
onChange={(value) => {
setFormData({ ...formData, color: value });
setFormErrors({ ...formErrors, color: "" });
}}
placeholder="#3B82F6"
maxLength={7}
className={input_style}
disabled={isLoading}
error={formErrors.color}
/>
</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">
<p className={`text-xs ${getThemeClasses("text-secondary")} 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">
<button type="submit" className={btn_primary} disabled={isLoading}>
{isLoading ? "Creating..." : "Create Tag"}
</button>
<button
<div className={`flex space-x-3 pt-4 border-t ${getThemeClasses("border-muted")}`}>
<Button
type="submit"
variant="primary"
disabled={isLoading}
loading={isLoading}
>
{!isLoading && (
<span className="inline-flex items-center gap-2">
<PlusIcon className="h-4 w-4" />
<span>Create Tag</span>
</span>
)}
{isLoading && "Creating..."}
</Button>
<Button
type="button"
variant="secondary"
onClick={() => navigate("/me/tags")}
className={btn_secondary}
disabled={isLoading}
>
Cancel
</button>
</Button>
</div>
</form>
</div>
</Card>
</div>
{/* 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>
</Layout>
);
};
export default TagCreate;
export default withPasswordProtection(TagCreate);

View file

@ -1,14 +1,25 @@
// File: src/pages/User/Tags/TagDelete.jsx
// 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 { useTags } from "../../../services/Services.jsx";
import Navigation from "../../../components/Navigation";
import Alert from "../../../components/UIX/Alert/Alert.jsx";
import withPasswordProtection from "../../../hocs/withPasswordProtection";
import Layout from "../../../components/Layout/Layout";
import {
Button,
Alert,
Card,
Breadcrumb,
Spinner,
useUIXTheme,
} from "../../../components/UIX";
import {
ArrowLeftIcon,
ExclamationTriangleIcon,
TagIcon,
TrashIcon,
Cog6ToothIcon,
} from "@heroicons/react/24/outline";
const TagDelete = () => {
@ -16,27 +27,34 @@ const TagDelete = () => {
const { tagId } = useParams();
const location = useLocation();
const { tagManager } = useTags();
const { getThemeClasses } = useUIXTheme();
const isMountedRef = useRef(true);
// State
const [isLoading, setIsLoading] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const [error, setError] = useState("");
const [tag, setTag] = useState(null);
// Get tag name from location state or load tag
// Cleanup on unmount
useEffect(() => {
if (location.state?.tagName) {
setTag({ id: tagId, name: location.state.tagName });
} else {
loadTag();
}
}, [tagId]);
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
// Load tag
const loadTag = async () => {
const loadTag = useCallback(async () => {
if (!tagManager) return;
try {
setIsLoading(true);
setError("");
const tags = await tagManager.listTags();
if (!isMountedRef.current) return;
const foundTag = tags.find((t) => t.id === tagId);
if (!foundTag) {
setError("Tag not found");
@ -44,152 +62,188 @@ const TagDelete = () => {
return;
}
setTag(foundTag);
setIsInitialized(true);
} catch (err) {
if (!isMountedRef.current) return;
if (import.meta.env.DEV) {
console.error("Failed to load tag:", err);
}
setError(err.message || "Failed to load tag");
setIsInitialized(true);
} finally {
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
const handleDelete = async () => {
const handleDelete = useCallback(async () => {
if (!isMountedRef.current) return;
try {
setError("");
setIsLoading(true);
if (import.meta.env.DEV) {
console.log("[TagDelete] Attempting to delete tag:", tagId);
}
await tagManager.deleteTag(tagId);
if (!isMountedRef.current) return;
if (import.meta.env.DEV) {
console.log("[TagDelete] Tag deleted successfully");
}
// Navigate back to list
navigate("/me/tags");
} catch (err) {
if (!isMountedRef.current) return;
if (import.meta.env.DEV) {
console.error("[TagDelete] Failed to delete tag:", err);
console.error("[TagDelete] Error details:", {
message: err.message,
status: err.status,
response: err.response,
});
}
setError(err.message || "Failed to delete tag");
setIsLoading(false);
}
};
}, [tagManager, tagId, navigate]);
const btn_danger =
"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 btn_secondary =
"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";
// Breadcrumb items
const breadcrumbItems = [
{ label: "Settings", to: "/me", icon: Cog6ToothIcon },
{ label: "Tags", to: "/me/tags", icon: TagIcon },
{ label: "Delete Tag", isActive: true },
];
return (
<div className="min-h-screen bg-gray-50">
<Navigation />
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Layout>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Breadcrumb Navigation */}
<Breadcrumb items={breadcrumbItems} />
{/* Main Card */}
<Card>
{/* Header */}
<div className="mb-8 animate-fade-in-down">
<button
<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 Tag
</h1>
<p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
Confirm tag deletion
</p>
</div>
</div>
</div>
<div className="flex-shrink-0">
<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"
variant="secondary"
size="sm"
>
<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>
<span className="inline-flex items-center gap-2">
<ArrowLeftIcon className="h-4 w-4" />
<span>Back to Tags</span>
</span>
</Button>
</div>
</div>
</div>
{/* Alert */}
{/* Messages */}
<div className="px-6 pt-6">
{error && (
<div className="mb-6 animate-fade-in-up">
<Alert type="error" onClose={() => setError("")}>
<Alert type="error" className="mb-4" onClose={() => setError("")}>
{error}
</Alert>
</div>
)}
</div>
{/* Confirmation */}
{/* Content */}
<div className="px-6 pb-6 pt-2">
{isLoading && !tag ? (
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-8 text-center animate-fade-in-up">
<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 tag...</p>
<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="bg-white rounded-xl shadow-lg border-2 border-red-100 p-8 animate-fade-in-up">
<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 bg-red-100">
<ExclamationTriangleIcon className="h-8 w-8 text-red-600" />
<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 text-gray-900 mb-2">
<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 text-gray-600">
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>
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">
<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.
</p>
</Alert>
</div>
</div>
</div>
</div>
<div className="flex space-x-3 mt-6 pt-6 border-t border-gray-200">
<button
<div className={`flex space-x-3 mt-6 pt-6 border-t ${getThemeClasses("border-muted")}`}>
<Button
onClick={handleDelete}
className={btn_danger}
variant="danger"
disabled={isLoading}
loading={isLoading}
>
{isLoading ? "Deleting..." : "Delete Tag"}
</button>
<button
{!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")}
className={btn_secondary}
variant="secondary"
disabled={isLoading}
>
Cancel
</button>
</Button>
</div>
</div>
) : null}
</div>
{/* 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>
</Card>
</div>
</Layout>
);
};
export default TagDelete;
export default withPasswordProtection(TagDelete);

View file

@ -1,35 +1,59 @@
// File: src/pages/User/Tags/TagEdit.jsx
// 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 { useTags } from "../../../services/Services.jsx";
import Navigation from "../../../components/Navigation";
import Alert from "../../../components/UIX/Alert/Alert.jsx";
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
import withPasswordProtection from "../../../hocs/withPasswordProtection";
import Layout from "../../../components/Layout/Layout";
import {
Button,
Input,
Alert,
Card,
Breadcrumb,
Spinner,
useUIXTheme,
} from "../../../components/UIX";
import {
ArrowLeftIcon,
TagIcon,
Cog6ToothIcon,
} from "@heroicons/react/24/outline";
const TagEdit = () => {
const navigate = useNavigate();
const { tagId } = useParams();
const { tagManager } = useTags();
const { getThemeClasses } = useUIXTheme();
const isMountedRef = useRef(true);
// State
const [isLoading, setIsLoading] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const [error, setError] = useState("");
const [formData, setFormData] = useState({ name: "", color: "#3B82F6" });
const [formErrors, setFormErrors] = useState({});
// Load tag on mount
// Cleanup on unmount
useEffect(() => {
loadTag();
}, [tagId]);
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
// Load tag on mount
const loadTag = useCallback(async () => {
if (!tagManager) return;
// Load tag
const loadTag = async () => {
try {
setIsLoading(true);
setError("");
const tags = await tagManager.listTags();
if (!isMountedRef.current) return;
const tag = tags.find((t) => t.id === tagId);
if (!tag) {
setError("Tag not found");
@ -37,18 +61,34 @@ const TagEdit = () => {
return;
}
setFormData({ name: tag.name, color: tag.color });
setIsInitialized(true);
} catch (err) {
if (!isMountedRef.current) return;
if (import.meta.env.DEV) {
console.error("Failed to load tag:", err);
}
setError(err.message || "Failed to load tag");
setIsInitialized(true);
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
};
}
}, [tagManager, tagId, navigate]);
useEffect(() => {
if (tagManager && !isInitialized) {
loadTag();
}
}, [tagManager, isInitialized, loadTag]);
// Handle update
const handleUpdate = async (e) => {
const handleUpdate = useCallback(async (e) => {
e.preventDefault();
if (!isMountedRef.current) return;
try {
setFormErrors({});
setError("");
@ -69,85 +109,113 @@ const TagEdit = () => {
setIsLoading(true);
await tagManager.updateTag(tagId, formData);
if (!isMountedRef.current) return;
// Navigate back to list
navigate("/me/tags");
} catch (err) {
if (!isMountedRef.current) return;
if (import.meta.env.DEV) {
console.error("Failed to update tag:", err);
}
setError(err.message || "Failed to update tag");
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
};
}
}, [formData, tagManager, tagId, navigate]);
const btn_primary =
"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 btn_secondary =
"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";
const input_style =
"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";
// Breadcrumb items
const breadcrumbItems = [
{ label: "Settings", to: "/me", icon: Cog6ToothIcon },
{ label: "Tags", to: "/me/tags", icon: TagIcon },
{ label: "Edit Tag", isActive: true },
];
return (
<div className="min-h-screen bg-gray-50">
<Navigation />
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Layout>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Breadcrumb Navigation */}
<Breadcrumb items={breadcrumbItems} />
{/* Main Card */}
<Card>
{/* Header */}
<div className="mb-8 animate-fade-in-down">
<button
<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-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")}
className="inline-flex items-center text-sm text-gray-600 hover:text-red-700 mb-4 transition-colors duration-200"
variant="secondary"
size="sm"
>
<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>
<span className="inline-flex items-center gap-2">
<ArrowLeftIcon className="h-4 w-4" />
<span>Back to Tags</span>
</span>
</Button>
</div>
</div>
</div>
{/* Alert */}
{/* Messages */}
<div className="px-6 pt-6">
{error && (
<div className="mb-6 animate-fade-in-up">
<Alert type="error" onClose={() => setError("")}>
<Alert type="error" className="mb-4" onClose={() => setError("")}>
{error}
</Alert>
</div>
)}
</div>
{/* Form */}
{/* Content */}
<div className="px-6 pb-6 pt-2">
{isLoading && !formData.name ? (
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-8 text-center animate-fade-in-up">
<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 tag...</p>
<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>
) : (
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-6 animate-fade-in-up">
<form onSubmit={handleUpdate} className="space-y-6">
<div>
<label htmlFor="tag_name" className={label_style}>
Tag Name <span className="text-red-500">*</span>
</label>
<input
id="tag_name"
<form onSubmit={handleUpdate} className="space-y-6 max-w-xl">
<Input
label="Tag Name"
type="text"
name="tag_name"
placeholder="e.g., Important, Work, Personal"
value={formData.name}
onChange={(e) => {
setFormData({ ...formData, name: e.target.value });
onChange={(value) => {
setFormData({ ...formData, name: value });
setFormErrors({ ...formErrors, name: "" });
}}
placeholder="e.g., Important, Work, Personal"
maxLength={50}
required
className={input_style}
disabled={isLoading}
error={formErrors.name}
icon={TagIcon}
/>
{formErrors.name && (
<p className="mt-1 text-sm text-red-600">{formErrors.name}</p>
)}
</div>
<div>
<label className={label_style}>
Tag Color <span className="text-red-500">*</span>
<label className={`block text-sm font-medium ${getThemeClasses("text-primary")} mb-2`}>
Tag Color <span className={getThemeClasses("text-error")}>*</span>
</label>
<div className="flex items-center space-x-4">
<input
@ -157,82 +225,56 @@ const TagEdit = () => {
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"
className={`h-12 w-24 rounded-lg cursor-pointer border-2 ${getThemeClasses("border-muted")} shadow-sm ${getThemeClasses("hover:border-accent")} transition-colors duration-200`}
disabled={isLoading}
/>
<div className="flex-1">
<input
<Input
type="text"
name="tag_color"
placeholder="#3B82F6"
value={formData.color}
onChange={(e) => {
setFormData({ ...formData, color: e.target.value });
onChange={(value) => {
setFormData({ ...formData, color: value });
setFormErrors({ ...formErrors, color: "" });
}}
placeholder="#3B82F6"
maxLength={7}
className={input_style}
disabled={isLoading}
error={formErrors.color}
/>
</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">
<p className={`text-xs ${getThemeClasses("text-secondary")} 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">
<button type="submit" className={btn_primary} disabled={isLoading}>
{isLoading ? "Updating..." : "Update Tag"}
</button>
<button
<div className={`flex space-x-3 pt-4 border-t ${getThemeClasses("border-muted")}`}>
<Button
type="submit"
variant="primary"
disabled={isLoading}
loading={isLoading}
>
{!isLoading && "Update Tag"}
{isLoading && "Updating..."}
</Button>
<Button
type="button"
variant="secondary"
onClick={() => navigate("/me/tags")}
className={btn_secondary}
disabled={isLoading}
>
Cancel
</button>
</Button>
</div>
</form>
</div>
)}
</div>
{/* 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>
</Card>
</div>
</Layout>
);
};
export default TagEdit;
export default withPasswordProtection(TagEdit);

View file

@ -1,63 +1,83 @@
// 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 { useTags, useAuth } from "../../../services/Services.jsx";
import Navigation from "../../../components/Navigation";
import Alert from "../../../components/UIX/Alert/Alert.jsx";
import { useTags } from "../../../services/Services.jsx";
import withPasswordProtection from "../../../hocs/withPasswordProtection";
import Layout from "../../../components/Layout/Layout";
import {
Button,
Alert,
Card,
Breadcrumb,
Spinner,
useUIXTheme,
} from "../../../components/UIX";
import {
PlusIcon,
TagIcon,
PencilIcon,
TrashIcon,
UserCircleIcon,
ShieldCheckIcon,
CheckIcon,
ArrowLeftIcon,
Cog6ToothIcon,
} from "@heroicons/react/24/outline";
const TagList = () => {
const navigate = useNavigate();
const { getThemeClasses } = useUIXTheme();
const { tagManager } = useTags();
const { meManager } = useAuth();
const isMountedRef = useRef(true);
// State
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 [success, setSuccess] = useState("");
const [userProfile, setUserProfile] = useState(null);
const [activeTab, setActiveTab] = useState("tags");
const tabs = [
{ 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
// Cleanup on unmount
useEffect(() => {
const loadUserProfile = async () => {
if (meManager) {
try {
const profile = await meManager.getCurrentUser();
setUserProfile(profile);
} catch (err) {
console.error("Failed to load user profile:", err);
}
}
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
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
useEffect(() => {
const handleTagCreated = () => loadTags();
@ -73,189 +93,159 @@ const TagList = () => {
window.removeEventListener("tagUpdated", handleTagUpdated);
window.removeEventListener("tagDeleted", handleTagDeleted);
};
}, []);
// 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);
}
};
}, [loadTags]);
// Handle delete
const handleDelete = async (tagId, tagName) => {
const handleDelete = useCallback((tagId, 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 (
<div className="min-h-screen bg-gray-50">
<Navigation />
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<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>
<Layout>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Breadcrumb Navigation */}
<Breadcrumb items={breadcrumbItems} />
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{/* Sidebar */}
<div className="lg:col-span-1">
{userProfile && (
<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="relative inline-block mb-4">
<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">
{userProfile.first_name?.charAt(0)}
{userProfile.last_name?.charAt(0)}
{/* Main Card */}
<Card>
{/* Header with icon, title, and action button */}
<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")}`}>
<TagIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
</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>
)}
<nav
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">
{/* Title and subtitle */}
<div>
<h2 className="text-2xl font-bold text-gray-900">My Tags</h2>
<p className="text-sm text-gray-600 mt-1">
<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>
<button
</div>
</div>
{/* Action buttons */}
<div className="flex-shrink-0 flex items-center space-x-3">
<Button
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
</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>
</div>
{/* Alerts */}
{/* Messages */}
<div className="px-6 pt-6">
{error && (
<Alert type="error" onClose={() => setError("")}>
<Alert type="error" className="mb-4" onClose={() => setError("")}>
{error}
</Alert>
)}
{success && (
<Alert type="success" onClose={() => setSuccess("")}>
<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="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 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="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 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 text-gray-900 mb-2">
<h3 className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`}>
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 className={`mb-6 ${getThemeClasses("text-secondary")}`}>
Create your first tag to organize your files
</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" />
<Button onClick={() => navigate("/me/tags/create")} icon={PlusIcon} variant="primary">
Create Your First Tag
</button>
</Button>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-2">
{tags.map((tag, index) => (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{tags.map((tag) => (
<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` }}
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-base font-semibold text-gray-900 truncate">
<h4 className={`text-sm font-medium truncate ${getThemeClasses("text-primary")}`}>
{tag.name}
</h4>
<p className="text-xs text-gray-500 mt-0.5 font-mono">
<p className={`text-xs mt-0.5 ${getThemeClasses("text-secondary")}`}>
{tag.color}
</p>
</div>
</div>
<div className="flex space-x-1 ml-2">
<button
<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`)}
className="p-2 text-gray-500 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors duration-200"
title="Edit tag"
variant="ghost"
size="sm"
aria-label={`Edit tag ${tag.name}`}
>
<PencilIcon className="h-4 w-4" />
</button>
<button
</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"
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>
</Button>
</div>
</div>
</div>
@ -263,42 +253,10 @@ const TagList = () => {
</div>
)}
</div>
</Card>
</div>
</div>
</div>
{/* 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>
</Layout>
);
};
export default TagList;
export default withPasswordProtection(TagList);

View file

@ -1,59 +1,97 @@
// File: src/pages/User/Tags/TagSearch.jsx
// 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 { useTags } from "../../../services/Services.jsx";
import withPasswordProtection from "../../../hocs/withPasswordProtection";
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 {
MagnifyingGlassIcon,
TagIcon,
XMarkIcon,
PlusIcon,
ArrowLeftIcon,
Cog6ToothIcon,
CheckIcon,
InformationCircleIcon,
} from "@heroicons/react/24/outline";
const TagSearch = () => {
const navigate = useNavigate();
const { getThemeClasses } = useUIXTheme();
const { tagManager } = useTags();
const isMountedRef = useRef(true);
// State
const [tags, setTags] = 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("");
// Load tags on mount
// Cleanup on unmount
useEffect(() => {
loadTags();
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
// Load tags
const loadTags = async () => {
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]);
// Toggle tag selection
const handleTagToggle = (tagId) => {
if (selectedTagIds.includes(tagId)) {
setSelectedTagIds(selectedTagIds.filter((id) => id !== tagId));
} else {
setSelectedTagIds([...selectedTagIds, tagId]);
}
};
const handleTagToggle = useCallback((tagId) => {
setSelectedTagIds((prev) =>
prev.includes(tagId)
? prev.filter((id) => id !== tagId)
: [...prev, tagId]
);
}, []);
// Handle search
const handleSearch = () => {
const handleSearch = useCallback(() => {
if (selectedTagIds.length === 0) {
setError("Please select at least one tag to search");
return;
@ -63,233 +101,218 @@ const TagSearch = () => {
navigate("/me/tags/search/results", {
state: { selectedTagIds },
});
};
}, [selectedTagIds, navigate]);
// Clear selection
const handleClearSelection = () => {
const handleClearSelection = useCallback(() => {
setSelectedTagIds([]);
setError("");
};
}, []);
// Memoize breadcrumb items
const breadcrumbItems = useMemo(() => [
{
label: "Settings",
to: "/me",
icon: Cog6ToothIcon,
},
{
label: "Tags",
to: "/me/tags",
},
{
label: "Search",
isActive: true,
},
], []);
return (
<Layout>
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="mb-8 animate-fade-in-down">
<div className="flex items-center justify-between">
<div className="flex items-center">
<button
onClick={() => navigate("/me/tags")}
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 className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Breadcrumb Navigation */}
<Breadcrumb items={breadcrumbItems} />
{/* 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")}`}>
<MagnifyingGlassIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
</div>
{/* Title and subtitle */}
<div>
<h1 className="text-3xl font-bold text-gray-900 flex items-center">
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
Search by Tags
<MagnifyingGlassIcon className="h-8 w-8 text-red-700 ml-2" />
</h1>
<p className="text-gray-600 mt-1">
<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>
{/* Alerts */}
{/* Messages */}
<div className="px-6 pt-6">
{error && (
<Alert type="error" onClose={() => setError("")}>
<Alert type="error" className="mb-4" onClose={() => setError("")}>
{error}
</Alert>
)}
</div>
{/* Main Content */}
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-8 animate-fade-in-up">
{/* Content */}
<div className="px-6 pb-6 pt-2">
{/* 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">
<TagIcon className="h-6 w-6 text-red-700 mr-3" />
<h2 className="text-xl font-semibold text-gray-900">
Select Tags
</h2>
</div>
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-600">
{selectedTagIds.length} selected
<TagIcon className={`h-5 w-5 mr-2 ${getThemeClasses("text-secondary")}`} />
<span className={`text-sm font-medium ${getThemeClasses("text-primary")}`}>
{selectedTagIds.length} tag{selectedTagIds.length !== 1 ? "s" : ""} selected
</span>
</div>
{selectedTagIds.length > 0 && (
<button
<Button
onClick={handleClearSelection}
className="text-sm text-red-700 hover:text-red-800 font-medium"
variant="ghost"
size="sm"
>
Clear All
</button>
</Button>
)}
</div>
</div>
</div>
{/* Tags Grid */}
{isLoading && tags.length === 0 ? (
<div className="text-center py-12">
<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 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="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 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 text-gray-900 mb-2">
<h3 className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`}>
No tags available
</h3>
<p className="text-gray-600 mb-6 max-w-md mx-auto">
<p className={`mb-6 ${getThemeClasses("text-secondary")}`}>
Create tags first before searching
</p>
<button
<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"
icon={PlusIcon}
variant="primary"
>
Create Your First Tag
</button>
</Button>
</div>
) : (
<>
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3 mb-8">
{tags.map((tag, index) => {
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{tags.map((tag) => {
const isSelected = selectedTagIds.includes(tag.id);
return (
<button
<div
key={tag.id}
role="button"
tabIndex={0}
aria-label={`${isSelected ? "Deselect" : "Select"} tag ${tag.name}`}
aria-pressed={isSelected}
onClick={() => handleTagToggle(tag.id)}
className={`p-4 rounded-lg border-2 transition-all duration-200 text-left animate-fade-in-up ${
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
? "border-red-700 bg-red-50 shadow-md"
: "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm"
? `${getThemeClasses("border-primary")} ${getThemeClasses("bg-accent-light")} shadow-md`
: `${getThemeClasses("border-secondary")} ${getThemeClasses("bg-card")} ${getThemeClasses("hover:border-primary")} hover:shadow-sm`
}`}
style={{ animationDelay: `${index * 30}ms` }}
>
<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 ${
isSelected ? "text-red-900" : "text-gray-900"
}`}
>
<h4 className={`text-base font-semibold truncate ${getThemeClasses("text-primary")}`}>
{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 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>
</button>
</div>
);
})}
</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={`mt-6 p-4 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-muted")}`}>
<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>
<InformationCircleIcon className={`h-5 w-5 ${getThemeClasses("text-info")}`} />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800">
<h3 className={`text-sm font-medium ${getThemeClasses("text-primary")}`}>
How tag search works
</h3>
<div className="mt-2 text-sm text-blue-700">
<p>
<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>
</>
)}
{/* 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>
</Card>
</div>
</Layout>
);
};
export default TagSearch;
export default withPasswordProtection(TagSearch);

View file

@ -1,36 +1,75 @@
// File: src/pages/User/Tags/TagSearchResults.jsx
// 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 { useTags, useCrypto } from "../../../services/Services.jsx";
import withPasswordProtection from "../../../hocs/withPasswordProtection";
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 {
MagnifyingGlassIcon,
FolderIcon,
DocumentIcon,
ArrowLeftIcon,
TagIcon,
Cog6ToothIcon,
InformationCircleIcon,
} 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 navigate = useNavigate();
const location = useLocation();
const { getThemeClasses } = useUIXTheme();
const { tagManager } = useTags();
const { CollectionCryptoService } = useCrypto();
const isMountedRef = useRef(true);
// Get selected tag IDs from navigation state
const selectedTagIds = location.state?.selectedTagIds || [];
// Get selected tag IDs from navigation state and validate
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
const [results, setResults] = useState(null);
const [decryptedCollections, setDecryptedCollections] = useState([]);
const [decryptedFiles, setDecryptedFiles] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isDecrypting, setIsDecrypting] = useState(false);
const [error, setError] = useState("");
// Cleanup on unmount
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
// Redirect if no tags selected
useEffect(() => {
if (!selectedTagIds || selectedTagIds.length === 0) {
@ -38,37 +77,10 @@ const TagSearchResults = () => {
}
}, [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
const decryptResults = async (searchResults) => {
const decryptResults = useCallback(async (searchResults) => {
if (!isMountedRef.current) return;
try {
setIsDecrypting(true);
@ -79,183 +91,302 @@ const TagSearchResults = () => {
const decrypted = await CollectionCryptoService.decryptCollectionFromAPI(collection);
return decrypted;
} catch (error) {
if (import.meta.env.DEV) {
console.error("[TagSearchResults] Failed to decrypt collection:", collection.id, error);
}
return null;
}
});
const decryptedColls = (await Promise.all(collectionPromises)).filter(Boolean);
if (isMountedRef.current) {
setDecryptedCollections(decryptedColls);
}
}
// Decrypt files
if (searchResults.files && searchResults.files.length > 0) {
// Dynamically import FileCryptoService
const { default: FileCryptoService } = await import(
"../../../services/Crypto/FileCryptoService.js"
);
// Use cached FileCryptoService
const FileCryptoService = await getFileCryptoService();
const filePromises = searchResults.files.map(async (file) => {
try {
const decrypted = await FileCryptoService.decryptFileFromAPI(file);
return decrypted;
} catch (error) {
if (import.meta.env.DEV) {
console.error("[TagSearchResults] Failed to decrypt file:", file.id, error);
}
return null;
}
});
const decryptedFileList = (await Promise.all(filePromises)).filter(Boolean);
if (isMountedRef.current) {
setDecryptedFiles(decryptedFileList);
}
}
} catch (error) {
if (import.meta.env.DEV) {
console.error("[TagSearchResults] Decryption failed:", error);
}
} finally {
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
const formatFileSize = (bytes) => {
const formatFileSize = useCallback((bytes) => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i];
};
}, []);
// Format date
const formatDate = (dateString) => {
const formatDate = useCallback((dateString) => {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
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 (
<Layout>
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="mb-8 animate-fade-in-down">
<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 className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Breadcrumb Navigation */}
<Breadcrumb items={breadcrumbItems} />
{/* 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")}`}>
<MagnifyingGlassIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
</div>
{/* Title and subtitle */}
<div>
<h1 className="text-3xl font-bold text-gray-900 flex items-center">
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
Search Results
<MagnifyingGlassIcon className="h-8 w-8 text-red-700 ml-2" />
</h1>
<p className="text-gray-600 mt-1">
<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>
{/* Action buttons */}
<div className="flex-shrink-0 flex items-center space-x-3">
<Button
onClick={() => navigate("/me/tags/search")}
variant="primary"
icon={MagnifyingGlassIcon}
>
New Search
</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>
{/* Alerts */}
{/* Messages */}
<div className="px-6 pt-6">
{error && (
<Alert type="error" onClose={() => setError("")}>
<Alert type="error" className="mb-4" onClose={() => setError("")}>
{error}
</Alert>
)}
</div>
{/* Content */}
<div className="px-6 pb-3 pt-2">
{/* Loading State */}
{isLoading ? (
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-12 text-center animate-fade-in-up">
<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 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")}>Searching...</p>
</div>
</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">
{/* Summary Stats */}
<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 text-red-700 mr-2" />
<FolderIcon className={`h-6 w-6 mr-2 ${getThemeClasses("text-secondary")}`} />
<div>
<p className="text-2xl font-bold text-gray-900">{results.collectionCount}</p>
<p className="text-sm text-gray-600">Collections</p>
<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 text-red-700 mr-2" />
<DocumentIcon className={`h-6 w-6 mr-2 ${getThemeClasses("text-secondary")}`} />
<div>
<p className="text-2xl font-bold text-gray-900">{results.fileCount}</p>
<p className="text-sm text-gray-600">Files</p>
<p className={`text-2xl font-bold ${getThemeClasses("text-primary")}`}>{totalFiles}</p>
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>Files</p>
</div>
</div>
</div>
<button
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"
>
<MagnifyingGlassIcon className="h-4 w-4 mr-2" />
New Search
</button>
{isDecrypting && (
<div className="flex items-center">
<Spinner size="sm" className="mr-2" />
<span className={`text-sm ${getThemeClasses("text-secondary")}`}>Decrypting...</span>
</div>
)}
</div>
</div>
{/* No Results */}
{results.collectionCount === 0 && results.fileCount === 0 ? (
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-12 text-center animate-fade-in-up">
<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">
<MagnifyingGlassIcon className="h-8 w-8 text-white" />
{!hasResults ? (
<div className="text-center py-3">
<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" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
<h3 className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`}>
No results found
</h3>
<p className="text-gray-600 mb-6 max-w-md mx-auto">
<p className={`mb-6 ${getThemeClasses("text-secondary")}`}>
No collections or files match all the selected tags. Try selecting fewer tags.
</p>
<button
<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"
icon={MagnifyingGlassIcon}
variant="primary"
>
Try Different Tags
</button>
</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" />
<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, index) => (
{decryptedCollections.map((collection) => (
<div
key={collection.id}
role="button"
tabIndex={0}
aria-label={`Open collection ${collection.name}`}
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` }}
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">
<div className="w-12 h-12 bg-gradient-to-br from-red-600 to-red-800 rounded-lg flex items-center justify-center">
<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>
<div className="ml-4 flex-1 min-w-0">
<h3 className="text-lg font-semibold text-gray-900 truncate">
<h3 className={`text-base font-semibold truncate ${getThemeClasses("text-primary")}`}>
{collection.name}
</h3>
{collection.description && (
<p className="mt-1 text-sm text-gray-600 line-clamp-2">
<p className={`mt-1 text-sm line-clamp-2 ${getThemeClasses("text-secondary")}`}>
{collection.description}
</p>
)}
<div className="mt-2 flex items-center text-xs text-gray-500">
<span>Created {formatDate(collection.created_at)}</span>
</div>
<p className={`mt-2 text-xs ${getThemeClasses("text-muted")}`}>
Created {formatDate(collection.created_at)}
</p>
</div>
</div>
</div>
@ -266,34 +397,38 @@ const TagSearchResults = () => {
{/* 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" />
<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, index) => (
{decryptedFiles.map((file) => (
<div
key={file.id}
role="button"
tabIndex={0}
aria-label={`Open file ${file.filename}`}
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` }}
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">
<div className="w-12 h-12 bg-gradient-to-br from-blue-600 to-blue-800 rounded-lg flex items-center justify-center">
<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>
<div className="ml-4 flex-1 min-w-0">
<h3 className="text-lg font-semibold text-gray-900 truncate">
<h3 className={`text-base font-semibold truncate ${getThemeClasses("text-primary")}`}>
{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>
<p className={`mt-2 text-xs ${getThemeClasses("text-muted")}`}>
{formatFileSize(file.size_in_bytes)} Uploaded {formatDate(file.created_at)}
</p>
</div>
</div>
</div>
@ -301,51 +436,33 @@ const TagSearchResults = () => {
</div>
</div>
)}
</div>
)}
{/* Decrypting State */}
{isDecrypting && (
<div className="text-center py-4">
<p className="text-sm text-gray-600">Decrypting results...</p>
{/* 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}
{/* 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>
</Card>
</div>
</Layout>
);
};
export default TagSearchResults;
export default withPasswordProtection(TagSearchResults);

View file

@ -361,10 +361,27 @@ class LocalStorageService {
localStorage.setItem(`login_session_${key}`, JSON.stringify(data));
}
// Get login session data
// Get login session data with validation
getLoginSessionData(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