gui work
This commit is contained in:
parent
81f60acd06
commit
8380cb8e6a
36 changed files with 5342 additions and 4434 deletions
|
|
@ -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,29 +38,31 @@ const GDPRFooter = memo(function GDPRFooter({
|
|||
<div className={`flex-shrink-0 border-t ${themeClasses.borderSecondary} ${themeClasses.bgCard}/50 backdrop-blur-sm relative z-10 ${containerClassName}`}>
|
||||
<div className={`max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 ${className}`}>
|
||||
{/* Security Features */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-8 text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<ShieldCheckIcon className={`h-4 w-4 ${getThemeClasses("icon-success")}`} />
|
||||
<span className={getThemeClasses("text-secondary")}>
|
||||
ChaCha20-Poly1305 Encryption
|
||||
</span>
|
||||
{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")}`} />
|
||||
<span className={getThemeClasses("text-secondary")}>
|
||||
ChaCha20-Poly1305 Encryption
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<ServerIcon className={`h-4 w-4 ${getThemeClasses("icon-info")}`} />
|
||||
<span className={getThemeClasses("text-secondary")}>Canadian Hosted</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<GlobeAltIcon className={`h-4 w-4 ${getThemeClasses("icon-privacy")}`} />
|
||||
<span className={getThemeClasses("text-secondary")}>Privacy First</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<HeartIcon className={`h-4 w-4 ${getThemeClasses("icon-featured")}`} />
|
||||
<span className={getThemeClasses("text-secondary")}>Made in Canada</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<ServerIcon className={`h-4 w-4 ${getThemeClasses("icon-info")}`} />
|
||||
<span className={getThemeClasses("text-secondary")}>Canadian Hosted</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<GlobeAltIcon className={`h-4 w-4 ${getThemeClasses("icon-privacy")}`} />
|
||||
<span className={getThemeClasses("text-secondary")}>Privacy First</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<HeartIcon className={`h-4 w-4 ${getThemeClasses("icon-featured")}`} />
|
||||
<span className={getThemeClasses("text-secondary")}>Made in Canada</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GDPR Information */}
|
||||
<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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
setLoading(false);
|
||||
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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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,13 +125,17 @@ 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 {
|
||||
setIsLoading(false);
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
setAuthMessage("");
|
||||
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`}>
|
||||
© 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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(
|
||||
"[CompleteLogin] Using verification data from sessionStorage",
|
||||
);
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
<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>
|
||||
<Alert type="info" className="mb-6">
|
||||
<div>
|
||||
<strong className="font-semibold">What happened?</strong>
|
||||
<p className="mt-1">{sessionInfo.description}</p>
|
||||
</div>
|
||||
</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" />
|
||||
<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">
|
||||
<li>• Your files remain encrypted and protected</li>
|
||||
<li>• No one can access your data without your password</li>
|
||||
<li>
|
||||
• Session expiry is a security feature, not a problem
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Alert type="success" className="mb-6">
|
||||
<div>
|
||||
<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>
|
||||
</ul>
|
||||
</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>© 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>
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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(" ");
|
||||
|
||||
console.log("[CompleteRecovery] Completing recovery with new password");
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("[CompleteRecovery] Completing recovery with new password");
|
||||
}
|
||||
|
||||
// Complete recovery with both recovery phrase and new password
|
||||
const response = await recoveryManager.completeRecoveryWithPhrase(
|
||||
await recoveryManager.completeRecoveryWithPhrase(
|
||||
normalizedPhrase,
|
||||
newPassword,
|
||||
);
|
||||
|
||||
console.log("[CompleteRecovery] Recovery completed successfully");
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("[CompleteRecovery] Recovery completed successfully");
|
||||
}
|
||||
|
||||
// Show success modal or alert
|
||||
alert(
|
||||
|
|
@ -94,50 +119,88 @@ const CompleteRecovery = () => {
|
|||
|
||||
// Navigate to login
|
||||
navigate("/login");
|
||||
} catch (error) {
|
||||
console.error("[CompleteRecovery] Recovery completion failed:", error);
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
const handleBackToVerify = () => {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error("[CompleteRecovery] Recovery completion failed:", err);
|
||||
}
|
||||
setError(err.message);
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [newPassword, confirmPassword, recoveryPhrase, recoveryManager, navigate]);
|
||||
|
||||
const handleBackToVerify = useCallback(() => {
|
||||
navigate("/recovery/verify");
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
// 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" />
|
||||
<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>
|
||||
<Alert
|
||||
type="error"
|
||||
dismissible
|
||||
onDismiss={() => setError("")}
|
||||
className="mb-6 animate-fade-in"
|
||||
>
|
||||
<div>
|
||||
<strong className="font-semibold">Recovery Error</strong>
|
||||
<p className="mt-1">{error}</p>
|
||||
</div>
|
||||
</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">
|
||||
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>
|
||||
<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>
|
||||
</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
|
||||
type={showNewPassword ? "text" : "password"}
|
||||
id="newPassword"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="Enter your new password"
|
||||
required
|
||||
disabled={loading}
|
||||
autoComplete="new-password"
|
||||
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-all duration-200 text-gray-900 placeholder-gray-500 pr-12 ${
|
||||
error && error.includes("password")
|
||||
? "border-red-300"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
>
|
||||
{showNewPassword ? (
|
||||
<EyeSlashIcon className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
) : (
|
||||
<EyeIcon className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Password must be at least 8 characters long
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
label="New Password"
|
||||
type={showNewPassword ? "text" : "password"}
|
||||
name="newPassword"
|
||||
placeholder="Enter your new password"
|
||||
value={newPassword}
|
||||
onChange={(value) => setNewPassword(value)}
|
||||
disabled={loading}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
icon={LockClosedIcon}
|
||||
suffix={newPasswordSuffix}
|
||||
helperText="Password must be at least 8 characters long"
|
||||
error={error && error.includes("password") ? error : null}
|
||||
/>
|
||||
|
||||
{/* 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
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
id="confirmPassword"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Confirm your new password"
|
||||
required
|
||||
disabled={loading}
|
||||
autoComplete="new-password"
|
||||
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-all duration-200 text-gray-900 placeholder-gray-500 pr-12 ${
|
||||
confirmPassword && newPassword === confirmPassword
|
||||
? "border-green-300 bg-green-50"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setShowConfirmPassword(!showConfirmPassword)
|
||||
}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeSlashIcon className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
) : (
|
||||
<EyeIcon className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
label="Confirm New Password"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
name="confirmPassword"
|
||||
placeholder="Confirm your new password"
|
||||
value={confirmPassword}
|
||||
onChange={(value) => setConfirmPassword(value)}
|
||||
disabled={loading}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
icon={LockClosedIcon}
|
||||
suffix={confirmPasswordSuffix}
|
||||
/>
|
||||
{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>© 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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -177,112 +177,200 @@ 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;
|
||||
}
|
||||
|
||||
const doc = printWindow.document;
|
||||
|
||||
// Build document using safe DOM manipulation (no document.write)
|
||||
// Create style element
|
||||
const style = doc.createElement("style");
|
||||
style.textContent = `
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.warning {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.mnemonic {
|
||||
background: #f8f9fa;
|
||||
border: 2px solid #dee2e6;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
line-height: 2;
|
||||
}
|
||||
.word {
|
||||
display: inline-block;
|
||||
margin: 5px;
|
||||
padding: 5px 10px;
|
||||
background: white;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
.privacy-notice {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #ddd;
|
||||
}
|
||||
.privacy-text {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
}
|
||||
`;
|
||||
doc.head.appendChild(style);
|
||||
|
||||
// Set title safely
|
||||
doc.title = "MapleFile Recovery Phrase";
|
||||
|
||||
// Build body content using textContent for user data (XSS-safe)
|
||||
const body = doc.body;
|
||||
|
||||
// Header section
|
||||
const header = doc.createElement("div");
|
||||
header.className = "header";
|
||||
|
||||
const h1 = doc.createElement("h1");
|
||||
h1.textContent = "MapleFile Recovery Phrase";
|
||||
header.appendChild(h1);
|
||||
|
||||
const accountP = doc.createElement("p");
|
||||
const accountStrong = doc.createElement("strong");
|
||||
accountStrong.textContent = "Account: ";
|
||||
accountP.appendChild(accountStrong);
|
||||
accountP.appendChild(doc.createTextNode(email)); // Safe: textContent equivalent
|
||||
header.appendChild(accountP);
|
||||
|
||||
const dateP = doc.createElement("p");
|
||||
const dateStrong = doc.createElement("strong");
|
||||
dateStrong.textContent = "Generated: ";
|
||||
dateP.appendChild(dateStrong);
|
||||
dateP.appendChild(doc.createTextNode(new Date().toLocaleString()));
|
||||
header.appendChild(dateP);
|
||||
|
||||
body.appendChild(header);
|
||||
|
||||
// Warning section
|
||||
const warning = doc.createElement("div");
|
||||
warning.className = "warning";
|
||||
const warningH3 = doc.createElement("h3");
|
||||
warningH3.textContent = "⚠️ IMPORTANT SECURITY NOTICE";
|
||||
warning.appendChild(warningH3);
|
||||
const warningP = doc.createElement("p");
|
||||
warningP.textContent = "This recovery phrase is the ONLY way to recover your account if you forget your password. Keep it safe and never share it with anyone.";
|
||||
warning.appendChild(warningP);
|
||||
body.appendChild(warning);
|
||||
|
||||
// Mnemonic words section
|
||||
const mnemonic = doc.createElement("div");
|
||||
mnemonic.className = "mnemonic";
|
||||
recoveryMnemonic.split(" ").forEach((word, index) => {
|
||||
const wordSpan = doc.createElement("span");
|
||||
wordSpan.className = "word";
|
||||
wordSpan.textContent = `${index + 1}. ${word}`; // Safe: textContent
|
||||
mnemonic.appendChild(wordSpan);
|
||||
});
|
||||
body.appendChild(mnemonic);
|
||||
|
||||
// Footer section
|
||||
const footer = doc.createElement("div");
|
||||
footer.className = "footer";
|
||||
|
||||
const tipsP = doc.createElement("p");
|
||||
const tipsStrong = doc.createElement("strong");
|
||||
tipsStrong.textContent = "Security Tips:";
|
||||
tipsP.appendChild(tipsStrong);
|
||||
footer.appendChild(tipsP);
|
||||
|
||||
const tipsList = doc.createElement("ul");
|
||||
const tips = [
|
||||
"Store this phrase in a secure location (safe, safety deposit box)",
|
||||
"Consider making multiple copies and storing them separately",
|
||||
"Never store this digitally (computer files, cloud storage, photos)",
|
||||
"Never share this phrase with anyone, including MapleFile support",
|
||||
"Write clearly and double-check each word"
|
||||
];
|
||||
tips.forEach((tip) => {
|
||||
const li = doc.createElement("li");
|
||||
li.textContent = tip;
|
||||
tipsList.appendChild(li);
|
||||
});
|
||||
footer.appendChild(tipsList);
|
||||
|
||||
// Privacy notice
|
||||
const privacyDiv = doc.createElement("div");
|
||||
privacyDiv.className = "privacy-notice";
|
||||
|
||||
const privacyTitleP = doc.createElement("p");
|
||||
const privacyStrong = doc.createElement("strong");
|
||||
privacyStrong.textContent = "Privacy Notice:";
|
||||
privacyTitleP.appendChild(privacyStrong);
|
||||
privacyDiv.appendChild(privacyTitleP);
|
||||
|
||||
const privacyTextP = doc.createElement("p");
|
||||
privacyTextP.className = "privacy-text";
|
||||
privacyTextP.textContent = "This recovery phrase is your personal cryptographic data. Data Controller: Maple Open Tech Inc. (Canada). Your GDPR rights: Access, rectification, erasure, restriction, portability, objection, and complaint to supervisory authority. Contact: privacy@mapleopentech.ca | This document was generated locally and contains no tracking.";
|
||||
privacyDiv.appendChild(privacyTextP);
|
||||
|
||||
footer.appendChild(privacyDiv);
|
||||
body.appendChild(footer);
|
||||
|
||||
doc.close();
|
||||
|
||||
// Track if print has been triggered to prevent double printing
|
||||
let printTriggered = false;
|
||||
|
||||
const triggerPrint = () => {
|
||||
if (printTriggered || printWindow.closed) return;
|
||||
printTriggered = true;
|
||||
|
||||
printWindow.focus();
|
||||
|
||||
// Close window after print dialog is dismissed (print or cancel)
|
||||
printWindow.onafterprint = () => {
|
||||
printWindow.close();
|
||||
};
|
||||
|
||||
printWindow.print();
|
||||
|
||||
// Fallback: close after delay if onafterprint isn't supported
|
||||
setTimeout(() => {
|
||||
if (!printWindow.closed) {
|
||||
printWindow.close();
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const printWindow = window.open("", "_blank");
|
||||
|
||||
// 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("");
|
||||
|
||||
printWindow.document.write(`
|
||||
<html>
|
||||
<head>
|
||||
<title>MapleFile Recovery Phrase</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.warning {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.mnemonic {
|
||||
background: #f8f9fa;
|
||||
border: 2px solid #dee2e6;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
line-height: 2;
|
||||
}
|
||||
.word {
|
||||
display: inline-block;
|
||||
margin: 5px;
|
||||
padding: 5px 10px;
|
||||
background: white;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>MapleFile Recovery Phrase</h1>
|
||||
<p><strong>Account:</strong> ${safeEmail}</p>
|
||||
<p><strong>Generated:</strong> ${safeDate}</p>
|
||||
</div>
|
||||
|
||||
<div class="warning">
|
||||
<h3>⚠️ IMPORTANT SECURITY NOTICE</h3>
|
||||
<p>This recovery phrase is the ONLY way to recover your account if you forget your password. Keep it safe and never share it with anyone.</p>
|
||||
</div>
|
||||
|
||||
<div class="mnemonic">
|
||||
${safeWords}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p><strong>Security Tips:</strong></p>
|
||||
<ul>
|
||||
<li>Store this phrase in a secure location (safe, safety deposit box)</li>
|
||||
<li>Consider making multiple copies and storing them separately</li>
|
||||
<li>Never store this digitally (computer files, cloud storage, photos)</li>
|
||||
<li>Never share this phrase with anyone, including MapleFile support</li>
|
||||
<li>Write clearly and double-check each word</li>
|
||||
</ul>
|
||||
|
||||
<div style="margin-top: 20px; padding-top: 20px; border-top: 1px solid #ddd;">
|
||||
<p><strong>Privacy Notice:</strong></p>
|
||||
<p style="font-size: 10px; color: #666;">
|
||||
This recovery phrase is your personal cryptographic data. Data Controller: Maple Open Tech Inc. (Canada).
|
||||
Your GDPR rights: Access, rectification, erasure, restriction, portability, objection, and complaint to supervisory authority.
|
||||
Contact: privacy@mapleopentech.ca | This document was generated locally and contains no tracking.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
printWindow.document.close();
|
||||
printWindow.print();
|
||||
// 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) => {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
try {
|
||||
const collectionKey = CollectionCryptoService.getCachedCollectionKey(
|
||||
file.collection_id,
|
||||
);
|
||||
if (!collectionKey) {
|
||||
decryptedFiles.push({
|
||||
// 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) {
|
||||
return {
|
||||
...file,
|
||||
name: "Locked File",
|
||||
_isDecrypted: false,
|
||||
};
|
||||
}
|
||||
|
||||
const decryptedFile = await FileCryptoService.decryptFileFromAPI(
|
||||
file,
|
||||
collectionKey,
|
||||
);
|
||||
return decryptedFile;
|
||||
} catch {
|
||||
return {
|
||||
...file,
|
||||
name: "Locked File",
|
||||
_isDecrypted: false,
|
||||
});
|
||||
continue;
|
||||
};
|
||||
}
|
||||
|
||||
const decryptedFile = await FileCryptoService.decryptFileFromAPI(
|
||||
file,
|
||||
collectionKey,
|
||||
);
|
||||
decryptedFiles.push(decryptedFile);
|
||||
} catch {
|
||||
decryptedFiles.push({
|
||||
...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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,29 +660,33 @@ const FileManagerIndex = () => {
|
|||
location.pathname,
|
||||
]);
|
||||
|
||||
const filteredCollections = collections.filter((collection) => {
|
||||
// Filter by search query
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
if (!(collection.name || "Locked Folder").toLowerCase().includes(query)) {
|
||||
return false;
|
||||
// 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();
|
||||
if (!(collection.name || "Locked Folder").toLowerCase().includes(query)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by selected tags (collection must have ALL selected tags)
|
||||
if (selectedTagIds.length > 0) {
|
||||
const collectionTags = collectionTagsMap[collection.id] || [];
|
||||
const collectionTagIdSet = new Set(collectionTags.map(tag => tag.id));
|
||||
const hasAllSelectedTags = selectedTagIds.every(tagId => collectionTagIdSet.has(tagId));
|
||||
if (!hasAllSelectedTags) {
|
||||
return false;
|
||||
// Filter by selected tags (collection must have ALL selected tags)
|
||||
if (selectedTagIds.length > 0) {
|
||||
const collectionTags = collectionTagsMap[collection.id] || [];
|
||||
const collectionTagIdSet = new Set(collectionTags.map(tag => tag.id));
|
||||
const hasAllSelectedTags = selectedTagIds.every(tagId => collectionTagIdSet.has(tagId));
|
||||
if (!hasAllSelectedTags) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
return true;
|
||||
});
|
||||
}, [collections, searchQuery, selectedTagIds, collectionTagsMap]);
|
||||
|
||||
const filterTypes = [
|
||||
// Memoize filter types to prevent recreation on every render
|
||||
const filterTypes = useMemo(() => [
|
||||
{
|
||||
key: "owned",
|
||||
label: "My Folders",
|
||||
|
|
@ -661,77 +708,102 @@ 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">
|
||||
<div>
|
||||
<h1
|
||||
className={`text-3xl font-bold flex items-center ${getThemeClasses("text-primary")}`}
|
||||
>
|
||||
My Files
|
||||
<SparklesIcon
|
||||
className={`h-8 w-8 ml-2 ${getThemeClasses("text-accent")}`}
|
||||
/>
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Organize and manage your encrypted files
|
||||
</p>
|
||||
</div>
|
||||
{/* Breadcrumb Navigation */}
|
||||
<Breadcrumb items={breadcrumbItems} />
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* Main Card */}
|
||||
<Card>
|
||||
{/* Header with icon, title, and action buttons */}
|
||||
<div className={`p-6 border-b ${getThemeClasses("border-secondary")}`}>
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start">
|
||||
{/* Icon */}
|
||||
<div className={`p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 ${getThemeClasses("bg-gradient-secondary")}`}>
|
||||
<FolderIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
|
||||
</div>
|
||||
{/* Title and subtitle */}
|
||||
<div>
|
||||
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
|
||||
My Files
|
||||
</h1>
|
||||
<p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
|
||||
Organize and manage your encrypted files
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Action buttons */}
|
||||
<div className="flex-shrink-0 flex items-center space-x-3">
|
||||
{/* Tag Filter Menu */}
|
||||
{availableTags.length > 0 && (
|
||||
<div className="relative">
|
||||
<Button
|
||||
onClick={() => setShowTagFilterMenu(!showTagFilterMenu)}
|
||||
variant={selectedTagIds.length > 0 ? "primary" : "secondary"}
|
||||
className="flex items-center"
|
||||
>
|
||||
{selectedTagIds.length > 0 ? (
|
||||
<>
|
||||
<div className="flex items-center -space-x-1 mr-2">
|
||||
{selectedTagIds.slice(0, 3).map(tagId => {
|
||||
const tag = availableTags.find(t => t.id === tagId);
|
||||
return (
|
||||
<span
|
||||
key={tagId}
|
||||
className="h-3 w-3 rounded-full border border-white"
|
||||
style={{ backgroundColor: tag?.color || '#6b7280' }}
|
||||
></span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{selectedTagIds.length === 1
|
||||
? availableTags.find(t => t.id === selectedTagIds[0])?.name || 'Tag'
|
||||
: `${selectedTagIds.length} tags`}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedTagIds([]);
|
||||
}}
|
||||
className="ml-2 hover:opacity-70"
|
||||
>
|
||||
<span className="text-xs">✕</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TagIcon className="h-4 w-4 mr-2" />
|
||||
All Tags
|
||||
</>
|
||||
)}
|
||||
<ChevronRightIcon
|
||||
className={`h-4 w-4 ml-2 transition-transform duration-200 ${
|
||||
showTagFilterMenu ? "rotate-90" : ""
|
||||
}`}
|
||||
/>
|
||||
<span className="inline-flex items-center whitespace-nowrap">
|
||||
{selectedTagIds.length > 0 ? (
|
||||
<>
|
||||
<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 (
|
||||
<span
|
||||
key={tagId}
|
||||
className="h-3 w-3 rounded-full border border-white"
|
||||
style={{ backgroundColor: tag?.color || '#6b7280' }}
|
||||
></span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
{selectedTagIds.length === 1
|
||||
? availableTags.find(t => t.id === selectedTagIds[0])?.name || 'Tag'
|
||||
: `${selectedTagIds.length} tags`}
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedTagIds([]);
|
||||
}}
|
||||
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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TagIcon className="h-4 w-4 mr-2 flex-shrink-0" />
|
||||
All Tags
|
||||
</>
|
||||
)}
|
||||
<ChevronRightIcon
|
||||
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" />
|
||||
{currentFilter.label}
|
||||
<ChevronRightIcon
|
||||
className={`h-4 w-4 ml-2 transition-transform duration-200 ${
|
||||
showFilterMenu ? "rotate-90" : ""
|
||||
}`}
|
||||
/>
|
||||
<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 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,101 +992,108 @@ 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>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="mb-8">
|
||||
<div className="relative max-w-2xl">
|
||||
<MagnifyingGlassIcon className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<Input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search folders..."
|
||||
className="pl-12 h-12 text-base w-full"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-4 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{(generalError || Object.keys(fieldErrors).length > 0) && (
|
||||
<Alert type="error" className="mb-8">
|
||||
{generalError && <p>{generalError}</p>}
|
||||
{Object.keys(fieldErrors).length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="font-semibold mb-1">
|
||||
Please fix the following errors:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
{Object.entries(fieldErrors).map(([field, message]) => (
|
||||
<li key={field}>
|
||||
<span className="font-medium capitalize">{field}:</span>{" "}
|
||||
{message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Collections Grid */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<div className="text-center">
|
||||
<div
|
||||
className={`h-12 w-12 spinner mx-auto mb-4 ${getThemeClasses("border-primary")}`}
|
||||
></div>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Loading folders...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
{/* Search Bar */}
|
||||
<div className="mb-6">
|
||||
<div className="relative max-w-2xl">
|
||||
<MagnifyingGlassIcon className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<Input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search folders..."
|
||||
aria-label="Search folders"
|
||||
className="pl-12 h-12 text-base w-full"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
onClick={() => setSearchQuery("")}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-4 top-1/2 transform -translate-y-1/2"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{(generalError || Object.keys(fieldErrors).length > 0) && (
|
||||
<Alert type="error" className="mb-6">
|
||||
{generalError && <p>{generalError}</p>}
|
||||
{Object.keys(fieldErrors).length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="font-semibold mb-1">
|
||||
Please fix the following errors:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
{Object.entries(fieldErrors).map(([field, message]) => (
|
||||
<li key={field}>
|
||||
<span className="font-medium capitalize">{field}:</span>{" "}
|
||||
{message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Collections Grid */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<div className="text-center">
|
||||
<Spinner size="lg" className="mx-auto mb-4" />
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Loading folders...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{filteredCollections.map((collection, index) => (
|
||||
<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
|
||||
|
|
@ -1121,101 +1234,102 @@ const FileManagerIndex = () => {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tag Filter Empty State */}
|
||||
{!isLoading && filteredCollections.length === 0 && selectedTagIds.length > 0 && !searchQuery && (
|
||||
<div className="text-center py-24">
|
||||
<div
|
||||
className={`h-20 w-20 ${getThemeClasses("bg-muted")} rounded-2xl flex items-center justify-center mx-auto mb-6`}
|
||||
>
|
||||
<TagIcon className="h-10 w-10 text-gray-400" />
|
||||
</div>
|
||||
<h3
|
||||
className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`}
|
||||
>
|
||||
No folders with {selectedTagIds.length === 1 ? 'this tag' : 'these tags'}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-8 max-w-md mx-auto">
|
||||
No folders are tagged with {selectedTagIds.length === 1
|
||||
? `"${availableTags.find(t => t.id === selectedTagIds[0])?.name || 'this tag'}"`
|
||||
: `all ${selectedTagIds.length} selected tags`}
|
||||
</p>
|
||||
<Button onClick={() => setSelectedTagIds([])} variant="secondary">
|
||||
<TagIcon className="h-4 w-4 mr-2" />
|
||||
Clear Tag Filter
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* Tag Filter Empty State */}
|
||||
{!isLoading && filteredCollections.length === 0 && selectedTagIds.length > 0 && !searchQuery && (
|
||||
<div className="text-center py-24">
|
||||
<div
|
||||
className={`h-20 w-20 ${getThemeClasses("bg-muted")} rounded-2xl flex items-center justify-center mx-auto mb-6`}
|
||||
>
|
||||
<TagIcon className="h-10 w-10 text-gray-400" />
|
||||
</div>
|
||||
<h3
|
||||
className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`}
|
||||
>
|
||||
No folders with {selectedTagIds.length === 1 ? 'this tag' : 'these tags'}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-8 max-w-md mx-auto">
|
||||
No folders are tagged with {selectedTagIds.length === 1
|
||||
? `"${availableTags.find(t => t.id === selectedTagIds[0])?.name || 'this tag'}"`
|
||||
: `all ${selectedTagIds.length} selected tags`}
|
||||
</p>
|
||||
<Button onClick={() => setSelectedTagIds([])} variant="secondary">
|
||||
<TagIcon className="h-4 w-4 mr-2" />
|
||||
Clear Tag Filter
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && filteredCollections.length === 0 && !searchQuery && selectedTagIds.length === 0 && (
|
||||
<div className="text-center py-24">
|
||||
<div
|
||||
className={`h-20 w-20 ${getThemeClasses("bg-muted")} rounded-2xl flex items-center justify-center mx-auto mb-6`}
|
||||
>
|
||||
<currentFilter.icon className="h-10 w-10 text-gray-400" />
|
||||
</div>
|
||||
<h3
|
||||
className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`}
|
||||
>
|
||||
{filterType === "shared"
|
||||
? "No shared folders"
|
||||
: filterType === "all"
|
||||
? "No folders found"
|
||||
: "No folders yet"}
|
||||
</h3>
|
||||
<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"}
|
||||
</p>
|
||||
{filterType !== "shared" && (
|
||||
<Button
|
||||
onClick={() => navigate("/file-manager/collections/create")}
|
||||
variant="primary"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 mr-2" />
|
||||
Create Your First Folder
|
||||
</Button>
|
||||
{/* Empty State */}
|
||||
{!isLoading && filteredCollections.length === 0 && !searchQuery && selectedTagIds.length === 0 && (
|
||||
<div className="text-center">
|
||||
<div
|
||||
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>
|
||||
<h3
|
||||
className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`}
|
||||
>
|
||||
{filterType === "shared"
|
||||
? "No shared folders"
|
||||
: filterType === "all"
|
||||
? "No folders found"
|
||||
: "No folders yet"}
|
||||
</h3>
|
||||
<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"
|
||||
: "Create your first folder now"}
|
||||
</p>
|
||||
{filterType !== "shared" && (
|
||||
<Button
|
||||
onClick={() => navigate("/file-manager/collections/create")}
|
||||
variant="primary"
|
||||
icon={PlusIcon}
|
||||
>
|
||||
Create Your First Folder
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Empty State */}
|
||||
{!isLoading && filteredCollections.length === 0 && searchQuery && (
|
||||
<div className="text-center py-24">
|
||||
<div
|
||||
className={`h-20 w-20 ${getThemeClasses("bg-muted")} rounded-2xl flex items-center justify-center mx-auto mb-6`}
|
||||
>
|
||||
<MagnifyingGlassIcon className="h-10 w-10 text-gray-400" />
|
||||
</div>
|
||||
<h3
|
||||
className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`}
|
||||
>
|
||||
No folders found
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-8">
|
||||
No folders match your search for "{searchQuery}"
|
||||
{selectedTagIds.length > 0 && ` with ${selectedTagIds.length === 1
|
||||
? `tag "${availableTags.find(t => t.id === selectedTagIds[0])?.name || 'selected tag'}"`
|
||||
: `${selectedTagIds.length} selected tags`}`}
|
||||
</p>
|
||||
<div className="flex items-center justify-center space-x-3">
|
||||
<Button onClick={() => setSearchQuery("")} variant="secondary">
|
||||
Clear search
|
||||
</Button>
|
||||
{selectedTagIds.length > 0 && (
|
||||
<Button onClick={() => setSelectedTagIds([])} variant="secondary">
|
||||
Clear tag filter
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Search Empty State */}
|
||||
{!isLoading && filteredCollections.length === 0 && searchQuery && (
|
||||
<div className="text-center py-24">
|
||||
<div
|
||||
className={`h-20 w-20 ${getThemeClasses("bg-muted")} rounded-2xl flex items-center justify-center mx-auto mb-6`}
|
||||
>
|
||||
<MagnifyingGlassIcon className="h-10 w-10 text-gray-400" />
|
||||
</div>
|
||||
<h3
|
||||
className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`}
|
||||
>
|
||||
No folders found
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-8">
|
||||
No folders match your search for "{searchQuery}"
|
||||
{selectedTagIds.length > 0 && ` with ${selectedTagIds.length === 1
|
||||
? `tag "${availableTags.find(t => t.id === selectedTagIds[0])?.name || 'selected tag'}"`
|
||||
: `${selectedTagIds.length} selected tags`}`}
|
||||
</p>
|
||||
<div className="flex items-center justify-center space-x-3">
|
||||
<Button onClick={() => setSearchQuery("")} variant="secondary">
|
||||
Clear search
|
||||
</Button>
|
||||
{selectedTagIds.length > 0 && (
|
||||
<Button onClick={() => setSelectedTagIds([])} variant="secondary">
|
||||
Clear tag filter
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation 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
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {
|
||||
setAvailableCollections(result.collections);
|
||||
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`}
|
||||
>
|
||||
<option value="">Choose a folder...</option>
|
||||
{availableCollections.map((collection) => (
|
||||
<option key={collection.id} value={collection.id}>
|
||||
{collection.name || "Unnamed Folder"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
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]"
|
||||
>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,94 +171,136 @@ 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")}`} />
|
||||
Help & Documentation
|
||||
</h1>
|
||||
<p className={`${getThemeClasses("text-secondary")} mt-2`}>
|
||||
Learn how to use MapleFile's secure file storage features
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{quickActions.map((action, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
className={`${getThemeClasses("hover:shadow")} ${getThemeClasses("hover:border-primary")} cursor-pointer transition-all duration-200`}
|
||||
onClick={() => navigate(action.path)}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className={`h-10 w-10 rounded-lg ${getThemeClasses("bg-accent-light")} flex items-center justify-center mb-3`}>
|
||||
<action.icon className={`h-6 w-6 ${action.color}`} />
|
||||
{/* 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={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
|
||||
Learn how to use MapleFile's secure file storage features
|
||||
</p>
|
||||
</div>
|
||||
<h3 className={`font-medium mb-1 ${getThemeClasses("text-primary")}`}>
|
||||
{action.title}
|
||||
</h3>
|
||||
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>
|
||||
{action.description}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Help Sections */}
|
||||
<div className="space-y-8">
|
||||
{helpSections.map((section, sectionIndex) => (
|
||||
<div key={sectionIndex}>
|
||||
<div className="flex items-center mb-4">
|
||||
<div className={`h-10 w-10 rounded-lg ${section.bgColor} flex items-center justify-center mr-3`}>
|
||||
<section.icon className={`h-6 w-6 ${section.color}`} />
|
||||
</div>
|
||||
<h2 className={`text-2xl font-bold ${getThemeClasses("text-primary")}`}>
|
||||
{section.title}
|
||||
</h2>
|
||||
</div>
|
||||
{/* Action button */}
|
||||
<div className="flex-shrink-0">
|
||||
<Button
|
||||
onClick={() => navigate("/dashboard")}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<HomeIcon className="h-4 w-4" />
|
||||
<span>Back to Dashboard</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{section.items.map((item, itemIndex) => (
|
||||
<Card key={itemIndex} className="h-full">
|
||||
<div className="p-6">
|
||||
<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>
|
||||
{/* 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" role="list" aria-label="Quick actions">
|
||||
{quickActions.map((action, index) => (
|
||||
<div
|
||||
key={index}
|
||||
role="listitem"
|
||||
tabIndex={0}
|
||||
aria-label={`${action.title}: ${action.description}`}
|
||||
className={`p-4 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-card")} ${getThemeClasses("hover:shadow")} ${getThemeClasses("hover:border-primary")} cursor-pointer transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
onClick={() => navigate(action.path)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
navigate(action.path);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={`h-10 w-10 rounded-lg ${getThemeClasses("bg-accent-light")} flex items-center justify-center mb-3`} aria-hidden="true">
|
||||
<action.icon className={`h-6 w-6 ${action.color}`} />
|
||||
</div>
|
||||
</Card>
|
||||
<h3 className={`font-medium mb-1 ${getThemeClasses("text-primary")}`}>
|
||||
{action.title}
|
||||
</h3>
|
||||
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>
|
||||
{action.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Additional Resources */}
|
||||
<div className="mt-12">
|
||||
<Card className={getThemeClasses("bg-accent-light")}>
|
||||
<div className="p-6">
|
||||
{/* Help Sections */}
|
||||
<div className="space-y-8">
|
||||
{helpSections.map((section, 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`} aria-hidden="true">
|
||||
<section.icon className={`h-6 w-6 ${section.color}`} />
|
||||
</div>
|
||||
<h2 id={`section-${sectionIndex}`} className={`text-2xl font-bold ${getThemeClasses("text-primary")}`}>
|
||||
{section.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" role="list" aria-label={`${section.title} topics`}>
|
||||
{section.items.map((item, itemIndex) => (
|
||||
<article
|
||||
key={itemIndex}
|
||||
role="listitem"
|
||||
className={`p-6 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-card")} h-full`}
|
||||
>
|
||||
<h3 className={`text-lg font-semibold mb-3 ${getThemeClasses("text-primary")}`}>
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className={`text-sm ${getThemeClasses("text-secondary")} leading-relaxed`}>
|
||||
{item.description}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Privacy & Security Notice */}
|
||||
<div className={`mt-12 p-6 rounded-lg ${getThemeClasses("bg-accent-light")}`}>
|
||||
<div className="flex items-start">
|
||||
<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">
|
||||
{/* Support Section */}
|
||||
<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>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* GDPR Footer */}
|
||||
<GDPRFooter className="mt-8" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,15 +71,25 @@ const BlockedUsers = () => {
|
|||
setError("");
|
||||
|
||||
try {
|
||||
console.log("[BlockedUsers] Loading blocked emails...");
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("[BlockedUsers] Loading blocked emails...");
|
||||
}
|
||||
const emails = await blockedEmailManager.getBlockedEmails(true);
|
||||
if (!isMountedRef.current) return;
|
||||
setBlockedEmails(emails);
|
||||
console.log("[BlockedUsers] Loaded", emails.length, "blocked emails");
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("[BlockedUsers] Loaded", emails.length, "blocked emails");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[BlockedUsers] Failed to load blocked emails:", err);
|
||||
if (!isMountedRef.current) return;
|
||||
if (import.meta.env.DEV) {
|
||||
console.error("[BlockedUsers] Failed to load blocked emails:", err);
|
||||
}
|
||||
setError(err.message || "Failed to load blocked users.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [blockedEmailManager]);
|
||||
|
||||
|
|
@ -72,244 +100,251 @@ 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 {
|
||||
console.log("[BlockedUsers] Adding email to blocked list:", newEmail);
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("[BlockedUsers] Adding email to blocked list:", newEmail);
|
||||
}
|
||||
await blockedEmailManager.addBlockedEmail(newEmail.trim());
|
||||
if (!isMountedRef.current) return;
|
||||
setSuccess(`${newEmail} has been blocked successfully.`);
|
||||
setNewEmail("");
|
||||
await loadBlockedEmails();
|
||||
} catch (err) {
|
||||
console.error("[BlockedUsers] Failed to add blocked email:", err);
|
||||
if (!isMountedRef.current) return;
|
||||
if (import.meta.env.DEV) {
|
||||
console.error("[BlockedUsers] Failed to add blocked email:", err);
|
||||
}
|
||||
setAddError(err.message || "Failed to block email.");
|
||||
} finally {
|
||||
setAddLoading(false);
|
||||
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 {
|
||||
console.log("[BlockedUsers] Removing email from blocked list:", email);
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("[BlockedUsers] Removing email from blocked list:", email);
|
||||
}
|
||||
await blockedEmailManager.removeBlockedEmail(email);
|
||||
if (!isMountedRef.current) return;
|
||||
setSuccess(`${email} has been unblocked.`);
|
||||
await loadBlockedEmails();
|
||||
} catch (err) {
|
||||
console.error("[BlockedUsers] Failed to remove blocked email:", err);
|
||||
if (!isMountedRef.current) return;
|
||||
if (import.meta.env.DEV) {
|
||||
console.error("[BlockedUsers] Failed to remove blocked email:", err);
|
||||
}
|
||||
setError(err.message || "Failed to unblock email.");
|
||||
} finally {
|
||||
setDeleteLoading("");
|
||||
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 />
|
||||
<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="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>
|
||||
|
||||
{/* 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>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Blocked Users</h1>
|
||||
<p className="text-gray-600">
|
||||
Manage users who cannot share folders with you
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success message */}
|
||||
{success && (
|
||||
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg flex items-center">
|
||||
<CheckCircleIcon className="h-5 w-5 text-green-600 mr-3" />
|
||||
<p className="text-green-700">{success}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-center">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-red-600 mr-3" />
|
||||
<p className="text-red-700">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Email Form */}
|
||||
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Block a User
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Enter the email address of a user you want to block. They will not
|
||||
be able to share folders with you.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleAddEmail} className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={(e) => {
|
||||
setNewEmail(e.target.value);
|
||||
if (addError) setAddError("");
|
||||
}}
|
||||
placeholder="Enter email address to block"
|
||||
className={input_style}
|
||||
disabled={addLoading}
|
||||
required
|
||||
/>
|
||||
{addError && (
|
||||
<p className="mt-2 text-sm text-red-600">{addError}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className={btn_primary}
|
||||
disabled={addLoading || !newEmail.trim()}
|
||||
>
|
||||
{addLoading ? (
|
||||
"Adding..."
|
||||
) : (
|
||||
<>
|
||||
<PlusIcon className="h-4 w-4 mr-2" />
|
||||
Block
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Blocked Emails List */}
|
||||
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Blocked Users ({blockedEmails.length})
|
||||
</h2>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-500">Loading blocked users...</p>
|
||||
</div>
|
||||
) : blockedEmails.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<NoSymbolIcon className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500">No blocked users</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Users you block won't be able to share folders with you.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{blockedEmails.map((blocked) => (
|
||||
<div
|
||||
key={blocked.blocked_email}
|
||||
className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border border-gray-200"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<EnvelopeIcon className="h-5 w-5 text-gray-400 mr-3" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
{blocked.blocked_email}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Blocked on{" "}
|
||||
{new Date(blocked.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
{/* Main Card */}
|
||||
<Card>
|
||||
{/* Header */}
|
||||
<div className={`p-6 border-b ${getThemeClasses("border-secondary")}`}>
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start">
|
||||
<div className="p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 bg-red-500">
|
||||
<NoSymbolIcon 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`}>
|
||||
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>
|
||||
<button
|
||||
onClick={() => handleRemoveEmail(blocked.blocked_email)}
|
||||
className={btn_danger}
|
||||
disabled={deleteLoading === blocked.blocked_email}
|
||||
>
|
||||
{deleteLoading === blocked.blocked_email ? (
|
||||
"Removing..."
|
||||
) : (
|
||||
<>
|
||||
<TrashIcon className="h-4 w-4 mr-1" />
|
||||
Unblock
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Button
|
||||
onClick={() => navigate("/me")}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
<span>Back to Settings</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
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>
|
||||
</div>
|
||||
{/* Messages */}
|
||||
<div className="px-6 pt-6">
|
||||
{success && (
|
||||
<Alert type="success" className="mb-4" onClose={() => setSuccess("")}>
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
{error && (
|
||||
<Alert type="error" className="mb-4" onClose={() => setError("")}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 pb-6 pt-2">
|
||||
{/* Add Email Form */}
|
||||
<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 ${getThemeClasses("text-secondary")} mb-4`}>
|
||||
Enter the email address of a user you want to block. They will not
|
||||
be able to share folders with you.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleAddEmail} className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="email"
|
||||
name="block_email"
|
||||
value={newEmail}
|
||||
onChange={(value) => {
|
||||
setNewEmail(value);
|
||||
if (addError) setAddError("");
|
||||
}}
|
||||
placeholder="Enter email address to block"
|
||||
disabled={addLoading}
|
||||
required
|
||||
error={addError}
|
||||
icon={EnvelopeIcon}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={addLoading || !newEmail.trim()}
|
||||
loading={addLoading}
|
||||
>
|
||||
{!addLoading && (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
<span>Block</span>
|
||||
</span>
|
||||
)}
|
||||
{addLoading && "Adding..."}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* Blocked Emails List */}
|
||||
<Card className={`border ${getThemeClasses("border-muted")} p-6`}>
|
||||
<h2 className={`text-lg font-semibold ${getThemeClasses("text-primary")} mb-4`}>
|
||||
Blocked Users ({blockedEmails.length})
|
||||
</h2>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
<Spinner size="lg" className="mx-auto mb-4" />
|
||||
<p className={getThemeClasses("text-secondary")}>Loading blocked users...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : blockedEmails.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<NoSymbolIcon className={`h-12 w-12 ${getThemeClasses("text-secondary")} mx-auto mb-4`} />
|
||||
<p className={getThemeClasses("text-secondary")}>No blocked users</p>
|
||||
<p className={`text-sm ${getThemeClasses("text-secondary")} mt-1`}>
|
||||
Users you block won't be able to share folders with you.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{blockedEmails.map((blocked) => (
|
||||
<div
|
||||
key={blocked.blocked_email}
|
||||
className={`flex items-center justify-between p-4 ${getThemeClasses("bg-muted")} rounded-lg border ${getThemeClasses("border-muted")}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<EnvelopeIcon className={`h-5 w-5 ${getThemeClasses("text-secondary")} mr-3`} />
|
||||
<div>
|
||||
<p className={`font-medium ${getThemeClasses("text-primary")}`}>
|
||||
{blocked.blocked_email}
|
||||
</p>
|
||||
<p className={`text-xs ${getThemeClasses("text-secondary")}`}>
|
||||
Blocked on{" "}
|
||||
{new Date(blocked.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handleRemoveEmail(blocked.blocked_email)}
|
||||
variant="danger"
|
||||
size="sm"
|
||||
disabled={deleteLoading === blocked.blocked_email}
|
||||
loading={deleteLoading === blocked.blocked_email}
|
||||
>
|
||||
{deleteLoading !== blocked.blocked_email && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<span>Unblock</span>
|
||||
</span>
|
||||
)}
|
||||
{deleteLoading === blocked.blocked_email && "Removing..."}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Info section */}
|
||||
<Alert type="info" className="mt-6">
|
||||
<div>
|
||||
<h3 className={`text-sm font-medium ${getThemeClasses("text-primary")} mb-2`}>
|
||||
How blocking works
|
||||
</h3>
|
||||
<ul className={`text-sm ${getThemeClasses("text-secondary")} space-y-1`}>
|
||||
<li>• Blocked users cannot share folders or files with you</li>
|
||||
<li>• You can still share folders with blocked users</li>
|
||||
<li>• Blocking is private - users are not notified when blocked</li>
|
||||
<li>• Existing shares are not affected when you block someone</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* CSS Animations */}
|
||||
<style>{`
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.animate-fade-in-up {
|
||||
animation: fade-in-up 0.5s ease-out both;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
console.error("Failed to load user info:", err);
|
||||
if (!isMountedRef.current) return;
|
||||
if (import.meta.env.DEV) {
|
||||
console.error("Failed to load user info:", err);
|
||||
}
|
||||
setError("Failed to load your account information.");
|
||||
}
|
||||
};
|
||||
|
|
@ -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) {
|
||||
console.error("Account deletion failed:", err);
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.error("Account deletion failed:", err);
|
||||
}
|
||||
|
||||
// Handle specific error cases
|
||||
if (err.response?.status === 401) {
|
||||
|
|
@ -125,118 +158,116 @@ const DeleteAccount = () => {
|
|||
setError("Failed to delete account. Please try again or contact support.");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
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
|
||||
type="password"
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500"
|
||||
placeholder="Your account password"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<Card className={`border ${getThemeClasses("border-muted")} p-6 space-y-4`}>
|
||||
<Input
|
||||
label="Enter your password to confirm"
|
||||
type="password"
|
||||
name="password"
|
||||
value={password}
|
||||
onChange={(value) => {
|
||||
setPassword(value);
|
||||
setError("");
|
||||
}}
|
||||
placeholder="Your account password"
|
||||
disabled={loading}
|
||||
icon={LockClosedIcon}
|
||||
/>
|
||||
|
||||
<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">
|
||||
{/* 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" />
|
||||
Delete Account
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Permanently delete your MapleFile account and all associated data
|
||||
</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} />
|
||||
|
||||
{/* Progress Indicator */}
|
||||
{renderProgressIndicator()}
|
||||
{/* Main Card */}
|
||||
<Card>
|
||||
{/* Header */}
|
||||
<div className={`p-6 border-b ${getThemeClasses("border-secondary")}`}>
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start">
|
||||
<div className={`p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 ${getThemeClasses("bg-error")}`}>
|
||||
<TrashIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
|
||||
Delete Account
|
||||
</h1>
|
||||
<p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
|
||||
Permanently delete your MapleFile account and all associated data
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Button
|
||||
onClick={() => navigate("/me")}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
<span>Back to Settings</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-8">
|
||||
{currentStep === 1 && renderStep1()}
|
||||
{currentStep === 2 && renderStep2()}
|
||||
{currentStep === 3 && renderStep3()}
|
||||
{currentStep === 4 && renderStep4()}
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
{/* Progress Indicator */}
|
||||
{renderProgressIndicator()}
|
||||
|
||||
{/* 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
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
setSuccess("Tag created successfully!");
|
||||
setFormData({ name: "", color: "#3B82F6" });
|
||||
setIsCreating(false);
|
||||
await loadTags();
|
||||
if (isMountedRef.current) {
|
||||
setSuccess("Tag created successfully!");
|
||||
setFormData({ name: "", color: "#3B82F6" });
|
||||
setIsCreating(false);
|
||||
await loadTags();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to create tag:", err);
|
||||
setError(err.message || "Failed to create tag");
|
||||
if (isMountedRef.current) {
|
||||
console.error("Failed to create tag:", err);
|
||||
setError(err.message || "Failed to create tag");
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
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);
|
||||
setSuccess("Tag updated successfully!");
|
||||
setFormData({ name: "", color: "#3B82F6" });
|
||||
setEditingTagId(null);
|
||||
await loadTags();
|
||||
if (isMountedRef.current) {
|
||||
setSuccess("Tag updated successfully!");
|
||||
setFormData({ name: "", color: "#3B82F6" });
|
||||
setEditingTagId(null);
|
||||
await loadTags();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to update tag:", err);
|
||||
setError(err.message || "Failed to update tag");
|
||||
if (isMountedRef.current) {
|
||||
console.error("Failed to update tag:", err);
|
||||
setError(err.message || "Failed to update tag");
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
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,200 +190,261 @@ const TagsManagement = () => {
|
|||
setSuccess("");
|
||||
setIsLoading(true);
|
||||
await tagManager.deleteTag(tagId);
|
||||
setSuccess("Tag deleted successfully!");
|
||||
await loadTags();
|
||||
if (isMountedRef.current) {
|
||||
setSuccess("Tag deleted successfully!");
|
||||
await loadTags();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to delete tag:", err);
|
||||
setError(err.message || "Failed to delete tag");
|
||||
if (isMountedRef.current) {
|
||||
console.error("Failed to delete tag:", err);
|
||||
setError(err.message || "Failed to delete tag");
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
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">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Manage Tags
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Create and organize tags for your files and collections
|
||||
</p>
|
||||
</div>
|
||||
{!isCreating && !editingTagId && (
|
||||
<Button
|
||||
onClick={() => setIsCreating(true)}
|
||||
icon={PlusIcon}
|
||||
variant="primary"
|
||||
>
|
||||
New Tag
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Layout>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Breadcrumb Navigation */}
|
||||
<Breadcrumb items={breadcrumbItems} />
|
||||
|
||||
{/* Alerts */}
|
||||
{error && (
|
||||
<Alert type="error" onClose={() => setError("")}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{success && (
|
||||
<Alert type="success" onClose={() => setSuccess("")}>
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Form */}
|
||||
{(isCreating || editingTagId) && (
|
||||
{/* Main Card */}
|
||||
<Card>
|
||||
<div className="p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{editingTagId ? "Edit Tag" : "Create New Tag"}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Tag Name"
|
||||
value={formData.name}
|
||||
onChange={(value) => {
|
||||
setFormData({ ...formData, name: value });
|
||||
setFormErrors({ ...formErrors, name: "" });
|
||||
}}
|
||||
error={formErrors.name}
|
||||
placeholder="e.g., Important, Work, Personal"
|
||||
maxLength={50}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Tag Color
|
||||
</label>
|
||||
<div className="flex items-center space-x-4">
|
||||
<input
|
||||
type="color"
|
||||
value={formData.color}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, color: e.target.value });
|
||||
setFormErrors({ ...formErrors, color: "" });
|
||||
}}
|
||||
className="h-10 w-20 rounded cursor-pointer border border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<Input
|
||||
value={formData.color}
|
||||
onChange={(value) => {
|
||||
setFormData({ ...formData, color: value });
|
||||
setFormErrors({ ...formErrors, color: "" });
|
||||
}}
|
||||
error={formErrors.color}
|
||||
placeholder="#3B82F6"
|
||||
maxLength={7}
|
||||
/>
|
||||
{/* 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>
|
||||
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
|
||||
Manage Tags
|
||||
</h1>
|
||||
<p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
|
||||
Create and organize tags for your files and collections
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Choose a color to identify this tag
|
||||
</p>
|
||||
</div>
|
||||
{/* Action buttons */}
|
||||
<div className="flex-shrink-0 flex items-center space-x-3">
|
||||
{!isCreating && !editingTagId && (
|
||||
<Button
|
||||
onClick={() => setIsCreating(true)}
|
||||
icon={PlusIcon}
|
||||
variant="primary"
|
||||
>
|
||||
New Tag
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => navigate("/me")}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
<span>Back to Settings</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 pt-4">
|
||||
<Button
|
||||
onClick={editingTagId ? handleEdit : handleCreate}
|
||||
variant="primary"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{editingTagId ? "Update Tag" : "Create Tag"}
|
||||
</Button>
|
||||
<Button onClick={cancelForm} variant="secondary" disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
{/* Messages */}
|
||||
<div className="px-6 pt-6">
|
||||
{error && (
|
||||
<Alert type="error" className="mb-4" onClose={() => setError("")}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{success && (
|
||||
<Alert type="success" className="mb-4" onClose={() => setSuccess("")}>
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tags List */}
|
||||
{isLoading && tags.length === 0 ? (
|
||||
<Card>
|
||||
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
Loading tags...
|
||||
</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">
|
||||
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>
|
||||
<Button onClick={() => setIsCreating(true)} icon={PlusIcon} variant="primary">
|
||||
Create Your First Tag
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<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 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"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{tag.name}
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{tag.color}
|
||||
</p>
|
||||
{/* Content */}
|
||||
<div className="px-6 pb-6 pt-2">
|
||||
{/* Create/Edit Form */}
|
||||
{(isCreating || editingTagId) && (
|
||||
<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>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Tag Name"
|
||||
value={formData.name}
|
||||
onChange={(value) => {
|
||||
setFormData({ ...formData, name: value });
|
||||
setFormErrors({ ...formErrors, name: "" });
|
||||
}}
|
||||
error={formErrors.name}
|
||||
placeholder="e.g., Important, Work, Personal"
|
||||
maxLength={50}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-2 ${getThemeClasses("text-primary")}`}>
|
||||
Tag Color
|
||||
</label>
|
||||
<div className="flex items-center space-x-4">
|
||||
<input
|
||||
type="color"
|
||||
value={formData.color}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, color: e.target.value });
|
||||
setFormErrors({ ...formErrors, color: "" });
|
||||
}}
|
||||
className={`h-10 w-20 rounded cursor-pointer border ${getThemeClasses("border-secondary")}`}
|
||||
/>
|
||||
<Input
|
||||
value={formData.color}
|
||||
onChange={(value) => {
|
||||
setFormData({ ...formData, color: value });
|
||||
setFormErrors({ ...formErrors, color: "" });
|
||||
}}
|
||||
error={formErrors.color}
|
||||
placeholder="#3B82F6"
|
||||
maxLength={7}
|
||||
/>
|
||||
</div>
|
||||
<p className={`text-xs mt-1 ${getThemeClasses("text-secondary")}`}>
|
||||
Choose a color to identify this tag
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 pt-4">
|
||||
<Button
|
||||
onClick={editingTagId ? handleEdit : handleCreate}
|
||||
variant="primary"
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
loadingText={editingTagId ? "Updating..." : "Creating..."}
|
||||
>
|
||||
{editingTagId ? "Update Tag" : "Create Tag"}
|
||||
</Button>
|
||||
<Button onClick={cancelForm} variant="secondary" disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags List */}
|
||||
{isLoading && tags.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<Spinner size="lg" className="mx-auto mb-4" />
|
||||
<p className={getThemeClasses("text-secondary")}>Loading tags...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : tags.length === 0 && !isCreating ? (
|
||||
<div className="text-center py-12">
|
||||
<div className={`h-20 w-20 ${getThemeClasses("bg-muted")} rounded-2xl flex items-center justify-center mx-auto mb-3`}>
|
||||
<TagIcon className="h-10 w-10 text-gray-400" />
|
||||
</div>
|
||||
<h3 className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`}>
|
||||
No tags yet
|
||||
</h3>
|
||||
<p className={`mb-6 ${getThemeClasses("text-secondary")}`}>
|
||||
Create your first tag to organize your files
|
||||
</p>
|
||||
<Button onClick={() => setIsCreating(true)} icon={PlusIcon} variant="primary">
|
||||
Create Your First Tag
|
||||
</Button>
|
||||
</div>
|
||||
) : tags.length > 0 && (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{tags.map((tag) => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className={`p-4 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-card")} ${getThemeClasses("hover:shadow")} transition-shadow duration-200`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
||||
<div
|
||||
className="w-5 h-5 rounded-full flex-shrink-0 shadow-sm"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className={`text-sm font-medium truncate ${getThemeClasses("text-primary")}`}>
|
||||
{tag.name}
|
||||
</h4>
|
||||
<p className={`text-xs mt-0.5 ${getThemeClasses("text-secondary")}`}>
|
||||
{tag.color}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-1 ml-2">
|
||||
<Button
|
||||
onClick={() => startEdit(tag)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={`Edit tag ${tag.name}`}
|
||||
>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDelete(tag.id)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={`Delete tag ${tag.name}`}
|
||||
className="hover:text-red-600 dark:hover:text-red-400"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2 ml-2">
|
||||
<button
|
||||
onClick={() => startEdit(tag)}
|
||||
className="p-1 text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
|
||||
title="Edit tag"
|
||||
>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(tag.id)}
|
||||
className="p-1 text-gray-500 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400"
|
||||
title="Delete tag"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagsManagement;
|
||||
export default withPasswordProtection(TagsManagement);
|
||||
|
|
|
|||
|
|
@ -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,165 +67,168 @@ const TagCreate = () => {
|
|||
|
||||
setIsLoading(true);
|
||||
await tagManager.createTag(formData);
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
// Navigate back to list
|
||||
navigate("/me/tags");
|
||||
} catch (err) {
|
||||
console.error("Failed to create tag:", err);
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.error("Failed to create tag:", err);
|
||||
}
|
||||
setError(err.message || "Failed to create tag");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
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">
|
||||
{/* 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">
|
||||
Create a new tag to organize your files and collections
|
||||
</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} />
|
||||
|
||||
{/* Alert */}
|
||||
{error && (
|
||||
<div className="mb-6 animate-fade-in-up">
|
||||
<Alert type="error" onClose={() => setError("")}>
|
||||
{error}
|
||||
</Alert>
|
||||
{/* Main Card */}
|
||||
<Card>
|
||||
{/* Header */}
|
||||
<div className={`p-6 border-b ${getThemeClasses("border-secondary")}`}>
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start">
|
||||
<div className={`p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 ${getThemeClasses("bg-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>
|
||||
)}
|
||||
|
||||
{/* 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"
|
||||
{/* Messages */}
|
||||
<div className="px-6 pt-6">
|
||||
{error && (
|
||||
<Alert type="error" className="mb-4" onClose={() => setError("")}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 pb-6 pt-2">
|
||||
<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>
|
||||
<div className="flex items-center space-x-4">
|
||||
<input
|
||||
type="color"
|
||||
value={formData.color}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, color: e.target.value });
|
||||
setFormErrors({ ...formErrors, color: "" });
|
||||
}}
|
||||
className="h-12 w-24 rounded-lg cursor-pointer border-2 border-gray-300 shadow-sm hover:border-red-500 transition-colors duration-200"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div>
|
||||
<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
|
||||
type="text"
|
||||
type="color"
|
||||
value={formData.color}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, color: e.target.value });
|
||||
setFormErrors({ ...formErrors, color: "" });
|
||||
}}
|
||||
placeholder="#3B82F6"
|
||||
maxLength={7}
|
||||
className={input_style}
|
||||
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
|
||||
type="text"
|
||||
name="tag_color"
|
||||
placeholder="#3B82F6"
|
||||
value={formData.color}
|
||||
onChange={(value) => {
|
||||
setFormData({ ...formData, color: value });
|
||||
setFormErrors({ ...formErrors, color: "" });
|
||||
}}
|
||||
maxLength={7}
|
||||
disabled={isLoading}
|
||||
error={formErrors.color}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className={`text-xs ${getThemeClasses("text-secondary")} mt-2`}>
|
||||
Choose a color to identify this tag visually
|
||||
</p>
|
||||
</div>
|
||||
{formErrors.color && (
|
||||
<p className="mt-1 text-sm text-red-600">{formErrors.color}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Choose a color to identify this tag visually
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 pt-4 border-t border-gray-200">
|
||||
<button type="submit" className={btn_primary} disabled={isLoading}>
|
||||
{isLoading ? "Creating..." : "Create Tag"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/me/tags")}
|
||||
className={btn_secondary}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<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")}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
console.error("Failed to load tag:", err);
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.error("Failed to load tag:", err);
|
||||
}
|
||||
setError(err.message || "Failed to load tag");
|
||||
setIsInitialized(true);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [tagManager, tagId, navigate]);
|
||||
|
||||
// Get tag name from location state or load tag
|
||||
useEffect(() => {
|
||||
if (location.state?.tagName) {
|
||||
setTag({ id: tagId, name: location.state.tagName });
|
||||
setIsInitialized(true);
|
||||
} else if (tagManager && !isInitialized) {
|
||||
loadTag();
|
||||
}
|
||||
}, [tagId, location.state, tagManager, isInitialized, loadTag]);
|
||||
|
||||
// Handle delete
|
||||
const handleDelete = async () => {
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
try {
|
||||
setError("");
|
||||
setIsLoading(true);
|
||||
console.log("[TagDelete] Attempting to delete tag:", tagId);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("[TagDelete] Attempting to delete tag:", tagId);
|
||||
}
|
||||
|
||||
await tagManager.deleteTag(tagId);
|
||||
console.log("[TagDelete] Tag deleted successfully");
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("[TagDelete] Tag deleted successfully");
|
||||
}
|
||||
|
||||
// Navigate back to list
|
||||
navigate("/me/tags");
|
||||
} catch (err) {
|
||||
console.error("[TagDelete] Failed to delete tag:", err);
|
||||
console.error("[TagDelete] Error details:", {
|
||||
message: err.message,
|
||||
status: err.status,
|
||||
response: err.response,
|
||||
});
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.error("[TagDelete] Failed to delete tag:", err);
|
||||
}
|
||||
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">
|
||||
{/* 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">Delete Tag</h1>
|
||||
<p className="text-gray-600 mt-1">Confirm tag deletion</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} />
|
||||
|
||||
{/* Alert */}
|
||||
{error && (
|
||||
<div className="mb-6 animate-fade-in-up">
|
||||
<Alert type="error" onClose={() => setError("")}>
|
||||
{error}
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirmation */}
|
||||
{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>
|
||||
) : tag ? (
|
||||
<div className="bg-white rounded-xl shadow-lg border-2 border-red-100 p-8 animate-fade-in-up">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-red-100">
|
||||
<ExclamationTriangleIcon className="h-8 w-8 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Delete Tag "{tag.name}"?
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-gray-600">
|
||||
Are you sure you want to delete this tag? This action cannot
|
||||
be undone.
|
||||
</p>
|
||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>Note:</strong> This will only delete the tag itself. Files and
|
||||
collections with this tag will not be affected.
|
||||
{/* Main Card */}
|
||||
<Card>
|
||||
{/* Header */}
|
||||
<div className={`p-6 border-b ${getThemeClasses("border-secondary")}`}>
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start">
|
||||
<div className={`p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 ${getThemeClasses("bg-error")}`}>
|
||||
<TrashIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
|
||||
Delete Tag
|
||||
</h1>
|
||||
<p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
|
||||
Confirm tag deletion
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 mt-6 pt-6 border-t border-gray-200">
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className={btn_danger}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Deleting..." : "Delete Tag"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate("/me/tags")}
|
||||
className={btn_secondary}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<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>
|
||||
) : null}
|
||||
|
||||
{/* Messages */}
|
||||
<div className="px-6 pt-6">
|
||||
{error && (
|
||||
<Alert type="error" className="mb-4" onClose={() => setError("")}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 pb-6 pt-2">
|
||||
{isLoading && !tag ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<Spinner size="lg" className="mx-auto mb-4" />
|
||||
<p className={getThemeClasses("text-secondary")}>Loading tag...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : tag ? (
|
||||
<div className="max-w-xl">
|
||||
<div className={`${getThemeClasses("bg-error-light")} border ${getThemeClasses("border-error")} rounded-xl p-6`}>
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className={`inline-flex items-center justify-center w-14 h-14 rounded-2xl ${getThemeClasses("bg-error-light")}`}>
|
||||
<ExclamationTriangleIcon className={`h-8 w-8 ${getThemeClasses("text-error")}`} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className={`text-xl font-semibold ${getThemeClasses("text-primary")} mb-2`}>
|
||||
Delete Tag "{tag.name}"?
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>
|
||||
Are you sure you want to delete this tag? This action cannot
|
||||
be undone.
|
||||
</p>
|
||||
<Alert type="info" className="mt-4">
|
||||
<strong>Note:</strong> This will only delete the tag itself. Files and
|
||||
collections with this tag will not be affected.
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`flex space-x-3 mt-6 pt-6 border-t ${getThemeClasses("border-muted")}`}>
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
variant="danger"
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
>
|
||||
{!isLoading && (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<span>Delete Tag</span>
|
||||
</span>
|
||||
)}
|
||||
{isLoading && "Deleting..."}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigate("/me/tags")}
|
||||
variant="secondary"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 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 TagDelete;
|
||||
export default withPasswordProtection(TagDelete);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
console.error("Failed to load tag:", err);
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.error("Failed to load tag:", err);
|
||||
}
|
||||
setError(err.message || "Failed to load tag");
|
||||
setIsInitialized(true);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
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,170 +109,172 @@ const TagEdit = () => {
|
|||
|
||||
setIsLoading(true);
|
||||
await tagManager.updateTag(tagId, formData);
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
// Navigate back to list
|
||||
navigate("/me/tags");
|
||||
} catch (err) {
|
||||
console.error("Failed to update tag:", err);
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.error("Failed to update tag:", err);
|
||||
}
|
||||
setError(err.message || "Failed to update tag");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
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">
|
||||
{/* 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">Edit Tag</h1>
|
||||
<p className="text-gray-600 mt-1">Update tag information</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} />
|
||||
|
||||
{/* Alert */}
|
||||
{error && (
|
||||
<div className="mb-6 animate-fade-in-up">
|
||||
<Alert type="error" onClose={() => setError("")}>
|
||||
{error}
|
||||
</Alert>
|
||||
{/* Main Card */}
|
||||
<Card>
|
||||
{/* Header */}
|
||||
<div className={`p-6 border-b ${getThemeClasses("border-secondary")}`}>
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:justify-between lg:space-y-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start">
|
||||
<div className={`p-3 rounded-2xl shadow-lg mr-4 flex-shrink-0 ${getThemeClasses("bg-gradient-secondary")}`}>
|
||||
<TagIcon className="h-8 w-8 sm:h-10 sm:w-10 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
|
||||
Edit Tag
|
||||
</h1>
|
||||
<p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
|
||||
Update tag information
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Button
|
||||
onClick={() => navigate("/me/tags")}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
<span>Back to Tags</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
{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>
|
||||
{/* Messages */}
|
||||
<div className="px-6 pt-6">
|
||||
{error && (
|
||||
<Alert type="error" className="mb-4" onClose={() => setError("")}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</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"
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 pb-6 pt-2">
|
||||
{isLoading && !formData.name ? (
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
<div className="flex items-center space-x-4">
|
||||
<input
|
||||
type="color"
|
||||
value={formData.color}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, color: e.target.value });
|
||||
setFormErrors({ ...formErrors, color: "" });
|
||||
}}
|
||||
className="h-12 w-24 rounded-lg cursor-pointer border-2 border-gray-300 shadow-sm hover:border-red-500 transition-colors duration-200"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div>
|
||||
<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
|
||||
type="text"
|
||||
type="color"
|
||||
value={formData.color}
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, color: e.target.value });
|
||||
setFormErrors({ ...formErrors, color: "" });
|
||||
}}
|
||||
placeholder="#3B82F6"
|
||||
maxLength={7}
|
||||
className={input_style}
|
||||
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
|
||||
type="text"
|
||||
name="tag_color"
|
||||
placeholder="#3B82F6"
|
||||
value={formData.color}
|
||||
onChange={(value) => {
|
||||
setFormData({ ...formData, color: value });
|
||||
setFormErrors({ ...formErrors, color: "" });
|
||||
}}
|
||||
maxLength={7}
|
||||
disabled={isLoading}
|
||||
error={formErrors.color}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className={`text-xs ${getThemeClasses("text-secondary")} mt-2`}>
|
||||
Choose a color to identify this tag visually
|
||||
</p>
|
||||
</div>
|
||||
{formErrors.color && (
|
||||
<p className="mt-1 text-sm text-red-600">{formErrors.color}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Choose a color to identify this tag visually
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 pt-4 border-t border-gray-200">
|
||||
<button type="submit" className={btn_primary} disabled={isLoading}>
|
||||
{isLoading ? "Updating..." : "Update Tag"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/me/tags")}
|
||||
className={btn_secondary}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<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")}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</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 TagEdit;
|
||||
export default withPasswordProtection(TagEdit);
|
||||
|
|
|
|||
|
|
@ -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,232 +93,170 @@ 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>
|
||||
{/* Title and subtitle */}
|
||||
<div>
|
||||
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
|
||||
Manage Tags
|
||||
</h1>
|
||||
<p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
|
||||
Create and organize tags for your files and collections
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">My Tags</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Create and organize tags for your files and collections
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
{/* 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>
|
||||
|
||||
{/* Alerts */}
|
||||
{error && (
|
||||
<Alert type="error" onClose={() => setError("")}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{success && (
|
||||
<Alert type="success" onClose={() => setSuccess("")}>
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Tags List */}
|
||||
{isLoading && tags.length === 0 ? (
|
||||
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-8 text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-red-800 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Loading tags...</p>
|
||||
</div>
|
||||
) : tags.length === 0 ? (
|
||||
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-12 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-red-600 to-red-800 mb-4">
|
||||
<TagIcon className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
No tags yet
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6 max-w-md mx-auto">
|
||||
Create your first tag to organize your files and collections
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate("/me/tags/create")}
|
||||
className="inline-flex items-center justify-center px-6 py-3 border border-transparent text-sm font-medium rounded-lg text-white bg-red-700 hover:bg-red-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors duration-200"
|
||||
>
|
||||
<PlusIcon className="h-5 w-5 mr-2" />
|
||||
Create Your First Tag
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-2">
|
||||
{tags.map((tag, index) => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-5 hover:shadow-xl transition-all duration-200 animate-fade-in-up"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
||||
<div
|
||||
className="w-5 h-5 rounded-full flex-shrink-0 shadow-sm"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-base font-semibold text-gray-900 truncate">
|
||||
{tag.name}
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 mt-0.5 font-mono">
|
||||
{tag.color}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-1 ml-2">
|
||||
<button
|
||||
onClick={() => navigate(`/me/tags/${tag.id}/edit`)}
|
||||
className="p-2 text-gray-500 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors duration-200"
|
||||
title="Edit tag"
|
||||
>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(tag.id, tag.name)}
|
||||
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors duration-200"
|
||||
title="Delete tag"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="px-6 pt-6">
|
||||
{error && (
|
||||
<Alert type="error" className="mb-4" onClose={() => setError("")}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{success && (
|
||||
<Alert type="success" className="mb-4" onClose={() => setSuccess("")}>
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 pb-6 pt-2">
|
||||
{/* Tags List */}
|
||||
{isLoading && tags.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<Spinner size="lg" className="mx-auto mb-4" />
|
||||
<p className={getThemeClasses("text-secondary")}>Loading tags...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : tags.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className={`h-20 w-20 ${getThemeClasses("bg-muted")} rounded-2xl flex items-center justify-center mx-auto mb-3`}>
|
||||
<TagIcon className="h-10 w-10 text-gray-400" />
|
||||
</div>
|
||||
<h3 className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`}>
|
||||
No tags yet
|
||||
</h3>
|
||||
<p className={`mb-6 ${getThemeClasses("text-secondary")}`}>
|
||||
Create your first tag to organize your files
|
||||
</p>
|
||||
<Button onClick={() => navigate("/me/tags/create")} icon={PlusIcon} variant="primary">
|
||||
Create Your First Tag
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{tags.map((tag) => (
|
||||
<div
|
||||
key={tag.id}
|
||||
role="article"
|
||||
aria-label={`Tag: ${tag.name}`}
|
||||
className={`p-4 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-card")} ${getThemeClasses("hover:shadow")} transition-shadow duration-200`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
||||
<div
|
||||
className="w-5 h-5 rounded-full flex-shrink-0 shadow-sm"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className={`text-sm font-medium truncate ${getThemeClasses("text-primary")}`}>
|
||||
{tag.name}
|
||||
</h4>
|
||||
<p className={`text-xs mt-0.5 ${getThemeClasses("text-secondary")}`}>
|
||||
{tag.color}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-1 ml-2" role="group" aria-label={`Actions for tag ${tag.name}`}>
|
||||
<Button
|
||||
onClick={() => navigate(`/me/tags/${tag.id}/edit`)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={`Edit tag ${tag.name}`}
|
||||
>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDelete(tag.id, tag.name)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={`Delete tag ${tag.name}`}
|
||||
className="hover:text-red-600 dark:hover:text-red-400"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
setTags(fetchedTags || []);
|
||||
if (isMountedRef.current) {
|
||||
setTags(fetchedTags || []);
|
||||
setIsInitialized(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load tags:", err);
|
||||
setError(err.message || "Failed to load tags");
|
||||
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 {
|
||||
setIsLoading(false);
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [tagManager]);
|
||||
|
||||
// Load tags when tagManager becomes available
|
||||
useEffect(() => {
|
||||
if (tagManager && !isInitialized) {
|
||||
loadTags();
|
||||
}
|
||||
}, [tagManager, isInitialized, loadTags]);
|
||||
|
||||
// Toggle tag selection
|
||||
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>
|
||||
<h1 className="text-3xl font-bold text-gray-900 flex items-center">
|
||||
Search by Tags
|
||||
<MagnifyingGlassIcon className="h-8 w-8 text-red-700 ml-2" />
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Select tags to find matching files and collections
|
||||
</p>
|
||||
<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-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
|
||||
Search by Tags
|
||||
</h1>
|
||||
<p className={`mt-2 text-base sm:text-lg ${getThemeClasses("text-secondary")}`}>
|
||||
Select tags to find matching files and collections
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Action buttons */}
|
||||
<div className="flex-shrink-0 flex items-center space-x-3">
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
disabled={selectedTagIds.length === 0}
|
||||
variant="primary"
|
||||
icon={MagnifyingGlassIcon}
|
||||
>
|
||||
Search {selectedTagIds.length > 0 && `(${selectedTagIds.length})`}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigate("/me/tags")}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
<span>Back to Tags</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alerts */}
|
||||
{error && (
|
||||
<Alert type="error" onClose={() => setError("")}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{/* Messages */}
|
||||
<div className="px-6 pt-6">
|
||||
{error && (
|
||||
<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">
|
||||
{/* Selection Counter */}
|
||||
<div className="mb-6 pb-6 border-b border-gray-200">
|
||||
<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
|
||||
</span>
|
||||
{/* Content */}
|
||||
<div className="px-6 pb-6 pt-2">
|
||||
{/* Selection Counter */}
|
||||
<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-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>
|
||||
) : tags.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-red-600 to-red-800 mb-4">
|
||||
<TagIcon className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
No tags available
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6 max-w-md mx-auto">
|
||||
Create tags first before searching
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate("/me/tags/create")}
|
||||
className="inline-flex items-center justify-center px-6 py-3 border border-transparent text-sm font-medium rounded-lg text-white bg-red-700 hover:bg-red-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors duration-200"
|
||||
>
|
||||
Create Your First Tag
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3 mb-8">
|
||||
{tags.map((tag, index) => {
|
||||
const isSelected = selectedTagIds.includes(tag.id);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => handleTagToggle(tag.id)}
|
||||
className={`p-4 rounded-lg border-2 transition-all duration-200 text-left animate-fade-in-up ${
|
||||
isSelected
|
||||
? "border-red-700 bg-red-50 shadow-md"
|
||||
: "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm"
|
||||
}`}
|
||||
style={{ animationDelay: `${index * 30}ms` }}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div
|
||||
className="w-5 h-5 rounded-full flex-shrink-0 shadow-sm"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4
|
||||
className={`text-base font-semibold truncate ${
|
||||
isSelected ? "text-red-900" : "text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{tag.name}
|
||||
</h4>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-red-700 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
className="w-4 h-4 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Search Button */}
|
||||
<div className="flex items-center justify-center pt-6 border-t border-gray-200">
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={selectedTagIds.length === 0}
|
||||
className={`inline-flex items-center justify-center px-8 py-4 border border-transparent text-base font-medium rounded-lg text-white transition-all duration-200 ${
|
||||
selectedTagIds.length === 0
|
||||
? "bg-gray-400 cursor-not-allowed"
|
||||
: "bg-gradient-to-r from-red-700 to-red-800 hover:from-red-800 hover:to-red-900 shadow-lg hover:shadow-xl"
|
||||
}`}
|
||||
>
|
||||
<MagnifyingGlassIcon className="h-5 w-5 mr-2" />
|
||||
Search {selectedTagIds.length > 0 && `(${selectedTagIds.length} tags)`}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
{tags.length > 0 && (
|
||||
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-4 animate-fade-in-up" style={{ animationDelay: "200ms" }}>
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg
|
||||
className="h-5 w-5 text-blue-600"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-blue-800">
|
||||
How tag search works
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-blue-700">
|
||||
<p>
|
||||
Search returns items that have <strong>ALL</strong> selected tags (AND logic).
|
||||
The more tags you select, the more specific your search becomes.
|
||||
</p>
|
||||
{/* Tags Grid */}
|
||||
{isLoading && tags.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<Spinner size="lg" className="mx-auto mb-4" />
|
||||
<p className={getThemeClasses("text-secondary")}>Loading tags...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : tags.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className={`h-20 w-20 ${getThemeClasses("bg-muted")} rounded-2xl flex items-center justify-center mx-auto mb-3`}>
|
||||
<TagIcon className="h-10 w-10 text-gray-400" />
|
||||
</div>
|
||||
<h3 className={`text-xl font-semibold mb-2 ${getThemeClasses("text-primary")}`}>
|
||||
No tags available
|
||||
</h3>
|
||||
<p className={`mb-6 ${getThemeClasses("text-secondary")}`}>
|
||||
Create tags first before searching
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => navigate("/me/tags/create")}
|
||||
icon={PlusIcon}
|
||||
variant="primary"
|
||||
>
|
||||
Create Your First Tag
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{tags.map((tag) => {
|
||||
const isSelected = selectedTagIds.includes(tag.id);
|
||||
return (
|
||||
<div
|
||||
key={tag.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${isSelected ? "Deselect" : "Select"} tag ${tag.name}`}
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => handleTagToggle(tag.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleTagToggle(tag.id);
|
||||
}
|
||||
}}
|
||||
className={`p-4 rounded-lg border-2 cursor-pointer transition-all duration-200 ${
|
||||
isSelected
|
||||
? `${getThemeClasses("border-primary")} ${getThemeClasses("bg-accent-light")} shadow-md`
|
||||
: `${getThemeClasses("border-secondary")} ${getThemeClasses("bg-card")} ${getThemeClasses("hover:border-primary")} hover:shadow-sm`
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={() => handleTagToggle(tag.id)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="w-5 h-5 rounded-full flex-shrink-0 shadow-sm"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className={`text-base font-semibold truncate ${getThemeClasses("text-primary")}`}>
|
||||
{tag.name}
|
||||
</h4>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className={`flex-shrink-0 w-6 h-6 ${getThemeClasses("bg-gradient-secondary")} rounded-full flex items-center justify-center`}>
|
||||
<CheckIcon className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className={`mt-6 p-4 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-muted")}`}>
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<InformationCircleIcon className={`h-5 w-5 ${getThemeClasses("text-info")}`} />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className={`text-sm font-medium ${getThemeClasses("text-primary")}`}>
|
||||
How tag search works
|
||||
</h3>
|
||||
<p className={`mt-1 text-sm ${getThemeClasses("text-secondary")}`}>
|
||||
Search returns items that have <strong>ALL</strong> selected tags (AND logic).
|
||||
The more tags you select, the more specific your search becomes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 TagSearch;
|
||||
export default withPasswordProtection(TagSearch);
|
||||
|
|
|
|||
|
|
@ -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,273 +91,378 @@ const TagSearchResults = () => {
|
|||
const decrypted = await CollectionCryptoService.decryptCollectionFromAPI(collection);
|
||||
return decrypted;
|
||||
} catch (error) {
|
||||
console.error("[TagSearchResults] Failed to decrypt collection:", collection.id, error);
|
||||
if (import.meta.env.DEV) {
|
||||
console.error("[TagSearchResults] Failed to decrypt collection:", collection.id, error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const decryptedColls = (await Promise.all(collectionPromises)).filter(Boolean);
|
||||
setDecryptedCollections(decryptedColls);
|
||||
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) {
|
||||
console.error("[TagSearchResults] Failed to decrypt file:", file.id, error);
|
||||
if (import.meta.env.DEV) {
|
||||
console.error("[TagSearchResults] Failed to decrypt file:", file.id, error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const decryptedFileList = (await Promise.all(filePromises)).filter(Boolean);
|
||||
setDecryptedFiles(decryptedFileList);
|
||||
if (isMountedRef.current) {
|
||||
setDecryptedFiles(decryptedFileList);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[TagSearchResults] Decryption failed:", error);
|
||||
if (import.meta.env.DEV) {
|
||||
console.error("[TagSearchResults] Decryption failed:", error);
|
||||
}
|
||||
} finally {
|
||||
setIsDecrypting(false);
|
||||
if (isMountedRef.current) {
|
||||
setIsDecrypting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [CollectionCryptoService]);
|
||||
|
||||
// Perform search
|
||||
const performSearch = useCallback(async () => {
|
||||
if (!tagManager || !isMountedRef.current) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("[TagSearchResults] Searching with tags:", selectedTagIds);
|
||||
}
|
||||
|
||||
const searchResults = await tagManager.searchByTags(selectedTagIds, 50);
|
||||
|
||||
if (isMountedRef.current) {
|
||||
setResults(searchResults);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("[TagSearchResults] Search completed:", searchResults);
|
||||
}
|
||||
|
||||
// Decrypt results
|
||||
await decryptResults(searchResults);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMountedRef.current) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error("[TagSearchResults] Search failed:", err);
|
||||
}
|
||||
setError(err.message || "Failed to search by tags");
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [tagManager, selectedTagIds, decryptResults]);
|
||||
|
||||
// Perform search on mount
|
||||
useEffect(() => {
|
||||
if (selectedTagIds && selectedTagIds.length > 0 && tagManager) {
|
||||
performSearch();
|
||||
}
|
||||
}, [tagManager]); // Only run when tagManager becomes available
|
||||
|
||||
// Format file size
|
||||
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>
|
||||
<h1 className="text-3xl font-bold text-gray-900 flex items-center">
|
||||
Search Results
|
||||
<MagnifyingGlassIcon className="h-8 w-8 text-red-700 ml-2" />
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Showing results for {selectedTagIds.length} selected tag{selectedTagIds.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Breadcrumb Navigation */}
|
||||
<Breadcrumb items={breadcrumbItems} />
|
||||
|
||||
{/* Alerts */}
|
||||
{error && (
|
||||
<Alert type="error" onClose={() => setError("")}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
) : results ? (
|
||||
<>
|
||||
{/* Summary */}
|
||||
<div className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-6 mb-6 animate-fade-in-up">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-6">
|
||||
<div className="flex items-center">
|
||||
<FolderIcon className="h-6 w-6 text-red-700 mr-2" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900">{results.collectionCount}</p>
|
||||
<p className="text-sm text-gray-600">Collections</p>
|
||||
</div>
|
||||
{/* 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>
|
||||
<div className="flex items-center">
|
||||
<DocumentIcon className="h-6 w-6 text-red-700 mr-2" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900">{results.fileCount}</p>
|
||||
<p className="text-sm text-gray-600">Files</p>
|
||||
</div>
|
||||
{/* Title and subtitle */}
|
||||
<div>
|
||||
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${getThemeClasses("text-primary")} leading-tight`}>
|
||||
Search Results
|
||||
</h1>
|
||||
<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>
|
||||
<button
|
||||
</div>
|
||||
{/* Action buttons */}
|
||||
<div className="flex-shrink-0 flex items-center space-x-3">
|
||||
<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"
|
||||
variant="primary"
|
||||
icon={MagnifyingGlassIcon}
|
||||
>
|
||||
<MagnifyingGlassIcon className="h-4 w-4 mr-2" />
|
||||
New Search
|
||||
</button>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigate("/me/tags/search")}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
<span>Back</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
No results found
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6 max-w-md mx-auto">
|
||||
No collections or files match all the selected tags. Try selecting fewer tags.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate("/me/tags/search")}
|
||||
className="inline-flex items-center justify-center px-6 py-3 border border-transparent text-sm font-medium rounded-lg text-white bg-red-700 hover:bg-red-800 transition-colors duration-200"
|
||||
>
|
||||
Try Different Tags
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{/* Collections Section */}
|
||||
{decryptedCollections.length > 0 && (
|
||||
<div className="animate-fade-in-up">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<FolderIcon className="h-6 w-6 text-red-700 mr-2" />
|
||||
Collections ({decryptedCollections.length})
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{decryptedCollections.map((collection, index) => (
|
||||
<div
|
||||
key={collection.id}
|
||||
onClick={() => navigate(`/file-manager/collections/${collection.id}`)}
|
||||
className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-6 hover:shadow-xl transition-all duration-200 cursor-pointer animate-fade-in-up"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-red-600 to-red-800 rounded-lg flex items-center justify-center">
|
||||
<FolderIcon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4 flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{collection.name}
|
||||
</h3>
|
||||
{collection.description && (
|
||||
<p className="mt-1 text-sm text-gray-600 line-clamp-2">
|
||||
{collection.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2 flex items-center text-xs text-gray-500">
|
||||
<span>Created {formatDate(collection.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Files Section */}
|
||||
{decryptedFiles.length > 0 && (
|
||||
<div className="animate-fade-in-up" style={{ animationDelay: "100ms" }}>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<DocumentIcon className="h-6 w-6 text-red-700 mr-2" />
|
||||
Files ({decryptedFiles.length})
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{decryptedFiles.map((file, index) => (
|
||||
<div
|
||||
key={file.id}
|
||||
onClick={() => navigate(`/file-manager/files/${file.id}`)}
|
||||
className="bg-white rounded-xl shadow-lg border border-gray-100/50 p-6 hover:shadow-xl transition-all duration-200 cursor-pointer animate-fade-in-up"
|
||||
style={{ animationDelay: `${(decryptedCollections.length + index) * 50}ms` }}
|
||||
>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-blue-600 to-blue-800 rounded-lg flex items-center justify-center">
|
||||
<DocumentIcon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4 flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{file.filename}
|
||||
</h3>
|
||||
<div className="mt-2 flex items-center space-x-4 text-xs text-gray-500">
|
||||
<span>{formatFileSize(file.size_in_bytes)}</span>
|
||||
<span>•</span>
|
||||
<span>Uploaded {formatDate(file.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decrypting State */}
|
||||
{isDecrypting && (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-sm text-gray-600">Decrypting results...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Messages */}
|
||||
<div className="px-6 pt-6">
|
||||
{error && (
|
||||
<Alert type="error" className="mb-4" onClose={() => setError("")}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
) : 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;
|
||||
}
|
||||
{/* Content */}
|
||||
<div className="px-6 pb-3 pt-2">
|
||||
{/* Loading State */}
|
||||
{isLoading ? (
|
||||
<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 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 mr-2 ${getThemeClasses("text-secondary")}`} />
|
||||
<div>
|
||||
<p className={`text-2xl font-bold ${getThemeClasses("text-primary")}`}>{totalCollections}</p>
|
||||
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>Collections</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<DocumentIcon className={`h-6 w-6 mr-2 ${getThemeClasses("text-secondary")}`} />
|
||||
<div>
|
||||
<p className={`text-2xl font-bold ${getThemeClasses("text-primary")}`}>{totalFiles}</p>
|
||||
<p className={`text-sm ${getThemeClasses("text-secondary")}`}>Files</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isDecrypting && (
|
||||
<div className="flex items-center">
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
<span className={`text-sm ${getThemeClasses("text-secondary")}`}>Decrypting...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@keyframes fade-in-up {
|
||||
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>
|
||||
{/* No Results */}
|
||||
{!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 mb-2 ${getThemeClasses("text-primary")}`}>
|
||||
No results found
|
||||
</h3>
|
||||
<p className={`mb-6 ${getThemeClasses("text-secondary")}`}>
|
||||
No collections or files match all the selected tags. Try selecting fewer tags.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => navigate("/me/tags/search")}
|
||||
icon={MagnifyingGlassIcon}
|
||||
variant="primary"
|
||||
>
|
||||
Try Different Tags
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{/* Collections Section */}
|
||||
{decryptedCollections.length > 0 && (
|
||||
<div>
|
||||
<h2 className={`text-lg font-semibold mb-4 flex items-center ${getThemeClasses("text-primary")}`}>
|
||||
<FolderIcon className={`h-5 w-5 mr-2 ${getThemeClasses("text-secondary")}`} />
|
||||
Collections ({decryptedCollections.length})
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{decryptedCollections.map((collection) => (
|
||||
<div
|
||||
key={collection.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Open collection ${collection.name}`}
|
||||
onClick={() => navigate(`/file-manager/collections/${collection.id}`)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
navigate(`/file-manager/collections/${collection.id}`);
|
||||
}
|
||||
}}
|
||||
className={`p-4 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-card")} hover:shadow-md transition-all duration-200 cursor-pointer`}
|
||||
>
|
||||
<div className="flex items-start">
|
||||
<div className={`flex-shrink-0 w-12 h-12 ${getThemeClasses("bg-gradient-secondary")} rounded-lg flex items-center justify-center`}>
|
||||
<FolderIcon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div className="ml-4 flex-1 min-w-0">
|
||||
<h3 className={`text-base font-semibold truncate ${getThemeClasses("text-primary")}`}>
|
||||
{collection.name}
|
||||
</h3>
|
||||
{collection.description && (
|
||||
<p className={`mt-1 text-sm line-clamp-2 ${getThemeClasses("text-secondary")}`}>
|
||||
{collection.description}
|
||||
</p>
|
||||
)}
|
||||
<p className={`mt-2 text-xs ${getThemeClasses("text-muted")}`}>
|
||||
Created {formatDate(collection.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Files Section */}
|
||||
{decryptedFiles.length > 0 && (
|
||||
<div>
|
||||
<h2 className={`text-lg font-semibold mb-4 flex items-center ${getThemeClasses("text-primary")}`}>
|
||||
<DocumentIcon className={`h-5 w-5 mr-2 ${getThemeClasses("text-secondary")}`} />
|
||||
Files ({decryptedFiles.length})
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{decryptedFiles.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Open file ${file.filename}`}
|
||||
onClick={() => navigate(`/file-manager/files/${file.id}`)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
navigate(`/file-manager/files/${file.id}`);
|
||||
}
|
||||
}}
|
||||
className={`p-4 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-card")} hover:shadow-md transition-all duration-200 cursor-pointer`}
|
||||
>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
<DocumentIcon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div className="ml-4 flex-1 min-w-0">
|
||||
<h3 className={`text-base font-semibold truncate ${getThemeClasses("text-primary")}`}>
|
||||
{file.filename}
|
||||
</h3>
|
||||
<p className={`mt-2 text-xs ${getThemeClasses("text-muted")}`}>
|
||||
{formatFileSize(file.size_in_bytes)} • Uploaded {formatDate(file.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Box */}
|
||||
<div className={`mt-6 p-4 rounded-lg border ${getThemeClasses("border-secondary")} ${getThemeClasses("bg-muted")}`}>
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<InformationCircleIcon className={`h-5 w-5 ${getThemeClasses("text-info")}`} />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className={`text-sm font-medium ${getThemeClasses("text-primary")}`}>
|
||||
About these results
|
||||
</h3>
|
||||
<p className={`mt-1 text-sm ${getThemeClasses("text-secondary")}`}>
|
||||
These results show items that have <strong>ALL</strong> of your selected tags.
|
||||
Click on any item to view its details.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagSearchResults;
|
||||
export default withPasswordProtection(TagSearchResults);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue